feat(ui): companion modal pairing screen — scan a QR to auto-connect the app
All checks were successful
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:
archipelago 2026-07-16 20:07:56 -04:00
parent 1a3170f1c3
commit 3028685e6b
2 changed files with 252 additions and 47 deletions

View File

@ -0,0 +1,81 @@
# Companion app pairing QR — integration handoff
**Status:** web-UI side SHIPPED (CompanionIntroOverlay.vue, 2026-07-16). This doc is
the contract + requirements for the companion-app side (worked on separately, on
the Mac).
## What the web UI now does
The "Remote Companion" intro modal (shown once after first dashboard login, and in
the public demo) gained a second screen:
1. **Screen 1 (existing):** APK download QR (desktop) / download button, plus a new
**"I've installed it"** button to the right of the download button.
2. **Screen 2 (new, slide transition):** a **pairing QR** the companion app scans to
auto-fill the server entry, with a **Back** button returning to screen 1. On
small screens (where you can't scan your own display) an
**"Open in companion app"** deep-link button is shown above Back, using the same
URI as the QR.
## The QR payload / deep link (the contract)
A single URI, also usable as an OS deep link:
```
archipelago://pair?v=1&url=<percent-encoded server URL>[&pw=<percent-encoded password>]
```
Query parameters:
| param | required | meaning |
|-------|----------|---------|
| `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". |
| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. |
| `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. |
Examples the web UI actually emits:
- Demo: `archipelago://pair?v=1&url=https%3A%2F%2Fdemo.archipelago-foundation.org&pw=entertoexit`
- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.168.1.228`
- Real node kiosk (UI runs on localhost, so it advertises the mDNS name from
`system.get-hostname`): `archipelago://pair?v=1&url=http%3A%2F%2Farchipelago.local`
## Companion app requirements
1. **Scan entry point:** a "Scan node code" action (screen-2 copy in the web UI
says: *"In the companion app, choose 'Scan node code' and point your phone
here"* — keep that wording or tell me the real label so I update the modal).
2. **Parse the URI** (from camera scan AND from an OS deep-link intent —
register the `archipelago://` scheme so the "Open in companion app" button on
phones works).
3. On success, **create/update a saved server entry**:
- Server address = `url` exactly as given (respect the scheme — the demo is
https, LAN nodes are typically http, `.local` mDNS names must work).
- If `pw` present, prefill the password and attempt auto-login; otherwise land
on the password prompt for that server.
- If an entry with the same origin already exists, update it rather than
duplicating.
4. **Demo flow (the showcase):** scanning the demo QR should take a fresh install
to a logged-in demo session in one step — url `https://demo.archipelago-foundation.org`,
password `entertoexit`, no manual typing.
5. **Robustness:**
- Tolerate unknown extra query params (forward compat — we may add `name`,
`cert` fingerprint, etc. under `v=1`).
- Self-signed HTTPS on `.local`/LAN addresses may appear later; don't hard-fail
the parse on scheme.
- Bad/foreign QR → clear error, stay on the scan screen.
## Notes / future extensions (not in v1)
- A real-node pairing **token** instead of a password (backend mints a one-time
token, QR carries it, app exchanges it for a session) — needs a backend RPC;
the `v` param exists so we can bump when this lands.
- Optional `name` param (node display name) so the app labels the entry nicely.
## Testing checklist (app side)
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
- [ ] Scan a real node's QR (LAN IP origin) → entry created, password prompt shown.
- [ ] Scan a kiosk node's QR (`http://<name>.local`) → mDNS resolution works on the phone.
- [ ] Tap "Open in companion app" on a phone browser → deep link opens the app with the same behavior.
- [ ] Re-scan same node → no duplicate entry.

View File

@ -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"
>
&times;
</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>