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
+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)