feat: v1.2.0-alpha — E2E encrypted mesh relay, steganography, relay status polling

Phase 5 mesh networking:
- E2E encrypted TX relay (X25519 + ChaCha20-Poly1305) — non-Archy nodes
  relay encrypted blobs transparently via Meshcore native routing
- Steganographic encoding modes (WeatherStation, SensorNetwork) — traffic
  looks like sensor data on the wire, 0xAA marker, configurable per-node
- Pre-flight Bitcoin Core health check on relay node — specific error codes
  (bitcoin_unreachable, bitcoin_syncing, tx_rejected) instead of generic fails
- mesh.relay-status RPC endpoint — frontend polls for relay result every 3s
- On-Chain / Lightning tabs in Off-Grid Bitcoin panel
- Archy Peers vs Mesh Broadcast relay mode selector
- Mesh view fills viewport (no page scroll), internal panel scrolling
- Version bump to 1.2.0-alpha

Also includes: deploy hardening, container fixes, IndeedHub updates,
boot screen, dashboard improvements, MASTER_PLAN task tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-17 23:56:37 +00:00
co-authored by Claude Opus 4.6
parent 70f1348c15
commit d37ec1dea5
48 changed files with 3432 additions and 438 deletions
+84 -35
View File
@@ -1,19 +1,20 @@
<template>
<div class="pb-6">
<!-- Desktop: tabs + search in one row -->
<div class="hidden md:flex items-center gap-4 mb-4">
<!-- Desktop: page tabs + category tabs + search -->
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
@click="activeTab = 'apps'"
>My Apps</button>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectedCategory = category.id"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
@click="activeTab = 'services'"
>Services</button>
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<input
v-model="searchQuery"
@@ -24,21 +25,8 @@
/>
</div>
<!-- Mobile: tabs + search -->
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
<div class="md:hidden mb-4">
<div class="mode-switcher mode-switcher-full mb-3">
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'apps' }"
@click="activeTab = 'apps'"
>My Apps</button>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
<button
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': activeTab === 'services' }"
@click="activeTab = 'services'"
>Services</button>
</div>
<input
v-model="searchQuery"
type="text"
@@ -253,6 +241,8 @@
</div>
</Transition>
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
@@ -267,7 +257,7 @@
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@@ -277,33 +267,88 @@ import { PackageState, type PackageDataEntry } from '../types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
// Tabs
const activeTab = ref<'apps' | 'services'>('apps')
// Tabs — support ?tab=services from Marketplace link
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
)
// Service container name patterns (backend/infra, not user-facing)
// Exact container names or prefixes that are backend services (not user-facing)
const SERVICE_NAMES = new Set([
'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
'immich_postgres', 'immich_redis',
'penpot-postgres', 'penpot-valkey', 'penpot-backend', 'penpot-exporter',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'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',
'mysql-mempool',
])
function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
const lower = id.toLowerCase()
return lower.includes('_db') || lower.includes('-db') && !lower.includes('indeedhub')
? SERVICE_NAMES.has(id)
: false
// Catch any indeedhub-build_* compose infrastructure containers
if (id.startsWith('indeedhub-build_')) return true
// Catch database containers
if (id.endsWith('_db') || id.endsWith('-db')) return true
return false
}
// Search
const searchQuery = ref('')
// Category filter (same categories as App Store)
const selectedCategory = ref('all')
// Known app → category mappings (matches App Store categorisation)
const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'onlyoffice': 'data',
'homeassistant': 'home', 'lorabell': 'home', 'endurain': 'home',
'searxng': 'community', 'ollama': 'community', 'grafana': 'data',
'nostr-rs-relay': 'nostr', 'nostrudel': 'nostr',
'tailscale': 'networking', 'nginx-proxy-manager': 'networking', 'portainer': 'networking',
'uptime-kuma': 'networking', 'dwn': 'data',
'botfights': 'l484', 'nwnn': 'l484', '484-kitchen': 'l484',
'call-the-operator': 'l484', 'syntropy-institute': 'l484', 't-zero': 'l484',
}
function getAppCategory(id: string, pkg: PackageDataEntry): string {
// Check hardcoded map first, then manifest category, then fallback
if (APP_CATEGORY_MAP[id]) return APP_CATEGORY_MAP[id]
const cat = (pkg.manifest as unknown as Record<string, unknown>)?.category as string | undefined
return cat || 'other'
}
const ALL_CATEGORIES = computed(() => [
{ id: 'all', name: t('marketplace.all') },
{ id: 'community', name: t('marketplace.community') },
{ id: 'nostr', name: 'Nostr' },
{ id: 'commerce', name: t('marketplace.commerce') },
{ id: 'money', name: t('marketplace.money') },
{ id: 'data', name: t('marketplace.data') },
{ 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') },
])
const categoriesWithApps = computed(() => {
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
return ALL_CATEGORIES.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
})
})
// Track loading states for each app action
const loadingActions = ref<Record<string, boolean>>({})
@@ -381,10 +426,14 @@ const packages = computed(() => {
// Web-only apps first (alphabetically), then all other apps (alphabetically)
const sortedPackageEntries = computed(() => {
const entries = Object.entries(packages.value)
// Filter by active tab
const filtered = entries.filter(([id]) => {
// Filter by active tab and category
const filtered = entries.filter(([id, pkg]) => {
const isSvc = isServiceContainer(id)
return activeTab.value === 'services' ? isSvc : !isSvc
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
return getAppCategory(id, pkg) === selectedCategory.value
}
return true
})
return filtered.sort(([idA, a], [idB, b]) => {
const aWeb = isWebOnlyApp(idA) ? 0 : 1