feat(ui): remote-access steps in the companion pairing flow
After 'I've installed it' the modal now walks WireGuard setup before pairing: install WireGuard (hosted APK + QR), scan the node's tunnel config (auto-provisioned 'companion-phone' peer via the existing vpn.create-peer/peer-config RPCs, rendered client-side), switch it on, then pair. Real nodes pair against http://10.44.0.1 — the tunnel only routes AllowedIPs 10.44.0.0/16, so the LAN origin is unreachable remotely. The demo fakes the tunnel via mock RPCs and keeps its public pairing URL. REMOTE_ACCESS_STEPS=false restores the old two-screen flow. Phones get the config as a downloadable .conf (can't scan own screen). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
299f7d8f39
commit
68c25534d4
@ -2239,6 +2239,33 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
bytes_out: 642_889_310,
|
bytes_out: 642_889_310,
|
||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
// Fake tunnel provisioning so the companion remote-access onboarding
|
||||||
|
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
|
||||||
|
// are not real; importing this config yields a harmless dead tunnel.
|
||||||
|
case 'vpn.list-peers': {
|
||||||
|
return res.json({ result: { peers: mockState.vpnPeers || [] } })
|
||||||
|
}
|
||||||
|
case 'vpn.create-peer':
|
||||||
|
case 'vpn.peer-config': {
|
||||||
|
const peerName = params?.name || 'device'
|
||||||
|
if (!mockState.vpnPeers) mockState.vpnPeers = []
|
||||||
|
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
|
||||||
|
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
|
||||||
|
}
|
||||||
|
const config = [
|
||||||
|
'[Interface]',
|
||||||
|
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
|
||||||
|
'Address = 10.44.0.7/32',
|
||||||
|
'DNS = 1.1.1.1',
|
||||||
|
'',
|
||||||
|
'[Peer]',
|
||||||
|
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
|
||||||
|
'Endpoint = demo.archipelago-foundation.org:51820',
|
||||||
|
'AllowedIPs = 10.44.0.0/16',
|
||||||
|
'PersistentKeepalive = 25',
|
||||||
|
].join('\n')
|
||||||
|
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
|
||||||
|
}
|
||||||
|
|
||||||
// Node visibility — interactive (persisted per demo session)
|
// Node visibility — interactive (persisted per demo session)
|
||||||
case 'network.get-visibility': {
|
case 'network.get-visibility': {
|
||||||
|
|||||||
@ -66,13 +66,130 @@
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
@click="advanceFromDownload"
|
||||||
>
|
>
|
||||||
I've installed it
|
I've installed it
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen 2 (remote access): install WireGuard -->
|
||||||
|
<div v-else-if="step === 'wireguard'" key="wireguard">
|
||||||
|
<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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
|
||||||
|
<p class="text-sm text-white/60 leading-relaxed">
|
||||||
|
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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="wgApkQrDataUrl"
|
||||||
|
:src="wgApkQrDataUrl"
|
||||||
|
alt="WireGuard 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="wireguardDownloadUrl"
|
||||||
|
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
|
||||||
|
>
|
||||||
|
Download WireGuard
|
||||||
|
</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="showWgQrScreen"
|
||||||
|
>
|
||||||
|
I've installed it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full mt-2 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>
|
||||||
|
|
||||||
|
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
|
||||||
|
<div v-else-if="step === 'wgqr'" key="wgqr">
|
||||||
|
<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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
|
||||||
|
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
|
||||||
|
<p class="text-sm text-white/60 leading-relaxed">
|
||||||
|
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center mb-3">
|
||||||
|
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="wgQrDataUrl"
|
||||||
|
:src="wgQrDataUrl"
|
||||||
|
alt="WireGuard tunnel config 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 flex items-center justify-center">
|
||||||
|
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
|
||||||
|
|
||||||
|
<!-- Same-device path: a phone can't scan its own screen, so offer
|
||||||
|
the config as a file WireGuard can import. -->
|
||||||
|
<button
|
||||||
|
v-if="wgConfig"
|
||||||
|
type="button"
|
||||||
|
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
|
||||||
|
@click="downloadWgConfig"
|
||||||
|
>
|
||||||
|
Download tunnel config
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="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 mb-2"
|
||||||
|
@click="showPairScreen"
|
||||||
|
>
|
||||||
|
I'm connected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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="showWireguardScreen('back')"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Screen 2: pair the app with this node -->
|
<!-- Screen 2: pair the app with this node -->
|
||||||
<div v-else key="pair">
|
<div v-else key="pair">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4">
|
||||||
@ -119,7 +236,7 @@
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
@click="backFromPair"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
@ -154,13 +271,43 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
|||||||
const PAIR_SCHEME = 'archipelago://pair'
|
const PAIR_SCHEME = 'archipelago://pair'
|
||||||
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
|
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
|
||||||
|
|
||||||
|
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
|
||||||
|
// between "I've installed it" and the pairing QR. Real nodes pair against the
|
||||||
|
// node's VPN address instead of the LAN origin; the demo walks the same steps
|
||||||
|
// with a mock tunnel config (its pairing QR still carries the public demo
|
||||||
|
// URL). Set to false to restore the original two-screen flow (instant revert).
|
||||||
|
const REMOTE_ACCESS_STEPS = true
|
||||||
|
|
||||||
|
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
|
||||||
|
// 10.44.0.0/16 — core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
|
||||||
|
// the node is reachable exclusively at its tunnel address. The pairing QR
|
||||||
|
// must therefore advertise this, not the browser's LAN origin. The flow has
|
||||||
|
// the user switch the tunnel on right before pairing, so it validates.
|
||||||
|
const VPN_NODE_URL = 'http://10.44.0.1'
|
||||||
|
|
||||||
|
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
|
||||||
|
// sha256 f92971bc…, mirrored from download.wireguard.com), served like the
|
||||||
|
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
|
||||||
|
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
|
||||||
|
? `${window.location.origin}/packages/wireguard.apk`
|
||||||
|
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
|
||||||
|
|
||||||
|
// Name of the auto-created VPN peer for the phone running the companion.
|
||||||
|
const WG_PEER_NAME = 'companion-phone'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const step = ref<'download' | 'pair'>('download')
|
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download')
|
||||||
const slideName = ref('slide-forward')
|
const slideName = ref('slide-forward')
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
const pairQrDataUrl = ref('')
|
const pairQrDataUrl = ref('')
|
||||||
const pairingUrl = ref('')
|
const pairingUrl = ref('')
|
||||||
|
const wgApkQrDataUrl = ref('')
|
||||||
|
const wgQrDataUrl = ref('')
|
||||||
|
const wgConfig = ref('')
|
||||||
|
const wgLoading = ref(false)
|
||||||
|
const wgError = 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 wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
|
||||||
|
|
||||||
const loginTransition = useLoginTransitionStore()
|
const loginTransition = useLoginTransitionStore()
|
||||||
|
|
||||||
@ -232,6 +379,17 @@ watch(visible, async (isVisible) => {
|
|||||||
light: '#ffffff',
|
light: '#ffffff',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
|
||||||
|
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
|
||||||
|
width: 512,
|
||||||
|
margin: 2,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
color: {
|
||||||
|
dark: '#111111',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}, { immediate: true, flush: 'post' })
|
}, { immediate: true, flush: 'post' })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -242,6 +400,9 @@ watch(visible, async (isVisible) => {
|
|||||||
*/
|
*/
|
||||||
async function resolveServerUrl(): Promise<string> {
|
async function resolveServerUrl(): Promise<string> {
|
||||||
if (IS_DEMO) return DEMO_SERVER_URL
|
if (IS_DEMO) return DEMO_SERVER_URL
|
||||||
|
// Remote-access flow: the app must connect through the tunnel the user just
|
||||||
|
// switched on — the LAN origin is unreachable from outside AllowedIPs.
|
||||||
|
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
|
||||||
const { hostname, origin } = window.location
|
const { hostname, origin } = window.location
|
||||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
||||||
try {
|
try {
|
||||||
@ -285,6 +446,77 @@ function showDownloadScreen() {
|
|||||||
step.value = 'download'
|
step.value = 'download'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function advanceFromDownload() {
|
||||||
|
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
|
||||||
|
else void showPairScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
|
||||||
|
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
|
||||||
|
step.value = 'wireguard'
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWgQrScreen() {
|
||||||
|
slideName.value = 'slide-forward'
|
||||||
|
step.value = 'wgqr'
|
||||||
|
void loadWgPeer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function backFromPair() {
|
||||||
|
if (REMOTE_ACCESS_STEPS) {
|
||||||
|
slideName.value = 'slide-back'
|
||||||
|
step.value = 'wgqr'
|
||||||
|
} else {
|
||||||
|
showDownloadScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create (or fetch) the phone's VPN peer and render its config as a QR.
|
||||||
|
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
|
||||||
|
// looked up first so reopening the modal never duplicates it.
|
||||||
|
async function loadWgPeer() {
|
||||||
|
if (wgQrDataUrl.value || wgLoading.value) return
|
||||||
|
wgLoading.value = true
|
||||||
|
wgError.value = ''
|
||||||
|
try {
|
||||||
|
const listed = await rpcClient
|
||||||
|
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||||
|
.catch(() => ({ peers: [] as { name: string }[] }))
|
||||||
|
const exists = (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||||
|
const res = await rpcClient.call<{ config: string; peer_ip: string }>({
|
||||||
|
method: exists ? 'vpn.peer-config' : 'vpn.create-peer',
|
||||||
|
params: { name: WG_PEER_NAME },
|
||||||
|
})
|
||||||
|
wgConfig.value = res.config
|
||||||
|
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||||
|
width: 512,
|
||||||
|
margin: 3,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
color: {
|
||||||
|
dark: '#111111',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
wgError.value = e instanceof Error ? e.message : 'Failed to generate the tunnel config'
|
||||||
|
} finally {
|
||||||
|
wgLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-device path: hand the config to the WireGuard app as an importable
|
||||||
|
// .conf file (a phone can't scan its own screen).
|
||||||
|
function downloadWgConfig() {
|
||||||
|
if (!wgConfig.value) return
|
||||||
|
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'archipelago.conf'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
step.value = 'download'
|
step.value = 'download'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user