diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 165cff25..b70d65af 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -110,6 +110,7 @@ import { useScreensaverStore } from '@/stores/screensaver' import { useUIModeStore } from '@/stores/uiMode' import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay' import { shouldShowIntroSplash } from '@/utils/introSplash' +import { isCompanionApp } from '@/utils/openExternal' const router = useRouter() const screensaverStore = useScreensaverStore() @@ -430,7 +431,8 @@ onMounted(async () => { // refresh) starts with the typing splash for the full effect. In-session SPA // navigation never remounts App, and deep-route refreshes keep their place. const { IS_DEMO } = await import('@/composables/useDemoIntro') - if (IS_DEMO && bootPath === '/') replayRequested = true + // Companion in-app demo never requests the splash replay; browser demo unaffected. + if (IS_DEMO && bootPath === '/' && !isCompanionApp()) replayRequested = true let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null // Root boots always ask the backend — even when this browser thinks it has // seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete` @@ -586,6 +588,12 @@ async function handleSplashComplete() { { const { IS_DEMO } = await import('@/composables/useDemoIntro') if (IS_DEMO) { + // Companion in-app demo skips the intro and lands on /login; browser demo unaffected. + if (isCompanionApp()) { + await router.push('/login').catch(() => {}) + reveal() + return + } await router.push('/onboarding/intro').catch(() => {}) reveal() return diff --git a/neode-ui/src/utils/__tests__/openExternal.test.ts b/neode-ui/src/utils/__tests__/openExternal.test.ts new file mode 100644 index 00000000..ac4c2f98 --- /dev/null +++ b/neode-ui/src/utils/__tests__/openExternal.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { isCompanionApp } from '../openExternal' + +// isCompanionApp() is the single companion-detection source used to skip the +// demo intro (App.vue + RootRedirect.vue) and by appLauncher — it must be true +// iff the native shell injected window.ArchipelagoNative with openInApp. + +type TestWindow = Window & { ArchipelagoNative?: unknown } +const w = window as TestWindow + +afterEach(() => { + delete w.ArchipelagoNative +}) + +describe('isCompanionApp', () => { + it('is false in a plain browser/PWA (no bridge injected)', () => { + expect(isCompanionApp()).toBe(false) + }) + + it('is true when the native shell injected the bridge with openInApp', () => { + w.ArchipelagoNative = { openInApp: () => {} } + expect(isCompanionApp()).toBe(true) + }) + + it('is false when the bridge exists but lacks a callable openInApp', () => { + w.ArchipelagoNative = { openExternal: () => {} } + expect(isCompanionApp()).toBe(false) + }) +}) diff --git a/neode-ui/src/views/RootRedirect.vue b/neode-ui/src/views/RootRedirect.vue index d43d96ef..b1831a29 100644 --- a/neode-ui/src/views/RootRedirect.vue +++ b/neode-ui/src/views/RootRedirect.vue @@ -17,6 +17,7 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { isOnboardingComplete } from '@/composables/useOnboarding' import { IS_DEMO } from '@/composables/useDemoIntro' +import { isCompanionApp } from '@/utils/openExternal' import BootScreen from '@/components/BootScreen.vue' const router = useRouter() @@ -80,6 +81,13 @@ async function checkOnboarded(): Promise { async function proceedToApp() { if (IS_DEMO) { + // Companion in-app demo skips the intro (acts as intro-already-seen) and + // lands on /login; browser demo unaffected. No log() here — it writes + // sessionStorage, and the skip path must write nothing. + if (isCompanionApp()) { + router.replace('/login').catch(() => {}) + return + } demoRoute() return } @@ -147,6 +155,12 @@ onMounted(async () => { if (isUp) { // Demo: per-day intro gate instead of server-side onboarding state. if (IS_DEMO) { + // Companion in-app demo skips the intro; browser demo unaffected. + // No log() — the skip path must write nothing to storage. + if (isCompanionApp()) { + router.replace('/login').catch(() => {}) + return + } demoRoute() return }