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
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:
parent
1a3170f1c3
commit
3028685e6b
81
docs/companion-pairing-qr.md
Normal file
81
docs/companion-pairing-qr.md
Normal 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.
|
||||||
@ -8,66 +8,120 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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"
|
aria-label="Close companion modal"
|
||||||
@click="dismiss"
|
@click="dismiss"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<Transition :name="slideName" mode="out-in">
|
||||||
<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">
|
<!-- Screen 1: get the app -->
|
||||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<div v-if="step === 'download'" key="download">
|
||||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
<div class="flex items-start gap-4 mb-4">
|
||||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
<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">
|
||||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||||
</svg>
|
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||||
</div>
|
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||||
<div class="min-w-0 flex-1">
|
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
</svg>
|
||||||
<p class="text-sm text-white/60 leading-relaxed">
|
</div>
|
||||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
<div class="min-w-0 flex-1">
|
||||||
</p>
|
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||||
</div>
|
<p class="text-sm text-white/60 leading-relaxed">
|
||||||
</div>
|
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="hidden md:flex justify-center mb-4">
|
||||||
<a
|
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||||
:href="companionDownloadUrl"
|
<img
|
||||||
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"
|
v-if="qrDataUrl"
|
||||||
target="_blank"
|
:src="qrDataUrl"
|
||||||
rel="noopener noreferrer"
|
alt="Companion app download QR code"
|
||||||
>
|
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||||
Download companion app
|
/>
|
||||||
</a>
|
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||||
<div class="hidden md:flex justify-center">
|
</div>
|
||||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
</div>
|
||||||
<img
|
|
||||||
v-if="qrDataUrl"
|
<div class="flex gap-2">
|
||||||
:src="qrDataUrl"
|
<a
|
||||||
alt="Companion app download QR code"
|
:href="companionDownloadUrl"
|
||||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
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"
|
||||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
<!-- Screen 2: pair the app with this node -->
|
||||||
:href="companionDownloadUrl"
|
<div v-else key="pair">
|
||||||
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"
|
<div class="flex items-start gap-4 mb-4">
|
||||||
target="_blank"
|
<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">
|
||||||
rel="noopener noreferrer"
|
<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" />
|
||||||
Download companion app
|
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||||
</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@ -77,8 +131,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import * as QRCode from 'qrcode'
|
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 { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
// 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`
|
? `${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'
|
: '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 visible = ref(false)
|
||||||
|
const step = ref<'download' | 'pair'>('download')
|
||||||
|
const slideName = ref('slide-forward')
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
|
const pairQrDataUrl = ref('')
|
||||||
|
const pairingUrl = ref('')
|
||||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||||
|
|
||||||
const loginTransition = useLoginTransitionStore()
|
const loginTransition = useLoginTransitionStore()
|
||||||
@ -151,8 +215,58 @@ watch(visible, async (isVisible) => {
|
|||||||
})
|
})
|
||||||
}, { immediate: true, flush: 'post' })
|
}, { 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() {
|
function dismiss() {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
|
step.value = 'download'
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, '1')
|
localStorage.setItem(STORAGE_KEY, '1')
|
||||||
} catch {
|
} catch {
|
||||||
@ -168,4 +282,14 @@ function dismiss() {
|
|||||||
.overlay-fade-leave-to { opacity: 0; }
|
.overlay-fade-leave-to { opacity: 0; }
|
||||||
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
||||||
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
|
.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>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user