feat(ui): companion modal pairing screen — scan a QR to auto-connect the app
Demo images / Build & push demo images (push) Successful in 2m45s
Demo images / Build & push demo images (push) Successful in 2m45s
The Remote Companion intro modal gains a second screen: an "I've installed it" button next to the download button slides over to a pairing QR the companion app scans to load the server details automatically, with a Back button to return. The QR is an archipelago://pair deep link carrying the server URL — the demo embeds https://demo.archipelago-foundation.org plus the shared demo password so a scan lands in a logged-in demo session; real nodes send only the address (kiosk advertises the mDNS .local name since its own origin is localhost). Phones also get a tap-to-open deep-link button since you can't scan your own screen. App-side contract + requirements handed off in docs/companion-pairing-qr.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1a3170f1c3
commit
3028685e6b
@@ -8,66 +8,120 @@
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors z-10"
|
||||
aria-label="Close companion modal"
|
||||
@click="dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Transition :name="slideName" mode="out-in">
|
||||
<!-- Screen 1: get the app -->
|
||||
<div v-if="step === 'download'" key="download">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download companion app
|
||||
</a>
|
||||
<div class="hidden md:flex justify-center">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="Companion app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
<div class="hidden md:flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="Companion app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download app
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showPairScreen"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download companion app
|
||||
</a>
|
||||
<!-- Screen 2: pair the app with this node -->
|
||||
<div v-else key="pair">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<path d="M13 13h3v3h-3zM17 17h3v3h-3z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
In the companion app, choose “Scan node code” and point your phone here — the server details fill in automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="pairQrDataUrl"
|
||||
:src="pairQrDataUrl"
|
||||
alt="Companion app pairing QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Same-device path: a QR on the phone's own screen can't be
|
||||
scanned, so offer the deep link directly on small screens. -->
|
||||
<a
|
||||
v-if="pairingUrl"
|
||||
:href="pairingUrl"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors mb-2"
|
||||
>
|
||||
Open in companion app
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showDownloadScreen"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -77,8 +131,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
@@ -90,8 +145,17 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
|
||||
// Deep-link scheme the companion app registers; carries the server entry the
|
||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||
const PAIR_SCHEME = 'archipelago://pair'
|
||||
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
|
||||
|
||||
const visible = ref(false)
|
||||
const step = ref<'download' | 'pair'>('download')
|
||||
const slideName = ref('slide-forward')
|
||||
const qrDataUrl = ref('')
|
||||
const pairQrDataUrl = ref('')
|
||||
const pairingUrl = ref('')
|
||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
@@ -151,8 +215,58 @@ watch(visible, async (isVisible) => {
|
||||
})
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
/**
|
||||
* The server URL the companion app should connect to. The demo advertises its
|
||||
* public https origin; a real node advertises whatever address the browser is
|
||||
* already using — except on the kiosk, where that is localhost and useless to
|
||||
* a phone, so fall back to the node's mDNS .local name.
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
const { hostname, origin } = window.location
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
|
||||
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
||||
} catch {
|
||||
// RPC unavailable — fall through to the (localhost) origin
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
async function buildPairingUrl(): Promise<string> {
|
||||
const params = new URLSearchParams({ v: '1', url: await resolveServerUrl() })
|
||||
// Only the shared demo password ever rides in the QR; real node passwords
|
||||
// are never available to the frontend and stay typed-by-hand in the app.
|
||||
if (IS_DEMO) params.set('pw', DEMO_PASSWORD)
|
||||
return `${PAIR_SCHEME}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function showPairScreen() {
|
||||
slideName.value = 'slide-forward'
|
||||
step.value = 'pair'
|
||||
if (!pairQrDataUrl.value) {
|
||||
pairingUrl.value = await buildPairingUrl()
|
||||
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
||||
width: 112,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function showDownloadScreen() {
|
||||
slideName.value = 'slide-back'
|
||||
step.value = 'download'
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
step.value = 'download'
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, '1')
|
||||
} catch {
|
||||
@@ -168,4 +282,14 @@ function dismiss() {
|
||||
.overlay-fade-leave-to { opacity: 0; }
|
||||
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
||||
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
|
||||
|
||||
/* Horizontal slide between the download and pairing screens */
|
||||
.slide-forward-enter-active,
|
||||
.slide-forward-leave-active,
|
||||
.slide-back-enter-active,
|
||||
.slide-back-leave-active { transition: transform 0.25s ease, opacity 0.25s ease; }
|
||||
.slide-forward-enter-from { transform: translateX(40px); opacity: 0; }
|
||||
.slide-forward-leave-to { transform: translateX(-40px); opacity: 0; }
|
||||
.slide-back-enter-from { transform: translateX(-40px); opacity: 0; }
|
||||
.slide-back-leave-to { transform: translateX(40px); opacity: 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user