Merge remote-tracking branch 'origin/main' into ark-merge

This commit is contained in:
Dorian
2026-07-14 22:08:55 +01:00
142 changed files with 7738 additions and 1336 deletions
+12
View File
@@ -37,6 +37,12 @@
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
@@ -88,6 +94,9 @@ import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
@@ -360,6 +369,9 @@ function onVisibilityChange() {
onMounted(async () => {
syncKioskSafeArea()
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
// setup modal on any page (the Mesh view's own poll takes over there).
useMeshStore().startGlobalDetection()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
+9 -3
View File
@@ -117,8 +117,12 @@ class RPCClient {
try { localStorage.removeItem('neode-auth') } catch { /* noop */ }
const isOnboarding = window.location.pathname.startsWith('/onboarding')
// Already on the login page: redirecting = a full reload of the
// page we're on. Any background poll that 401s would refresh
// /login in a loop forever (seen with the global mesh poll).
const isLogin = window.location.pathname.startsWith('/login')
console.warn(`[RPC] 401 on ${method} | path=${window.location.pathname} | onboarding=${isOnboarding} | redirecting=${RPCClient._sessionExpiredRedirecting}`)
if (!isOnboarding && !RPCClient._sessionExpiredRedirecting) {
if (!isOnboarding && !isLogin && !RPCClient._sessionExpiredRedirecting) {
RPCClient._sessionExpiredRedirecting = true
console.warn(`[RPC] Session expired — redirecting to /login in 300ms`)
setTimeout(() => {
@@ -668,10 +672,12 @@ class RPCClient {
}
// Federation
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
async federationInvite(
trustLevel: 'trusted' | 'observer' = 'trusted'
): Promise<{ code: string; did: string; onion: string; trust_level: string }> {
return this.call({
method: 'federation.invite',
params: {},
params: { trust_level: trustLevel },
})
}
@@ -77,12 +77,17 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import * as QRCode from 'qrcode'
import { IS_DEMO } from '@/composables/useDemoIntro'
const STORAGE_KEY = 'neode_companion_intro_seen'
// Absolute URL so the QR works when scanned by a phone (a relative path has no
// host to resolve). Points at the companion APK hosted on the 146 release server
// (publicly reachable) rather than the local node's /packages copy.
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
// The demo serves the APK from its own public origin instead, so the QR never
// exposes the release-server address.
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'
const visible = ref(false)
const qrDataUrl = ref('')
@@ -35,7 +35,7 @@
<div class="flex gap-2 mt-6">
<button
type="button"
class="flex-1 rounded-lg bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white text-sm font-semibold py-2.5 transition-colors"
class="flex-1 glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-semibold py-2.5"
:disabled="loading || !selected"
@click="confirm"
>
@@ -135,7 +135,7 @@
<!-- Open Channel Modal -->
<Teleport to="body">
<div v-if="showOpenModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div v-if="showOpenModal" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@@ -233,7 +233,7 @@
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
// Global "back up your Lightning seed" nudge. The full backup flow lives on
// the LND app-details page (views/appDetails/LndSeedBackup.vue); this banner
// just surfaces it proactively once a wallet seed exists and hasn't been
// acknowledged as backed up yet.
//
// lnd.seed-backup-status → { available, acknowledged }:
// available=false → no captured seed (LND not installed / legacy wallet) → stay quiet
// acknowledged=true → user already confirmed the backup → stay quiet
// available && !acknowledged → prompt
const SNOOZE_KEY = 'lnd-seed-backup-prompt-snooze-until'
const SNOOZE_MS = 24 * 60 * 60 * 1000
const POLL_MS = 5 * 60 * 1000
const LND_DETAILS_PATH = '/dashboard/apps/lnd'
const router = useRouter()
const route = useRoute()
const appStore = useAppStore()
const needsBackup = ref(false)
const snoozedUntil = ref(readSnooze())
let pollTimer: ReturnType<typeof setInterval> | null = null
// Once the backend says acked (or there is no seed to back up), stop polling
// for the rest of the session — nothing left for this banner to do.
let settled = false
function readSnooze(): number {
try {
const raw = localStorage.getItem(SNOOZE_KEY)
const ts = raw ? Number(raw) : 0
return Number.isFinite(ts) ? ts : 0
} catch {
return 0
}
}
function isAuthed(): boolean {
// Same guard as useMessageToast — never poll unauthenticated (background
// 401s on the login page cause refresh loops).
try { return localStorage.getItem('neode-auth') === 'true' } catch { return false }
}
async function pollStatus() {
if (settled || !isAuthed()) return
try {
const res = await rpcClient.call<{ available: boolean; acknowledged: boolean }>({
method: 'lnd.seed-backup-status',
timeout: 5000,
})
if (!res.available || res.acknowledged) {
needsBackup.value = false
settled = true
stopPolling()
} else {
needsBackup.value = true
}
} catch {
// Transient RPC failure — keep the current state, retry on next tick.
}
}
function startPolling() {
if (pollTimer || settled) return
void pollStatus()
pollTimer = setInterval(() => {
if (document.visibilityState === 'visible') void pollStatus()
}, POLL_MS)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
watch(() => appStore.isAuthenticated, (authed) => {
if (authed) startPolling()
else {
stopPolling()
needsBackup.value = false
}
}, { immediate: true })
// The LND details page renders its own prominent backup card — hide the
// banner there, and re-check status when the user navigates away so an ack
// made on that page hides the banner immediately (not after the next tick).
const onLndPage = computed(() => route.path === LND_DETAILS_PATH)
watch(onLndPage, (now, was) => {
if (was && !now && !settled && isAuthed()) void pollStatus()
})
const show = computed(() =>
needsBackup.value && !onLndPage.value && Date.now() >= snoozedUntil.value,
)
function backUpNow() {
router.push(LND_DETAILS_PATH).catch(() => {})
}
function snooze() {
const until = Date.now() + SNOOZE_MS
snoozedUntil.value = until
try { localStorage.setItem(SNOOZE_KEY, String(until)) } catch { /* noop */ }
}
onBeforeUnmount(stopPolling)
</script>
<template>
<Teleport to="body">
<Transition name="seed-banner">
<div
v-if="show"
role="alert"
class="fixed bottom-4 left-4 right-4 z-[95] mx-auto w-auto max-w-lg rounded-xl border border-orange-400/30 p-4 seed-banner-glass md:left-auto md:right-6 md:bottom-6"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86l-8.4 14.55A1.5 1.5 0 003.19 21h17.62a1.5 1.5 0 001.3-2.59l-8.4-14.55a1.5 1.5 0 00-2.62 0z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">Back up your Lightning wallet seed</p>
<p class="mt-0.5 text-sm text-white/70">
Your Lightning wallet has a recovery seed that hasn't been backed up yet.
Write it down now so your funds survive a disk failure.
</p>
<div class="mt-3 flex gap-2">
<button
type="button"
class="glass-button glass-button-warning rounded-lg px-4 py-2 text-sm font-medium"
@click="backUpNow"
>Back up now</button>
<button
type="button"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="snooze"
>Later</button>
</div>
</div>
<button
type="button"
aria-label="Dismiss for now"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
@click="snooze"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.seed-banner-glass {
background: rgba(15, 15, 20, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.seed-banner-enter-active,
.seed-banner-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.seed-banner-enter-from,
.seed-banner-leave-to {
opacity: 0;
transform: translateY(12px);
}
</style>
+5 -1
View File
@@ -3,10 +3,12 @@
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
tabindex="-1"
data-controller-ignore
class="w-10 h-6 rounded-full shrink-0 transition-colors relative"
:class="modelValue ? 'bg-orange-500' : 'bg-white/15'"
:class="[modelValue ? 'bg-orange-500' : 'bg-white/15', disabled ? 'opacity-40 cursor-not-allowed' : '']"
@click="$emit('update:modelValue', !modelValue)"
>
<div
@@ -19,6 +21,8 @@
<script setup lang="ts">
defineProps<{
modelValue: boolean
disabled?: boolean
ariaLabel?: string
}>()
defineEmits<{
+45 -2
View File
@@ -1,12 +1,32 @@
<template>
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. -->
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap">
<button
v-for="f in filters"
:key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@click="activeFilter = f.key"
>
{{ f.label }}<span v-if="countFor(f.key)" class="text-white/35"> · {{ countFor(f.key) }}</span>
</button>
</div>
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div>
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
<div
v-for="tx in transactions"
v-for="tx in filteredTransactions"
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
@@ -75,6 +95,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
@@ -93,7 +114,7 @@ interface WalletTransaction {
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
}
defineProps<{
const props = defineProps<{
show: boolean
transactions: WalletTransaction[]
}>()
@@ -101,6 +122,28 @@ defineProps<{
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
const filters: Array<{ key: FilterKey; label: string }> = [
{ key: 'all', label: 'All' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'lightning', label: '⚡ Lightning' },
{ key: 'ecash', label: 'Ecash' },
]
const activeFilter = ref<FilterKey>('all')
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
if (f === 'all') return true
if (f === 'onchain') return isOnchain(tx)
if (f === 'lightning') return tx.kind === 'lightning'
return tx.kind === 'cashu' || tx.kind === 'fedimint'
}
const filteredTransactions = computed(() => props.transactions.filter(tx => matchesFilter(tx, activeFilter.value)))
function countFor(f: FilterKey): number {
if (f === 'all') return 0
return props.transactions.filter(tx => matchesFilter(tx, f)).length
}
function close() {
emit('close')
}
@@ -0,0 +1,66 @@
<template>
<BaseModal
:show="show"
title="Request to Peer"
max-width="max-w-md"
:z-index="'z-[3200]'"
@close="$emit('cancel')"
>
<p class="text-white/70 text-sm mb-1">
Send a connection request to
<span class="text-white font-medium">{{ targetLabel }}</span>
</p>
<p class="text-white/45 text-xs mb-4">
They'll see your request and approve or decline it. Approved peers connect
at the Peer level never trusted automatically.
</p>
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
<textarea
v-model="message"
rows="3"
maxlength="280"
placeholder="Hey — mind if we peer?"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60 resize-none"
></textarea>
<p class="text-[11px] text-white/30 text-right mt-1">{{ message.length }}/280</p>
<div class="flex gap-2 mt-4">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('cancel')">
Cancel
</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="sending"
@click="$emit('send', message.trim() || undefined)"
>
{{ sending ? 'Sending…' : 'Send Request' }}
</button>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import BaseModal from '@/components/BaseModal.vue'
const props = defineProps<{
show: boolean
targetLabel: string
sending?: boolean
}>()
defineEmits<{
send: [message: string | undefined]
cancel: []
}>()
const message = ref('')
watch(
() => props.show,
(visible) => {
if (visible) message.value = ''
},
)
</script>
@@ -0,0 +1,240 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Device Detected' : 'Set Up Your Mesh Radio'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
>
<!-- Step 1: detection + hardware graphic -->
<div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-44 h-44 relative">
<!-- pulsing signal waves behind the board image -->
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
<path d="M130 44 a30 30 0 0 1 0 28" />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1">
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
<span class="font-mono text-orange-300">{{ devicePath }}</span>
</p>
<p class="text-white/50 text-xs mt-2">
Connect it to join the off-grid mesh chat, block headers, and payments
keep flowing even without internet.
</p>
<div class="flex gap-2 mt-6">
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="dismiss">
Not Now
</button>
<button class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" @click="step = 2">
Set Up
</button>
</div>
</div>
<!-- Step 2: configuration with presets -->
<div v-else>
<p class="text-white/60 text-xs mb-4">
Archipelago presets are pre-selected confirm the region and you're on the mesh.
</p>
<div class="space-y-4">
<!-- Region the meaning adapts to the firmware: Meshtastic takes a
region enum directly; MeshCore/RNode take raw RF params owned by
the firmware/daemon, so the region only drives displayed guidance. -->
<div>
<label class="block text-sm text-white/80 mb-1">LoRa region / frequency plan</label>
<select v-model="form.region" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Keep the radio's current region</option>
<option v-for="r in LORA_REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p v-if="suggestedRegion && form.region === suggestedRegion.code" class="text-[11px] text-green-400/80 mt-1">
Suggested from your node's location
</p>
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
{{ selectedRegion.code }} limits airtime to {{ selectedRegion.dutyCyclePct }}% duty cycle ({{ selectedRegion.band }} MHz) the radio enforces this automatically.
</p>
<p v-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} set on the radio via its MeshCore app if it differs.
</p>
<p v-else-if="effectiveKind === 'meshcore' && selectedRegion" class="text-[11px] text-sky-300/80 mt-1">
MeshCore radios keep their flashed RF settings make sure the radio is on the {{ selectedRegion.band }} MHz band for {{ selectedRegion.code }}.
</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>
</div>
<!-- Node name -->
<div>
<label class="block text-sm text-white/80 mb-1">Name on the mesh</label>
<input v-model="form.name" maxlength="24" placeholder="e.g. basement-node"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<!-- Channel (Meshtastic secondary channel / MeshCore group channel;
Reticulum has no channel concept IFAC lives in the daemon config) -->
<div v-if="effectiveKind !== 'reticulum'">
<label class="block text-sm text-white/80 mb-1">Channel</label>
<input v-model="form.channel" maxlength="11"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
<p class="text-[11px] text-white/40 mt-1">
Archipelago nodes find each other on the "archipelago" channel; the public default channel stays active for interop.
</p>
</div>
<!-- Firmware (advanced) -->
<div>
<button class="text-xs text-orange-300 hover:text-orange-200" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▾' : '▸' }} Advanced
</button>
<div v-if="showAdvanced" class="mt-2">
<label class="block text-sm text-white/80 mb-1">Radio firmware</label>
<select v-model="form.deviceKind" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="auto">Auto-detect (Meshcore Meshtastic RNode)</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum / RNode</option>
</select>
<p class="text-[11px] text-white/40 mt-1">
Pin this if you know what's flashed on the board auto-detect probes each firmware in order.
</p>
</div>
</div>
</div>
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
<div class="flex gap-2 mt-6">
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
<button
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="connecting"
@click="connect"
>
{{ connecting ? 'Connecting…' : 'Connect to Mesh' }}
</button>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore } from '@/stores/mesh'
import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
const mesh = useMeshStore()
const appStore = useAppStore()
const router = useRouter()
const step = ref<1 | 2>(1)
const showAdvanced = ref(false)
const connecting = ref(false)
const error = ref('')
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() =>
resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
)
const suggestedRegion = computed(() => {
const info = appStore.serverInfo as { lat?: number | null; lon?: number | null } | undefined
if (info?.lat != null && info?.lon != null) {
return suggestRegionFromLatLon(info.lat, info.lon)
}
return undefined
})
const form = ref({
region: '',
name: '',
channel: 'archipelago',
deviceKind: 'auto',
})
const selectedRegion = computed(() => regionByCode(form.value.region))
// The firmware whose options we surface: the explicit pin wins, else the
// last detected/connected type, else meshtastic-style (where presets apply).
const effectiveKind = computed(() => {
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
const t = (mesh.status?.device_type ?? '').toLowerCase()
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
})
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
// (Re)apply presets each time a new device surfaces the modal
watch(show, (visible) => {
if (visible) {
step.value = 1
error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago'
form.value.deviceKind = mesh.status?.device_kind ?? 'auto'
}
})
function dismiss() {
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
}
async function connect() {
connecting.value = true
error.value = ''
try {
const path = devicePath.value
await mesh.configure({
enabled: true,
device_path: path,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
...(form.value.region ? { lora_region: form.value.region } : {}),
device_kind: form.value.deviceKind,
})
mesh.dismissDetectedDevice(path)
void router.push('/mesh')
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
} finally {
connecting.value = false
}
}
</script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -262,12 +262,12 @@ describe('useAppLauncherStore', () => {
it('opens known prepackaged websites in new tab on desktop when requested', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://nwnn.l484.com', title: 'Next Web News Network', openInNewTab: true })
store.open({ url: 'https://botfights.net', title: 'BotFights', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://nwnn.l484.com',
'https://botfights.net',
'_blank',
'noopener,noreferrer',
)
@@ -281,12 +281,12 @@ describe('useAppLauncherStore', () => {
})
const store = useAppLauncherStore()
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
store.open({ url: 'https://botfights.net', title: 'BotFights', openInNewTab: true })
// Iframeable prepackaged sites stay in-app via the store panel (no route
// change, no background swap) just like every other mobile launch.
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('arch-presentation')
expect(store.panelAppId).toBe('botfights')
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).not.toHaveBeenCalled()
})
-6
View File
@@ -328,12 +328,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Check external URLs
const EXTERNAL_APP_HOSTS: Record<string, string> = {
'botfights.net': 'botfights',
'nwnn.l484.com': 'nwnn',
'484.kitchen': '484-kitchen',
'cta.tx1138.com': 'call-the-operator',
'present.l484.com': 'arch-presentation',
'syntropy.institute': 'syntropy-institute',
'teeminuszero.net': 't-zero',
'nostrudel.ninja': 'nostrudel',
}
return EXTERNAL_APP_HOSTS[u.hostname] || null
+62 -2
View File
@@ -23,6 +23,31 @@ export interface MeshStatus {
receive_block_headers?: boolean
/** Operator-configured LoRa region (e.g. "EU_868"), for the Device tab. */
region?: string | null
/** Persisted config: LoRa region the driver programs on fresh radios. */
lora_region?: string | null
/** Persisted config: firmware pin (meshcore|meshtastic|reticulum), null = auto-detect. */
device_kind?: string | null
/** USB identity per detected port (for the setup modal's board image). */
detected_device_info?: Array<{
path: string
vid?: string | null
pid?: string | null
product?: string | null
manufacturer?: string | null
}>
}
/** Params accepted by mesh.configure (superset of the status fields). */
export interface MeshConfigureParams {
enabled?: boolean
device_path?: string
channel_name?: string
broadcast_identity?: boolean
advert_name?: string
announce_block_headers?: boolean
receive_block_headers?: boolean
lora_region?: string
device_kind?: string
}
export interface MeshPeer {
@@ -234,6 +259,38 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
// ── Global device-detection (for the app-wide "device detected" modal) ──
// Ports the user dismissed the setup modal for, persisted per browser.
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
const dismissedDetectedPaths = ref<Set<string>>(new Set(
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
))
const undismissedDetectedDevices = computed(() => {
const s = status.value
if (!s || s.device_connected || s.enabled) return []
return (s.detected_devices || []).filter(p => !dismissedDetectedPaths.value.has(p))
})
function dismissDetectedDevice(path: string) {
dismissedDetectedPaths.value.add(path)
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
}
let globalDetectTimer: ReturnType<typeof setInterval> | null = null
/** App-wide light poll so the detected-device modal works on every page
* (the Mesh view's own 5s poll takes over while it is mounted). */
function startGlobalDetection(intervalMs = 15000) {
if (globalDetectTimer) return
// Never poll unauthenticated — a 401 from a background poll on the
// login page caused a login-refresh loop (2026-07-14, .116).
const authed = () => {
try { return !!localStorage.getItem('neode-auth') } catch { return false }
}
if (authed()) void fetchStatus()
globalDetectTimer = setInterval(() => {
if (document.visibilityState === 'visible' && authed()) void fetchStatus()
}, intervalMs)
}
async function fetchPeers() {
try {
const res = await rpcClient.call<{ peers: MeshPeer[]; count: number }>({
@@ -455,12 +512,12 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function configure(config: Partial<MeshStatus>) {
async function configure(config: MeshConfigureParams) {
try {
error.value = null
await rpcClient.call<{ configured: boolean }>({
method: 'mesh.configure',
params: config,
params: config as Record<string, unknown>,
})
await fetchStatus()
} catch (err: unknown) {
@@ -851,6 +908,9 @@ export const useMeshStore = defineStore('mesh', () => {
blockHeaders,
latestBlockHeight,
fetchStatus,
undismissedDetectedDevices,
dismissDetectedDevice,
startGlobalDetection,
fetchPeers,
fetchMessages,
sendMessage,
+22
View File
@@ -3081,3 +3081,25 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-deadman-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-deadman-info { display: flex; gap: 12px; flex-wrap: wrap; }
.mesh-deadman-info-item { font-size: 0.75rem; color: rgba(255,255,255,0.4); }
/* Global dropdown normalization
Native select arrows rendered with inconsistent right padding per browser
and per ad-hoc utility classes, and long labels could run into the icon.
One rule for every select: no native arrow, one chevron at a fixed inset,
uniform right padding (wins over pr-* utilities), and ellipsis truncation. */
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.55)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1rem 1rem;
padding-right: 2.5rem !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
select::-ms-expand {
display: none;
}
-204
View File
@@ -508,208 +508,4 @@ export const dummyApps: Record<string, PackageDataEntry> = {
status: ServiceStatus.Running
}
},
'nwnn': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Decentralized news aggregator',
icon: '/assets/img/app-icons/nwnn.png'
},
manifest: {
id: 'nwnn',
title: 'Next Web News Network',
version: '1.0.0',
description: {
short: 'Decentralized news aggregator, synced from Telegram',
long: 'Next Web News Network (NWNN) is a decentralized news aggregation platform that curates and syncs content from Telegram channels. Stay informed with the latest developments in Bitcoin, decentralization, and sovereign technology. Clean reading experience with no ads or tracking.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://nwnn.l484.com',
website: 'https://nwnn.l484.com',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'https://nwnn.l484.com' }
},
status: ServiceStatus.Running
}
},
'484-kitchen': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'K484 application platform',
icon: '/assets/img/app-icons/484-kitchen.png'
},
manifest: {
id: '484-kitchen',
title: '484 Kitchen',
version: '1.0.0',
description: {
short: 'K484 application platform',
long: '484 Kitchen is a creative application platform from the K484 collective. Explore experimental tools, interactive experiences, and cutting-edge web applications built with a focus on sovereignty and decentralization.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://484.kitchen',
website: 'https://484.kitchen',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'https://484.kitchen' }
},
status: ServiceStatus.Running
}
},
'call-the-operator': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Escape the Matrix',
icon: '/assets/img/app-icons/call-the-operator.png'
},
manifest: {
id: 'call-the-operator',
title: 'Call the Operator',
version: '1.0.0',
description: {
short: 'Escape the Matrix — explore decentralized alternatives',
long: 'Call the Operator is an interactive guide to escaping the centralized matrix. Discover decentralized alternatives to mainstream services, learn about self-sovereignty, and take back control of your digital life. Beautiful dreamcore aesthetic with immersive 3D visuals.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://cta.tx1138.com',
website: 'https://cta.tx1138.com',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'https://cta.tx1138.com' }
},
status: ServiceStatus.Running
}
},
'arch-presentation': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Archipelago presentation',
icon: '/assets/img/app-icons/arch-presentation.png'
},
manifest: {
id: 'arch-presentation',
title: 'Arch Presentation',
version: '1.0.0',
description: {
short: 'Archipelago: The Future of Decentralized Infrastructure',
long: 'The official Archipelago presentation deck. Learn about the vision, architecture, and roadmap of the Archipelago Bitcoin Node OS. Interactive slides showcasing the future of decentralized personal infrastructure, self-sovereign computing, and the Web5 stack.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://present.l484.com',
website: 'https://present.l484.com',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'https://present.l484.com' }
},
status: ServiceStatus.Running
}
},
'syntropy-institute': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Frequency analysis and therapy',
icon: '/assets/img/app-icons/syntropy-institute.png'
},
manifest: {
id: 'syntropy-institute',
title: 'Syntropy Institute',
version: '1.0.0',
description: {
short: 'Medicine Reimagined — frequency analysis and therapy',
long: 'Syntropy Institute presents a new paradigm in health and wellness through frequency analysis and therapy. Explore cutting-edge research into bioresonance, quantum biology, and the energetic foundations of health. A bridge between ancient healing wisdom and modern technology.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://syntropy.institute',
website: 'https://syntropy.institute',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'https://syntropy.institute' }
},
status: ServiceStatus.Running
}
},
't-zero': {
state: PackageState.Running,
'static-files': {
license: 'MIT',
instructions: 'Documentary series',
icon: '/assets/img/app-icons/t-zero.png'
},
manifest: {
id: 't-zero',
title: 'T-0',
version: '1.0.0',
description: {
short: 'Documentary series on decentralization and Bitcoin',
long: 'T-0 (Tee Minus Zero) is a documentary series exploring the intersection of decentralization, Bitcoin, and personal sovereignty. Follow the stories of builders, dreamers, and freedom advocates creating the infrastructure for a more sovereign future.'
},
'release-notes': 'Initial release',
license: 'MIT',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': 'https://teeminuszero.net',
website: 'https://teeminuszero.net',
'donation-url': null
},
installed: {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
'interface-addresses': {
main: { 'tor-address': '', 'lan-address': 'https://teeminuszero.net' }
},
status: ServiceStatus.Running
}
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* LoRa regional frequency plans the canonical region list the backend's
* Meshtastic driver can program (core/.../mesh/meshtastic.rs region table),
* annotated with band / duty-cycle / power constraints from the Meshtastic
* firmware `regions[]` table so the UI can surface legality hints.
*
* Also used to derive suggested radio parameters for MeshCore and RNode
* presets (those firmwares take raw frequency/BW/SF/CR, not a region enum).
*/
export interface LoraRegion {
/** Wire value for mesh.configure { lora_region } */
code: string
label: string
/** Frequency range, MHz (display) */
band: string
/** Regional duty-cycle cap, percent (100 = none) */
dutyCyclePct: number
/** Max TX power, dBm */
maxPowerDbm: number
}
export const LORA_REGIONS: LoraRegion[] = [
{ code: 'US', label: 'United States / Americas (915 MHz)', band: '902928', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'EU_868', label: 'Europe (868 MHz)', band: '869.4869.65', dutyCyclePct: 10, maxPowerDbm: 27 },
{ code: 'EU_433', label: 'Europe (433 MHz)', band: '433434', dutyCyclePct: 10, maxPowerDbm: 10 },
{ code: 'ANZ', label: 'Australia / New Zealand (915 MHz)', band: '915928', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'ANZ_433', label: 'Australia / New Zealand (433 MHz)', band: '433.05434.79', dutyCyclePct: 100, maxPowerDbm: 14 },
{ code: 'NZ_865', label: 'New Zealand (865 MHz)', band: '864868', dutyCyclePct: 100, maxPowerDbm: 36 },
{ code: 'CN', label: 'China (470 MHz)', band: '470510', dutyCyclePct: 100, maxPowerDbm: 19 },
{ code: 'JP', label: 'Japan (920 MHz)', band: '920.5923.5', dutyCyclePct: 100, maxPowerDbm: 13 },
{ code: 'KR', label: 'South Korea (920 MHz)', band: '920923', dutyCyclePct: 100, maxPowerDbm: 23 },
{ code: 'TW', label: 'Taiwan (920 MHz)', band: '920925', dutyCyclePct: 100, maxPowerDbm: 27 },
{ code: 'RU', label: 'Russia (868 MHz)', band: '868.7869.2', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'IN', label: 'India (865 MHz)', band: '865867', dutyCyclePct: 100, maxPowerDbm: 30 },
{ code: 'TH', label: 'Thailand (920 MHz)', band: '920925', dutyCyclePct: 10, maxPowerDbm: 27 },
{ code: 'UA_868', label: 'Ukraine (868 MHz)', band: '868868.6', dutyCyclePct: 1, maxPowerDbm: 14 },
{ code: 'UA_433', label: 'Ukraine (433 MHz)', band: '433434.7', dutyCyclePct: 10, maxPowerDbm: 10 },
{ code: 'MY_919', label: 'Malaysia (919 MHz)', band: '919924', dutyCyclePct: 100, maxPowerDbm: 27 },
{ code: 'MY_433', label: 'Malaysia (433 MHz)', band: '433435', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'SG_923', label: 'Singapore (923 MHz)', band: '917925', dutyCyclePct: 100, maxPowerDbm: 20 },
{ code: 'PH_915', label: 'Philippines (915 MHz)', band: '915918', dutyCyclePct: 100, maxPowerDbm: 24 },
{ code: 'PH_868', label: 'Philippines (868 MHz)', band: '868869.4', dutyCyclePct: 100, maxPowerDbm: 14 },
{ code: 'PH_433', label: 'Philippines (433 MHz)', band: '433434.7', dutyCyclePct: 100, maxPowerDbm: 10 },
{ code: 'LORA_24', label: 'Worldwide (2.4 GHz, SX1280 only)', band: '24002483.5', dutyCyclePct: 100, maxPowerDbm: 10 },
]
export function regionByCode(code: string | null | undefined): LoraRegion | undefined {
if (!code) return undefined
return LORA_REGIONS.find(r => r.code === code.toUpperCase())
}
/**
* Coarse lat/lon LoRa region suggestion. Bounding boxes, most-specific
* first good enough to preselect the dropdown; the user confirms.
*/
export function suggestRegionFromLatLon(lat: number, lon: number): LoraRegion | undefined {
const boxes: Array<{ code: string; latMin: number; latMax: number; lonMin: number; lonMax: number }> = [
{ code: 'JP', latMin: 24, latMax: 46, lonMin: 123, lonMax: 146 },
{ code: 'KR', latMin: 33, latMax: 39, lonMin: 124, lonMax: 132 },
{ code: 'TW', latMin: 21.5, latMax: 25.5, lonMin: 119.5, lonMax: 122.5 },
{ code: 'PH_915', latMin: 4, latMax: 21, lonMin: 116, lonMax: 127 },
{ code: 'SG_923', latMin: 1, latMax: 1.6, lonMin: 103.5, lonMax: 104.2 },
{ code: 'MY_919', latMin: 0.8, latMax: 7.5, lonMin: 99, lonMax: 119.5 },
{ code: 'TH', latMin: 5.5, latMax: 20.5, lonMin: 97, lonMax: 106 },
{ code: 'IN', latMin: 6, latMax: 36, lonMin: 68, lonMax: 97.5 },
{ code: 'CN', latMin: 18, latMax: 54, lonMin: 73, lonMax: 135 },
{ code: 'UA_868', latMin: 44, latMax: 52.5, lonMin: 22, lonMax: 40.5 },
{ code: 'RU', latMin: 41, latMax: 82, lonMin: 27, lonMax: 180 },
{ code: 'ANZ', latMin: -48, latMax: -9, lonMin: 112, lonMax: 180 },
{ code: 'EU_868', latMin: 34, latMax: 72, lonMin: -25, lonMax: 45 },
// Americas
{ code: 'US', latMin: -56, latMax: 72, lonMin: -170, lonMax: -30 },
]
for (const b of boxes) {
if (lat >= b.latMin && lat <= b.latMax && lon >= b.lonMin && lon <= b.lonMax) {
return regionByCode(b.code)
}
}
return undefined
}
/** Community MeshCore radio plan for a region, where one is well established.
* MeshCore has no region enum the firmware takes raw freq/BW/SF/CR so
* these are display-level suggestions (EU is the verified community default;
* other regions show the legal band and defer to the local community plan). */
export interface MeshcorePlan {
freqMhz: number
bwKhz: number
sf: number
cr: number
}
export const MESHCORE_PLANS: Record<string, MeshcorePlan> = {
EU_868: { freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
EU_433: { freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 },
}
export function meshcorePlanFor(code: string | null | undefined): MeshcorePlan | undefined {
if (!code) return undefined
return MESHCORE_PLANS[code.toUpperCase()]
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Best-effort mapping from a detected serial port's USB identity to a board
* image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0
* github.com/meshtastic/web-flasher, public/img/devices/).
*
* Native-USB boards (T-Deck, RAK4631, T1000-E) report their name in the USB
* product string, so those match precisely. UART-bridge boards only identify
* the bridge chip (CP2102/CH340), so vid:pid picks the most common board for
* that chip and the label stays honest ("LoRa radio").
*/
export interface DetectedDeviceInfo {
path: string
vid?: string | null
pid?: string | null
product?: string | null
manufacturer?: string | null
}
export interface MeshDeviceImage {
/** Path under public/ */
image: string
/** Human name shown in the modal */
label: string
/** True when the match is exact (product string), false for chip-level guesses */
exact: boolean
}
const IMG = '/assets/img/mesh-devices'
/** product-string keyword → image (checked in order, case-insensitive) */
const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [
{ match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' },
{ match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' },
{ match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' },
{ match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' },
{ match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' },
{ match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' },
{ match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' },
{ match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' },
{ match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' },
{ match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' },
{ match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' },
{ match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' },
{ match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' },
{ match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' },
{ match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' },
{ match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' },
{ match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' },
{ match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' },
{ match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' },
{ match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' },
{ match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' },
]
/** vid:pid → most-likely board (bridge chips = chip-level guess) */
const VIDPID_MATCHES: Record<string, { image: string; label: string; exact: boolean }> = {
// Silicon Labs CP210x — Heltec V3 family ships this bridge
'10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false },
// WCH CH340 — most T-Beam / T-LoRa boards
'1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false },
'1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false },
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
'303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false },
// RAK4631 nRF52 native USB
'239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true },
// Raspberry Pi (Pico W)
'2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false },
}
const FALLBACK: MeshDeviceImage = {
image: `${IMG}/unknown-new.svg`,
label: 'LoRa mesh radio',
exact: false,
}
export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): MeshDeviceImage {
if (!info) return FALLBACK
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
if (product) {
for (const m of PRODUCT_MATCHES) {
if (m.match.test(product)) return { image: m.image, label: m.label, exact: true }
}
}
if (info.vid && info.pid) {
const hit = VIDPID_MATCHES[`${info.vid}:${info.pid}`.toLowerCase()]
if (hit) return hit
}
return FALLBACK
}
+1 -1
View File
@@ -348,7 +348,7 @@
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
<p class="font-medium text-white/80 mb-2">Easy sources</p>
<p>Use images from Docker Hub, GHCR, the VPS2 Gitea registry (146.59.87.168:3000), or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
<p>Use images from Docker Hub, GHCR, the Archipelago app registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
</div>
<div class="mt-5 flex gap-3">
+1 -1
View File
@@ -114,7 +114,7 @@
v-if="store.getAppVisualState(app.id) === 'running'"
type="button"
data-controller-launch-btn
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
class="px-4 py-2 glass-button glass-button-warning rounded text-sm font-medium flex items-center gap-2"
@click="launchApp(app)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+9 -5
View File
@@ -15,13 +15,15 @@
@rotate="rotateDid"
/>
<!-- View Tabs -->
<div v-if="nodes.length > 0" class="flex gap-1 mb-6 p-1 bg-black/20 rounded-lg w-fit">
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
<button
v-for="tab in viewTabs"
:key="tab.id"
class="px-4 py-2 rounded text-sm font-medium transition-colors"
:class="activeView === tab.id ? 'bg-white/10 text-white border-b-2 border-orange-400' : 'text-white/50 hover:text-white/70'"
class="mode-switcher-btn"
role="tab"
:aria-selected="activeView === tab.id"
:class="{ 'mode-switcher-btn-active': activeView === tab.id }"
@click="setView(tab.id)"
>
{{ tab.label }}
@@ -400,7 +402,9 @@ async function generateInvite() {
try {
generatingInvite.value = true
error.value = ''
const result = await rpcClient.federationInvite()
// The invite type is not cosmetic: it sets the trust level the invite
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
const result = await rpcClient.federationInvite(inviteType.value)
inviteCode.value = result.code
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
+5 -56
View File
@@ -40,13 +40,7 @@ const sendError = ref('')
const broadcasting = ref(false)
const configuring = ref(false)
const connectingDevice = ref<string | null>(null)
// Onboarding modal (#6): guides a first-time connect for a freshly-detected,
// not-yet-connected device a friendlier wrapper around the same Connect
// action the "Detected USB devices" list already offers, not a new setup
// engine. `onboardingDismissed` remembers paths the user closed without
// connecting, so it doesn't reappear every poll tick for the same device.
const showOnboardingModal = ref(false)
const onboardingDismissed = ref<Set<string>>(new Set())
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
const chatScrollEl = ref<HTMLElement | null>(null)
const messageInputRef = ref<HTMLInputElement | null>(null)
const mobileShowChat = ref(false)
@@ -1041,35 +1035,12 @@ async function handleToggleEnabled() {
async function handleConnectDevice(devicePath: string) {
connectingDevice.value = devicePath
try {
await mesh.configure({ enabled: true, device_path: devicePath } as Partial<import('@/stores/mesh').MeshStatus>)
showOnboardingModal.value = false
await mesh.configure({ enabled: true, device_path: devicePath })
} finally {
connectingDevice.value = null
}
}
const undismissedDetectedDevices = computed(() =>
(mesh.status?.detected_devices ?? []).filter((d) => !onboardingDismissed.value.has(d))
)
function dismissOnboarding() {
for (const d of undismissedDetectedDevices.value) onboardingDismissed.value.add(d)
showOnboardingModal.value = false
}
// Pop the onboarding modal the moment a device is detected but not yet
// connected same trigger condition the inline "Detected USB devices" list
// already uses (mesh.status.detected_devices non-empty + not connected),
// just surfaced as a guided prompt instead of requiring the user to notice
// the collapsed Device card.
watch(
() => [mesh.status?.device_connected, undismissedDetectedDevices.value.length] as const,
([connected, count]) => {
if (!connected && count > 0) showOnboardingModal.value = true
},
{ immediate: true },
)
function signalBars(rssi: number | null, snr: number | null = null): number {
if (rssi !== null) {
if (rssi > -60) return 4
@@ -2448,31 +2419,9 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
</div>
</div>
<!-- Onboarding modal (#6): guided first-connect prompt for a freshly
detected, not-yet-connected mesh device wraps the same Connect
action the inline "Detected USB devices" list already offers. -->
<div v-if="showOnboardingModal" class="mesh-transport-modal-backdrop" @click.self="dismissOnboarding">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">📡 Mesh Device Found</h3>
<p class="mesh-transport-sub">
A radio was detected but isn't connected yet. Connect it to start using off-grid mesh chat.
</p>
<div class="mesh-transport-options">
<button
v-for="dev in undismissedDetectedDevices"
:key="dev"
class="mesh-transport-option"
:disabled="connectingDevice !== null"
@click="handleConnectDevice(dev)"
>
<span class="mesh-transport-icon">📡</span>
<span class="mesh-transport-label">{{ dev }}</span>
<span class="mesh-transport-meta">{{ connectingDevice === dev ? 'Connecting…' : 'Connect' }}</span>
</button>
</div>
<button class="mesh-transport-cancel" @click="dismissOnboarding">Not now</button>
</div>
</div>
<!-- The "mesh device detected" setup flow is now the global
MeshDeviceSetupModal mounted in App.vue (fires on every page,
board image + region presets), so no local modal here. -->
<MediaLightbox
:items="lightboxItems"
+5 -10
View File
@@ -259,19 +259,13 @@
<p v-if="iface.ipv4.length > 0" class="text-sm text-white/80">{{ iface.ipv4[0] }}</p>
<p v-else class="text-sm text-white/40">No IP</p>
</div>
<button
<ToggleSwitch
v-if="iface.type === 'wifi'"
:model-value="iface.state === 'up'"
:disabled="togglingWifiRadio"
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
:class="[iface.state === 'up' ? 'bg-green-500/60' : 'bg-white/15', togglingWifiRadio ? 'opacity-40 cursor-not-allowed' : '']"
:aria-label="iface.state === 'up' ? 'Turn off wifi adapter' : 'Turn on wifi adapter'"
@click="toggleWifiRadio(iface)"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
:class="iface.state === 'up' ? 'translate-x-5' : 'translate-x-0.5'"
></span>
</button>
@update:model-value="toggleWifiRadio(iface)"
/>
</div>
</div>
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
@@ -413,6 +407,7 @@ import QuickActionsCard from './server/QuickActionsCard.vue'
import TorServicesCard from './server/TorServicesCard.vue'
import ServerModals from './server/ServerModals.vue'
import FipsNetworkCard from './server/FipsNetworkCard.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import type { TorServiceInfo } from './server/TorServicesCard.vue'
const appStore = useAppStore()
+7 -7
View File
@@ -48,7 +48,7 @@
<select
v-model="selectedVersion"
:disabled="versionBusy"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-orange-400/60"
>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
</select>
@@ -60,7 +60,7 @@
type="checkbox"
v-model="autoUpdate"
:disabled="versionBusy || isPinned"
class="h-4 w-4 accent-blue-500"
class="h-4 w-4 accent-orange-500"
/>
</label>
<p v-if="isPinned" class="text-white/40 text-xs -mt-2">{{ t('appDetails.autoUpdatePinnedNote') }}</p>
@@ -81,7 +81,7 @@
<button
v-else
type="button"
class="w-full rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-medium py-2 transition-colors"
class="w-full glass-button glass-button-warning rounded-lg disabled:opacity-50 text-sm font-medium py-2"
:disabled="versionBusy || !versionDirty"
@click="applyVersionConfig(false)"
>
@@ -126,7 +126,7 @@
:href="lanUrl"
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 text-sm break-all"
class="text-orange-300 hover:text-orange-200 text-sm break-all"
>
{{ interfaceAddresses['lan-address'] }}
</a>
@@ -167,7 +167,7 @@
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
<button type="button" class="text-xs text-blue-300 hover:text-blue-200" @click="copyCredential(cred.label, cred.value)">
<button type="button" class="text-xs text-orange-300 hover:text-orange-200" @click="copyCredential(cred.label, cred.value)">
{{ copiedCredential === cred.label ? 'Copied' : 'Copy' }}
</button>
</div>
@@ -181,7 +181,7 @@
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.requirements') }}</h3>
<div class="space-y-3">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-orange-300 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<div class="flex-1">
@@ -211,7 +211,7 @@
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
class="flex items-center gap-2 text-orange-300 hover:text-orange-200 transition-colors"
>
<svg
v-if="link.kind === 'website'"
@@ -5,14 +5,7 @@
import { PackageState } from '@/types/api'
/** Web-only app detection (no container -- external websites) */
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
export const WEB_ONLY_APP_URLS: Record<string, string> = {}
/** Map route/marketplace app IDs to backend package keys (container names). */
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
@@ -90,12 +83,6 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },
'484-kitchen': { dev: 'https://484.kitchen', prod: 'https://484.kitchen' },
'call-the-operator': { dev: 'https://cta.tx1138.com', prod: 'https://cta.tx1138.com' },
'arch-presentation': { dev: 'https://present.l484.com', prod: 'https://present.l484.com' },
'syntropy-institute': { dev: 'https://syntropy.institute', prod: 'https://syntropy.institute' },
't-zero': { dev: 'https://teeminuszero.net', prod: 'https://teeminuszero.net' },
}
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
@@ -45,12 +45,6 @@ export const HTTPS_PROXY_PATHS: Record<string, string> = {
/** External HTTPS apps -- always loaded directly */
export const EXTERNAL_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
}
@@ -58,11 +52,10 @@ export const APP_TITLES: Record<string, string> = {
...GENERATED_APP_TITLES,
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'botfights': 'BotFights', 'gitea': 'Gitea',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager',
'call-the-operator': 'Call The Operator', 'syntropy-institute': 'Syntropy Institute',
't-zero': 'T-Zero', 'nostrudel': 'noStrudel',
'nostrudel': 'noStrudel',
}
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
-1
View File
@@ -7,7 +7,6 @@ export const APP_STORE_CATEGORIES = [
{ id: 'data', name: 'Data' },
{ id: 'home', name: 'Home' },
{ id: 'networking', name: 'Networking' },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: 'Other' },
] as const
+3 -46
View File
@@ -26,9 +26,6 @@ export const SERVICE_NAMES = new Set([
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// L484 web-only apps — parked in Services for now
'nwnn', '484-kitchen', 'call-the-operator',
'syntropy-institute', 't-zero', 'arch-presentation',
])
const INTERNAL_TOOLING_NAMES = new Set([
@@ -69,8 +66,7 @@ export const APP_CATEGORY_MAP: Record<string, string> = {
'nostrudel': 'nostr',
'tailscale': 'networking', 'netbird': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking',
'botfights': 'community', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
'botfights': 'community',
}
export function getAppCategory(id: string, pkg: PackageDataEntry): string {
@@ -154,52 +150,14 @@ export function buildServiceCategories(t: (key: string) => string): Array<{ id:
}
// Web-only app IDs and their URLs
export const WEB_ONLY_APP_URLS: Record<string, string> = {
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
export const WEB_ONLY_APP_URLS: Record<string, string> = {}
export function isWebOnlyApp(id: string): boolean {
return id in WEB_ONLY_APP_URLS
}
// Web-only apps (no container) -- always show as installed bookmarks
export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
'nwnn': {
state: 'running' as PackageState,
manifest: { id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', description: { short: 'Decentralized news aggregator, synced from Telegram', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/nwnn.png' },
},
'484-kitchen': {
state: 'running' as PackageState,
manifest: { id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', description: { short: 'K484 application platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/484-kitchen.png' },
},
'call-the-operator': {
state: 'running' as PackageState,
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
},
'arch-presentation': {
state: 'running' as PackageState,
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
},
'syntropy-institute': {
state: 'running' as PackageState,
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/syntropy-institute.png' },
},
't-zero': {
state: 'running' as PackageState,
manifest: { id: 't-zero', title: 'T-0', version: '1.0.0', description: { short: 'Documentary series on decentralization and Bitcoin', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/t-zero.png' },
},
}
export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {}
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
export const TAB_LAUNCH_APPS = new Set([
@@ -370,7 +328,6 @@ export function buildAllCategories(t: (key: string) => string) {
{ id: 'media', name: 'Media' },
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: t('marketplace.other') },
]
}
@@ -109,12 +109,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'nostrudel', title: 'noStrudel', version: '0.40.0', category: 'nostr', description: 'Feature-rich Nostr web client. Browse feeds, post notes, manage relays with NIP-07.', icon: '/assets/img/app-icons/nostrudel.svg', author: 'hzrd149', dockerImage: '', repoUrl: 'https://github.com/hzrd149/nostrudel', webUrl: 'https://nostrudel.ninja' },
{ id: 'botfights', title: 'BotFights', version: '1.0.0', category: 'community', description: 'Bot arena + 2-player arcade fighter with controller support. AI bots battle in trivia, humans duke it out with controllers.', icon: '/assets/img/app-icons/botfights.svg', author: 'BotFights', dockerImage: `${R}/botfights:1.1.0`, repoUrl: 'https://botfights.net' },
{ id: 'gitea', title: 'Gitea', version: '1.23', category: 'development', description: 'Self-hosted Git service with container registry, CI/CD, issue tracking, and package hosting.', icon: '/assets/img/app-icons/gitea.svg', author: 'Gitea', dockerImage: 'docker.io/gitea/gitea:1.23', repoUrl: 'https://gitea.com' },
{ id: 'nwnn', title: 'Next Web News Network', version: '1.0.0', category: 'l484', description: 'Decentralized news aggregator. Community-curated Bitcoin and sovereignty content.', icon: '/assets/img/app-icons/nwnn.png', author: 'L484', dockerImage: '', repoUrl: 'https://nwnn.l484.com', webUrl: 'https://nwnn.l484.com' },
{ id: '484-kitchen', title: '484 Kitchen', version: '1.0.0', category: 'l484', description: 'K484 application platform for the L484 network.', icon: '/assets/img/app-icons/484-kitchen.png', author: 'L484', dockerImage: '', repoUrl: 'https://484.kitchen', webUrl: 'https://484.kitchen' },
{ id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', category: 'l484', description: 'Escape the Matrix — explore decentralized alternatives and reclaim sovereignty.', icon: '/assets/img/app-icons/call-the-operator.png', author: 'TX1138', dockerImage: '', repoUrl: 'https://cta.tx1138.com', webUrl: 'https://cta.tx1138.com' },
{ id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', category: 'l484', description: 'The Future of Decentralized Infrastructure — interactive Archipelago presentation.', icon: '/assets/img/app-icons/arch-presentation.png', author: 'L484', dockerImage: '', repoUrl: 'https://present.l484.com', webUrl: 'https://present.l484.com' },
{ id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', category: 'l484', description: 'Medicine Reimagined — Manual Kinetics, Syntropy Frequency, and concierge protocols.', icon: '/assets/img/app-icons/syntropy-institute.png', author: 'Syntropy Institute', dockerImage: '', repoUrl: 'https://syntropy.institute', webUrl: 'https://syntropy.institute' },
{ id: 't-zero', title: 'T-0', version: '1.0.0', category: 'l484', description: 'Documentary series exploring decentralization and the mavericks building the ungovernable future.', icon: '/assets/img/app-icons/t-zero.png', author: 'T-0', dockerImage: '', repoUrl: 'https://teeminuszero.net', webUrl: 'https://teeminuszero.net' },
]
}
@@ -100,12 +100,21 @@
</div>
</div>
</Transition>
<PeerRequestModal
:show="requestTarget !== null"
:target-label="requestTarget?.label ?? ''"
:sending="sendingTo !== null && sendingTo === requestTarget?.target"
@send="confirmRequest"
@cancel="requestTarget = null"
/>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
interface DiscoverableNode {
nostr_pubkey: string
@@ -156,22 +165,33 @@ async function refresh() {
}
}
async function sendTo(node: DiscoverableNode) {
await sendInternal(node.nostr_pubkey)
// Request confirmation modal: peer requests always offer an optional
// message before anything is sent (Request/Cancel).
const requestTarget = ref<{ target: string; label: string; clearManual: boolean } | null>(null)
function sendTo(node: DiscoverableNode) {
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
}
async function sendDirect() {
function sendDirect() {
const v = manualNpub.value.trim()
if (!v) return
await sendInternal(v)
manualNpub.value = ''
requestTarget.value = { target: v, label: v.length > 21 ? `${v.slice(0, 12)}${v.slice(-6)}` : v, clearManual: true }
}
async function sendInternal(target: string) {
async function confirmRequest(message: string | undefined) {
const req = requestTarget.value
if (!req) return
await sendInternal(req.target, message)
if (req.clearManual) manualNpub.value = ''
requestTarget.value = null
}
async function sendInternal(target: string, message?: string) {
sendingTo.value = target
error.value = ''
try {
await rpcClient.handshakeConnect(target)
await rpcClient.handshakeConnect(target, message)
emit('sent')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Send failed'
@@ -431,83 +431,5 @@ export function getCuratedAppList(): MarketplaceApp[] {
manifestUrl: undefined,
repoUrl: 'https://gitea.com',
},
{
id: 'nwnn',
title: 'Next Web News Network',
version: '1.0.0',
category: 'l484',
description: 'Decentralized news and link aggregator, synchronized from Telegram. Community-curated content on Bitcoin, sovereignty, and decentralized tech.',
icon: '/assets/img/app-icons/nwnn.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://nwnn.l484.com',
webUrl: 'https://nwnn.l484.com'
},
{
id: '484-kitchen',
title: '484 Kitchen',
version: '1.0.0',
category: 'l484',
description: 'K484 application platform — an internal tool for the L484 network.',
icon: '/assets/img/app-icons/484-kitchen.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://484.kitchen',
webUrl: 'https://484.kitchen'
},
{
id: 'call-the-operator',
title: 'Call the Operator',
version: '1.0.0',
category: 'l484',
description: 'Escape the Matrix — a portal for exploring decentralized alternatives and reclaiming digital sovereignty.',
icon: '/assets/img/app-icons/call-the-operator.png',
author: 'TX1138',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://cta.tx1138.com',
webUrl: 'https://cta.tx1138.com'
},
{
id: 'arch-presentation',
title: 'Arch Presentation',
version: '1.0.0',
category: 'l484',
description: 'Archipelago: The Future of Decentralized Infrastructure — an interactive presentation about the Archipelago project vision.',
icon: '/assets/img/app-icons/arch-presentation.png',
author: 'L484',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://present.l484.com',
webUrl: 'https://present.l484.com'
},
{
id: 'syntropy-institute',
title: 'Syntropy Institute',
version: '1.0.0',
category: 'l484',
description: 'Medicine Reimagined — Manual Kinetics, Syntropy Frequency analysis-therapy, digital homeopathy, and concierge protocols.',
icon: '/assets/img/app-icons/syntropy-institute.png',
author: 'Syntropy Institute',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://syntropy.institute',
webUrl: 'https://syntropy.institute'
},
{
id: 't-zero',
title: 'T-0',
version: '1.0.0',
category: 'l484',
description: 'Documentary series exploring decentralization, Bitcoin, and the mavericks building the ungovernable future. Conversations with the builders, powered by Nostr.',
icon: '/assets/img/app-icons/t-zero.png',
author: 'T-0',
dockerImage: '',
manifestUrl: undefined,
repoUrl: 'https://teeminuszero.net',
webUrl: 'https://teeminuszero.net'
}
]
}
+132 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { LORA_REGIONS, regionByCode, meshcorePlanFor } from '@/utils/loraRegions'
const mesh = useMeshStore()
@@ -18,6 +19,64 @@ async function handleReboot() {
rebooting.value = false
}
}
// Editable settings (persisted via mesh.configure)
const form = ref({
region: '',
deviceKind: 'auto',
channel: 'archipelago',
name: '',
broadcastIdentity: true,
})
const saving = ref(false)
const saveError = ref<string | null>(null)
const saveDone = ref(false)
let seeded = false
// Seed the form once from status (don't clobber in-progress edits on poll)
watch(
() => mesh.status,
(s) => {
if (!s || seeded) return
seeded = true
form.value.region = s.lora_region ?? ''
form.value.deviceKind = s.device_kind ?? 'auto'
form.value.channel = s.channel_name || 'archipelago'
form.value.name = s.self_advert_name ?? ''
},
{ immediate: true },
)
const selectedRegion = computed(() => regionByCode(form.value.region))
const deviceType = computed(() => mesh.status?.device_type ?? 'unknown')
// Firmware whose options apply: explicit pin wins, else the connected type.
const effectiveKind = computed(() => {
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
const t = deviceType.value.toLowerCase()
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
})
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
async function saveSettings() {
saving.value = true
saveError.value = null
saveDone.value = false
try {
await mesh.configure({
lora_region: form.value.region,
device_kind: form.value.deviceKind,
channel_name: form.value.channel.trim() || 'archipelago',
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
broadcast_identity: form.value.broadcastIdentity,
})
saveDone.value = true
setTimeout(() => { saveDone.value = false }, 3000)
} catch (e) {
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
} finally {
saving.value = false
}
}
</script>
<template>
@@ -39,7 +98,7 @@ async function handleReboot() {
<span class="mesh-stat-value">{{ mesh.status.self_advert_name ?? '—' }}</span>
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Region</span>
<span class="mesh-stat-label">Region (radio)</span>
<span class="mesh-stat-value">{{ mesh.status.region ?? 'Not set' }}</span>
</div>
<div class="mesh-stat">
@@ -48,7 +107,77 @@ async function handleReboot() {
</div>
<div class="mesh-stat">
<span class="mesh-stat-label">Type</span>
<span class="mesh-stat-value">{{ mesh.status.device_type === 'unknown' ? '—' : mesh.status.device_type }}</span>
<span class="mesh-stat-value">{{ deviceType === 'unknown' ? '—' : deviceType }}</span>
</div>
</div>
<!-- Radio settings -->
<div class="mt-5 pt-4 border-t border-white/10">
<h4 class="text-sm font-semibold text-white mb-3">Radio Settings</h4>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-white/60 mb-1">LoRa region / frequency plan</label>
<select v-model="form.region" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="">Keep the radio's current region</option>
<option v-for="r in LORA_REGIONS" :key="r.code" :value="r.code">{{ r.label }}</option>
</select>
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
{{ selectedRegion.code }}: {{ selectedRegion.dutyCyclePct }}% duty-cycle limit, {{ selectedRegion.band }} MHz, max {{ selectedRegion.maxPowerDbm }} dBm
</p>
<p v-if="effectiveKind === 'meshtastic' || effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-1">
Applied to fresh (region-unset) Meshtastic radios; a radio that already has a region keeps it.
</p>
<p v-else-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} the radio's flashed RF settings apply; adjust via a MeshCore client if they differ.
</p>
<p v-else-if="effectiveKind === 'meshcore'" class="text-[11px] text-sky-300/80 mt-1">
MeshCore radios keep their flashed RF settings verify the radio matches your region's band{{ selectedRegion ? ` (${selectedRegion.band} MHz)` : '' }}.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
</p>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Radio firmware</label>
<select v-model="form.deviceKind" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option value="auto">Auto-detect (Meshcore Meshtastic RNode)</option>
<option value="meshcore">MeshCore</option>
<option value="meshtastic">Meshtastic</option>
<option value="reticulum">Reticulum / RNode</option>
</select>
<p class="text-[11px] text-white/40 mt-1">
Pin the flashed firmware so no other protocol's probe bytes touch the port.
</p>
</div>
<div v-if="effectiveKind !== 'reticulum'">
<label class="block text-xs text-white/60 mb-1">Channel</label>
<input v-model="form.channel" maxlength="11" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Name on the mesh</label>
<input v-model="form.name" maxlength="24" placeholder="node name" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
</label>
<p v-if="effectiveKind === 'auto'" class="text-[11px] text-white/40 mt-3">
Options adapt to the detected firmware: region + channel program Meshtastic radios;
MeshCore and RNode own their RF parameters in firmware/daemon config; name and identity apply to all.
</p>
<div class="flex items-center gap-3 mt-4">
<button
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="saving"
@click="saveSettings"
>
{{ saving ? 'Saving…' : 'Save Settings' }}
</button>
<span v-if="saveDone" class="text-xs text-green-400">Saved applies on next radio session</span>
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
</div>
</div>
@@ -362,34 +362,33 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.00-alpha -->
<!-- v1.7.100-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.00-alpha</span>
<span class="text-xs text-white/40">June 18, 2026</span>
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.100-alpha</span>
<span class="text-xs text-white/40">July 14, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.</p>
<p>You can now use your node's AI assistant straight from a normal chat. Send "!ai &lt;your question&gt;" in a direct message to an AI-enabled node and the answer comes right back in the same conversation whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.</p>
<p>The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.</p>
<p>Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.</p>
<p>In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.</p>
<p>In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.</p>
<p>Mesh direct messages are now private and end-to-end encrypted to the recipient they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).</p>
<p>You can now message standard meshcore apps (like the phone companion) and they can message you text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.</p>
<p>New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.</p>
<p>"Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.</p>
<p>The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.</p>
<p>Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.</p>
<p>You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.</p>
<p>Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.</p>
<p>You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.</p>
<p>Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.</p>
<p>The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).</p>
<p>Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.</p>
<p>You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.</p>
<p>Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.</p>
<p>For advanced testing, Settings includes an optional update &amp; app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.</p>
<p>Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update and switching is designed to be safe, with no surprise resyncs.</p>
<p>Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.</p>
<p>Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.</p>
<p>The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together with a payment confirmation screen and an automatic refund if a purchase fails.</p>
<p>Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in with board pictures, full radio settings, and signal-strength indicators.</p>
<p>Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).</p>
<p>Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.</p>
<p>Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.</p>
<p>Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.</p>
<p>Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar.</p>
<p>Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai &lt;your question&gt;" in a direct message for an AI answer that comes back on the same path it arrived with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel.</p>
<p>The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute, and mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box.</p>
<p>You can message standard meshcore phone apps and they can message you readable text both ways, private replies instead of public-channel broadcasts. Federated Archipelago nodes now appear on the Mesh Map.</p>
<p>Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons.</p>
<p>BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels.</p>
<p>Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint.</p>
<p>The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon.</p>
<p>Six placeholder "apps" that were just web bookmarks are gone from the store, the Bitcoin dashboard works fully offline, Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart loops.</p>
<p>Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot.</p>
<p>Consistent dropdowns, toggles, tabs, and modal styling across the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings; and a fresh node no longer reinstalls apps just because their definition file exists on disk.</p>
</div>
</div>
<!-- v1.7.99-alpha -->
@@ -1068,7 +1067,7 @@ init()
<p>Installing, updating, and removing apps no longer freezes the UI. The backend now spawns the actual work in the background and returns immediately, so the progress bar starts moving right away instead of the whole page locking up for 30+ seconds while podman pulls an image.</p>
<p>Install progress bar actually reflects reality now. It previously stayed at 0% until the very end because podman doesn't emit parseable progress when run without a TTY. Replaced byte-counting with seven clearly-labelled phases Preparing, Pulling image, Creating container, Starting, Waiting for health, Finalizing, Done each mapped to a fixed percentage so the bar only moves forward.</p>
<p>Launch button now appears the moment an install finishes, instead of waiting up to 60 seconds for the next container scan. After a successful install or update, the backend kicks the scanner and waits for a fresh manifest to land before flipping the app to Running, so the UI always has real port and UI-route info by the time the card becomes clickable.</p>
<p>Retired the decommissioned .23 Hetzner VPS mirror. New nodes default to OVH (146.59.87.168) as Server 1 and tx1138 as Server 2 for both system updates and the app registry. Existing nodes auto-purge any saved .23 entries on next load so they stop paying connection-timeout penalties against a dead host.</p>
<p>Retired the decommissioned Hetzner VPS mirror. New nodes default to the OVH mirror as Server 1 and tx1138 as Server 2 for both system updates and the app registry. Existing nodes auto-purge any saved entries for the dead mirror on next load so they stop paying connection-timeout penalties against a dead host.</p>
<p>Update-available badges and version comparisons work again across every app. The backend was looking for its pinned-image catalog at the wrong path and silently getting an empty result on deployed nodes, which meant the UI never showed "update available" even when a newer image was ready. The search path now matches where the image recipe actually installs the file.</p>
<p>Nodes with a 2 TB data drive are no longer silently configured as pruned Bitcoin nodes. The disk-size check that decides whether to enable pruning was measuring the tiny OS partition instead of the large encrypted data partition, so every archy install with a separate data volume was flipping into prune=550 mode on reconcile and deleting its historical blocks on the next bitcoin-knots restart. The check now measures the actual data partition, so full-archive nodes stay full-archive.</p>
<p>Recovery from a failed update no longer leaves a container permanently missing. When an app update failed partway through, the rollback path tried to restart the old container by name even though the forward path had already deleted it, leaving a hole in the node that required manual intervention. The reconcile tool now supports a --create-missing flag that rebuilds any registered container from its canonical spec, giving the update flow a safe recovery path.</p>
@@ -1226,7 +1225,7 @@ init()
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App installs now show a real download progress bar same accuracy as the system update bar. You'll see "Downloading: 50.5 / 200.0 MB (25%)" with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a "stuck at 0%" again.</p>
<p>Uninstalls now show what's actually happening: "Stopping containers (2/5)", "Cleaning up volumes", "Removing app data" — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.</p>
<p>OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
<p>The OVH mirror is now baked in as Server 3 by default for both updates and the app registry extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
</div>
</div>
<!-- v1.7.29-alpha -->
+157 -44
View File
@@ -8,9 +8,13 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.nodeVisibility') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.nodeVisibilityDesc') }}</p>
<p class="text-white/70 text-sm">
Make your node publicly discoverable. When enabled, anyone on the Nostr
network can find your node and request a connection requests always
wait for your approval and join as a Peer, never trusted.
</p>
</div>
<div v-if="visibilityLoading" class="shrink-0">
<svg class="animate-spin h-5 w-5 text-white/40" fill="none" viewBox="0 0 24 24">
@@ -20,32 +24,24 @@
</div>
</div>
<!-- Visibility Options -->
<div class="space-y-2 flex-1 min-h-0">
<button
v-for="opt in visibilityOptions"
:key="opt.value"
@click="setVisibility(opt.value)"
<!-- Enable switch -->
<div class="flex items-center justify-between gap-3 p-3 rounded-lg bg-white/5 border border-white/10">
<div class="min-w-0">
<p class="text-sm font-medium text-white">Enable</p>
<p class="text-xs text-white/50">
{{ discoverEnabled ? 'Your node is public — anyone can discover it and request to peer' : 'Your node is hidden from discovery' }}
</p>
</div>
<ToggleSwitch
:model-value="discoverEnabled"
:disabled="settingVisibility"
class="w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left"
:class="nodeVisibility === opt.value
? 'bg-white/10 border-white/25 text-white'
: 'bg-white/5 border-white/10 text-white/60 hover:bg-white/8 hover:text-white/80'"
>
<div class="w-3 h-3 rounded-full shrink-0 border-2 flex items-center justify-center"
:class="nodeVisibility === opt.value ? 'border-green-400' : 'border-white/30'"
>
<div v-if="nodeVisibility === opt.value" class="w-1.5 h-1.5 rounded-full bg-green-400"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{{ opt.label }}</p>
<p class="text-xs text-white/50">{{ opt.description }}</p>
</div>
</button>
aria-label="Enable public discoverability"
@update:model-value="toggleDiscoverable"
/>
</div>
<!-- Onion address (shown when discoverable/public) -->
<div v-if="nodeVisibility !== 'hidden' && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
<!-- Onion address (shown when public) -->
<div v-if="discoverEnabled && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourTorAddress') }}</p>
@@ -59,10 +55,58 @@
</div>
</div>
<!-- Discoverable nodes -->
<div v-if="discoverEnabled" class="mt-4 flex-1 min-h-0">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-white">Discoverable nodes</p>
<button
class="px-2.5 py-1 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50"
:disabled="discovering"
@click="discoverNodes"
>
{{ discovering ? 'Searching…' : 'Refresh' }}
</button>
</div>
<div v-if="discovering && discoveredNodes.length === 0" class="py-4 text-center text-white/45 text-xs">
Searching relays
</div>
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
</div>
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1">
<div
v-for="node in discoveredNodes"
:key="node.nostr_pubkey"
class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-white truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
:disabled="requestingPeer === node.nostr_pubkey || requestedPeers.has(node.nostr_pubkey)"
@click="requestModalTarget = node"
>
{{ requestedPeers.has(node.nostr_pubkey) ? 'Requested' : requestingPeer === node.nostr_pubkey ? 'Sending…' : 'Request to Peer' }}
</button>
</div>
</div>
</div>
<!-- Warning -->
<p v-if="nodeVisibility !== 'hidden'" class="mt-3 text-xs text-amber-400/80">
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
{{ t('web5.discoverableWarning') }}
</p>
<PeerRequestModal
:show="requestModalTarget !== null"
:target-label="requestModalTarget ? shortNpub(requestModalTarget.nostr_npub) : ''"
:sending="requestingPeer !== null"
@send="confirmPeerRequest"
@cancel="requestModalTarget = null"
/>
</div>
</template>
@@ -70,6 +114,8 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
import { safeClipboardWrite } from './utils'
import type { VisibilityLevel } from './types'
@@ -87,37 +133,65 @@ const nodeVisibility = ref<VisibilityLevel>('hidden')
const nodeOnionAddress = ref<string | null>(null)
const visibilityLoading = ref(false)
const settingVisibility = ref(false)
const discoverEnabled = ref(false)
const visibilityOptions = [
{ value: 'hidden' as VisibilityLevel, label: 'Hidden', description: 'Your node is not discoverable by others' },
{ value: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Federated peers can find and connect to your node' },
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Accepting connections from any Archipelago node' },
]
interface DiscoverableNode {
nostr_pubkey: string
nostr_npub: string
did: string
version: string
}
const discoveredNodes = ref<DiscoverableNode[]>([])
const discovering = ref(false)
const requestingPeer = ref<string | null>(null)
const requestedPeers = ref(new Set<string>())
function shortNpub(npub: string): string {
if (!npub) return 'unknown'
return npub.length > 21 ? `${npub.slice(0, 12)}${npub.slice(-6)}` : npub
}
async function loadVisibility() {
visibilityLoading.value = true
try {
const res = await rpcClient.call<{ visibility: string; onion_address?: string }>({ method: 'network.get-visibility' })
nodeVisibility.value = (res.visibility as VisibilityLevel) || 'hidden'
nodeOnionAddress.value = res.onion_address || null
// Nostr discovery is the functional flag: when on, a presence event
// (DID + npub never the onion) is published to public relays so
// ANYONE can find this node and request a connection. The legacy
// network.get-visibility tri-state is only read for the onion display.
const [disc, vis] = await Promise.all([
rpcClient.nostrDiscoveryStatus(),
rpcClient
.call<{ visibility: string; onion_address?: string; tor_address?: string }>({ method: 'network.get-visibility' })
.catch(() => null),
])
discoverEnabled.value = !!disc.enabled
nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden'
nodeOnionAddress.value = vis?.onion_address || vis?.tor_address || null
if (discoverEnabled.value) void discoverNodes()
} catch {
nodeVisibility.value = 'hidden'
discoverEnabled.value = false
} finally {
visibilityLoading.value = false
}
}
async function setVisibility(level: VisibilityLevel) {
if (settingVisibility.value || nodeVisibility.value === level) return
async function toggleDiscoverable(enabled: boolean) {
if (settingVisibility.value) return
settingVisibility.value = true
try {
const res = await rpcClient.call<{ visibility: string; onion_address?: string }>({
method: 'network.set-visibility',
params: { visibility: level },
})
nodeVisibility.value = (res.visibility as VisibilityLevel) || level
nodeOnionAddress.value = res.onion_address || nodeOnionAddress.value
emit('toast', t('web5.visibilitySetTo', { level }))
// Public means public: the switch drives nostr presence publishing.
const res = await rpcClient.nostrSetDiscovery(enabled)
discoverEnabled.value = !!res.enabled
// Keep the legacy visibility string in sync (cosmetic; best-effort).
const level: VisibilityLevel = enabled ? 'public' : 'hidden'
rpcClient
.call({ method: 'network.set-visibility', params: { visibility: level } })
.then(() => { nodeVisibility.value = level })
.catch(() => {})
emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery')
if (enabled) void discoverNodes()
else discoveredNodes.value = []
} catch {
emit('toast', t('web5.failedToUpdateVisibility'))
} finally {
@@ -125,6 +199,45 @@ async function setVisibility(level: VisibilityLevel) {
}
}
async function discoverNodes() {
if (discovering.value) return
discovering.value = true
try {
const res = await rpcClient.handshakeDiscover()
discoveredNodes.value = res.nodes || []
} catch {
// keep whatever we had; the empty-state copy explains relay gossip lag
} finally {
discovering.value = false
}
}
const requestModalTarget = ref<DiscoverableNode | null>(null)
async function confirmPeerRequest(message: string | undefined) {
const node = requestModalTarget.value
if (!node) return
await requestToPeer(node, message)
requestModalTarget.value = null
}
async function requestToPeer(node: DiscoverableNode, message?: string) {
if (requestingPeer.value) return
requestingPeer.value = node.nostr_pubkey
try {
// Connection requests always land as Peer (observer) on approval
// never trusted so a mistaken request can't hand over fleet access.
await rpcClient.handshakeConnect(node.nostr_pubkey, message)
requestedPeers.value.add(node.nostr_pubkey)
requestedPeers.value = new Set(requestedPeers.value)
emit('toast', 'Peer request sent — awaiting their approval')
} catch (e) {
emit('toast', e instanceof Error ? e.message : 'Failed to send peer request')
} finally {
requestingPeer.value = null
}
}
function copyOnionAddress() {
if (!nodeOnionAddress.value) return
safeClipboardWrite(nodeOnionAddress.value)