Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch #149

Merged
lfg2025 merged 10 commits from companion/0.5.28-ship into main 2026-08-31 18:38:44 +00:00
4 changed files with 65 additions and 3 deletions
Showing only changes of commit d259f3cbb9 - Show all commits
@@ -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>