Merge branch 'main' into gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf

# Conflicts:
#	.planning/config.json
This commit is contained in:
archipelago
2026-08-06 16:02:47 -04:00
26 changed files with 1438 additions and 67 deletions
@@ -256,8 +256,11 @@
<p v-if="effectiveKind === 'meshcore' && rfPreset" class="text-[11px] text-sky-300/80 mt-1">
MeshCore RF params for {{ selectedRegion?.code }} are applied to the radio automatically on connect.
</p>
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
<p v-if="effectiveKind === 'reticulum' && rnodePlan" class="text-[11px] text-sky-300/80 mt-1">
RNode plan for {{ form.region }}: {{ (rnodePlan.frequency / 1e6).toFixed(4) }} MHz, {{ rnodePlan.bandwidth / 1000 }} kHz, SF{{ rnodePlan.spreading_factor }}, CR4/{{ rnodePlan.coding_rate }}, {{ rnodePlan.txpower }} dBm applied on connect, and the radio confirms it.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
Pick a region to apply its recommended RNode RF plan on connect editable any time in Mesh Device settings.
</p>
</div>
@@ -302,7 +305,7 @@ import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
const mesh = useMeshStore()
@@ -373,6 +376,10 @@ const form = ref({
})
const selectedRegion = computed(() => regionByCode(form.value.region))
/** The chosen region's recommended RNode RF plan (undefined = none chosen). */
const rnodePlan = computed(() =>
form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined,
)
// The firmware whose options we surface: the probe result wins, else the
// last connected type, else meshtastic-style (where presets apply).
const effectiveKind = computed(() => {
@@ -501,6 +508,17 @@ async function applySetup() {
}
}
await mesh.configure(params)
// RNode radios: the region's recommended RF plan is applied through the
// Reticulum daemon's persisted settings (mesh.rnode-config-apply), the
// same round-trip the Device panel uses — the radio confirms the values
// itself after the daemon restarts with them. Best-effort here: a
// failure must not abort the connect the user just asked for.
if (effectiveKind.value === 'reticulum' && rnodePlan.value) {
mesh.suppressDeviceDetect()
void mesh
.applyRnodeConfig({ enabled: true, port: null, ...rnodePlan.value })
.catch(() => {})
}
mesh.dismissDetectedDevice(path)
void router.push('/dashboard/mesh')
} catch (e) {
+11
View File
@@ -356,9 +356,19 @@ export const useMeshStore = defineStore('mesh', () => {
// The modal waits for 2 sightings so it doesn't flash during the couple of
// seconds an ordinary reconnect (same radio, transient blip) needs.
const detectSightings = ref<Record<string, number>>({})
/** Epoch-ms until which the device-setup modal must NOT auto-open: an
* operator-initiated radio restart (settings apply, Reboot Radio) takes
* the radio down for ~15-20s, and the modal treated that healthy,
* expected gap as "a new stick was plugged in" and interrupted the flow
* (operator, 2026-08-06). */
const suppressDetectUntil = ref(0)
function suppressDeviceDetect(ms = 90_000) {
suppressDetectUntil.value = Date.now() + ms
}
const undismissedDetectedDevices = computed(() => {
const s = status.value
if (!s) return []
if (Date.now() < suppressDetectUntil.value) return []
return (s.detected_devices || []).filter(p =>
dismissedDetected.value[p] !== pluggedAt(s, p) &&
// The port the live session occupies is not a candidate…
@@ -1148,6 +1158,7 @@ export const useMeshStore = defineStore('mesh', () => {
latestBlockHeight,
fetchStatus,
undismissedDetectedDevices,
suppressDeviceDetect,
dismissDetectedDevice,
flashFlowPath,
openFlashFlow,
+24
View File
@@ -126,3 +126,27 @@ export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
{ id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
]
/** Recommended Reticulum RNode RF plan per region. Applied via
* mesh.rnode-config-apply (the daemon restarts the radio with these and the
* radio confirms them back). EU868 is the operator-validated Portugal plan:
* 869.4625 MHz sits in the 10%-duty 869.4869.65 sub-band clear of the
* default community channel, with the EU airtime locks written in. Others
* follow RNS community conventions with the region's legal power cap. */
export interface RnodeRegionPlan {
frequency: number
bandwidth: number
spreading_factor: number
coding_rate: number
txpower: number
airtime_limit_short: number | null
airtime_limit_long: number | null
}
export const RNODE_REGION_PLANS: Record<string, RnodeRegionPlan> = {
EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 },
US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null },
IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
}
+22 -1
View File
@@ -41,6 +41,7 @@
:refresh-key="refreshKey"
:blocked-reason="blockedReason"
:blocked-title="blockedTitle"
:warming-up="warmingUp"
:electrs-sync="electrsSync"
@iframe-load="onLoad"
@iframe-error="onError"
@@ -112,6 +113,7 @@ import {
initialDisplayMode, resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { PackageState } from '@/types/api'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
@@ -168,6 +170,25 @@ const appIcon = computed(() =>
: `/assets/img/app-icons/${appId.value}.png`
)
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
// A container that is up but not yet answering its probe is STARTING, not
// broken — bitcoind serves RPC error -28 for its whole warm-up and lnd is
// unreachable until the wallet unlocks, so both spent that window reading as
// a hard "App not reachable" failure. The retry machinery below already
// tolerates it (6 × 10s); this only makes the headline tell the truth while
// those retries are still in flight. Once they are exhausted, the failure is
// real again and the copy reverts.
const MAX_AUTO_RETRIES = 6
const warmingUp = computed(() =>
iframeBlocked.value &&
!mustOpenNewTab.value &&
!blockedReason.value &&
autoRetryCount.value < MAX_AUTO_RETRIES &&
(packageEntry.value?.state === PackageState.Running ||
packageEntry.value?.state === PackageState.Starting ||
packageEntry.value?.state === PackageState.Restarting ||
packageEntry.value?.health === 'starting')
)
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
// Reactive so the overlay/teleport/footer/animation decisions track the live
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
@@ -350,7 +371,7 @@ function onError() {
isRefreshing.value = false
iframeBlocked.value = true
// Auto-retry up to 6 times (60s total) for apps that are still starting
if (!mustOpenNewTab.value && autoRetryCount.value < 6) {
if (!mustOpenNewTab.value && autoRetryCount.value < MAX_AUTO_RETRIES) {
autoRetryId = setTimeout(() => {
autoRetryCount.value++
refresh()
@@ -68,14 +68,18 @@
<Transition name="content-fade">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
the padlock reads as "blocked/denied" and this state is neither. -->
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center overflow-hidden" :class="{ 'animate-pulse': warmingUp }">
<img v-if="warmingUp" :src="appIcon" :alt="appTitle" class="w-full h-full object-cover" @error="handleImageError" />
<svg v-else class="w-8 h-8 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6">
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
</p>
@@ -131,6 +135,9 @@ const props = defineProps<{
refreshKey: number
blockedReason?: string
blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the
// auto-retries are still in flight — a warm-up, not a failure.
warmingUp?: boolean
// Non-null only for ElectrumX while its index is still building — shows the
// sync screen and gates the iframe until status flips to "synced".
electrsSync?: ElectrsSyncStatus | null
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import AppSessionFrame from '../AppSessionFrame.vue'
// Regression cover for the operator-reported defect: a container that is up
// but has not finished warming up (bitcoind serving RPC -28, lnd before the
// wallet unlocks) rendered the hard "App not reachable" failure copy for the
// whole warm-up window. The retry machinery already tolerated it — only the
// headline lied.
function mountFrame(props: Record<string, unknown> = {}) {
return mount(AppSessionFrame, {
props: {
appUrl: 'http://localhost:8332/',
appId: 'bitcoin-knots',
appTitle: 'Bitcoin',
appIcon: '/icons/bitcoin.png',
loading: false,
iframeBlocked: true,
mustOpenNewTab: false,
autoRetryCount: 1,
refreshKey: 0,
...props,
},
global: { stubs: { AppLoadingScreen: true, Transition: false } },
})
}
describe('AppSessionFrame warm-up state', () => {
it('reads as starting, not unreachable, while the container is warming up', () => {
const text = mountFrame({ warmingUp: true }).text()
expect(text).toContain('Bitcoin is starting…')
expect(text).not.toContain('App not reachable')
})
it('says the container is running so the copy does not imply it is stopped', () => {
const text = mountFrame({ warmingUp: true }).text()
expect(text).toContain("container is running but hasn't finished warming up")
expect(text).not.toContain('the container is stopped')
})
it('still surfaces the automatic re-check while warming up', () => {
expect(mountFrame({ warmingUp: true, autoRetryCount: 3 }).text()).toContain(
'Checking again automatically (3)',
)
})
it('reverts to the real failure once warm-up is over (retries exhausted)', () => {
const text = mountFrame({ warmingUp: false, autoRetryCount: 6 }).text()
expect(text).toContain('App not reachable')
expect(text).not.toContain('is starting…')
})
it('leaves the explicit blocked-reason path untouched', () => {
const text = mountFrame({
warmingUp: false,
blockedReason: 'Waiting for Bitcoin to finish syncing.',
blockedTitle: 'Waiting for Bitcoin sync',
}).text()
expect(text).toContain('Waiting for Bitcoin sync')
expect(text).not.toContain('App not reachable')
})
it('leaves the new-tab path untouched', () => {
const text = mountFrame({ warmingUp: false, mustOpenNewTab: true }).text()
expect(text).toContain('This app opens in a new tab')
})
})
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { appOrigin, matchPageScheme } from '../appSessionConfig'
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
// nodes must be completely unaffected, which is what most of these pin.
function setLocation(protocol: string, hostname: string) {
Object.defineProperty(window, 'location', {
value: { protocol, hostname },
writable: true,
configurable: true,
})
}
afterEach(() => vi.unstubAllGlobals())
describe('appOrigin', () => {
it('stays on http for an http dashboard', () => {
setLocation('http:', 'archi-dev-box')
expect(appOrigin(8334)).toBe('http://archi-dev-box:8334')
})
it('follows an https dashboard onto the app port', () => {
setLocation('https:', 'archi-dev-box')
expect(appOrigin(8334)).toBe('https://archi-dev-box:8334')
})
it('keeps the hostname the user actually typed, not a fixed name', () => {
setLocation('https:', '100.69.68.39')
expect(appOrigin(3000)).toBe('https://100.69.68.39:3000')
})
})
describe('matchPageScheme', () => {
it('leaves backend-reported http URLs alone on an http page', () => {
setLocation('http:', 'node')
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
})
it('upgrades a backend-reported http URL on an https page', () => {
setLocation('https:', 'node')
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
})
it('does not touch anything but the scheme', () => {
setLocation('https:', 'node')
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
})
it('leaves an already-https URL untouched', () => {
setLocation('https:', 'node')
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
})
})
@@ -107,11 +107,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
// shell when proxied under a path prefix on some nodes.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
return 'http://' + window.location.hostname + ':8334'
return appOrigin(8334)
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
// The backend reports runtime URLs as http:// because that is how the app
// binds locally. Sent to a browser on an HTTPS dashboard that is mixed
// content and the frame is blocked outright, so follow the page instead.
base = matchPageScheme(base)
if (routeQueryPath) base += routeQueryPath
return base
}
@@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const port = APP_PORTS[id]
if (!port) return ''
let base = 'http://' + window.location.hostname + ':' + String(port)
let base = appOrigin(port)
if (routeQueryPath) base += routeQueryPath
return base
}
/**
* An app's origin on this host, on the SAME scheme as the page.
*
* An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as
* mixed content before any cookie question arises — and it is also what makes
* the two origins schemefully cross-site, so the session cookie is withheld.
* Following the page's scheme fixes both at once and keeps plain HTTP working
* exactly as before on nodes that serve the dashboard over HTTP.
*
* On HTTPS this requires the app port to actually serve TLS with a certificate
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
* Node certificate. A certificate warning cannot be accepted inside an iframe,
* so an untrusted app port renders nothing rather than prompting.
*/
export function appOrigin(port: number): string {
return `${pageScheme()}//${window.location.hostname}:${port}`
}
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
export function matchPageScheme(url: string): string {
if (pageScheme() !== 'https:') return url
return url.replace(/^http:\/\//i, 'https://')
}
/**
* The page's scheme, defaulting to http.
*
* A real browser always has location.protocol; this defends the non-browser
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
* is the safe direction — it preserves today's behaviour rather than inventing
* an https URL for a port that may not serve TLS.
*/
function pageScheme(): string {
const p = window.location?.protocol
return p === 'https:' || p === 'http:' ? p : 'http:'
}
/** Resolve a human-readable title for an app */
export function resolveAppTitle(id: string): string {
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
+6 -14
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS } from '@/utils/loraRegions'
import { LORA_REGIONS, regionByCode, meshcorePlanFor, MESHCORE_RF_PRESETS, RNODE_REGION_PLANS } from '@/utils/loraRegions'
const mesh = useMeshStore()
@@ -13,6 +13,8 @@ async function handleReboot() {
rebooting.value = true
rebootError.value = null
rebootMessage.value = null
// Same as apply: the radio goes away on purpose for ~15-20s.
mesh.suppressDeviceDetect()
try {
const res = await mesh.rebootRadio()
// The backend now waits for the device's acknowledgement and says what
@@ -26,19 +28,6 @@ async function handleReboot() {
}
// ── RNode (Reticulum) RF settings — full round-trip with device read-back ──
// Recommended plans per region for Reticulum RNode radios. EU868 is the
// operator-validated Portugal plan (869.4625 MHz keeps clear of the default
// community channel while staying in the 10%-duty 869.4869.65 sub-band;
// airtime locks match EU duty-cycle law). Others use the RNS community
// conventions for the band with the region's legal power cap.
const RNODE_REGION_PLANS: Record<string, { frequency: number; bandwidth: number; spreading_factor: number; coding_rate: number; txpower: number; airtime_limit_short: number | null; airtime_limit_long: number | null }> = {
EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 },
US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null },
IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
}
const rnodeForm = ref({
enabled: true,
@@ -101,6 +90,9 @@ async function loadRnodeConfig() {
async function applyRnodeSettings() {
rnodeApplying.value = true
rnodeResult.value = null
// Applying deliberately restarts the radio daemon; without this the
// "new device detected" modal interrupts the flow mid-apply.
mesh.suppressDeviceDetect()
try {
const res = await mesh.applyRnodeConfig({
enabled: rnodeForm.value.enabled,
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
// This node signs its own certificates with a CA that never leaves it. Install
// that CA once per device and every port on this node is trusted — which is what
// lets a gated app load inside the dashboard's frame at all: a cert warning
// cannot be clicked through inside an iframe, so an untrusted app port simply
// fails to render.
const fingerprint = ref('')
const fingerprintError = ref('')
const loading = ref(true)
const caAvailable = ref(false)
// SHA-256 over the DER bytes — the same number `openssl x509 -fingerprint
// -sha256` prints, so the two can be compared character for character.
async function computeFingerprint(pem: string): Promise<string> {
const body = pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, '')
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))
const digest = await crypto.subtle.digest('SHA-256', der)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
.join(':')
}
onMounted(async () => {
try {
const res = await fetch('/ca.crt', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const pem = await res.text()
if (!pem.includes('BEGIN CERTIFICATE')) throw new Error('not a certificate')
caAvailable.value = true
// crypto.subtle only exists in a secure context. That is exactly the case
// this feature is meant to fix, so an HTTP dashboard lands here — say so
// and give the offline command rather than showing nothing.
if (!window.crypto?.subtle) {
fingerprintError.value =
'The fingerprint cannot be computed over a plain HTTP connection. Verify it on the node instead: openssl x509 -in /etc/archipelago/ssl/ca.crt -noout -fingerprint -sha256'
} else {
fingerprint.value = await computeFingerprint(pem)
}
} catch {
caAvailable.value = false
} finally {
loading.value = false
}
})
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Node certificate</h3>
<p class="text-sm text-white/60 mb-4">
Install this node's certificate on a device and it stops warning you about
this node — on every port, not just the dashboard. Apps that open inside
the dashboard need this: a certificate warning cannot be accepted inside an
embedded frame, so an untrusted app shows nothing at all.
</p>
<div v-if="loading" class="text-sm text-white/50">Checking…</div>
<div
v-else-if="!caAvailable"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
This node has not generated a certificate authority yet. Run
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
on the node, then reload this page.
</div>
<div v-else class="space-y-4">
<div>
<a
href="/ca.crt"
download="archipelago-node-ca.crt"
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download this node's certificate
</a>
</div>
<div>
<p class="text-sm font-medium text-white/80 mb-1">Fingerprint (SHA-256)</p>
<p v-if="fingerprint" class="font-mono text-xs text-white/70 break-all select-all">{{ fingerprint }}</p>
<p v-else class="text-xs text-orange-300/80">{{ fingerprintError }}</p>
<p class="text-xs text-white/50 mt-2">
Check this matches the fingerprint the node itself prints before you trust
it. If they differ, something is intercepting the connection do not install it.
</p>
</div>
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
How to install it
</summary>
<div class="mt-2 space-y-3 text-sm text-white/60">
<p><strong class="text-white/80">macOS</strong> open the file, add it to the
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
and set When using this certificate to <em>Always Trust</em>.</p>
<p><strong class="text-white/80">iOS / iPadOS</strong> download it in Safari and
allow the profile, then Settings General VPN &amp; Device Management to
install it, and finally Settings General About Certificate Trust Settings
to switch it on. Both steps are required.</p>
<p><strong class="text-white/80">Windows</strong> right-click Install
Certificate Local Machine place it in <em>Trusted Root Certification
Authorities</em>.</p>
<p><strong class="text-white/80">Android</strong> Settings Security
Encryption &amp; credentials Install a certificate CA certificate.</p>
<p><strong class="text-white/80">Linux</strong> copy to
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
Firefox keeps its own store add it under Settings Privacy &amp; Security
View Certificates Authorities.</p>
<p class="text-white/50">
You are trusting this node, not a company. The signing key stays on the node
and only ever signs this node's own address. Anyone who takes the node also
takes that key remove the certificate from your devices if you retire it.
</p>
</div>
</details>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
</script>
@@ -16,6 +17,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<AIDataAccessSection />
<WebhookSection />
<TelemetrySection />
<NodeCertificateSection />
<BackupSection />
<SystemDangerZone />
</template>