Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch #149
@@ -143,6 +143,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import * as QRCode from 'qrcode'
|
import * as QRCode from 'qrcode'
|
||||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||||
|
import { isCompanionApp } from '@/utils/openExternal'
|
||||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
@@ -205,10 +206,12 @@ const POST_INTRO_GRACE_MS = 2000
|
|||||||
|
|
||||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// Running inside the companion app's own WebView (it injects this JS bridge).
|
// Running inside the companion app's own WebView (it injects the JS bridge —
|
||||||
|
// detected with the canonical helper, not a raw window check, so the gate
|
||||||
|
// is identical everywhere the question is asked).
|
||||||
// The "get the companion app" pitch is nonsense there — the user is already in
|
// The "get the companion app" pitch is nonsense there — the user is already in
|
||||||
// it. Server management for connected companions lives in the NESMenu instead.
|
// it. Server management for connected companions lives in the NESMenu instead.
|
||||||
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
|
const IN_COMPANION_APP = isCompanionApp()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (IN_COMPANION_APP) return
|
if (IN_COMPANION_APP) return
|
||||||
@@ -247,9 +250,13 @@ function maybeShow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||||
|
// The trigger itself is already a no-op inside the companion (useCompanionIntro),
|
||||||
|
// and this watcher refuses to open there too, so no caller can ever pop the
|
||||||
|
// install pitch inside the app it installs (#61).
|
||||||
watch(companionIntroRequested, (requested) => {
|
watch(companionIntroRequested, (requested) => {
|
||||||
if (!requested) return
|
if (!requested) return
|
||||||
companionIntroRequested.value = false
|
companionIntroRequested.value = false
|
||||||
|
if (IN_COMPANION_APP) return
|
||||||
if (calmTicker) {
|
if (calmTicker) {
|
||||||
clearInterval(calmTicker)
|
clearInterval(calmTicker)
|
||||||
calmTicker = null
|
calmTicker = null
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, afterEach, beforeEach } from 'vitest'
|
||||||
|
import { companionIntroRequested, openCompanionIntro } from '../useCompanionIntro'
|
||||||
|
|
||||||
|
// #61: the manual intro trigger (App Store banner etc.) must be a no-op inside
|
||||||
|
// the companion app's WebView — the "install the companion" pitch is nonsense
|
||||||
|
// where the user is already running it. The auto-popup was already gated
|
||||||
|
// (CompanionIntroOverlay.onMounted); openCompanionIntro is the second, manual
|
||||||
|
// path and the banner render (CompanionBanner) the third.
|
||||||
|
|
||||||
|
type TestWindow = Window & { ArchipelagoNative?: unknown }
|
||||||
|
const w = window as TestWindow
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
companionIntroRequested.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete w.ArchipelagoNative
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openCompanionIntro', () => {
|
||||||
|
it('raises the manual intro request in a plain browser/PWA', () => {
|
||||||
|
expect(companionIntroRequested.value).toBe(false)
|
||||||
|
openCompanionIntro()
|
||||||
|
expect(companionIntroRequested.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op inside the companion app (bridge with openInApp)', () => {
|
||||||
|
w.ArchipelagoNative = { openInApp: () => {}, openExternal: () => {} }
|
||||||
|
openCompanionIntro()
|
||||||
|
expect(companionIntroRequested.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still fires when the bridge exists but is not the companion shell', () => {
|
||||||
|
// Partial bridge (no openInApp) is not the companion app — a future
|
||||||
|
// embedder must still see the pitch.
|
||||||
|
w.ArchipelagoNative = { openExternal: () => {} }
|
||||||
|
openCompanionIntro()
|
||||||
|
expect(companionIntroRequested.value).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { isCompanionApp } from '@/utils/openExternal'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-view trigger for the Remote Companion intro/pairing modal
|
* Cross-view trigger for the Remote Companion intro/pairing modal
|
||||||
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
* (CompanionIntroOverlay, mounted once in Dashboard.vue). Views like the
|
||||||
* App Store banner call openCompanionIntro() to pop it on demand — this
|
* App Store banner call openCompanionIntro() to pop it on demand — this
|
||||||
* bypasses the once-per-browser auto-show gate.
|
* bypasses the once-per-browser auto-show gate.
|
||||||
|
*
|
||||||
|
* Inside the companion app's own WebView the whole pitch is nonsense — the
|
||||||
|
* user is already running it — so the trigger is a no-op there (#61: the
|
||||||
|
* auto-popup was gated, this manual path and CompanionBanner weren't).
|
||||||
*/
|
*/
|
||||||
export const companionIntroRequested = ref(false)
|
export const companionIntroRequested = ref(false)
|
||||||
|
|
||||||
export function openCompanionIntro(): void {
|
export function openCompanionIntro(): void {
|
||||||
|
if (isCompanionApp()) return
|
||||||
companionIntroRequested.value = true
|
companionIntroRequested.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Companion app banner — same format as the featured app banner, with a
|
<!-- Companion app banner — same format as the featured app banner, with a
|
||||||
phone mockup rising out of the right edge. Clicking anywhere (or the
|
phone mockup rising out of the right edge. Clicking anywhere (or the
|
||||||
Install button) opens the Remote Companion download/pairing modal. -->
|
Install button) opens the Remote Companion download/pairing modal.
|
||||||
|
Not rendered at all inside the companion app's WebView: pitching
|
||||||
|
"install the companion" to someone already in it is noise (#61). -->
|
||||||
<div
|
<div
|
||||||
|
v-if="showPitch"
|
||||||
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
class="featured-banner companion-banner glass-card mb-8 relative overflow-hidden cursor-pointer"
|
||||||
@click="openCompanionIntro()"
|
@click="openCompanionIntro()"
|
||||||
>
|
>
|
||||||
@@ -41,7 +44,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { isCompanionApp } from '@/utils/openExternal'
|
||||||
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
import { openCompanionIntro } from '@/composables/useCompanionIntro'
|
||||||
|
|
||||||
|
const showPitch = computed(() => !isCompanionApp())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user