diff --git a/.gitea/workflows/build-iso.yml b/.gitea/workflows/build-iso.yml
new file mode 100644
index 00000000..d1abdc54
--- /dev/null
+++ b/.gitea/workflows/build-iso.yml
@@ -0,0 +1,62 @@
+name: Build Archipelago release ISO (gated)
+
+# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
+# Dispatch-only on purpose: the ISO is cut per release, not per push, and
+# the iso-builder runner is a live node — builds are deliberate events.
+on:
+ workflow_dispatch:
+
+jobs:
+ build-iso:
+ runs-on: iso-builder
+ timeout-minutes: 180
+ steps:
+ - name: Sync source to workspace
+ run: |
+ # Direct fetch + sync (actions/checkout token is broken on this Gitea)
+ REPO_DIR="$HOME/Projects/archy"
+ [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
+ cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
+ echo "=== Source at commit: $(git log --oneline -1) ==="
+
+ - name: Install ISO build dependencies
+ run: |
+ if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
+ grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
+ echo "ISO build deps already installed, skipping apt"
+ else
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq \
+ debootstrap squashfs-tools xorriso \
+ isolinux syslinux-common mtools \
+ grub-efi-amd64-bin grub-pc-bin grub-common
+ fi
+
+ - name: Build backend + frontend if stale
+ run: |
+ REPO_DIR="$HOME/Projects/archy"
+ [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
+ cd "$REPO_DIR"
+ . "$HOME/.cargo/env" 2>/dev/null || true
+ VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
+ if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
+ cargo build --release --manifest-path core/Cargo.toml -p archipelago
+ fi
+ if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
+ (cd neode-ui && npm ci && npm run build)
+ fi
+
+ - name: Gated ISO build (gates + build + smoke + qemu)
+ run: |
+ REPO_DIR="$HOME/Projects/archy"
+ [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
+ cd "$REPO_DIR"
+ . "$HOME/.cargo/env" 2>/dev/null || true
+ bash scripts/build-iso-release.sh
+
+ - name: Report artifacts
+ if: always()
+ run: |
+ REPO_DIR="$HOME/Projects/archy"
+ [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
+ ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index beacd6d0..ef60d323 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,16 +1,16 @@
## Summary
-
+
-## Changes
+## Verification
--
+
## Checklist
-- [ ] TypeScript type-check passes (`npm run type-check`)
-- [ ] Frontend builds (`npm run build`)
-- [ ] Tests pass (`npm test`)
-- [ ] Rust clippy clean (if backend changes)
-- [ ] No new compiler warnings
-- [ ] Tested on live server
+- [ ] Rust formatting/clippy/tests pass when backend code changed.
+- [ ] Frontend type-check/build/tests pass when frontend code changed.
+- [ ] App manifests validate when app packaging changed.
+- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
+- [ ] Docs are updated for user-facing or developer-facing behavior changes.
+- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 88697e19..d385323b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,11 +8,11 @@ on:
env:
RUST_VERSION: stable
- NODE_VERSION: 18
+ NODE_VERSION: 20
jobs:
rust:
- name: Rust (fmt + clippy + test)
+ name: Rust
runs-on: ubuntu-latest
defaults:
run:
@@ -28,17 +28,17 @@ jobs:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- - name: Check formatting
+ - name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- - name: Tests
+ - name: Test
run: cargo test --all-features
frontend:
- name: Frontend (type-check + lint)
+ name: Frontend
runs-on: ubuntu-latest
defaults:
run:
@@ -52,14 +52,31 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- cache: 'npm'
+ cache: npm
cache-dependency-path: neode-ui/package-lock.json
- - name: Install dependencies
+ - name: Install
run: npm ci
- name: Type check
run: npm run type-check
+ - name: Test
+ run: npm test
+
- name: Build
run: npm run build
+
+ manifests:
+ name: App Manifests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Validate manifests
+ run: |
+ for manifest in apps/*/manifest.yml; do
+ ./scripts/validate-app-manifest.sh --repo-audit "$manifest"
+ done
diff --git a/.gitignore b/.gitignore
index 53492bb0..82c46e47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,9 @@
-# SSH keys (sandbox copies)
+# SSH keys and sandbox copies
.ssh/
# Rust build output
target/
**/target/
-Cargo.lock
# Node.js
node_modules/
@@ -12,7 +11,6 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-package-lock.json
pnpm-debug.log*
# Build outputs
@@ -28,49 +26,46 @@ build/
*.swo
*~
.DS_Store
+._*
+Thumbs.db
# Environment and local overrides
.env
.env.local
.env.*.local
+.env.production
+core/.env.production
scripts/deploy-config.sh
# Logs
logs/
*.log
-# OS
-.DS_Store
-Thumbs.db
-
# Testing
coverage/
.nyc_output/
-# Temporary files
-*.tmp
-*.temp
-
-# Build artifacts
+# Image / release artifacts
*.iso
*.img
*.dmg
*.app
+*.apk
+*.keystore
+*.s9pk
+*.tar.gz
-# Release artifacts live in Gitea Release attachments, not Git history.
+# Release artifacts live in release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
-# macOS build output
-build/macos/
-
# Image recipe output
image-recipe/output/
image-recipe/*.iso
image-recipe/*.img
-# Loop tool artifacts (created in every subdirectory)
+# Loop tool artifacts
*/loop/
loop/loop/
loop/loop.log.bak
@@ -78,19 +73,17 @@ loop/loop.log.bak
# Separate repos nested in tree
web/
-._*
-
-# Resilience harness reports (generated, contains session cookies)
+# Resilience harness reports contain session cookies.
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
+.claude/
.pnpm-store/
**/__pycache__/
*.bak
-.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.
diff --git a/Android/COMPANION_RELEASE.md b/Android/COMPANION_RELEASE.md
index 1ef25999..e508c580 100644
--- a/Android/COMPANION_RELEASE.md
+++ b/Android/COMPANION_RELEASE.md
@@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
**aborts** if any is missing.
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
+6. The first-launch companion modal and Android "Share this app" QR point at
+ `http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
+ repo artifact is built, mirror that exact APK to the VPS2-served path before
+ calling the release done.
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
skips the clean build and the signature enforcement and is exactly how a broken
@@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
## Verify the published download after shipping
-The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
-what you built and signed:
+The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
+to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
+built and signed:
```bash
SERVED=neode-ui/public/packages/archipelago-companion.apk
-URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
-curl -sS -o /tmp/live.apk "$URL"
-shasum -a 256 "$SERVED" /tmp/live.apk # must match
-apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
+GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
+QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
+curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
+curl -sS -o /tmp/live-qr.apk "$QR_URL"
+shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
+apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
```
diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts
index 5cbbe440..0fb58dd7 100644
--- a/Android/app/build.gradle.kts
+++ b/Android/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
- versionCode = 26
- versionName = "0.5.6"
+ versionCode = 45
+ versionName = "0.5.25"
vectorDrawables {
useSupportLibrary = true
diff --git a/Android/app/debug.keystore b/Android/app/debug.keystore
deleted file mode 100644
index d99c47cf..00000000
Binary files a/Android/app/debug.keystore and /dev/null differ
diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml
index bad2ab4d..1ed5e9a3 100644
--- a/Android/app/src/main/AndroidManifest.xml
+++ b/Android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,18 @@
android:usesCleartextTraffic="true"
tools:targetApi="35">
+
+
+
+
+
+ scope.launch {
+ prefs.upsertPartyPeer(peer)
+ FipsManager.requestMeshRestart(this@ArchyVpnService)
+ }
+ }
}
}
@@ -116,26 +160,40 @@ class ArchyVpnService : VpnService() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
+ val fipsPrefs = FipsPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
- val ulas = try {
- prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct()
+ val targets = try {
+ prefs.savedServers.first()
+ .mapNotNull { it.meshIp.ifBlank { null } }
+ .map { it to 80 } +
+ // Party phones answer on the flare port, not :80.
+ fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
} catch (_: Exception) {
emptyList()
- }
- for (ula in ulas) {
- try {
- java.net.Socket().use { s ->
- s.connect(
- java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80),
- 20_000,
- )
+ }.distinct()
+ if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
+ // Probe all targets CONCURRENTLY with a short timeout — the
+ // old sequential 20s-per-target loop let one cold node starve
+ // every other target for the whole aggressive window.
+ targets.map { (ula, port) ->
+ launch {
+ try {
+ java.net.Socket().use { s ->
+ s.connect(
+ java.net.InetSocketAddress(
+ java.net.InetAddress.getByName(ula),
+ port,
+ ),
+ 5_000,
+ )
+ }
+ } catch (_: Exception) {
+ // Cold path / node away — the attempt still drove
+ // session establishment; try again next round.
}
- } catch (_: Exception) {
- // Cold path / node away — the connect attempt still
- // drove session establishment; try again next round.
}
- }
+ }.forEach { it.join() }
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
@@ -144,14 +202,83 @@ class ArchyVpnService : VpnService() {
}
}
+ /**
+ * Track the phone's default network and hand the mesh over to it as the
+ * phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
+ * 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
+ * network instead of dying on the one it launched with.
+ * 2. re-home the mesh — kick the session warmer so discovery + sessions
+ * rebuild on the new path immediately; the node's own fast-reconnect
+ * (1s) redials peers over the new route.
+ * onAvailable also fires for the FIRST network, which is how the initial
+ * underlying network gets set.
+ */
+ private fun registerNetworkHandoff() {
+ if (networkCallback != null) return
+ val cm = getSystemService(ConnectivityManager::class.java) ?: return
+ connectivityManager = cm
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ val cb = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ handoffTo(network)
+ }
+
+ override fun onLost(network: Network) {
+ // The lost network was our underlying one — clear the pin so
+ // the system falls back to whatever default remains; the next
+ // onAvailable re-pins explicitly.
+ if (network == currentUnderlying) {
+ currentUnderlying = null
+ runCatching { setUnderlyingNetworks(null) }
+ }
+ }
+ }
+ networkCallback = cb
+ // requestNetwork tracks the BEST network of the request; when the
+ // phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
+ // one. (registerDefaultNetworkCallback would also work; requestNetwork
+ // lets us extend to BLE-capable transports later.)
+ runCatching { cm.requestNetwork(request, cb) }
+ }
+
+ private fun handoffTo(network: Network) {
+ val changed = network != currentUnderlying
+ currentUnderlying = network
+ // Always re-assert; cheap and covers capability changes on the same
+ // Network object.
+ runCatching { setUnderlyingNetworks(arrayOf(network)) }
+ if (changed && FipsNative.isRunning()) {
+ Log.i(TAG, "network handoff → re-homing mesh on new default network")
+ // Fresh warmer pass drives immediate rediscovery/session rebuild
+ // on the new path instead of waiting out dead-link timeouts.
+ startSessionWarmer()
+ }
+ }
+
+ private fun unregisterNetworkHandoff() {
+ val cm = connectivityManager
+ val cb = networkCallback
+ if (cm != null && cb != null) {
+ runCatching { cm.unregisterNetworkCallback(cb) }
+ }
+ networkCallback = null
+ connectivityManager = null
+ currentUnderlying = null
+ }
+
private fun shutdown() {
warmerJob?.cancel()
+ unregisterNetworkHandoff()
+ FlareServer.stop()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
+ unregisterNetworkHandoff()
FipsNative.stop()
scope.cancel()
super.onDestroy()
diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt
index 346089ca..398356ec 100644
--- a/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt
+++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt
@@ -21,6 +21,12 @@ object FipsManager {
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow = _consentNeeded
+ /** True after a pairing changed the peer set while the node was running —
+ * tells the service a restart is genuinely needed (the ONLY case; a
+ * routine app open must keep the warm mesh, not rebuild it). */
+ @Volatile
+ var peersDirty: Boolean = false
+
fun consentHandled() {
_consentNeeded.value = false
}
@@ -34,7 +40,16 @@ object FipsManager {
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
- _consentNeeded.value = true
+ peersDirty = true
+ // Restart the mesh with the new peer RIGHT NOW when consent already
+ // exists — relying on the consentNeeded collector left a running
+ // mesh on the OLD peer list whenever the collector wasn't active
+ // (fresh pairings looked dead until a full app restart).
+ if (VpnService.prepare(context) == null) {
+ startService(context)
+ } else {
+ _consentNeeded.value = true
+ }
}
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
@@ -64,6 +79,22 @@ object FipsManager {
context.startForegroundService(intent)
}
+ /**
+ * Re-run the mesh with current prefs (party listen toggled, peer added).
+ * Marks the peer set dirty so startMesh genuinely restarts the node —
+ * otherwise the keep-warm fast path would skip the new config.
+ * First-timers go through the consent flow.
+ */
+ fun requestMeshRestart(context: Context) {
+ if (!FipsNative.available) return
+ peersDirty = true
+ if (VpnService.prepare(context) == null) {
+ startService(context)
+ } else {
+ _consentNeeded.value = true
+ }
+ }
+
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)
diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt
index f81734eb..cd52a13a 100644
--- a/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt
+++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt
@@ -21,7 +21,13 @@ object FipsNative {
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
- external fun start(secret: String, peersJson: String, tunFd: Int): String
+
+ /**
+ * [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
+ * that port so a nearby phone can dial us directly (party mode); the node
+ * stays leaf-only either way.
+ */
+ external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String
diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt
index d0d4df8e..7065f82f 100644
--- a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt
+++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt
@@ -3,9 +3,12 @@ package com.archipelago.app.fips
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
@@ -22,6 +25,26 @@ internal const val ARCHY_ANCHOR_NPUB =
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
+/**
+ * Public FIPS network anchors (join.fips.network — the dual-transport TCP
+ * pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
+ * fips_network_anchors()). Baked into every pairing at trailing priority so
+ * a degraded/unreachable vps2 anchor can never strand the phone: the mesh
+ * still joins the public tree and routes to the node through it.
+ */
+internal val PUBLIC_FIPS_ANCHORS = listOf(
+ Triple(
+ "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
+ "23.182.128.74:443",
+ "tcp",
+ ),
+ Triple(
+ "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
+ "217.77.8.91:443",
+ "tcp",
+ ),
+)
+
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
@@ -34,6 +57,12 @@ class FipsPreferences(private val context: Context) {
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
+ /** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
+ private val partyPeersKey = stringPreferencesKey("fips_party_peers")
+ /** Party mode: accept a direct inbound mesh link (UDP 2121). */
+ private val partyListenKey = booleanPreferencesKey("fips_party_listen")
+ /** Name shown in this phone's party QR and outgoing flares. */
+ private val partyNameKey = stringPreferencesKey("fips_party_name")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
@@ -60,6 +89,157 @@ class FipsPreferences(private val context: Context) {
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
+ // ── Mesh Party (phone↔phone) ────────────────────────────────────────────
+
+ suspend fun partyListen(): Boolean =
+ context.fipsDataStore.data.first()[partyListenKey] ?: false
+
+ val partyListenFlow: Flow
+ get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
+
+ suspend fun setPartyListen(enabled: Boolean) {
+ context.fipsDataStore.edit { it[partyListenKey] = enabled }
+ }
+
+ suspend fun partyName(): String =
+ context.fipsDataStore.data.first()[partyNameKey]
+ ?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
+
+ suspend fun setPartyName(name: String) {
+ context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
+ }
+
+ val partyPeersFlow: Flow>
+ get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
+
+ suspend fun partyPeers(): List =
+ parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
+
+ /** Matched by npub, so re-scanning updates the direct-dial address in place. */
+ suspend fun upsertPartyPeer(peer: PartyPeer) {
+ context.fipsDataStore.edit { prefs ->
+ val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
+ prefs[partyPeersKey] = toJson(kept + peer)
+ }
+ }
+
+ suspend fun removePartyPeer(npub: String) {
+ context.fipsDataStore.edit { prefs ->
+ val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
+ prefs[partyPeersKey] = toJson(kept)
+ }
+ }
+
+ /**
+ * Node peers + direct-dial party peers, in the fips PeerConfig JSON the
+ * Rust node deserializes. Party peers get the best priority: on a shared
+ * LAN/hotspot the direct link beats every anchor path, and while off-LAN
+ * the failed dial is cheap (auto-reconnect keeps retrying, which is
+ * exactly what makes the link snap up the moment both phones share WiFi).
+ * Party peers without an underlay address are mesh-routed and need no
+ * entry here at all.
+ */
+ suspend fun combinedPeersJson(): String {
+ val merged = JSONArray(peersJson())
+ val party = partyPeers()
+ for (peer in party) {
+ if (peer.ip.isBlank() || peer.port <= 0) continue
+ merged.put(JSONObject().apply {
+ put("npub", peer.npub)
+ put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
+ put("addresses", JSONArray().put(JSONObject().apply {
+ put("transport", "udp")
+ put("addr", "${peer.ip}:${peer.port}")
+ put("priority", 5)
+ }))
+ })
+ }
+ // A party-only phone (never paired with a node) still needs a public
+ // rendezvous to reach its peers ACROSS the internet — without it, two
+ // bare phones would be hotspot/LAN-only. Node pairing normally bakes
+ // this anchor in; do the same when there are party peers.
+ if (party.isNotEmpty() &&
+ (0 until merged.length()).none {
+ merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
+ }
+ ) {
+ merged.put(JSONObject().apply {
+ put("npub", ARCHY_ANCHOR_NPUB)
+ put("alias", "archipelago-anchor")
+ put("addresses", JSONArray().put(JSONObject().apply {
+ put("transport", ARCHY_ANCHOR_TRANSPORT)
+ put("addr", ARCHY_ANCHOR_ADDR)
+ put("priority", 40)
+ }))
+ })
+ }
+ // Backfill anchor peers for entries paired before newer QR/app
+ // releases added them. `upsertNodePeer` persists these on re-scan, but
+ // startup must also self-heal old DataStore state so updating the APK is
+ // enough to get off-LAN redundancy.
+ if (merged.length() > 0) {
+ addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
+ for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
+ val (npub, addr, transport) = anchor
+ addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
+ }
+ }
+ return merged.toString()
+ }
+
+ private fun addAnchorIfMissing(
+ peers: JSONArray,
+ npub: String,
+ alias: String,
+ addr: String,
+ transport: String,
+ priority: Int,
+ ) {
+ if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
+ peers.put(JSONObject().apply {
+ put("npub", npub)
+ put("alias", alias)
+ put("addresses", JSONArray().put(JSONObject().apply {
+ put("transport", transport)
+ put("addr", addr)
+ put("priority", priority)
+ }))
+ })
+ }
+
+ private fun parsePartyPeers(json: String): List = try {
+ val arr = JSONArray(json)
+ (0 until arr.length()).mapNotNull { i ->
+ val o = arr.optJSONObject(i) ?: return@mapNotNull null
+ val npub = o.optString("npub")
+ val ula = o.optString("ula")
+ if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
+ PartyPeer(
+ npub = npub,
+ ula = ula,
+ name = o.optString("name").ifBlank { "Phone" },
+ ip = o.optString("ip"),
+ port = o.optInt("port"),
+ )
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+ private fun toJson(peers: List): String {
+ val arr = JSONArray()
+ for (p in peers) {
+ arr.put(JSONObject().apply {
+ put("npub", p.npub)
+ put("ula", p.ula)
+ put("name", p.name)
+ put("ip", p.ip)
+ put("port", p.port)
+ })
+ }
+ return arr.toString()
+ }
+
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
@@ -71,16 +251,19 @@ class FipsPreferences(private val context: Context) {
val incoming = mutableListOf()
incoming += JSONObject().apply {
put("npub", info.npub)
- put("alias", alias.ifBlank { "Archipelago" })
+ put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
val addresses = JSONArray()
- if (info.udpPort > 0) {
+ // .fips hosts are unresolvable on Android (no system .fips DNS):
+ // storing one gives the mesh a dial target that fails every
+ // handshake and stalls first connect on anchor discovery.
+ if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "udp")
put("addr", "${info.host}:${info.udpPort}")
put("priority", 10)
})
}
- if (info.tcpPort > 0) {
+ if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
addresses.put(JSONObject().apply {
put("transport", "tcp")
put("addr", "${info.host}:${info.tcpPort}")
@@ -91,9 +274,10 @@ class FipsPreferences(private val context: Context) {
}
for (anchor in info.anchors) {
if (anchor.npub == info.npub) continue
+ if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
incoming += JSONObject().apply {
put("npub", anchor.npub)
- put("alias", "Mesh anchor")
+ put("alias", "mesh-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", anchor.transport)
put("addr", anchor.addr)
@@ -108,7 +292,7 @@ class FipsPreferences(private val context: Context) {
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
- put("alias", "Archipelago anchor")
+ put("alias", "archipelago-anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
@@ -116,6 +300,21 @@ class FipsPreferences(private val context: Context) {
}))
}
}
+ // And the public FIPS network anchors at trailing priority, so one
+ // degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
+ for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
+ val (npub, addr, transport) = anchor
+ if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
+ incoming += JSONObject().apply {
+ put("npub", npub)
+ put("alias", "fips-network-anchor-${i + 1}")
+ put("addresses", JSONArray().put(JSONObject().apply {
+ put("transport", transport)
+ put("addr", addr)
+ put("priority", 50 + i * 10)
+ }))
+ }
+ }
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
@@ -129,3 +328,14 @@ class FipsPreferences(private val context: Context) {
}
}
}
+
+/**
+ * Peer aliases feed the fips host map as `.fips` hostnames; anything
+ * that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
+ * silently drops the peer from name resolution. Slug it instead of losing it.
+ */
+internal fun hostSafeAlias(alias: String): String =
+ alias.lowercase()
+ .replace(Regex("[^a-z0-9.-]+"), "-")
+ .trim('-', '.')
+ .ifBlank { "archipelago" }
diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt
new file mode 100644
index 00000000..a48a2294
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt
@@ -0,0 +1,398 @@
+package com.archipelago.app.fips
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.InputStream
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.util.UUID
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+/** One chat/photo message in a party conversation, keyed by the peer's npub. */
+data class FlareMessage(
+ val id: String,
+ val peerNpub: String,
+ val fromMe: Boolean,
+ val name: String,
+ val text: String = "",
+ val photoPath: String = "",
+ val ts: Long,
+ val status: Status = Status.RECEIVED,
+) {
+ enum class Status { SENDING, SENT, FAILED, RECEIVED }
+}
+
+/** In-memory conversation store (demo scope — nothing persists across restarts). */
+object FlareStore {
+ private val _messages = MutableStateFlow>(emptyList())
+ val messages: StateFlow> = _messages
+
+ fun add(message: FlareMessage) {
+ _messages.value = _messages.value + message
+ }
+
+ fun setStatus(id: String, status: FlareMessage.Status) {
+ _messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
+ }
+}
+
+/**
+ * Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is
+ * fine there because FIPS is the encryption + peer-identity layer (same
+ * stance as the node's ULA-only peer listener). This is what makes the phone
+ * a *server* on the mesh: another phone (or `curl -6` from any mesh node)
+ * reaches it by npub-derived address with no port forwarding, DNS, or CA.
+ *
+ * FIPS authenticates the node, not the request (project doctrine), so inputs
+ * are still validated at this boundary: size caps, JSON shape, no
+ * client-controlled paths.
+ */
+object FlareServer {
+ private const val TAG = "FlareServer"
+ private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
+ private const val MAX_TEXT_CHARS = 4_000
+ private const val MAX_HEADER_BYTES = 16 * 1024
+
+ private var socket: ServerSocket? = null
+ private var pool: ExecutorService? = null
+ @Volatile private var identityName = "Phone"
+ @Volatile private var identityNpub = ""
+ @Volatile private var photoDir: File? = null
+
+ /** Invoked when a peer announces itself (POST /hello) — pairing used to
+ * be one-way: only the SCANNING phone learned the other side, so the
+ * scanned phone had no peer, no chat entry, no way in. The VPN service
+ * wires this to upsert the peer + restart the mesh config. */
+ @Volatile var onHello: ((PartyPeer) -> Unit)? = null
+
+ @Synchronized
+ fun start(context: Context, ula: String, myNpub: String, myName: String) {
+ stop()
+ identityNpub = myNpub
+ identityName = myName
+ photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
+ val pool = Executors.newCachedThreadPool().also { this.pool = it }
+ pool.execute {
+ try {
+ val server = ServerSocket().apply {
+ reuseAddress = true
+ bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
+ }
+ socket = server
+ Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
+ while (!server.isClosed) {
+ val client = try {
+ server.accept()
+ } catch (_: Exception) {
+ break
+ }
+ pool.execute { handle(client) }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "flare server died: $e")
+ }
+ }
+ }
+
+ @Synchronized
+ fun stop() {
+ try {
+ socket?.close()
+ } catch (_: Exception) {
+ }
+ socket = null
+ pool?.shutdownNow()
+ pool = null
+ }
+
+ private fun handle(client: Socket) {
+ client.use { sock ->
+ sock.soTimeout = 30_000
+ try {
+ val input = BufferedInputStream(sock.getInputStream())
+ val requestLine = readLine(input) ?: return
+ val parts = requestLine.trim().split(" ")
+ if (parts.size < 2) return respond(sock, 400, json("bad_request"))
+ val (method, path) = parts[0] to parts[1]
+
+ var contentLength = 0
+ var from = ""
+ var fromName = ""
+ var headerBytes = requestLine.length
+ while (true) {
+ val line = readLine(input) ?: return
+ if (line.isEmpty()) break
+ headerBytes += line.length
+ if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
+ val idx = line.indexOf(':')
+ if (idx <= 0) continue
+ val key = line.substring(0, idx).trim().lowercase()
+ val value = line.substring(idx + 1).trim()
+ when (key) {
+ "content-length" -> contentLength = value.toIntOrNull() ?: 0
+ "x-from" -> from = value.take(80)
+ "x-name" -> fromName = value.take(80)
+ }
+ }
+
+ when {
+ method == "GET" && (path == "/" || path.startsWith("/?")) ->
+ respondHtml(sock, profilePage())
+ method == "POST" && path == "/hello" -> {
+ if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
+ val body = readExactly(input, contentLength) ?: return
+ receiveHello(String(body, Charsets.UTF_8))
+ respond(sock, 200, """{"ok":true}""")
+ }
+ method == "POST" && path == "/flare" -> {
+ if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
+ val body = readExactly(input, contentLength) ?: return
+ receiveFlare(String(body, Charsets.UTF_8))
+ respond(sock, 200, """{"ok":true}""")
+ }
+ method == "POST" && path == "/photo" -> {
+ if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
+ if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
+ val body = readExactly(input, contentLength) ?: return
+ receivePhoto(from, fromName, body)
+ respond(sock, 200, """{"ok":true}""")
+ }
+ else -> respond(sock, 404, json("not_found"))
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "request failed: $e")
+ }
+ }
+ }
+
+ /** A peer that scanned OUR QR announces itself — mutual pairing. */
+ private fun receiveHello(body: String) {
+ val o = try {
+ JSONObject(body)
+ } catch (_: Exception) {
+ return
+ }
+ val from = o.optString("from")
+ val ula = o.optString("ula")
+ if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
+ if (from == identityNpub) return
+ val peer = PartyPeer(
+ npub = from.take(80),
+ ula = ula.take(64),
+ name = o.optString("name").take(24).ifBlank { "Phone" },
+ ip = o.optString("ip").take(40),
+ port = o.optInt("port", 0),
+ )
+ onHello?.invoke(peer)
+ // Seed the conversation so the chat has a visible entry on this side.
+ FlareStore.add(
+ FlareMessage(
+ id = UUID.randomUUID().toString(),
+ peerNpub = peer.npub,
+ fromMe = false,
+ name = peer.name,
+ text = "👋 ${peer.name} joined the party",
+ ts = System.currentTimeMillis(),
+ )
+ )
+ }
+
+ private fun receiveFlare(body: String) {
+ val o = try {
+ JSONObject(body)
+ } catch (_: Exception) {
+ return
+ }
+ val from = o.optString("from")
+ if (!from.startsWith("npub1")) return
+ val text = o.optString("text").take(MAX_TEXT_CHARS)
+ if (text.isBlank()) return
+ FlareStore.add(
+ FlareMessage(
+ id = UUID.randomUUID().toString(),
+ peerNpub = from,
+ fromMe = false,
+ name = o.optString("name").take(80).ifBlank { "Phone" },
+ text = text,
+ ts = System.currentTimeMillis(),
+ )
+ )
+ }
+
+ private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
+ // Server-generated filename — the sender never controls the path.
+ val dir = photoDir ?: return
+ val file = File(dir, "${UUID.randomUUID()}.jpg")
+ file.writeBytes(bytes)
+ FlareStore.add(
+ FlareMessage(
+ id = UUID.randomUUID().toString(),
+ peerNpub = from,
+ fromMe = false,
+ name = fromName.ifBlank { "Phone" },
+ photoPath = file.absolutePath,
+ ts = System.currentTimeMillis(),
+ )
+ )
+ }
+
+ private fun profilePage(): String {
+ val npub = identityNpub
+ val name = identityName
+ return """
+
+
+ $name — on the mesh
+
+
⚡ $name
+
$npub
+
This page is being served by a phone, addressed by its
+ cryptographic identity over the FIPS mesh.
+
No port forwarding. No DNS. No certificate authority. No cloud.
+ The key is the address — and the transport underneath can be
+ 5G, WiFi, or a hotspot with no internet at all.
+
+ """.trimIndent()
+ }
+
+ // ── tiny HTTP plumbing ──────────────────────────────────────────────────
+
+ /** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
+ private fun readLine(input: InputStream): String? {
+ val sb = StringBuilder()
+ while (true) {
+ val b = input.read()
+ if (b == -1) return if (sb.isEmpty()) null else sb.toString()
+ if (b == '\n'.code) return sb.toString().trimEnd('\r')
+ sb.append(b.toChar())
+ if (sb.length > MAX_HEADER_BYTES) return null
+ }
+ }
+
+ private fun readExactly(input: InputStream, length: Int): ByteArray? {
+ val buf = ByteArray(length)
+ var off = 0
+ while (off < length) {
+ val n = input.read(buf, off, length - off)
+ if (n == -1) return null
+ off += n
+ }
+ return buf
+ }
+
+ private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
+
+ private fun respond(sock: Socket, status: Int, body: String) =
+ writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
+
+ private fun respondHtml(sock: Socket, body: String) =
+ writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
+
+ private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
+ val reason = when (status) {
+ 200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
+ 413 -> "Payload Too Large"; 431 -> "Headers Too Large"
+ else -> "Error"
+ }
+ val head = "HTTP/1.1 $status $reason\r\n" +
+ "Content-Type: $contentType\r\n" +
+ "Content-Length: ${body.size}\r\n" +
+ "Connection: close\r\n\r\n"
+ sock.getOutputStream().apply {
+ write(head.toByteArray(Charsets.ISO_8859_1))
+ write(body)
+ flush()
+ }
+ }
+}
+
+/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
+object FlareClient {
+ // Connect timeout must outlive cold mesh-session establishment (~15s via
+ // the public tree per HANDOFF-2026-07-23); the attempt itself drives
+ // session setup, same trick as the VPN service's session warmer.
+ private val http = OkHttpClient.Builder()
+ .connectTimeout(25, TimeUnit.SECONDS)
+ .readTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+
+ private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
+
+ /** Announce myself to a freshly scanned peer so pairing becomes MUTUAL —
+ * their phone gets me as a peer + a chat entry without scanning back.
+ * Blocking — call from Dispatchers.IO. */
+ fun sendHello(
+ peer: PartyPeer,
+ myNpub: String,
+ myName: String,
+ myUla: String,
+ myIp: String?,
+ myPort: Int,
+ ): Boolean = try {
+ val body = JSONObject()
+ .put("from", myNpub)
+ .put("name", myName)
+ .put("ula", myUla)
+ .put("ip", myIp ?: "")
+ .put("port", if (myIp != null) myPort else 0)
+ .toString()
+ .toRequestBody("application/json".toMediaType())
+ http.newCall(
+ Request.Builder().url("${base(peer)}/hello").post(body).build()
+ ).execute().use { it.isSuccessful }
+ } catch (_: Exception) {
+ false
+ }
+
+ /** Blocking — call from Dispatchers.IO. */
+ fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
+ val body = JSONObject()
+ .put("from", myNpub)
+ .put("name", myName)
+ .put("text", text)
+ .put("ts", System.currentTimeMillis())
+ .toString()
+ .toRequestBody("application/json".toMediaType())
+ http.newCall(
+ Request.Builder().url("${base(peer)}/flare").post(body).build()
+ ).execute().use { it.isSuccessful }
+ } catch (_: Exception) {
+ false
+ }
+
+ /** Blocking — call from Dispatchers.IO. */
+ fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
+ http.newCall(
+ Request.Builder()
+ .url("${base(peer)}/photo")
+ .header("X-From", myNpub)
+ .header("X-Name", myName)
+ .post(jpeg.toRequestBody("image/jpeg".toMediaType()))
+ .build()
+ ).execute().use { it.isSuccessful }
+ } catch (_: Exception) {
+ false
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt
new file mode 100644
index 00000000..b79e148e
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt
@@ -0,0 +1,102 @@
+package com.archipelago.app.fips
+
+import android.net.Uri
+import java.net.Inet4Address
+import java.net.NetworkInterface
+
+/**
+ * Phone↔phone mesh pairing ("Mesh Party") QR contract:
+ *
+ * archipelago://party?v=1&npub=&ula=&name=[&ip=&port=]
+ *
+ * npub + ula alone are enough to chat *through* the mesh (anchors route by
+ * node address, no underlay info needed). ip/port are present only while the
+ * showing phone has its inbound UDP listener up (party mode) — the scanner
+ * then also gets a direct-dial link that works on a shared LAN/hotspot with
+ * no internet at all. Same versioning stance as the node pairing QR
+ * (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
+ */
+data class PartyPeer(
+ val npub: String,
+ val ula: String,
+ val name: String,
+ /** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
+ val ip: String = "",
+ val port: Int = 0,
+)
+
+object PartyQr {
+ const val SCHEME_HOST = "party"
+ private const val SUPPORTED_MAJOR = 1
+
+ /** UDP port a party-mode phone listens on (matches the node's mesh port). */
+ const val PARTY_UDP_PORT = 2121
+
+ /** Application-layer chat/beam port, bound only on the mesh ULA. */
+ const val FLARE_PORT = 5680
+
+ fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
+ val b = Uri.Builder()
+ .scheme("archipelago")
+ .authority(SCHEME_HOST)
+ .appendQueryParameter("v", "1")
+ .appendQueryParameter("npub", npub)
+ .appendQueryParameter("ula", ula)
+ .appendQueryParameter("name", name)
+ if (!ip.isNullOrBlank() && port > 0) {
+ b.appendQueryParameter("ip", ip)
+ b.appendQueryParameter("port", port.toString())
+ }
+ return b.build().toString()
+ }
+
+ /** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
+ fun parse(raw: String): PartyPeer? {
+ val uri = try {
+ Uri.parse(raw.trim())
+ } catch (_: Exception) {
+ return null
+ }
+ if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
+ if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
+ val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
+ if (major != SUPPORTED_MAJOR) return null
+
+ val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
+ val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
+ if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
+ return PartyPeer(
+ npub = npub,
+ ula = ula,
+ name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
+ ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
+ port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
+ )
+ }
+
+ /**
+ * This phone's private IPv4 on WiFi or its own hotspot, for the QR's
+ * direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
+ * the hotspot-host phone advertises the address its guests can reach.
+ */
+ fun localWifiIpv4(): String? {
+ val candidates = mutableListOf>() // ifname → addr
+ try {
+ for (nif in NetworkInterface.getNetworkInterfaces()) {
+ if (!nif.isUp || nif.isLoopback) continue
+ for (addr in nif.inetAddresses) {
+ if (addr is Inet4Address && addr.isSiteLocalAddress) {
+ candidates += nif.name to addr.hostAddress.orEmpty()
+ }
+ }
+ }
+ } catch (_: Exception) {
+ return null
+ }
+ val hotspot = candidates.firstOrNull {
+ it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
+ }
+ return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
+ ?.second
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt
new file mode 100644
index 00000000..aac1270a
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt
@@ -0,0 +1,77 @@
+package com.archipelago.app.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.archipelago.app.ui.screens.PixelArtLogo
+import com.archipelago.app.ui.theme.BitcoinOrange
+import com.archipelago.app.ui.theme.SurfaceBlack
+import com.archipelago.app.ui.theme.TextMuted
+
+/**
+ * The branded "F*CK IPs" full-screen loader — shown whenever the app is
+ * dialing the node over the mesh (relaunch race, post-scan first connect),
+ * instead of an anonymous spinner. The point of the brand: what's loading
+ * is a connection to a cryptographic identity, not an IP.
+ */
+@Composable
+fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(SurfaceBlack),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ // The brand's circle-container logo (as on the connect screen /
+ // web login): pixel-art "a" centered in a black disc.
+ Box(
+ Modifier
+ .size(120.dp)
+ .clip(androidx.compose.foundation.shape.CircleShape)
+ .background(Color.Black)
+ .border(
+ 1.dp,
+ Color.White.copy(alpha = 0.14f),
+ androidx.compose.foundation.shape.CircleShape,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ PixelArtLogo(Modifier.size(64.dp))
+ }
+ Spacer(Modifier.height(20.dp))
+ Text(
+ text = "F*CK IPs MESH",
+ color = BitcoinOrange,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ letterSpacing = 4.sp,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = message,
+ color = TextMuted,
+ fontSize = 13.sp,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(24.dp))
+ CircularProgressIndicator(color = BitcoinOrange)
+ }
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt
index 724f7c3b..00931af1 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@@ -108,6 +109,7 @@ fun NESController(
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
+ onToggleStyle: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
@@ -205,6 +207,7 @@ fun NESController(
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
+ onToggleStyle?.let { StyleBtn(c, Modifier, it) }
}
}
}
@@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
}
}
+/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
+ * no longer carries it). */
+@Composable
+fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ var p by remember { mutableStateOf(false) }
+ Box(
+ modifier = modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(if (p) c.capsulePress else c.capsule)
+ .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
+ }
+}
+
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt
index 929573a3..3e7ef978 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt
@@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.Dashboard
+import androidx.compose.material.icons.filled.Dns
+import androidx.compose.material.icons.filled.Groups
+import androidx.compose.material.icons.filled.Keyboard
+import androidx.compose.material.icons.filled.SportsEsports
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.AnnotatedString
+import com.archipelago.app.fips.FipsManager
+import com.archipelago.app.fips.FipsNative
+import com.archipelago.app.fips.FipsPreferences
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
-import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@@ -75,26 +94,29 @@ fun NESMenu(
visible: Boolean,
servers: List,
activeServer: ServerEntry?,
- isGamepadMode: Boolean,
- controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
- onToggleMode: () -> Unit,
- onToggleStyle: () -> Unit,
+ onRemote: () -> Unit,
+ onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
+ onMeshParty: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
+ // Contained hub overlay: a centred glass panel (not full-screen) that
+ // holds the card page and its sub-pages (Nodes, FIPS) and scrolls
+ // inside its own bounds when content is tall. Tapping the dimmed
+ // backdrop dismisses.
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
- MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
+ MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
}
}
}
@@ -104,17 +126,16 @@ fun NESMenu(
private fun MenuPanel(
servers: List,
activeServer: ServerEntry?,
- isGamepadMode: Boolean,
- controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
- onToggleMode: () -> Unit,
- onToggleStyle: () -> Unit,
+ onRemote: () -> Unit,
+ onKeyboard: () -> Unit,
onBackToWebView: (() -> Unit)?,
+ onMeshParty: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
@@ -122,14 +143,15 @@ private fun MenuPanel(
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
+ var https by remember { mutableStateOf(false) }
fun resetForm() {
- nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
+ nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
- nm = server.name; addr = server.address; pwd = server.password
+ nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
showAdd = false
}
@@ -137,156 +159,381 @@ private fun MenuPanel(
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
- // Preserve fields the compact form doesn't expose (scheme, port).
- onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
+ // Preserve port (compact form doesn't expose it); scheme is now editable.
+ onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
} else {
- onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
+ onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
}
resetForm()
}
+ var page by remember { mutableStateOf(HubPage.HUB) }
+
Column(
modifier = Modifier
.widthIn(max = 420.dp)
+ .fillMaxWidth()
.padding(horizontal = 20.dp)
+ // Cap height just short of the full screen; the panel wraps short
+ // content and only scrolls in the rare case it outgrows this.
+ .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
.clip(RoundedCornerShape(PANEL_R))
- .background(PanelBg)
+ .background(PanelBg.copy(alpha = 0.86f))
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
- .padding(22.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
+ .verticalScroll(rememberScrollState())
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- // Title
- Text(
- "Menu",
- color = TextPrimary,
- fontSize = 18.sp,
- fontWeight = FontWeight.SemiBold,
- letterSpacing = 2.sp,
- modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.Center,
- )
- Spacer(Modifier.height(2.dp))
-
- // Servers
- servers.forEach { server ->
- val active = server.serialize() == activeServer?.serialize()
- MenuItem(
- label = server.displayName(),
- selected = active,
- onClick = { onSelectServer(server) },
- onEdit = { startEdit(server) },
- onRemove = { onRemoveServer(server) },
- )
- }
-
- if (servers.isEmpty()) {
- Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
- }
-
- // Add / edit server
- if (showAdd || editing != null) {
- Column(
- Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(ROW_R))
- .background(FieldBg)
- .border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
- .padding(12.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Row(
- Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
+ // Header: back (on sub-pages) or title, and a close on the hub.
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ if (page == HubPage.HUB) {
+ Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
+ IconRound(Icons.Default.Close, "Close") { onDismiss() }
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
+ Spacer(Modifier.width(12.dp))
Text(
- if (editing != null) "Edit Server" else "Add Server",
- color = TextMuted,
- fontSize = 13.sp,
- letterSpacing = 1.sp,
- fontWeight = FontWeight.Medium,
- )
- Text(
- "Cancel",
- color = TextMuted,
- fontSize = 13.sp,
- modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
+ if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
+ color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
)
}
- GlassField(
- value = nm, onValueChange = { nm = it },
- placeholder = "Name (optional)",
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
- )
- GlassField(
- value = addr, onValueChange = { addr = it.trim() },
- placeholder = "192.168.1.100",
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
- )
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
- GlassField(
- value = pwd, onValueChange = { pwd = it },
- placeholder = "Password",
- modifier = Modifier.weight(1f),
- visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
- keyboardActions = KeyboardActions(onGo = { submit() }),
- )
- Box(
- Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
- .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
- .clickable { submit() },
- contentAlignment = Alignment.Center,
- ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
- }
+ IconRound(Icons.Default.Close, "Close") { onDismiss() }
}
- } else {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Box(Modifier.weight(1f)) {
- MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
+ }
+ Spacer(Modifier.height(4.dp))
+
+ when (page) {
+ HubPage.HUB -> {
+ // Card page — one card per destination. Dashboard first: it's a
+ // peer of the others so three-finger → hub → Dashboard returns
+ // to the node UI, same shape as every other option.
+ if (onBackToWebView != null) {
+ HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
}
- if (onScanQr != null) {
- // Add server by scanning the node's pairing QR
- Box(
+ HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
+ HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
+ HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
+ page = HubPage.NODES
+ }
+ if (FipsNative.available) {
+ HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
+ }
+ if (onMeshParty != null) {
+ HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
+ }
+ // Dark/Classic style lives on the remote/keyboard screen next to
+ // the settings button — not here.
+ }
+
+ HubPage.NODES -> {
+ servers.forEach { server ->
+ val active = server.serialize() == activeServer?.serialize()
+ MenuItem(
+ label = server.displayName(),
+ selected = active,
+ onClick = { onSelectServer(server) },
+ onEdit = { startEdit(server) },
+ onRemove = { onRemoveServer(server) },
+ )
+ }
+ if (servers.isEmpty()) {
+ Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
+ }
+
+ if (showAdd || editing != null) {
+ Column(
Modifier
- .size(ROW_H)
+ .fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
- .background(RowBg)
+ .background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
- .clickable { onScanQr() },
- contentAlignment = Alignment.Center,
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- Icon(
- Icons.Default.QrCodeScanner,
- contentDescription = stringResource(R.string.add_server_qr),
- tint = BitcoinOrange,
- modifier = Modifier.size(24.dp),
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ if (editing != null) "Edit Server" else "Add Server",
+ color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
+ )
+ Text(
+ "Cancel", color = TextMuted, fontSize = 13.sp,
+ modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
+ )
+ }
+ GlassField(
+ value = nm, onValueChange = { nm = it },
+ placeholder = "Name (optional)",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
+ GlassField(
+ value = addr, onValueChange = { addr = it.trim() },
+ placeholder = "192.168.1.100",
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ GlassField(
+ value = pwd, onValueChange = { pwd = it },
+ placeholder = "Password",
+ modifier = Modifier.weight(1f),
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
+ keyboardActions = KeyboardActions(onGo = { submit() }),
+ )
+ Box(
+ Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
+ .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
+ .clickable { submit() },
+ contentAlignment = Alignment.Center,
+ ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
+ }
+ // HTTPS scheme toggle (available on both add and edit).
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(ROW_R))
+ .clickable { https = !https }
+ .padding(vertical = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
+ Box(
+ Modifier
+ .width(46.dp).height(26.dp)
+ .clip(RoundedCornerShape(13.dp))
+ .background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
+ .border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
+ contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
+ ) {
+ Box(
+ Modifier
+ .padding(horizontal = 3.dp)
+ .size(20.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(if (https) Color.White else TextMuted),
+ )
+ }
+ }
+ }
+ } else {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Box(Modifier.weight(1f)) {
+ MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
+ }
+ if (onScanQr != null) {
+ Box(
+ Modifier
+ .size(ROW_H)
+ .clip(RoundedCornerShape(ROW_R))
+ .background(RowBg)
+ .border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
+ .clickable { onScanQr() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Default.QrCodeScanner,
+ contentDescription = stringResource(R.string.add_server_qr),
+ tint = BitcoinOrange,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
}
}
}
+
+ HubPage.FIPS -> {
+ FipsSection(embedded = true)
+ }
}
+ }
+}
- Spacer(Modifier.height(2.dp))
- Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
- Spacer(Modifier.height(2.dp))
+private enum class HubPage { HUB, NODES, FIPS }
- // Mode toggle
- MenuItem(
- label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
- onClick = onToggleMode,
+/** Big tappable destination card for the hub page: icon + title + subtitle. */
+@Composable
+private fun HubCard(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ title: String,
+ subtitle: String,
+ onClick: () -> Unit,
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(ROW_R))
+ .background(RowBg)
+ .border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
+ .clickable { onClick() }
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Box(
+ Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
+ }
+ Column(Modifier.weight(1f)) {
+ Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
+ }
+ }
+}
+
+/** Small circular icon button used in the hub header. */
+@Composable
+private fun IconRound(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ desc: String,
+ onClick: () -> Unit,
+) {
+ Box(
+ Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(20.dp))
+ .background(RowBg)
+ .border(1.dp, RowBorder, RoundedCornerShape(20.dp))
+ .clickable { onClick() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
+ }
+}
+
+/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
+private data class FipsInfo(
+ val available: Boolean,
+ val running: Boolean,
+ val npub: String,
+ val meshAddress: String,
+ val peerCount: Int,
+)
+
+/**
+ * FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
+ * running state, its mesh identity (npub), its mesh address (fd…ULA), how
+ * many peers/anchors it's configured with — and a one-tap Reconnect that
+ * re-homes the mesh (also the manual fix if a network handoff ever misses).
+ * Collapsed by default so the menu stays compact.
+ */
+@Composable
+private fun FipsSection(embedded: Boolean = false) {
+ if (!FipsNative.available) return
+ val context = LocalContext.current
+ val clipboard = LocalClipboardManager.current
+ var expanded by remember { mutableStateOf(embedded) }
+ var info by remember { mutableStateOf(null) }
+
+ // Load identity/state when the section opens (cheap DataStore + JSON read).
+ LaunchedEffect(expanded) {
+ if (expanded && info == null) {
+ val prefs = FipsPreferences(context)
+ val id = prefs.identity()
+ val peers = runCatching {
+ org.json.JSONArray(prefs.peersJson()).length()
+ }.getOrDefault(0)
+ info = FipsInfo(
+ available = true,
+ running = FipsNative.isRunning(),
+ npub = id?.npub.orEmpty(),
+ meshAddress = id?.address.orEmpty(),
+ peerCount = peers,
+ )
+ }
+ }
+
+ Column(Modifier.fillMaxWidth()) {
+ // Embedded in the hub's FIPS sub-page the header row would duplicate
+ // the page title, so only the standalone (collapsible) form shows it.
+ if (!embedded) MenuItem(
+ label = "FIPS Mesh",
+ labelColor = BitcoinOrange,
+ onClick = { expanded = !expanded },
)
+ if (expanded) {
+ val i = info
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .padding(top = 6.dp)
+ .clip(RoundedCornerShape(ROW_R))
+ .background(FieldBg)
+ .border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
+ .padding(14.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (i == null) {
+ Text("Loading…", color = TextMuted, fontSize = 13.sp)
+ } else {
+ FipsRow("Status", if (i.running) "Connected" else "Stopped",
+ valueColor = if (i.running) BitcoinOrange else TextMuted)
+ if (i.meshAddress.isNotBlank()) {
+ FipsRow("Mesh address", i.meshAddress, mono = true,
+ onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
+ }
+ if (i.npub.isNotBlank()) {
+ FipsRow("Identity (npub)", i.npub, mono = true,
+ onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
+ }
+ FipsRow("Peers & anchors", i.peerCount.toString())
+ Text(
+ "Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
+ color = TextMuted, fontSize = 11.sp,
+ )
+ MenuItem(
+ label = "Reconnect mesh",
+ labelColor = BitcoinOrange,
+ onClick = {
+ FipsManager.requestMeshRestart(context)
+ info = null
+ if (!embedded) expanded = false
+ },
+ )
+ }
+ }
+ }
+ }
+}
- // Style toggle
- MenuItem(
- label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
- onClick = onToggleStyle,
+@Composable
+private fun FipsRow(
+ label: String,
+ value: String,
+ valueColor: Color = TextPrimary,
+ mono: Boolean = false,
+ onCopy: (() -> Unit)? = null,
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
+ Text(
+ value,
+ color = valueColor,
+ fontSize = if (mono) 11.sp else 13.sp,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.End,
)
-
- // Back to dashboard
- if (onBackToWebView != null) {
- MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
+ if (onCopy != null) {
+ Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
}
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt
index 0b93e684..eaf3afc0 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt
@@ -43,6 +43,7 @@ fun NESPortraitController(
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
+ onToggleStyle: (() -> Unit)? = null,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
@@ -151,6 +152,10 @@ fun NESPortraitController(
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
+ onToggleStyle?.let {
+ Spacer(Modifier.width(10.dp))
+ StyleBtn(c, Modifier, it)
+ }
}
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt
index e6d531e3..61a7940a 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt
@@ -238,6 +238,7 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
+ val focusScheduler = Executors.newSingleThreadScheduledExecutor()
providerFuture.addListener({
val p = providerFuture.get()
@@ -245,12 +246,15 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
- // CameraX's analysis default is 640x480 — too few pixels per module
- // to decode a modal-sized QR at arm's length. 1280x720 more than
- // doubles the pixel density at negligible analysis cost.
+ // Dense Lightning-invoice QRs need BOTH enough pixels per module and
+ // sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
+ // lens, which won't focus close) left dense invoices undecodable
+ // while sparse address QRs still read — the "scanner doesn't pick up
+ // invoices" report. 1920x1080 roughly doubles module resolution so a
+ // QR held at the camera's actual focus distance still resolves.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
- .setTargetResolution(android.util.Size(1280, 720))
+ .setTargetResolution(android.util.Size(1920, 1080))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
@@ -261,13 +265,27 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
}
try {
p.unbindAll()
- p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
+ val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
+ // Force a centre autofocus on a repeating tick. A hand-held QR is
+ // a static scene, so continuous-AF often never retriggers and the
+ // lens sits at its resting (far) focus — fatal for dense codes.
+ // A normalized centre point works before the view is measured.
+ val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
+ .createPoint(0.5f, 0.5f)
+ val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
+ point,
+ androidx.camera.core.FocusMeteringAction.FLAG_AF,
+ ).disableAutoCancel().build()
+ focusScheduler.scheduleWithFixedDelay({
+ runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
+ }, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
+ focusScheduler.shutdownNow()
provider?.unbindAll()
analysisExecutor.shutdown()
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt
index 8ec48d87..138c817b 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt
@@ -12,15 +12,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
+import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
+import com.archipelago.app.ui.screens.PartyScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
@@ -31,6 +35,8 @@ object Routes {
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
+ const val MESH_PARTY = "mesh_party"
+ const val FLARE = "flare"
}
@Composable
@@ -122,6 +128,9 @@ fun AppNavHost(
) {
composable(Routes.INTRO) {
IntroScreen(
+ onMeshParty = {
+ navController.navigate(Routes.MESH_PARTY)
+ },
onContinue = {
scope.launch {
prefs.markIntroSeen()
@@ -170,15 +179,46 @@ fun AppNavHost(
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
+ onRemoteKeyboard = {
+ navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
+ },
+ onMeshParty = {
+ navController.navigate(Routes.MESH_PARTY)
+ },
)
}
}
- composable(Routes.REMOTE_INPUT) {
+ composable(
+ "${Routes.REMOTE_INPUT}?keyboard={keyboard}",
+ arguments = listOf(
+ navArgument("keyboard") {
+ type = NavType.BoolType
+ defaultValue = false
+ },
+ ),
+ ) { entry ->
RemoteInputScreen(
onBack = {
navController.popBackStack()
},
+ onMeshParty = {
+ navController.navigate(Routes.MESH_PARTY)
+ },
+ startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
+ )
+ }
+
+ composable(Routes.MESH_PARTY) {
+ PartyScreen(
+ onBack = { navController.popBackStack() },
+ onOpenChat = { navController.navigate(Routes.FLARE) },
+ )
+ }
+
+ composable(Routes.FLARE) {
+ FlareScreen(
+ onBack = { navController.popBackStack() },
)
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt
new file mode 100644
index 00000000..f9f24a30
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt
@@ -0,0 +1,359 @@
+package com.archipelago.app.ui.screens
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.archipelago.app.fips.FipsManager
+import com.archipelago.app.fips.FipsNative
+import com.archipelago.app.fips.FipsPreferences
+import com.archipelago.app.fips.FlareClient
+import com.archipelago.app.fips.FlareMessage
+import com.archipelago.app.fips.FlareStore
+import com.archipelago.app.fips.PartyPeer
+import com.archipelago.app.ui.theme.BitcoinOrange
+import com.archipelago.app.ui.theme.SurfaceDark
+import com.archipelago.app.ui.theme.TextMuted
+import com.archipelago.app.ui.theme.TextPrimary
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.util.UUID
+
+private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
+private val BubbleBorder = Color.White.copy(alpha = 0.08f)
+
+/**
+ * Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
+ * E2E encrypted by the mesh layer and addressed by npub; whether it travels
+ * via a public anchor (5G) or a direct hotspot link is invisible up here —
+ * which is the entire point.
+ */
+@Composable
+fun FlareScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val prefs = remember { FipsPreferences(context) }
+ val scope = rememberCoroutineScope()
+
+ var identity by remember { mutableStateOf(null) }
+ var myName by remember { mutableStateOf("Phone") }
+ val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
+ var selectedNpub by remember { mutableStateOf(null) }
+ val allMessages by FlareStore.messages.collectAsState()
+ var draft by remember { mutableStateOf("") }
+
+ LaunchedEffect(Unit) {
+ identity = FipsManager.ensureIdentity(prefs)
+ myName = prefs.partyName()
+ }
+ LaunchedEffect(peers) {
+ if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
+ selectedNpub = peers.firstOrNull()?.npub
+ }
+ }
+
+ val peer = peers.firstOrNull { it.npub == selectedNpub }
+ val messages = allMessages.filter { it.peerNpub == selectedNpub }
+ val listState = rememberLazyListState()
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
+ }
+
+ fun sendText() {
+ val target = peer ?: return
+ val me = identity ?: return
+ val text = draft.trim()
+ if (text.isEmpty()) return
+ draft = ""
+ val msg = FlareMessage(
+ id = UUID.randomUUID().toString(),
+ peerNpub = target.npub,
+ fromMe = true,
+ name = myName,
+ text = text,
+ ts = System.currentTimeMillis(),
+ status = FlareMessage.Status.SENDING,
+ )
+ FlareStore.add(msg)
+ scope.launch(Dispatchers.IO) {
+ val ok = FlareClient.sendText(target, me.npub, myName, text)
+ FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
+ }
+ }
+
+ fun sendPhoto(uri: Uri) {
+ val target = peer ?: return
+ val me = identity ?: return
+ scope.launch(Dispatchers.IO) {
+ val jpeg = compressPhoto(context, uri) ?: return@launch
+ // Local copy so our own bubble renders the sent photo.
+ val dir = File(context.cacheDir, "flare").apply { mkdirs() }
+ val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
+ val msg = FlareMessage(
+ id = UUID.randomUUID().toString(),
+ peerNpub = target.npub,
+ fromMe = true,
+ name = myName,
+ photoPath = local.absolutePath,
+ ts = System.currentTimeMillis(),
+ status = FlareMessage.Status.SENDING,
+ )
+ FlareStore.add(msg)
+ val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
+ FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
+ }
+ }
+
+ val photoPicker = rememberLauncherForActivityResult(
+ ActivityResultContracts.GetContent()
+ ) { uri -> uri?.let { sendPhoto(it) } }
+
+ BackHandler { onBack() }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .background(SurfaceDark)
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding(),
+ ) {
+ // Header
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
+ peer?.let {
+ Text(
+ it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
+ color = BitcoinOrange,
+ fontSize = 11.sp,
+ )
+ }
+ }
+ Spacer(Modifier.size(48.dp))
+ }
+
+ // Peer tabs when chatting with more than one phone
+ if (peers.size > 1) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ peers.forEach { p ->
+ val active = p.npub == selectedNpub
+ Text(
+ p.name,
+ color = if (active) BitcoinOrange else TextMuted,
+ fontSize = 13.sp,
+ modifier = Modifier
+ .clip(RoundedCornerShape(10.dp))
+ .background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
+ .border(
+ 1.dp,
+ if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
+ RoundedCornerShape(10.dp),
+ )
+ .clickable { selectedNpub = p.npub }
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+ }
+ }
+ }
+
+ if (peer == null) {
+ Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
+ Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
+ }
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(messages, key = { it.id }) { msg ->
+ MessageBubble(msg)
+ }
+ }
+
+ // Composer
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(
+ Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(BubbleTheirs)
+ .border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
+ .clickable { photoPicker.launch("image/*") },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
+ }
+ OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = { sendText() }),
+ textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White.copy(alpha = 0.3f),
+ unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
+ cursorColor = BitcoinOrange,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ )
+ Box(
+ Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(BitcoinOrange.copy(alpha = 0.15f))
+ .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
+ .clickable { sendText() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text("➤", color = BitcoinOrange, fontSize = 18.sp)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MessageBubble(msg: FlareMessage) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
+ ) {
+ Column(
+ Modifier
+ .widthIn(max = 300.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
+ .border(
+ 1.dp,
+ if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
+ RoundedCornerShape(16.dp),
+ )
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ ) {
+ if (msg.photoPath.isNotBlank()) {
+ val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
+ bmp?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "Beamed photo",
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(10.dp)),
+ contentScale = ContentScale.FillWidth,
+ )
+ }
+ }
+ if (msg.text.isNotBlank()) {
+ Text(msg.text, color = TextPrimary, fontSize = 15.sp)
+ }
+ Text(
+ when (msg.status) {
+ FlareMessage.Status.SENDING -> "sending…"
+ FlareMessage.Status.SENT -> "sent · E2E via mesh"
+ FlareMessage.Status.FAILED -> "failed — tap to retry later"
+ FlareMessage.Status.RECEIVED -> msg.name
+ },
+ color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
+ fontSize = 10.sp,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+}
+
+/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
+private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
+ withContext(Dispatchers.IO) {
+ try {
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ context.contentResolver.openInputStream(uri)?.use {
+ BitmapFactory.decodeStream(it, null, bounds)
+ }
+ var sample = 1
+ while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
+ val opts = BitmapFactory.Options().apply { inSampleSize = sample }
+ val bitmap = context.contentResolver.openInputStream(uri)?.use {
+ BitmapFactory.decodeStream(it, null, opts)
+ } ?: return@withContext null
+ val out = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
+ bitmap.recycle()
+ out.toByteArray()
+ } catch (_: Exception) {
+ null
+ }
+ }
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
index 5426a825..db42dff9 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
@@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
-fun IntroScreen(onContinue: () -> Unit) {
+fun IntroScreen(
+ onContinue: () -> Unit,
+ // Mesh Party works with no node at all (phone↔phone) — offered right on
+ // the first screen so a friend who just got the app can join a party.
+ onMeshParty: () -> Unit = {},
+) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
@@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ GlassButton(
+ text = stringResource(R.string.mesh_party),
+ onClick = onMeshParty,
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ )
}
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt
new file mode 100644
index 00000000..31494993
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt
@@ -0,0 +1,519 @@
+package com.archipelago.app.ui.screens
+
+import android.graphics.Bitmap
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.archipelago.app.fips.FipsManager
+import com.archipelago.app.fips.FipsNative
+import com.archipelago.app.fips.FipsPreferences
+import com.archipelago.app.fips.FlareClient
+import com.archipelago.app.fips.PartyPeer
+import com.archipelago.app.fips.PartyQr
+import com.archipelago.app.ui.components.CameraQrPreview
+import com.archipelago.app.ui.theme.BitcoinOrange
+import com.archipelago.app.ui.theme.SurfaceDark
+import com.archipelago.app.ui.theme.TextMuted
+import com.archipelago.app.ui.theme.TextPrimary
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.qrcode.QRCodeWriter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private val CardBg = Color.White.copy(alpha = 0.05f)
+private val CardBorder = Color.White.copy(alpha = 0.08f)
+
+/** Public companion download (vps2 demo host — serves the same APK as the
+ * nodes' QR link). Rendered as the "Share this app" QR. */
+private const val APP_DOWNLOAD_URL =
+ "http://146.59.87.168:2100/packages/archipelago-companion.apk"
+
+/**
+ * Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the
+ * two embedded mesh nodes link up: through anchors when there's internet,
+ * directly over any shared WiFi/hotspot when there isn't.
+ */
+@Composable
+fun PartyScreen(
+ onBack: () -> Unit,
+ onOpenChat: () -> Unit,
+) {
+ val context = LocalContext.current
+ val prefs = remember { FipsPreferences(context) }
+ val scope = rememberCoroutineScope()
+
+ var identity by remember { mutableStateOf(null) }
+ var name by remember { mutableStateOf("") }
+ var localIp by remember { mutableStateOf(null) }
+ var showScanner by remember { mutableStateOf(false) }
+ var showShareQr by remember { mutableStateOf(false) }
+ var scanHint by remember { mutableStateOf(null) }
+
+ // Camera permission — fresh installs (and reinstalls: uninstall wipes
+ // grants) land here with no CAMERA grant, and the raw preview just showed
+ // black. Ask the moment the scanner opens.
+ var hasCamera by remember {
+ mutableStateOf(
+ androidx.core.content.ContextCompat.checkSelfPermission(
+ context, android.Manifest.permission.CAMERA,
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ )
+ }
+ val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
+ ) { hasCamera = it }
+ LaunchedEffect(showScanner) {
+ if (showScanner && !hasCamera) {
+ cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
+ }
+ }
+ val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
+ val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
+
+ LaunchedEffect(Unit) {
+ identity = FipsManager.ensureIdentity(prefs)
+ name = prefs.partyName()
+ // The hotspot/WiFi address can change while this screen is open
+ // (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
+ while (true) {
+ localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
+ delay(3_000)
+ }
+ }
+
+ val qrPayload = identity?.let { id ->
+ PartyQr.build(
+ npub = id.npub,
+ ula = id.address,
+ name = name.ifBlank { "Phone" },
+ ip = if (listenOn) localIp else null,
+ port = PartyQr.PARTY_UDP_PORT,
+ )
+ }
+ val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
+
+ BackHandler {
+ when {
+ showScanner -> showScanner = false
+ showShareQr -> showShareQr = false
+ else -> onBack()
+ }
+ }
+
+ Box(Modifier.fillMaxSize().background(SurfaceDark)) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
+ Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
+ Spacer(Modifier.size(56.dp))
+ }
+
+ // My QR card
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(20.dp))
+ .background(CardBg)
+ .border(1.dp, CardBorder, RoundedCornerShape(20.dp))
+ .padding(18.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ qrBitmap?.let { bmp ->
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color.White)
+ .padding(12.dp),
+ ) {
+ Image(
+ bitmap = bmp.asImageBitmap(),
+ contentDescription = "My mesh party QR",
+ modifier = Modifier.size(220.dp),
+ )
+ }
+ } ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
+
+ identity?.let {
+ Text(
+ it.npub.take(16) + "…" + it.npub.takeLast(6),
+ color = TextMuted,
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ )
+ }
+
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it.take(24)
+ scope.launch { prefs.setPartyName(name) }
+ },
+ placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.White.copy(alpha = 0.3f),
+ unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
+ cursorColor = BitcoinOrange,
+ focusedTextColor = TextPrimary,
+ unfocusedTextColor = TextPrimary,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ // Direct-link toggle (hotspot mode)
+ Row(
+ Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.padding(end = 12.dp)) {
+ Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ Text(
+ when {
+ listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
+ listenOn -> "Waiting for a WiFi/hotspot address…"
+ else -> "Off — mesh routes via anchors only"
+ },
+ color = if (listenOn) BitcoinOrange else TextMuted,
+ fontSize = 12.sp,
+ )
+ }
+ Switch(
+ checked = listenOn,
+ onCheckedChange = { on ->
+ scope.launch {
+ prefs.setPartyListen(on)
+ FipsManager.requestMeshRestart(context)
+ }
+ },
+ colors = SwitchDefaults.colors(
+ checkedTrackColor = BitcoinOrange,
+ checkedThumbColor = Color.White,
+ ),
+ )
+ }
+ }
+
+ GlassButton(
+ text = "Scan a Phone",
+ onClick = { showScanner = true },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ )
+
+ if (peers.isNotEmpty()) {
+ Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
+ peers.forEach { peer ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .background(CardBg)
+ .border(1.dp, CardBorder, RoundedCornerShape(14.dp))
+ .clickable { onOpenChat() }
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.padding(end = 8.dp)) {
+ Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
+ Text(
+ peer.npub.take(14) + "…" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
+ color = TextMuted,
+ fontSize = 11.sp,
+ )
+ }
+ Text(
+ "✕",
+ color = TextMuted,
+ fontSize = 16.sp,
+ modifier = Modifier.clickable {
+ scope.launch {
+ prefs.removePartyPeer(peer.npub)
+ FipsManager.requestMeshRestart(context)
+ }
+ }.padding(8.dp),
+ )
+ }
+ }
+ GlassButton(
+ text = "Open Flare Chat",
+ onClick = onOpenChat,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ )
+ } else {
+ Text(
+ "Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
+ color = TextMuted,
+ fontSize = 13.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
+ )
+ }
+ Spacer(Modifier.height(12.dp))
+ // Hand the app itself to a friend: shares this install's own APK
+ // through the system sheet (Quick Share/Bluetooth), so a nearby
+ // phone gets the companion with zero internet — the whole party
+ // premise.
+ GlassButton(
+ text = "Share this app",
+ onClick = { showShareQr = true },
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ )
+ Spacer(Modifier.height(12.dp))
+ }
+
+ // "Share this app" — a QR of the public download link, so the other
+ // phone scans it with its normal camera and installs over any
+ // internet. (The vps2 demo host serves the same APK the nodes do.)
+ AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.94f))
+ .clickable { showShareQr = false },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
+ dlQr?.let { bmp ->
+ Box(
+ Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color.White)
+ .padding(14.dp),
+ ) {
+ Image(
+ bitmap = bmp.asImageBitmap(),
+ contentDescription = "Companion download QR",
+ modifier = Modifier.size(240.dp),
+ )
+ }
+ }
+ Spacer(Modifier.height(18.dp))
+ Text(
+ "Scan with any camera to download\nthe Archipelago Companion",
+ color = TextPrimary,
+ fontSize = 15.sp,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ "…or send the APK file directly",
+ color = BitcoinOrange,
+ fontSize = 13.sp,
+ modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
+ )
+ Spacer(Modifier.height(6.dp))
+ Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
+ }
+ }
+ }
+
+ // Party QR scanner overlay
+ AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
+ Box(Modifier.fillMaxSize().background(Color.Black)) {
+ if (!hasCamera) {
+ Column(
+ Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(
+ "Camera access is needed to scan a party QR.",
+ color = TextPrimary,
+ fontSize = 15.sp,
+ textAlign = TextAlign.Center,
+ )
+ GlassButton(
+ text = "Grant camera access",
+ onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ )
+ }
+ } else CameraQrPreview(onDecoded = { text ->
+ val peer = PartyQr.parse(text)
+ when {
+ peer == null -> scanHint = "Not a mesh party QR"
+ peer.npub == identity?.npub -> scanHint = "That's your own QR"
+ else -> {
+ showScanner = false
+ scanHint = null
+ scope.launch {
+ prefs.upsertPartyPeer(peer)
+ // Pick up the direct-dial PeerConfig (and the
+ // listener, if ours is on) immediately.
+ FipsManager.requestMeshRestart(context)
+ // Pairing must be MUTUAL: announce ourselves so
+ // the scanned phone gets us as a peer + a chat
+ // entry without scanning back. Retried while
+ // the fresh link/session comes up.
+ val me = identity
+ if (me != null) {
+ launch(Dispatchers.IO) {
+ for (attempt in 0 until 6) {
+ val ok = FlareClient.sendHello(
+ peer = peer,
+ myNpub = me.npub,
+ myName = name.ifBlank { "Phone" },
+ myUla = me.address,
+ myIp = if (listenOn) localIp else null,
+ myPort = PartyQr.PARTY_UDP_PORT,
+ )
+ if (ok) break
+ delay(3_000)
+ }
+ }
+ }
+ onOpenChat()
+ }
+ }
+ }
+ })
+ Box(
+ Modifier
+ .align(Alignment.Center)
+ .size(260.dp)
+ .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
+ )
+ Text(
+ "Close",
+ color = TextPrimary,
+ fontSize = 16.sp,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .statusBarsPadding()
+ .clickable { showScanner = false }
+ .padding(20.dp),
+ )
+ scanHint?.let {
+ Text(
+ it,
+ color = BitcoinOrange,
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 48.dp)
+ .fillMaxWidth(),
+ )
+ }
+ }
+ }
+ }
+
+ // Scan hints fade so the camera feels live again.
+ LaunchedEffect(scanHint) {
+ if (scanHint != null) {
+ delay(2500)
+ scanHint = null
+ }
+ }
+}
+
+/** Render a QR payload as a bitmap (dark modules on white). */
+private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
+ val matrix = QRCodeWriter().encode(
+ payload,
+ BarcodeFormat.QR_CODE,
+ size,
+ size,
+ mapOf(EncodeHintType.MARGIN to 1),
+ )
+ val pixels = IntArray(size * size)
+ for (y in 0 until size) {
+ for (x in 0 until size) {
+ pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
+ }
+ }
+ Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
+} catch (_: Exception) {
+ null
+}
+
+/** Share this install's own APK via the system share sheet — a nearby friend
+ * gets the companion with no internet at all (Quick Share / Bluetooth). */
+private fun shareCompanionApk(context: android.content.Context) {
+ try {
+ val src = java.io.File(context.applicationInfo.sourceDir)
+ val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
+ val out = java.io.File(dir, "archipelago-companion.apk")
+ src.copyTo(out, overwrite = true)
+ val uri = androidx.core.content.FileProvider.getUriForFile(
+ context, "${context.packageName}.fileprovider", out,
+ )
+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
+ type = "application/vnd.android.package-archive"
+ putExtra(android.content.Intent.EXTRA_STREAM, uri)
+ addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(
+ android.content.Intent.createChooser(send, "Share Archipelago Companion")
+ .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
+ )
+ } catch (_: Exception) {
+ // No share targets / copy failed — nothing sensible to do here.
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt
index f38cc485..9af0ba08 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt
@@ -4,8 +4,10 @@ import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -54,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
-fun RemoteInputScreen(onBack: () -> Unit) {
+fun RemoteInputScreen(
+ onBack: () -> Unit,
+ onMeshParty: (() -> Unit)? = null,
+ // Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
+ startInKeyboard: Boolean = false,
+) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
@@ -63,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
- var isGamepadMode by remember { mutableStateOf(true) }
+ var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
@@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit) {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
+ fun toggleStyle() {
+ controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
+ }
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
@@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
+ onToggleStyle = ::toggleStyle,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
@@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
+ onToggleStyle = ::toggleStyle,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
@@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
)
}
- // Settings icon top-right in keyboard mode
- com.archipelago.app.ui.components.SettingsBtn(
- c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
- modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
- onClick = { showModal = true },
- )
+ // Settings + style icons top-right in keyboard mode
+ Row(
+ Modifier.align(Alignment.TopEnd).padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ com.archipelago.app.ui.components.SettingsBtn(
+ c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
+ onClick = { showModal = true },
+ )
+ com.archipelago.app.ui.components.StyleBtn(
+ c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
+ onClick = ::toggleStyle,
+ )
+ }
}
}
}
@@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit) {
visible = showModal,
servers = savedServers,
activeServer = activeServer,
- isGamepadMode = isGamepadMode,
- controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
@@ -245,11 +263,10 @@ fun RemoteInputScreen(onBack: () -> Unit) {
}
}
},
- onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
- onToggleStyle = {
- controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
- },
+ onRemote = { isGamepadMode = true; showModal = false },
+ onKeyboard = { isGamepadMode = false; showModal = false },
onBackToWebView = { showModal = false; onBack() },
+ onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
index 0eb1de37..166f527f 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
@@ -75,6 +75,7 @@ import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager
+import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
@@ -610,6 +611,14 @@ fun ServerConnectScreen(
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
+
+ // Full-screen branded loader while the first connect runs — most
+ // visibly right after a pairing-QR scan, when the mesh may still be
+ // establishing (LAN probe → tunnel up → ULA probe can take a while).
+ // The small inline spinner stays for context; this owns the screen.
+ if (isConnecting) {
+ MeshLoadingScreen()
+ }
}
}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
index 01bc313e..dbf90741 100644
--- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
@@ -23,20 +23,28 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -64,19 +72,30 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
+import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.GestureHintOverlay
+import com.archipelago.app.ui.components.MeshLoadingScreen
+import com.archipelago.app.ui.components.NESMenu
+import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
+import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@@ -87,28 +106,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
-/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
-private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
- val u = android.net.Uri.parse(base)
- val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
- java.net.Socket().use {
- it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
- true
- }
-} catch (_: Exception) {
- false
-}
-
-/** Fastest answering origin: LAN inside a short window, else the mesh ULA
- * (patient — a cold session may still be establishing), else LAN anyway so
- * the existing error/fallback path handles it. */
-private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
- withContext(Dispatchers.IO) {
- if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
- if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
- lanUrl
- }
-
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
try {
@@ -137,6 +134,158 @@ private fun isSameHost(url: String, base: String): Boolean {
}
}
+/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving
+ * the kiosk and coming back reattaches the LIVE page — no reload, no
+ * re-login, no reconnect. Dropped on retry/disconnect/server change. */
+private object KioskWebView {
+ var instance: WebView? = null
+ var url: String? = null
+
+ // Live-composition delegates for the JS bridges (see the factory) — the
+ // registered interface objects call through these, so reattaching the
+ // retained view re-points them instead of leaving stale closures.
+ var onRouteOutbound: (String) -> Unit = {}
+ var onOpenInApp: (String) -> Unit = {}
+ var onQrOpen: () -> Unit = {}
+ var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
+ var onQrClose: () -> Unit = {}
+
+ fun drop() {
+ instance?.let {
+ (it.parent as? ViewGroup)?.removeView(it)
+ it.destroy()
+ }
+ instance = null
+ url = null
+ }
+}
+
+/** Inject the safe-area CSS vars from the CURRENT window insets. Android
+ * WebView doesn't populate env(safe-area-inset-*); worse, on a cold start
+ * onPageFinished can run before the view is attached — rootWindowInsets is
+ * null then, and injecting 0px collapsed the UI's top/bottom margins (and
+ * put the tab bar inside the gesture zone, killing its taps). Called from
+ * onPageFinished, from the window-insets listener (fires when real insets
+ * arrive), and on reattach. */
+private fun injectSafeAreaVars(view: WebView) {
+ val insets = view.rootWindowInsets ?: return // listener re-fires when real
+ val density = view.resources.displayMetrics.density
+ val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
+ val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
+ view.evaluateJavascript(
+ """
+ (function() {
+ var style = document.getElementById('archipelago-android-insets');
+ if (!style) {
+ style = document.createElement('style');
+ style.id = 'archipelago-android-insets';
+ document.head.appendChild(style);
+ }
+ style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
+ // Vue components sample the var into reactive state; tell them it
+ // changed (an authenticated session can mount before we run).
+ window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
+ })();
+ """.trimIndent(),
+ null,
+ )
+}
+
+/** In-app browser pages (node apps + same-node links) don't consume the
+ * neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
+ * their content ran up under the status bar. Pad the document body down by
+ * the status-bar height: the padded strip shows the page's OWN background
+ * (padding is inside the element), so the bar keeps the page colour while
+ * content starts below it — the pre-edge-to-edge look, without the black bar.
+ *
+ * Body padding only moves normal-flow content. fixed/sticky elements anchored
+ * at the viewport top (IndeeHub's floating header) stayed glued under the
+ * status bar, so we also push each of those down by the inset — once, marked
+ * via data attribute — and keep a throttled MutationObserver running so
+ * headers an SPA mounts after load get the same treatment.
+ * Idempotent; runs on start (early) and finish (after the app rewrites head). */
+private fun injectTopInset(view: WebView) {
+ val insets = view.rootWindowInsets ?: return
+ val density = view.resources.displayMetrics.density
+ val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
+ if (sat <= 0) return
+ view.evaluateJavascript(
+ """
+ (function() {
+ var SAT = $sat;
+ var s = document.getElementById('archy-top-inset');
+ if (!s) {
+ s = document.createElement('style');
+ s.id = 'archy-top-inset';
+ (document.head || document.documentElement).appendChild(s);
+ }
+ s.textContent =
+ 'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
+ function push(el) {
+ if (el.dataset.archyInset) return;
+ var cs = getComputedStyle(el);
+ if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
+ var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
+ if (isNaN(top) || top >= SAT) return;
+ el.style.setProperty('top', (top + SAT) + 'px', 'important');
+ el.dataset.archyInset = '1';
+ }
+ function sweep() {
+ if (!document.body) return;
+ // Fixed/sticky bars live shallow in the tree (portals mount on
+ // body); depth cap keeps the computed-style pass off big lists.
+ var els = document.body.querySelectorAll(
+ 'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
+ for (var i = 0; i < els.length; i++) push(els[i]);
+ }
+ sweep();
+ if (!window.__archyInsetObserver) {
+ var queued = false, last = 0;
+ window.__archyInsetObserver = new MutationObserver(function() {
+ if (queued) return;
+ queued = true;
+ var wait = Math.max(0, 250 - (Date.now() - last));
+ setTimeout(function() {
+ queued = false;
+ last = Date.now();
+ sweep();
+ }, wait);
+ });
+ window.__archyInsetObserver.observe(document.documentElement,
+ { childList: true, subtree: true });
+ }
+ })();
+ """.trimIndent(),
+ null,
+ )
+}
+
+/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
+private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
+ val u = android.net.Uri.parse(base)
+ val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
+ java.net.Socket().use {
+ it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
+ true
+ }
+} catch (_: Exception) {
+ false
+}
+
+/** Fastest answering origin: LAN inside a short window, else the mesh ULA
+ * (patient — a cold session may still be establishing). If NEITHER answers,
+ * fall back to the mesh URL when we have one — off-LAN the LAN IP is
+ * unreachable, and loading it just produced a confusing "can't reach
+ * 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
+ * instead means the load retries against the path that's actually coming up,
+ * and any error shows the mesh address rather than a dead LAN IP. */
+private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
+ withContext(Dispatchers.IO) {
+ if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
+ if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
+ meshUrl ?: lanUrl
+ }
+
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@@ -175,6 +324,10 @@ fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
+ // Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
+ onRemoteKeyboard: () -> Unit = {},
+ // Opens the phone-to-phone Mesh Party screen; null hides its hub card.
+ onMeshParty: (() -> Unit)? = null,
// Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
@@ -185,6 +338,13 @@ fun WebViewScreen(
meshFallbackUrl: String? = null,
) {
var isLoading by remember { mutableStateOf(true) }
+ // First kiosk load (often over the FIPS mesh) gets the full branded
+ // loader; later navigations keep just the slim top progress bar.
+ var firstLoadDone by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ snapshotFlow { isLoading }.first { !it }
+ firstLoadDone = true
+ }
var loadProgress by remember { mutableIntStateOf(0) }
var triedMeshFallback by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
@@ -198,6 +358,13 @@ fun WebViewScreen(
var startUrl by remember(serverUrl) { mutableStateOf(null) }
var raceNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
+ // A retained live session exists — reattach instantly: no race, no
+ // reload, no re-login (remote ⇄ dashboard round trip).
+ if (KioskWebView.instance != null && KioskWebView.url == serverUrl) {
+ isLoading = false
+ startUrl = serverUrl
+ return@LaunchedEffect
+ }
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
// Starting on the mesh: don't bounce back to it on error (it IS it).
if (picked != serverUrl) triedMeshFallback = true
@@ -226,7 +393,7 @@ fun WebViewScreen(
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
// the ULA while app links may carry the LAN IP (and vice versa) —
// comparing against one host bounced same-node apps (Pine, Home
- // Assistant) out to the phone's external browser.
+ // Assistant, BTCPay) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
@@ -239,6 +406,14 @@ fun WebViewScreen(
// One-time three-finger-hold teaching overlay (initial=true: never flash
// it while DataStore is still loading).
val prefs = remember { ServerPreferences(webViewContext) }
+
+ // Hub menu overlay state — the three-finger hold opens the menu right here
+ // over the dashboard (it used to jump to the remote screen).
+ val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
+ val activeServer by prefs.activeServer.collectAsState(initial = null)
+ var showHubMenu by remember { mutableStateOf(false) }
+ var showPairScanner by remember { mutableStateOf(false) }
+
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
var gestureHintDismissed by remember { mutableStateOf(false) }
// Don't teach the gesture on top of the login/splash — arm the overlay
@@ -313,7 +488,10 @@ fun WebViewScreen(
text = stringResource(R.string.retry),
onClick = {
// Re-race LAN vs mesh — the network we're on may have
- // changed since the last pick.
+ // changed since the last pick. Drop the retained view:
+ // an errored session must genuinely reload.
+ KioskWebView.drop()
+ webView = null
hasError = false
isLoading = true
triedMeshFallback = false
@@ -327,16 +505,17 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.disconnect),
- onClick = onDisconnect,
+ onClick = {
+ KioskWebView.drop()
+ onDisconnect()
+ },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else if (startUrl == null) {
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
// far cheaper than letting Chromium retry a dead LAN IP.
- Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- CircularProgressIndicator(color = BitcoinOrange)
- }
+ MeshLoadingScreen()
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
@@ -344,7 +523,15 @@ fun WebViewScreen(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
- WebView(context).apply {
+ // Reattach the retained kiosk WebView (remote ⇄ dashboard
+ // must not reload the node UI). Everything configured
+ // below is idempotent, and re-running it rebinds clients,
+ // bridges and listeners to THIS composition's state —
+ // stale closures from the previous visit are replaced.
+ if (KioskWebView.url != serverUrl) KioskWebView.drop()
+ val reused = KioskWebView.instance
+ (reused ?: WebView(context)).apply {
+ (parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -368,6 +555,14 @@ fun WebViewScreen(
val webViewRef = this
+ // Re-inject the safe-area vars whenever REAL insets
+ // arrive — on cold start onPageFinished often beats
+ // window attachment and would otherwise bake in 0px.
+ setOnApplyWindowInsetsListener { v, insets ->
+ (v as? WebView)?.let { injectSafeAreaVars(it) }
+ v.onApplyWindowInsets(insets)
+ }
+
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
@@ -381,20 +576,38 @@ fun WebViewScreen(
}
}
+ // Bridge callbacks are DELEGATED through the holder:
+ // the interface objects registered on a retained
+ // WebView survive recomposition, and re-registering
+ // them doesn't reliably swap the JS-visible object
+ // without a reload — with direct closures, a
+ // remote ⇄ dashboard round trip left the bridges
+ // writing to a dead composition's state ("apps don't
+ // launch"). These assignments re-point the live
+ // interface objects at THIS composition every attach.
+ KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
+ KioskWebView.onOpenInApp = { url -> inAppUrl = url }
+ KioskWebView.onQrOpen = {
+ walletScannerStatus = null
+ walletScannerVisible = true
+ }
+ KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err }
+ KioskWebView.onQrClose = { walletScannerVisible = false }
+
// JS bridge. The web UI calls:
// window.ArchipelagoNative.openExternal(url) — host-routed
// window.ArchipelagoNative.openInApp(url) — force in-app
// Falls back to window.open in a plain mobile browser.
- addJavascriptInterface(
+ if (reused == null) addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
- webViewRef.post { routeOutbound(url) }
+ webViewRef.post { KioskWebView.onRouteOutbound(url) }
}
@android.webkit.JavascriptInterface
fun openInApp(url: String) {
- webViewRef.post { inAppUrl = url }
+ webViewRef.post { KioskWebView.onOpenInApp(url) }
}
},
"ArchipelagoNative",
@@ -406,24 +619,21 @@ fun WebViewScreen(
// window.ArchipelagoQr.close() — code accepted, tear down
// Decodes flow back through window.__archyQrResult(text);
// a user cancel calls window.__archyQrCancelled().
- addJavascriptInterface(
+ if (reused == null) addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun open() {
- webViewRef.post {
- walletScannerStatus = null
- walletScannerVisible = true
- }
+ webViewRef.post { KioskWebView.onQrOpen() }
}
@android.webkit.JavascriptInterface
fun setStatus(message: String, isError: Boolean) {
- webViewRef.post { walletScannerStatus = message to isError }
+ webViewRef.post { KioskWebView.onQrStatus(message, isError) }
}
@android.webkit.JavascriptInterface
fun close() {
- webViewRef.post { walletScannerVisible = false }
+ webViewRef.post { KioskWebView.onQrClose() }
}
},
"ArchipelagoQr",
@@ -439,34 +649,7 @@ fun WebViewScreen(
isLoading = false
if (view == null) return
- // Convert physical pixels → CSS pixels
- val density = view.resources.displayMetrics.density
- val satPx = view.rootWindowInsets
- ?.getInsets(android.view.WindowInsets.Type.statusBars())
- ?.top ?: 0
- val sabPx = view.rootWindowInsets
- ?.getInsets(android.view.WindowInsets.Type.navigationBars())
- ?.bottom ?: 0
- val sat = (satPx / density).toInt()
- val sab = (sabPx / density).toInt()
-
- // Android WebView doesn't populate env(safe-area-inset-*).
- // Set CSS custom properties the web UI can use as fallback:
- // var(--safe-area-top, env(safe-area-inset-top, 0px))
- view.evaluateJavascript(
- """
- (function() {
- var style = document.getElementById('archipelago-android-insets');
- if (!style) {
- style = document.createElement('style');
- style.id = 'archipelago-android-insets';
- document.head.appendChild(style);
- }
- style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
- })();
- """.trimIndent(),
- null,
- )
+ injectSafeAreaVars(view)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages
@@ -616,7 +799,8 @@ fun WebViewScreen(
}
}
- // Three-finger hold (500ms) → navigate to remote input.
+ // Three-finger hold (500ms) → open the hub menu overlay
+ // in place (Remote/Keyboard cards do the navigating).
// Three fingers, not two: two-finger scroll/pinch on the
// page collided with the old two-finger hold.
var threeFingerStart = 0L
@@ -634,7 +818,7 @@ fun WebViewScreen(
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
if (System.currentTimeMillis() - threeFingerStart > 500) {
threeFingerFired = true
- onRemoteInput()
+ showHubMenu = true
}
}
}
@@ -650,7 +834,21 @@ fun WebViewScreen(
}
webView = this
- loadUrl(initialUrl)
+ if (reused == null) {
+ KioskWebView.instance = this
+ KioskWebView.url = serverUrl
+ loadUrl(initialUrl)
+ } else {
+ // Reattached views keep stale measurements until an
+ // input event — that was the top/bottom UI being
+ // wrong until a tap. Force a fresh pass, and re-sync
+ // the page's safe-area vars while we're at it.
+ post {
+ requestLayout()
+ invalidate()
+ injectSafeAreaVars(this)
+ }
+ }
}
},
)
@@ -669,6 +867,39 @@ fun WebViewScreen(
)
}
+ // Branded first-load screen while the mesh session comes up.
+ AnimatedVisibility(
+ visible = isLoading && !firstLoadDone,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Column(
+ Modifier.fillMaxSize().background(SurfaceBlack),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ buildAnnotatedString {
+ withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") }
+ withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") }
+ },
+ fontSize = 40.sp,
+ fontWeight = FontWeight.Black,
+ letterSpacing = 4.sp,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ "connecting to your archipelago",
+ color = TextMuted,
+ fontSize = 13.sp,
+ letterSpacing = 1.sp,
+ )
+ Spacer(Modifier.height(28.dp))
+ CircularProgressIndicator(color = BitcoinOrange)
+ }
+ }
+
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
@@ -712,6 +943,68 @@ fun WebViewScreen(
)
}
}
+
+ // Hub menu overlay — opened by the three-finger hold, drawn above
+ // everything (also reachable from the error screen, where switching
+ // servers is exactly what's needed).
+ NESMenu(
+ visible = showHubMenu,
+ servers = savedServers,
+ activeServer = activeServer,
+ onDismiss = { showHubMenu = false },
+ onSelectServer = { server ->
+ showHubMenu = false
+ scope.launch { prefs.setActiveServer(server) }
+ },
+ onAddServer = { server ->
+ scope.launch {
+ prefs.addSavedServer(server)
+ if (activeServer == null) prefs.setActiveServer(server)
+ }
+ },
+ onScanQr = { showPairScanner = true },
+ onEditServer = { original, updated ->
+ scope.launch {
+ prefs.updateSavedServer(original, updated)
+ // Editing the live server reloads the kiosk with the new
+ // address/credentials via the activeServer recomposition.
+ if (original.serialize() == activeServer?.serialize()) {
+ prefs.setActiveServer(updated)
+ }
+ }
+ },
+ onRemoveServer = { server ->
+ scope.launch {
+ prefs.removeSavedServer(server)
+ // Nothing left to show — back to the Connect screen.
+ val remaining = savedServers.count { it.serialize() != server.serialize() }
+ if (remaining == 0) {
+ prefs.clearActiveServer()
+ showHubMenu = false
+ onDisconnect()
+ }
+ }
+ },
+ onRemote = { showHubMenu = false; onRemoteInput() },
+ onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
+ onBackToWebView = { showHubMenu = false },
+ onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
+ )
+
+ // Pairing-QR scan launched from the menu's Nodes page; the menu stays
+ // open behind it so the new entry appears as soon as it closes.
+ QrScannerOverlay(
+ visible = showPairScanner,
+ onDismiss = { showPairScanner = false },
+ onServerScanned = { scan ->
+ showPairScanner = false
+ scope.launch {
+ val merged = prefs.upsertServer(scan.server)
+ FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
+ if (activeServer == null) prefs.setActiveServer(merged)
+ }
+ },
+ )
}
}
@@ -758,7 +1051,17 @@ private fun InAppBrowser(
fun isSameNode(u: String): Boolean =
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
var browser by remember { mutableStateOf(null) }
- var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
+ // Loader title: never show a raw IP host — a mesh ULA like
+ // [fd79:1aa:…] is technically the host but reads as garbage on the
+ // loading screen. Show a neutral name until the page reports its
+ // real (onReceivedTitle upgrades it).
+ var title by remember {
+ mutableStateOf(
+ android.net.Uri.parse(url).host
+ ?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
+ ?: "Archipelago",
+ )
+ }
var favicon by remember { mutableStateOf(null) }
var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
@@ -796,7 +1099,22 @@ private fun InAppBrowser(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
- .windowInsetsPadding(WindowInsets.safeDrawing),
+ // Whole-overlay touch shield: every touch not handled by a child
+ // (control-bar gaps, inset strips) dies here instead of falling
+ // through to the kiosk's tab bar behind (a near-miss on Close
+ // was opening the AIUI tab underneath).
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ )
+ // Bottom inset handled by the touch-shield strip below the bar.
+ // No TOP inset padding: the WebView draws edge-to-edge behind the
+ // status bar so the app's own background fills it — the padded
+ // version painted an opaque black bar there (user-rejected look).
+ .windowInsetsPadding(
+ WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
+ ),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
@@ -847,12 +1165,14 @@ private fun InAppBrowser(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
+ view?.let { injectTopInset(it) }
}
override fun onPageFinished(view: WebView?, u: String?) {
loading = false
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
+ view?.let { injectTopInset(it) }
}
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
@@ -985,6 +1305,21 @@ private fun InAppBrowser(
)
}
}
+
+ // Touch-shield over the gesture-nav strip: solid black AND consumes
+ // taps — stray touches below the control bar landed on the kiosk's
+ // tab bar behind this overlay (opening the AIUI chat by accident).
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .windowInsetsBottomHeight(WindowInsets.navigationBars)
+ .background(Color.Black)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ ),
+ )
}
}
diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml
index 40be6925..6f92ff2c 100644
--- a/Android/app/src/main/res/values/strings.xml
+++ b/Android/app/src/main/res/values/strings.xml
@@ -11,6 +11,7 @@
Your Sovereign\nPersonal ServerBitcoin node, app platform, and private cloud — all in one box you control.Get Started
+ Mesh PartyUse HTTPSPort (optional)Saved Servers
diff --git a/Android/app/src/main/res/xml/file_paths.xml b/Android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..40e6b2d8
--- /dev/null
+++ b/Android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Android/rust/archy-fips-core/Cargo.lock b/Android/rust/archy-fips-core/Cargo.lock
index a6bbd62c..b99f5ba2 100644
--- a/Android/rust/archy-fips-core/Cargo.lock
+++ b/Android/rust/archy-fips-core/Cargo.lock
@@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fips"
version = "0.3.0-dev"
-source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6"
+source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f"
dependencies = [
"bech32",
"chacha20poly1305",
diff --git a/Android/rust/archy-fips-core/Cargo.toml b/Android/rust/archy-fips-core/Cargo.toml
index 199d4517..e6cc8cf4 100644
--- a/Android/rust/archy-fips-core/Cargo.toml
+++ b/Android/rust/archy-fips-core/Cargo.toml
@@ -23,7 +23,10 @@ crate-type = ["lib", "cdylib"]
[dependencies]
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
-fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
+# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
+# discovery re-fire on topology change (fresh 5G join: first route no longer
+# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
+fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
anyhow = "1.0"
serde_json = "1.0"
hex = "0.4"
diff --git a/Android/rust/archy-fips-core/src/jni_glue.rs b/Android/rust/archy-fips-core/src/jni_glue.rs
index c433651f..5f891135 100644
--- a/Android/rust/archy-fips-core/src/jni_glue.rs
+++ b/Android/rust/archy-fips-core/src/jni_glue.rs
@@ -76,8 +76,9 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
out(&env, json)
}
-/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
+/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
+/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
mut env: JNIEnv,
@@ -85,11 +86,13 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
secret: JString,
peers_json: JString,
tun_fd: jint,
+ listen_port: jint,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let peers = jstr(&mut env, &peers_json);
- let json = match mesh::start(&secret, &peers, tun_fd) {
+ let listen_port = u16::try_from(listen_port).unwrap_or(0);
+ let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
Ok((npub, address)) => {
serde_json::json!({ "npub": npub, "address": address }).to_string()
}
diff --git a/Android/rust/archy-fips-core/src/mesh.rs b/Android/rust/archy-fips-core/src/mesh.rs
index 2a76090a..ed33db90 100644
--- a/Android/rust/archy-fips-core/src/mesh.rs
+++ b/Android/rust/archy-fips-core/src/mesh.rs
@@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result {
}
/// Build the phone-side node config: leaf-only (never routes third-party
-/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
-/// TUN enabled but attached to the VpnService fd rather than created.
-pub fn build_config(secret: &str, peers: Vec) -> Config {
+/// traffic — battery), no DNS responder, TUN enabled but attached to the
+/// VpnService fd rather than created.
+///
+/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
+/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
+/// local link (party mode); leaf_only still guarantees we never carry
+/// third-party transit even while accepting an inbound link.
+pub fn build_config(secret: &str, peers: Vec, listen_port: u16) -> Config {
let mut cfg = Config::default();
cfg.node.identity.nsec = Some(secret.to_string());
cfg.node.identity.persistent = false;
@@ -74,13 +79,33 @@ pub fn build_config(secret: &str, peers: Vec) -> Config {
cfg.tun.enabled = true;
cfg.tun.mtu = Some(1280);
cfg.dns.enabled = false;
- // Ephemeral UDP port: outbound dialing works, nothing predictable listens.
cfg.transports.udp = TransportInstances::Single(UdpConfig {
- bind_addr: Some("0.0.0.0:0".to_string()),
+ bind_addr: Some(format!("0.0.0.0:{listen_port}")),
..Default::default()
});
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
cfg.transports.tcp = TransportInstances::Single(Default::default());
+ // Fast-connect profile — a phone opens the app and expects the node NOW.
+ // Stock pacing is tuned for always-on routers: a failed discovery backs
+ // off 30s, session resends gap out to 8-16s, dead links redial at
+ // 5s→300s. Over 5G that stacked into a ~40s first connect (observed
+ // 2026-07-24: anchor +10s, session +40s). Retries only fire while a
+ // link/session is down, so steady-state traffic is unchanged.
+ cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
+ cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
+ cfg.node.retry.max_retries = 30;
+ cfg.node.rate_limit.handshake_resend_interval_ms = 400;
+ cfg.node.rate_limit.handshake_resend_backoff = 1.5;
+ cfg.node.rate_limit.handshake_max_resends = 10;
+ cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
+ cfg.node.discovery.backoff_max_secs = 30;
+ cfg.node.discovery.retry_interval_secs = 2;
+ cfg.node.discovery.max_attempts = 3;
+ // Lookups launched before the tree position settles are doomed; a 10s
+ // completion timeout made each one cost 10s before the 1s retry could
+ // fire (observed: 19s to a route on a fresh join). Fail fast instead —
+ // the resend-within-window above still gives each attempt two shots.
+ cfg.node.discovery.timeout_secs = 5;
cfg.peers = peers;
cfg
}
@@ -94,7 +119,7 @@ pub fn parse_peers(peers_json: &str) -> Result> {
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
/// detached — the node owns it from here). Returns (npub, ula) on success.
/// Any previously running node is stopped first.
-pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
+pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
stop();
// Android hands the VpnService TUN fd over in non-blocking mode on some
@@ -111,7 +136,7 @@ pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, Str
}
let peers = parse_peers(peers_json)?;
- let config = build_config(secret, peers);
+ let config = build_config(secret, peers, listen_port);
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
let npub = node.npub();
let address = node.identity().address().to_ipv6().to_string();
@@ -247,7 +272,7 @@ mod tests {
#[test]
fn config_is_leaf_only_with_tun() {
let id = generate_identity().unwrap();
- let cfg = build_config(&id.secret_hex, vec![]);
+ let cfg = build_config(&id.secret_hex, vec![], 0);
assert!(cfg.node.leaf_only);
assert!(cfg.tun.enabled);
assert_eq!(cfg.tun.mtu(), 1280);
@@ -255,4 +280,16 @@ mod tests {
assert!(!cfg.transports.udp.is_empty());
assert!(!cfg.transports.tcp.is_empty());
}
+
+ #[test]
+ fn listen_port_sets_fixed_udp_bind() {
+ let id = generate_identity().unwrap();
+ let cfg = build_config(&id.secret_hex, vec![], 2121);
+ // Party mode keeps leaf_only — accepting a link is not routing transit.
+ assert!(cfg.node.leaf_only);
+ let TransportInstances::Single(udp) = &cfg.transports.udp else {
+ panic!("expected single UDP transport");
+ };
+ assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
+ }
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6da26e81..d1088d0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,57 @@
# Changelog
-## v1.7.112-alpha (2026-07-22)
+## v1.7.117-alpha (2026-07-27)
+- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
+- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
+- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
+- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
+- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
+
+## v1.7.116-alpha (2026-07-27)
+
+- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.
+- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
+- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
+
+## v1.7.115-alpha (2026-07-26)
+
+- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
+- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
+- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
+
+## v1.7.114-alpha (2026-07-26)
+
+- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
+- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
+- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
+- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
+- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
+
+## v1.7.113-alpha (2026-07-25)
+
+- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
+- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
+- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
+- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
+
+## v1.7.112-alpha (2026-07-23)
+
+- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
+- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
+- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
+- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
+- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
+- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
+- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
+- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
+- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
+- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
+- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
+- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
+- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
+- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
+- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..90378f2b
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,19 @@
+# Code of Conduct
+
+## Our standard
+
+Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
+harassment, personal attacks, and discriminatory language are not.
+
+## Scope
+
+This code of conduct applies to project repositories, issue trackers, pull
+requests, documentation, chat, and community spaces connected to Archipelago.
+
+## Enforcement
+
+Maintainers may edit, hide, or remove comments and may restrict participation
+for behavior that makes collaboration unsafe or unproductive.
+
+Report conduct concerns privately through the repository owner account or the
+private contact channel listed on the project homepage.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b255972c..ab6cdb0b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,161 +1,100 @@
# Contributing to Archipelago
-Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
+This project is preparing for public developer contribution. The highest-value
+contributions are focused fixes, tests, app manifests, documentation
+improvements, and clear bug reports with reproducible evidence.
-## Code of Conduct
+## Development setup
-Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
-
-## Getting Started
-
-1. Fork the repository on the project's Gitea instance
-2. Clone your fork: `git clone /archy.git`
-3. Set up the dev environment (see `docs/developer-guide.md`)
-4. Create a feature branch: `git checkout -b feature/your-feature`
-
-## Development Setup
-
-### Frontend (Vue.js)
+### Frontend
```bash
cd neode-ui
npm install
-npm start # Dev server on :8100
-npm run type-check # TypeScript validation
-npm run build # Production build
-npm test # Run tests
+npm start
+npm run type-check
+npm test
```
-### Backend (Rust)
-
-Build on a Linux server (Debian 13), **not** macOS:
+### Backend
```bash
-cargo clippy --all-targets --all-features
-cargo fmt --all
+cd core
+cargo fmt --all -- --check
+cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
```
-### Deploy to dev server
+Linux is required for host integration work involving Podman, systemd,
+networking, or image builds. Frontend development works locally with the mock
+backend.
+
+## App manifests
+
+App packages live under `apps//manifest.yml` and use the schema
+documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
+before submitting:
```bash
-./scripts/deploy-to-target.sh --live
+./scripts/validate-app-manifest.sh apps//manifest.yml
+python3 scripts/generate-app-catalog.py
+python3 scripts/check-app-catalog-drift.py --release --strict
```
-## Code Style
+App submissions must:
-### Frontend (TypeScript + Vue)
+- pin container image versions;
+- avoid hardcoded secrets;
+- use `security.no_new_privileges: true`;
+- use `security.readonly_root: true` unless the manifest explains why writable
+ root is required;
+- request only necessary Linux capabilities;
+- store durable data under `/var/lib/archipelago//`;
+- define truthful health checks and launch interfaces for user-facing UIs.
-- `
diff --git a/neode-ui/src/components/SeedRevealPanel.vue b/neode-ui/src/components/SeedRevealPanel.vue
new file mode 100644
index 00000000..a480bdcb
--- /dev/null
+++ b/neode-ui/src/components/SeedRevealPanel.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.
+
+
+
+ {{ i + 1 }}.
+ {{ w }}
+
+
+
+
+
+
+
+
+ {{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
+ Tap to {{ hidden ? 'reveal' : 'hide' }}.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The code contains your seed words as plain text — treat it exactly like the words
+ themselves. Note: this is an LND aezeed, not a BIP39
+ phrase — it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
+ not into hardware wallets like Passport.
+
+
+ SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import
+ seeds by QR. Treat this code exactly like the words themselves.
+
+
+ Plain text words — for wallets that read the phrase as text. Treat this code exactly
+ like the words themselves.
+
+
@@ -94,7 +102,7 @@
:disabled="!!connecting"
@click="step = 2"
>
- Set Up with Archipelago Settings
+ Set Recommended
@@ -209,8 +217,8 @@
- These are the latest Archipelago settings — nothing is written to the
- radio until you confirm.
+ These are the recommended Archipelago settings — nothing is written to
+ the radio until you confirm.
@@ -308,6 +316,30 @@ const probing = ref(false)
const probe = ref(null)
const probeError = ref('')
+// Time-driven probe progress: the probe RPC is a single opaque call that can
+// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
+// advances on a clock toward 92% and snaps to 100% when the result lands.
+const probeProgress = ref(0)
+const probeStage = ref('Waiting for the radio to boot…')
+let probeTicker: ReturnType | null = null
+function startProbeProgress() {
+ stopProbeProgress()
+ probeProgress.value = 0
+ probeStage.value = 'Waiting for the radio to boot…'
+ const startedAt = Date.now()
+ probeTicker = setInterval(() => {
+ const elapsed = (Date.now() - startedAt) / 1000
+ // ~92% at 30s, decelerating — never looks stuck, never lies "done".
+ probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
+ if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
+ if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
+ }, 400)
+}
+function stopProbeProgress(done = false) {
+ if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
+ if (done) probeProgress.value = 100
+}
+
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
@@ -380,6 +412,7 @@ watch([show, devicePath], async ([visible]) => {
probe.value = null
probeError.value = ''
probing.value = true
+ startProbeProgress()
const path = devicePath.value
try {
const res = await mesh.probeDevice(path)
@@ -389,6 +422,7 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = e instanceof Error ? e.message : String(e)
}
} finally {
+ stopProbeProgress(true)
if (devicePath.value === path) probing.value = false
}
}, { immediate: false })
diff --git a/neode-ui/src/composables/useCachedResource.ts b/neode-ui/src/composables/useCachedResource.ts
new file mode 100644
index 00000000..28ed43a5
--- /dev/null
+++ b/neode-ui/src/composables/useCachedResource.ts
@@ -0,0 +1,107 @@
+// Stale-while-revalidate resource hook over the shared resources store.
+//
+// Usage:
+// const files = useCachedResource({
+// key: 'cloud.my-files',
+// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
+// ttlMs: 30_000,
+// })
+// // template: files.data renders instantly on revisit (cache), while
+// // files.loadState === 'refreshing' drives a subtle refresh indicator.
+//
+// Behavior:
+// - Synchronous hydrate: memory (survives navigation) → sessionStorage
+// snapshot (survives reload) → fetch.
+// - Sticky-ready: never regresses ready → loading; refreshes are
+// 'refreshing' so content stays on screen.
+// - Stale-while-revalidate: on mount, cached data is shown immediately and a
+// background refresh runs only if the TTL has lapsed (or never fetched).
+// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
+// - revalidateOnFocus: refreshes when the tab regains focus and the data is
+// stale (debounced by TTL, so focus-flapping is free).
+// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
+// the last subscribed component unmounts.
+
+import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
+import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
+
+export interface CachedResourceOptions {
+ /** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
+ key: string
+ /** Fetch fresh data. Receives an abort signal tied to component lifetime. */
+ fetcher: (signal: AbortSignal) => Promise
+ /** Data older than this triggers a background revalidate (default 30s). */
+ ttlMs?: number
+ /** Snapshot to sessionStorage so reloads paint instantly (default true).
+ * Disable for large payloads. */
+ persist?: boolean
+ /** Revalidate (if stale) when the window regains focus (default true). */
+ revalidateOnFocus?: boolean
+ /** Fetch on first use (default true). Set false for lazy resources. */
+ immediate?: boolean
+}
+
+export interface CachedResource {
+ entry: ResourceEntry
+ /** Convenience computed views over the entry. */
+ data: ComputedRef
+ loadState: ComputedRef
+ error: ComputedRef
+ /** True when data exists but is older than the TTL (drive an age badge). */
+ isStale: ComputedRef
+ ageMs: ComputedRef
+ /** Force a refresh now (deduped with any in-flight one). */
+ refresh: () => Promise
+ /** Mark stale + debounce-refresh all mounted users of this key. */
+ invalidate: () => void
+ /** Optimistically update cached data; returns rollback for RPC failure. */
+ optimistic: (update: (current: T | null) => T) => () => void
+}
+
+export function useCachedResource(opts: CachedResourceOptions): CachedResource {
+ const store = useResourcesStore()
+ const ttlMs = opts.ttlMs ?? 30_000
+ const persist = opts.persist ?? true
+ const entry = store.entry(opts.key, persist)
+
+ const aborter = new AbortController()
+ const fetcher = () => opts.fetcher(aborter.signal)
+ const refresh = () => store.refresh(opts.key, fetcher, { persist })
+
+ const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
+ const refreshIfStale = () => {
+ if (stale()) void refresh()
+ }
+
+ // Register as a live revalidator so invalidate(key) reaches us.
+ const unsubscribe = store.subscribe(opts.key, () => void refresh())
+
+ const onFocus = () => refreshIfStale()
+ if (opts.revalidateOnFocus ?? true) {
+ window.addEventListener('focus', onFocus)
+ }
+
+ // Tied to the owning effect scope (component setup or manual scope);
+ // outside any scope (tests, module init) there's nothing to dispose.
+ if (getCurrentScope()) {
+ onScopeDispose(() => {
+ unsubscribe()
+ window.removeEventListener('focus', onFocus)
+ aborter.abort()
+ })
+ }
+
+ if (opts.immediate ?? true) refreshIfStale()
+
+ return {
+ entry,
+ data: computed(() => entry.data),
+ loadState: computed(() => entry.loadState),
+ error: computed(() => entry.error),
+ isStale: computed(() => entry.data !== null && stale()),
+ ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
+ refresh,
+ invalidate: () => store.invalidate(opts.key),
+ optimistic: (update) => store.optimistic(opts.key, update),
+ }
+}
diff --git a/neode-ui/src/locales/en.json b/neode-ui/src/locales/en.json
index 00aeddfd..98b2e15a 100644
--- a/neode-ui/src/locales/en.json
+++ b/neode-ui/src/locales/en.json
@@ -415,6 +415,7 @@
"totalEarned": "Total Earned",
"monthlyAvg": "Monthly Avg",
"ecashBalance": "Ecash Balance",
+ "totalBitcoin": "Total Bitcoin",
"onChain": "On-chain",
"lightning": "Lightning",
"ecash": "Ecash",
diff --git a/neode-ui/src/locales/es.json b/neode-ui/src/locales/es.json
index f94ff178..3c9dd268 100644
--- a/neode-ui/src/locales/es.json
+++ b/neode-ui/src/locales/es.json
@@ -413,6 +413,7 @@
"totalEarned": "Total ganado",
"monthlyAvg": "Promedio mensual",
"ecashBalance": "Saldo Ecash",
+ "totalBitcoin": "Bitcoin total",
"onChain": "On-chain",
"lightning": "Lightning",
"ecash": "Ecash",
diff --git a/neode-ui/src/stores/__tests__/resources.test.ts b/neode-ui/src/stores/__tests__/resources.test.ts
new file mode 100644
index 00000000..d96efb23
--- /dev/null
+++ b/neode-ui/src/stores/__tests__/resources.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+
+import { useResourcesStore } from '../resources'
+import { useCachedResource } from '@/composables/useCachedResource'
+
+describe('resources store — stale-while-revalidate semantics', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ sessionStorage.clear()
+ vi.useFakeTimers()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('first fetch goes idle → loading → ready with data', async () => {
+ const store = useResourcesStore()
+ const e = store.entry('k1')
+ expect(e.loadState).toBe('idle')
+ const p = store.refresh('k1', async () => 'hello')
+ expect(e.loadState).toBe('loading')
+ await p
+ expect(e.loadState).toBe('ready')
+ expect(e.data).toBe('hello')
+ expect(e.fetchedAt).not.toBeNull()
+ })
+
+ it('sticky-ready: refresh never regresses ready → loading', async () => {
+ const store = useResourcesStore()
+ await store.refresh('k2', async () => 1)
+ const e = store.entry('k2')
+ const p = store.refresh('k2', async () => 2)
+ expect(e.loadState).toBe('refreshing')
+ await p
+ expect(e.loadState).toBe('ready')
+ expect(e.data).toBe(2)
+ })
+
+ it('keeps last-known data on refresh error (ready + error set)', async () => {
+ const store = useResourcesStore()
+ await store.refresh('k3', async () => 'good')
+ const e = store.entry('k3')
+ await store.refresh('k3', async () => {
+ throw new Error('boom')
+ })
+ expect(e.data).toBe('good')
+ expect(e.loadState).toBe('ready')
+ expect(e.error).toBe('boom')
+ })
+
+ it('errors with no prior data land in error state', async () => {
+ const store = useResourcesStore()
+ await store.refresh('k4', async () => {
+ throw new Error('down')
+ })
+ const e = store.entry('k4')
+ expect(e.loadState).toBe('error')
+ expect(e.data).toBeNull()
+ })
+
+ it('dedups concurrent refreshes for the same key', async () => {
+ const store = useResourcesStore()
+ const fetcher = vi.fn(async () => 'once')
+ const p1 = store.refresh('k5', fetcher)
+ const p2 = store.refresh('k5', fetcher)
+ await Promise.all([p1, p2])
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it('hydrates a new entry from the sessionStorage snapshot', async () => {
+ const store = useResourcesStore()
+ await store.refresh('k6', async () => ({ n: 42 }))
+ // Fresh pinia = fresh memory cache, same sessionStorage.
+ setActivePinia(createPinia())
+ const store2 = useResourcesStore()
+ const e = store2.entry<{ n: number }>('k6')
+ expect(e.loadState).toBe('ready')
+ expect(e.data).toEqual({ n: 42 })
+ })
+
+ it('optimistic update applies immediately and rollback restores', async () => {
+ const store = useResourcesStore()
+ await store.refresh('k7', async () => ['a'])
+ const e = store.entry('k7')
+ const rollback = store.optimistic('k7', (cur) => [...(cur ?? []), 'b'])
+ expect(e.data).toEqual(['a', 'b'])
+ rollback()
+ expect(e.data).toEqual(['a'])
+ })
+
+ it('invalidate marks stale and debounce-runs subscribers', async () => {
+ const store = useResourcesStore()
+ await store.refresh('k8', async () => 1)
+ const revalidate = vi.fn()
+ store.subscribe('k8', revalidate)
+ store.invalidate('k8')
+ expect(store.entry('k8').fetchedAt).toBeNull()
+ expect(revalidate).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(900)
+ expect(revalidate).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('useCachedResource composable', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ sessionStorage.clear()
+ })
+
+ it('fetches immediately when stale and exposes reactive views', async () => {
+ const fetcher = vi.fn(async () => 'data')
+ const r = useCachedResource({ key: 'c1', fetcher, revalidateOnFocus: false })
+ await r.refresh()
+ expect(fetcher).toHaveBeenCalled()
+ expect(r.data.value).toBe('data')
+ expect(r.loadState.value).toBe('ready')
+ expect(r.isStale.value).toBe(false)
+ })
+
+ it('does not refetch within TTL (instant render from cache)', async () => {
+ const fetcher = vi.fn(async () => 'v1')
+ const r1 = useCachedResource({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
+ await r1.refresh()
+ // Second component using the same key inside the TTL: no new fetch.
+ const fetcher2 = vi.fn(async () => 'v2')
+ const r2 = useCachedResource({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
+ expect(r2.data.value).toBe('v1')
+ expect(fetcher2).not.toHaveBeenCalled()
+ })
+})
diff --git a/neode-ui/src/stores/cloud.ts b/neode-ui/src/stores/cloud.ts
index 88877ea8..119ff8c5 100644
--- a/neode-ui/src/stores/cloud.ts
+++ b/neode-ui/src/stores/cloud.ts
@@ -8,6 +8,11 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false)
const error = ref(null)
const authenticated = ref(false)
+ // Per-path listing cache: re-entering a folder paints the last listing
+ // immediately (no spinner) while the fresh listing loads behind it.
+ const pathCache = new Map()
+ // Last-wins guard for overlapping navigations (fast folder hopping).
+ let navSeq = 0
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
@@ -36,7 +41,22 @@ export const useCloudStore = defineStore('cloud', () => {
}
async function navigate(path: string): Promise {
- loading.value = true
+ const seq = ++navSeq
+ const apply = (p: string, result: FileBrowserItem[]) => {
+ pathCache.set(p, result)
+ if (seq !== navSeq) return // a newer navigation superseded this one
+ items.value = result
+ currentPath.value = p
+ }
+ // Stale-while-revalidate: show the cached listing for this path
+ // immediately (no spinner), then refresh it underneath.
+ const cached = pathCache.get(path)
+ if (cached) {
+ items.value = cached
+ currentPath.value = path
+ } else {
+ loading.value = true
+ }
error.value = null
try {
if (!authenticated.value) {
@@ -47,9 +67,7 @@ export const useCloudStore = defineStore('cloud', () => {
}
}
try {
- const result = await fileBrowserClient.listDirectory(path)
- items.value = result
- currentPath.value = path
+ apply(path, await fileBrowserClient.listDirectory(path))
} catch {
// Directory may not exist — try to create it, then retry
if (path !== '/') {
@@ -57,23 +75,20 @@ export const useCloudStore = defineStore('cloud', () => {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName)
- const result = await fileBrowserClient.listDirectory(path)
- items.value = result
- currentPath.value = path
+ apply(path, await fileBrowserClient.listDirectory(path))
} catch {
// Fall back to root
- const result = await fileBrowserClient.listDirectory('/')
- items.value = result
- currentPath.value = '/'
+ apply('/', await fileBrowserClient.listDirectory('/'))
}
} else {
throw new Error('Failed to list root directory')
}
}
} catch (e) {
- error.value = e instanceof Error ? e.message : 'Failed to load files'
+ // Keep showing the cached listing on a failed revalidate.
+ if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
- loading.value = false
+ if (seq === navSeq) loading.value = false
}
}
@@ -112,6 +127,7 @@ export const useCloudStore = defineStore('cloud', () => {
items.value = []
loading.value = false
error.value = null
+ pathCache.clear()
}
return {
diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts
index 6bcaa72c..a16814b6 100644
--- a/neode-ui/src/stores/mesh.ts
+++ b/neode-ui/src/stores/mesh.ts
@@ -291,12 +291,16 @@ export const useMeshStore = defineStore('mesh', () => {
async function fetchStatus() {
try {
loading.value = true
- error.value = null
const res = await rpcClient.call({ method: 'mesh.status' })
status.value = res
trackDetectedDevices(res)
} catch (err: unknown) {
- error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
+ // Don't clobber a user-action error (broadcast/configure/send) — this
+ // runs on a 5s poll, and the old `error.value = null` on entry meant
+ // any real error banner survived at most one poll tick.
+ if (!error.value) {
+ error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
+ }
} finally {
loading.value = false
}
@@ -1018,6 +1022,18 @@ export const useMeshStore = defineStore('mesh', () => {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
+ /** Ask the backend to actively re-query the radio's contact table (and by
+ * extension re-drain daemon events for Reticulum) — the server-side half
+ * of the Refresh button; refreshAll() alone only re-reads caches. */
+ async function refreshRadio(): Promise {
+ try {
+ const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
+ return !!res.refreshed
+ } catch {
+ return false
+ }
+ }
+
return {
status,
peers,
@@ -1049,6 +1065,7 @@ export const useMeshStore = defineStore('mesh', () => {
broadcastIdentity,
configure,
refreshAll,
+ refreshRadio,
markChatRead,
clearViewingChat,
sendInvoice,
diff --git a/neode-ui/src/stores/resources.ts b/neode-ui/src/stores/resources.ts
new file mode 100644
index 00000000..d0ea37ea
--- /dev/null
+++ b/neode-ui/src/stores/resources.ts
@@ -0,0 +1,166 @@
+// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
+//
+// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
+// keys its router-view by route.path, so each visit unmounted and refetched
+// everything. This store is the single place resource state lives instead:
+// keyed entries survive navigation (Pinia) and reloads (sessionStorage
+// snapshot), and `useCachedResource` renders them instantly while
+// revalidating in the background.
+//
+// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
+// hand-rolled versions):
+// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
+// refreshes show as 'refreshing' so the UI keeps the data visible.
+// - keep-last-known-value on error: a failed revalidate leaves data in place
+// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
+// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
+
+import { defineStore } from 'pinia'
+import { reactive } from 'vue'
+
+export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
+
+export interface ResourceEntry {
+ data: T | null
+ loadState: ResourceLoadState
+ /** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
+ fetchedAt: number | null
+ error: string | null
+}
+
+const SNAPSHOT_PREFIX = 'resource:'
+
+function readSnapshot(key: string): { data: T; fetchedAt: number } | null {
+ try {
+ const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
+ if (!raw) return null
+ const parsed = JSON.parse(raw)
+ if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
+ } catch {
+ /* corrupt/absent snapshot — fall through to a fresh fetch */
+ }
+ return null
+}
+
+function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
+ try {
+ sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
+ } catch {
+ /* quota exceeded or unserializable — memory cache still works */
+ }
+}
+
+export const useResourcesStore = defineStore('resources', () => {
+ const entries = reactive(new Map())
+ // Non-reactive bookkeeping: in-flight fetches + active revalidators.
+ const inflight = new Map>()
+ const revalidators = new Map void>>()
+ const invalidateTimers = new Map>()
+
+ /** Get (or create) the reactive entry for a key, hydrating from the
+ * sessionStorage snapshot on first sight so revisits after a reload paint
+ * before any RPC completes. Pass `persist: false` to skip snapshots. */
+ function entry(key: string, persist = true): ResourceEntry {
+ let e = entries.get(key)
+ if (!e) {
+ const snap = persist ? readSnapshot(key) : null
+ e = reactive({
+ data: snap ? snap.data : null,
+ loadState: snap ? 'ready' : 'idle',
+ fetchedAt: snap ? snap.fetchedAt : null,
+ error: null,
+ })
+ entries.set(key, e)
+ }
+ return e as ResourceEntry
+ }
+
+ /** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
+ * Concurrent calls for the same key share one in-flight fetch. */
+ function refresh(
+ key: string,
+ fetcher: () => Promise,
+ opts: { persist?: boolean } = {},
+ ): Promise {
+ const existing = inflight.get(key)
+ if (existing) return existing
+ const e = entry(key, opts.persist ?? true)
+ e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
+ const p = (async () => {
+ try {
+ const data = await fetcher()
+ e.data = data
+ e.error = null
+ e.fetchedAt = Date.now()
+ e.loadState = 'ready'
+ if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
+ } catch (err) {
+ e.error = err instanceof Error ? err.message : String(err)
+ // Keep last-known data visible; only 'error' when we have nothing.
+ e.loadState = e.data !== null ? 'ready' : 'error'
+ } finally {
+ inflight.delete(key)
+ }
+ })()
+ inflight.set(key, p)
+ return p
+ }
+
+ /** Mark a key stale and (debounced) re-run every mounted subscriber's
+ * fetcher. Call after a mutation or on a relevant WS push. */
+ function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
+ const e = entries.get(key)
+ if (e) e.fetchedAt = null
+ const subs = revalidators.get(key)
+ if (!subs || subs.size === 0) return
+ const t = invalidateTimers.get(key)
+ if (t) clearTimeout(t)
+ invalidateTimers.set(
+ key,
+ setTimeout(() => {
+ invalidateTimers.delete(key)
+ for (const fn of subs) fn()
+ }, opts.debounceMs ?? 800),
+ )
+ }
+
+ /** Register a live revalidator for a key (used by useCachedResource);
+ * returns an unsubscribe fn. */
+ function subscribe(key: string, revalidate: () => void): () => void {
+ let subs = revalidators.get(key)
+ if (!subs) {
+ subs = new Set()
+ revalidators.set(key, subs)
+ }
+ subs.add(revalidate)
+ return () => {
+ subs.delete(revalidate)
+ }
+ }
+
+ /** Optimistically apply `update` to the cached value; returns a rollback.
+ * Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
+ function optimistic(key: string, update: (current: T | null) => T): () => void {
+ const e = entry(key)
+ const before = e.data
+ const beforeState = e.loadState
+ e.data = update(before)
+ if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
+ return () => {
+ e.data = before
+ e.loadState = beforeState
+ }
+ }
+
+ /** Drop a key entirely (memory + snapshot). */
+ function evict(key: string): void {
+ entries.delete(key)
+ try {
+ sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
+ } catch {
+ /* noop */
+ }
+ }
+
+ return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
+})
diff --git a/neode-ui/src/stores/sync.ts b/neode-ui/src/stores/sync.ts
index fcf1cd1f..fef77f6a 100644
--- a/neode-ui/src/stores/sync.ts
+++ b/neode-ui/src/stores/sync.ts
@@ -2,9 +2,38 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
-import type { DataModel } from '../types/api'
+import type { DataModel, PatchOperation } from '../types/api'
import { wsClient, applyDataPatch } from '../api/websocket'
import { rpcClient } from '../api/rpc-client'
+import { useResourcesStore } from './resources'
+
+/** Unescape one JSON-pointer segment (RFC 6901: ~1 → '/', ~0 → '~'). */
+function pointerSegment(path: string, prefix: string): string {
+ const seg = path.slice(prefix.length).split('/')[0] ?? ''
+ return seg.replace(/~1/g, '/').replace(/~0/g, '~')
+}
+
+/** B5: bridge /ws/db pushes into the cached-resource layer. Each patch op
+ * maps to the resource keys whose backing data it changes; invalidate()
+ * debounces (800ms) and only refetches keys with mounted subscribers, so a
+ * patch storm costs one revalidation per key. The 30s staleness
+ * reconciliation stays as the backstop for anything unmapped. */
+function invalidateResourcesForPatch(patch: PatchOperation[]): void {
+ const resources = useResourcesStore()
+ for (const op of patch) {
+ const path = op.path ?? ''
+ if (path.startsWith('/peer-health/')) {
+ // A peer flipping reachability changes both its browse result and the
+ // federation node list's online state.
+ const onion = pointerSegment(path, '/peer-health/')
+ if (onion) resources.invalidate(`cloud.peer-browse:${onion}`)
+ resources.invalidate('federation.nodes')
+ } else if (path.startsWith('/package-data/')) {
+ // App installs/uninstalls add or remove their tor services.
+ resources.invalidate('server.tor-services')
+ }
+ }
+}
export const useSyncStore = defineStore('sync', () => {
// State
@@ -108,6 +137,7 @@ export const useSyncStore = defineStore('sync', () => {
try {
if (import.meta.env.DEV) console.log('[Store] Applying patch at revision', update.rev || 'unknown')
data.value = applyDataPatch(data.value, update.patch)
+ invalidateResourcesForPatch(update.patch)
// Mark as connected once we receive any valid patch
if (!isConnected.value) {
isConnected.value = true
diff --git a/neode-ui/src/utils/seedqr.ts b/neode-ui/src/utils/seedqr.ts
new file mode 100644
index 00000000..7bfc5f59
--- /dev/null
+++ b/neode-ui/src/utils/seedqr.ts
@@ -0,0 +1,21 @@
+/**
+ * SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime,
+ * SeedSigner, Keystone, Nunchuk, Sparrow, …): each BIP39 word becomes its
+ * zero-padded 4-digit wordlist index (0000–2047), concatenated into one
+ * digit stream and rendered as a numeric-mode QR. A 24-word seed is 96
+ * digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr
+ *
+ * Only valid for real BIP39 mnemonics — LND's aezeed shares the wordlist but
+ * is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it.
+ */
+export async function toSeedQrDigits(words: string[]): Promise {
+ if (words.length === 0) return null
+ const { wordlist } = await import('@scure/bip39/wordlists/english.js')
+ const digits: string[] = []
+ for (const raw of words) {
+ const idx = wordlist.indexOf(raw.trim().toLowerCase())
+ if (idx < 0) return null // not a BIP39 word — caller falls back to text
+ digits.push(idx.toString().padStart(4, '0'))
+ }
+ return digits.join('')
+}
diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue
index 16e6a8bf..75a84b81 100644
--- a/neode-ui/src/views/AppSession.vue
+++ b/neode-ui/src/views/AppSession.vue
@@ -685,9 +685,9 @@ onBeforeUnmount(() => {
min-height: var(--app-session-mobile-bar-height, 84px);
padding: 10px 16px;
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
- background: rgba(0, 0, 0, 0.25);
- backdrop-filter: blur(18px);
- -webkit-backdrop-filter: blur(18px);
+ /* Solid black, not translucent: the app iframe's theme colour bled
+ through the bar and its safe-area strip on phones. */
+ background: #000;
border-top: 1px solid rgba(255, 255, 255, 0.06);
transform: translateZ(0);
}
diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue
index 9e562a0e..1993cfc6 100644
--- a/neode-ui/src/views/Apps.vue
+++ b/neode-ui/src/views/Apps.vue
@@ -646,37 +646,51 @@ function launchAppNow(id: string) {
useAppLauncherStore().openSession(id)
}
-async function maybeShowCredentialsBeforeLaunch(id: string): Promise {
- try {
- const result = await rpcClient.call({
+// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
+// hostage for its full 5s timeout over the mesh (home-card launches skip this
+// gate entirely, which is why they always felt instant). First launch waits at
+// most LAUNCH_CRED_BUDGET_MS; the RPC keeps running in the background and its
+// answer is memoized, so every later launch of that app resolves instantly.
+const LAUNCH_CRED_BUDGET_MS = 1200
+const credentialsCache = new Map()
+
+function fetchCredentials(id: string): Promise {
+ return rpcClient
+ .call({
method: 'package.credentials',
params: { app_id: id },
timeout: 5000,
})
- const credentials = resolveAppCredentials(id, result)
- if (!credentials) return false
- credentialModal.value = {
- show: true,
- appId: id,
- title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
- description: credentials.description || 'Use these credentials when the app asks you to sign in.',
- credentials: credentials.credentials,
- copied: '',
- }
- return true
- } catch {
- const credentials = resolveAppCredentials(id, null)
- if (!credentials) return false
- credentialModal.value = {
- show: true,
- appId: id,
- title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
- description: credentials.description || 'Use these credentials when the app asks you to sign in.',
- credentials: credentials.credentials,
- copied: '',
- }
- return true
+ .then((r) => {
+ credentialsCache.set(id, r)
+ return r
+ })
+ .catch(() => {
+ credentialsCache.set(id, null)
+ return null
+ })
+}
+
+async function maybeShowCredentialsBeforeLaunch(id: string): Promise {
+ const result = credentialsCache.has(id)
+ ? credentialsCache.get(id) ?? null
+ : await Promise.race([
+ fetchCredentials(id),
+ // Budget exceeded → launch with the static fallback config; the
+ // in-flight RPC still lands in the cache for next time.
+ new Promise((resolve) => setTimeout(() => resolve(null), LAUNCH_CRED_BUDGET_MS)),
+ ])
+ const credentials = resolveAppCredentials(id, result)
+ if (!credentials) return false
+ credentialModal.value = {
+ show: true,
+ appId: id,
+ title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
+ description: credentials.description || 'Use these credentials when the app asks you to sign in.',
+ credentials: credentials.credentials,
+ copied: '',
}
+ return true
}
function closeCredentialModal() {
diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue
index b314767c..1b77f585 100644
--- a/neode-ui/src/views/Cloud.vue
+++ b/neode-ui/src/views/Cloud.vue
@@ -194,7 +194,7 @@
Open Federation
-
+
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
@@ -216,6 +216,13 @@
{{ f.peerName }}
+
+
+ Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}…
+
FIPS startup is more reliable on nodes that have the packaged fips.service instead of Archipelago's archipelago-fips.service. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
+
App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
+
Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
+
Public-source preparation now includes a Nostr Git hosting plan using ngit, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
+
Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
+
+
+
+
+
+ v1.7.116-alpha
+ July 27, 2026
+
+
+
Nodes no longer get stuck on "server starting up" after an update or reboot. The backend now reports ready immediately and recovers its apps in the background, and it always restarts itself if it ever goes down — the days-long "server starting up" hang is gone.
+
Installing apps no longer crashes the node. A recent change that made app screens reachable over the mesh was holding onto every app's port in advance, so installing an app collided with it and the port-cleanup took the backend down and rolled the install back. Installs are clean now.
+
Rolls up v1.7.115: app screens and the dashboard load over the mesh out of the box, with IPv6 support end to end, and nodes rejoin the mesh in seconds after their rendezvous point restarts.
+
+
+
+
+
+ v1.7.115-alpha
+ July 26, 2026
+
+
+
The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
+
The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
+
Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
+
+
+
+
+
+ v1.7.114-alpha
+ July 26, 2026
+
+
+
Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
+
The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
+
Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
+
Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
+
Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
+
+
+
+
+
+ v1.7.113-alpha
+ July 25, 2026
+
+
+
Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
+
Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
+
The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
+
The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
+
+
v1.7.112-alpha
- July 22, 2026
+ July 23, 2026
+
Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
+
Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
+
The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
+
Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
+
Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
+
The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
+
Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
+
Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
+
Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
+
Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
+
Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
+
A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
+
Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
+
If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
+
Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
diff --git a/neode-ui/src/views/settings/BackupSection.vue b/neode-ui/src/views/settings/BackupSection.vue
index a4580277..151ad739 100644
--- a/neode-ui/src/views/settings/BackupSection.vue
+++ b/neode-ui/src/views/settings/BackupSection.vue
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
+import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
const { t } = useI18n()
@@ -329,20 +330,7 @@ defineExpose({ loadBackups })
-
Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.
-
-
-
- {{ i + 1 }}.
- {{ w }}
-
-
-
-
+
diff --git a/neode-ui/src/views/web5/Web5.vue b/neode-ui/src/views/web5/Web5.vue
index 729528ba..59473ae3 100644
--- a/neode-ui/src/views/web5/Web5.vue
+++ b/neode-ui/src/views/web5/Web5.vue
@@ -93,8 +93,9 @@ let web5AnimationDone = false
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
+import { useCachedResource } from '@/composables/useCachedResource'
import { safeClipboardWrite } from './utils'
-import type { ProfitsData, WalletTransaction, HwWalletDevice } from './types'
+import type { ProfitsData, HwWalletDevice } from './types'
import Web5QuickActions from './Web5QuickActions.vue'
// import Web5Wallet from './Web5Wallet.vue' // hidden for now
@@ -136,7 +137,13 @@ function showToast(text: string) {
}
// --- Networking Profits ---
-const profitsBreakdown = ref(null)
+const profitsRes = useCachedResource({
+ key: 'web5.networking-profits',
+ fetcher: (signal) => rpcClient.call({ method: 'wallet.networking-profits', signal, dedup: true, maxRetries: 1 }),
+})
+const profitsBreakdown = computed(() =>
+ profitsRes.data.value
+ ?? (profitsRes.error.value ? { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 } : null))
const networkingProfitsDisplay = computed(() => {
if (!profitsBreakdown.value) return '...'
const sats = profitsBreakdown.value.total_sats
@@ -146,15 +153,6 @@ const networkingProfitsDisplay = computed(() => {
return `\u20BF${btc.toFixed(8).replace(/0+$/, '').replace(/\.$/, '')}`
})
-async function loadNetworkingProfits() {
- try {
- const res = await rpcClient.call({ method: 'wallet.networking-profits' })
- profitsBreakdown.value = res
- } catch {
- profitsBreakdown.value = { total_sats: 0, content_sales_sats: 0, routing_fees_sats: 0 }
- }
-}
-
// --- DID State ---
const storedDid = ref(null)
try {
@@ -290,64 +288,35 @@ async function copyDidDocument() {
}
// --- Wallet / LND Balances ---
-const walletConnected = ref(false)
+// Cached: balances/transactions paint instantly on revisit and revalidate
+// behind the cached value; errors keep the last-known data.
+const lndInfoRes = useCachedResource<{
+ balance_sats: number
+ channel_balance_sats: number
+ synced_to_chain: boolean
+}>({
+ key: 'web5.lnd-info',
+ fetcher: (signal) => rpcClient.call({ method: 'lnd.getinfo', signal, dedup: true, maxRetries: 1 }),
+})
+// connectWallet() can still "disconnect" the (hidden) wallet card UI-side.
+const walletManuallyDisconnected = ref(false)
+const walletConnected = computed(() =>
+ !walletManuallyDisconnected.value && lndInfoRes.data.value !== null && !lndInfoRes.error.value)
const connectingWallet = ref(false)
-const lndOnchainBalance = ref(0)
-const lndChannelBalance = ref(0)
-const walletError = ref('')
-const ecashBalance = ref(0)
-
-// Transactions — wallet card hidden, but loadTransactions still called for QuickActions walletConnected state
-const walletTransactions = ref([])
+// Ecash/transaction/balance display lives in the hidden wallet card — when it
+// returns, add cached resources for wallet.ecash-balance / lnd.gettransactions
+// here rather than reviving the old eager loaders.
// Hardware wallets
const detectedHwWallets = ref([])
-async function loadLndBalances() {
- try {
- const res = await rpcClient.call<{
- balance_sats: number
- channel_balance_sats: number
- synced_to_chain: boolean
- }>({ method: 'lnd.getinfo' })
- lndOnchainBalance.value = res.balance_sats || 0
- lndChannelBalance.value = res.channel_balance_sats || 0
- walletConnected.value = true
- walletError.value = ''
- } catch (e) {
- walletConnected.value = false
- lndOnchainBalance.value = 0
- lndChannelBalance.value = 0
- walletError.value = e instanceof Error ? e.message : 'Failed to load wallet balances'
- }
-}
-
-async function loadEcashBalance() {
- try {
- const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
- ecashBalance.value = res.balance_sats ?? 0
- } catch {
- // Keep last-known balance on a transient failure rather than flashing 0.
- }
-}
-
-async function loadTransactions() {
- try {
- const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions' })
- walletTransactions.value = res.transactions || []
- walletError.value = ''
- } catch (e) {
- walletTransactions.value = []
- walletError.value = e instanceof Error ? e.message : 'Failed to load transactions'
- }
-}
-
async function connectWallet() {
if (walletConnected.value) {
- walletConnected.value = false
+ walletManuallyDisconnected.value = true
} else {
connectingWallet.value = true
- await loadLndBalances()
+ walletManuallyDisconnected.value = false
+ await lndInfoRes.refresh()
connectingWallet.value = false
}
}
@@ -361,13 +330,8 @@ async function detectHardwareWallets() {
}
}
-// function reloadBalances() { // wallet hidden
-// loadLndBalances()
-// loadEcashBalance()
-// loadTransactions()
-// }
-
-// Auto-refresh wallet data every 30s
+// Auto-refresh wallet data every 30s while mounted (B5 will move this to
+// WS-push invalidation; the store dedups overlapping refreshes).
let walletRefreshInterval: ReturnType | null = null
onMounted(() => {
@@ -392,20 +356,15 @@ onMounted(() => {
// credentialsRef.value?.loadCredentials() // hidden for now
// sharedContentRef.value?.loadContentItems() // hidden for now
- // Load local state data
- loadEcashBalance()
- loadNetworkingProfits()
- loadLndBalances()
- loadTransactions()
+ // Wallet/profits resources fetch themselves on first use (and skip the
+ // fetch entirely when the cached value is still fresh).
detectHardwareWallets()
// Shared content loaded by the component itself via expose
// The SharedContent component manages its own loadContentItems
walletRefreshInterval = setInterval(() => {
- loadLndBalances()
- loadTransactions()
- loadEcashBalance()
+ void lndInfoRes.refresh()
}, 30000)
})
diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue
index bb57e5aa..bdb64866 100644
--- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue
+++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue
@@ -350,13 +350,17 @@ async function loadPeers() {
const hadPeers = peers.value.length > 0 || observers.value.length > 0
loadingPeers.value = true
try {
- const res = await rpcClient.listPeers()
+ // Independent RPCs — fetched together (serialized they stacked two full
+ // mesh round-trips before anything rendered).
+ const [res, fedSettled] = await Promise.all([
+ rpcClient.listPeers(),
+ rpcClient.federationListNodes().catch(() => null),
+ ])
const peerList = res.peers || []
const observerList: Peer[] = []
try {
- const fedRes = await rpcClient.federationListNodes()
- const fedNodes = fedRes.nodes || []
+ const fedNodes = fedSettled?.nodes || []
for (const n of fedNodes) {
if (!n.onion || n.trust_level === 'untrusted') {
continue
diff --git a/neode-ui/src/views/web5/Web5SendReceiveModals.vue b/neode-ui/src/views/web5/Web5SendReceiveModals.vue
index cc596c36..52fc3cf5 100644
--- a/neode-ui/src/views/web5/Web5SendReceiveModals.vue
+++ b/neode-ui/src/views/web5/Web5SendReceiveModals.vue
@@ -291,10 +291,10 @@ async function unifiedSend() {
unifiedSendError.value = t('web5.pasteInvoice')
return
}
- const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
- method: 'lnd.payinvoice',
- params: { payment_request: unifiedSendDest.value.trim() },
- })
+ // Waits out slow multi-hop routing and only reports failure when LND
+ // itself declares the payment failed — never on a timeout.
+ const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
+ if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
sendResultHash.value = res.payment_hash
} else {
if (!unifiedSendDest.value.trim()) {
diff --git a/neode-ui/src/views/web5/Web5Wallet.vue b/neode-ui/src/views/web5/Web5Wallet.vue
index 00195014..dc00a514 100644
--- a/neode-ui/src/views/web5/Web5Wallet.vue
+++ b/neode-ui/src/views/web5/Web5Wallet.vue
@@ -95,10 +95,21 @@
{{ lndOnchainBalance.toLocaleString() }} sats
diff --git a/release-manifest.json b/release-manifest.json
index 959457be..325bf843 100644
--- a/release-manifest.json
+++ b/release-manifest.json
@@ -1,36 +1,29 @@
{
"changelog": [
- "Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — \"what's the block height?\", \"how many peers am I connected to?\", \"is bitcoin synced?\", \"what's my Lightning balance?\" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.",
- "Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom \"Yo Archy\" wake word is in the works.)",
- "Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.",
- "Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.",
- "The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.",
- "Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.",
- "Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text \"/bin/bash\"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.",
- "Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.",
- "Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.",
- "On the phone home screen, the wallet card moved up to sit right under My Apps."
+ "Nodes no longer get stuck on \"server starting up\" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.",
+ "Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.",
+ "Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts."
],
"components": [
{
- "current_version": "1.7.111-alpha",
- "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago",
+ "current_version": "1.7.116-alpha",
+ "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
"name": "archipelago",
- "new_version": "1.7.111-alpha",
- "sha256": "9e50577470cb4c5dd67b4316a3efaaa87e7afac15a8b5c2e3841edc6c3ed6550",
- "size_bytes": 50615920
+ "new_version": "1.7.116-alpha",
+ "sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
+ "size_bytes": 51787208
},
{
- "current_version": "1.7.111-alpha",
- "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago-frontend-1.7.111-alpha.tar.gz",
- "name": "archipelago-frontend-1.7.111-alpha.tar.gz",
- "new_version": "1.7.111-alpha",
- "sha256": "3ea73cd8e98312b3b6877e00434363fe575362e7b2b134e03a9004d4e42930c9",
- "size_bytes": 174650727
+ "current_version": "1.7.116-alpha",
+ "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
+ "name": "archipelago-frontend-1.7.116-alpha.tar.gz",
+ "new_version": "1.7.116-alpha",
+ "sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
+ "size_bytes": 178062490
}
],
- "release_date": "2026-07-22",
- "signature": "1cc569a121f2cc5560840198115732155db4ee037ea98c4be8cf9ef8a7b227e26dc686a88a779f1e40daaa94b5cb44083b78147f45398a29188bf2834488eb03",
+ "release_date": "2026-07-27",
+ "signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
- "version": "1.7.111-alpha"
+ "version": "1.7.116-alpha"
}
diff --git a/releases/manifest.json b/releases/manifest.json
index 959457be..325bf843 100644
--- a/releases/manifest.json
+++ b/releases/manifest.json
@@ -1,36 +1,29 @@
{
"changelog": [
- "Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — \"what's the block height?\", \"how many peers am I connected to?\", \"is bitcoin synced?\", \"what's my Lightning balance?\" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.",
- "Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom \"Yo Archy\" wake word is in the works.)",
- "Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.",
- "Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.",
- "The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.",
- "Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.",
- "Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text \"/bin/bash\"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.",
- "Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.",
- "Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.",
- "On the phone home screen, the wallet card moved up to sit right under My Apps."
+ "Nodes no longer get stuck on \"server starting up\" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.",
+ "Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.",
+ "Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts."
],
"components": [
{
- "current_version": "1.7.111-alpha",
- "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago",
+ "current_version": "1.7.116-alpha",
+ "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago",
"name": "archipelago",
- "new_version": "1.7.111-alpha",
- "sha256": "9e50577470cb4c5dd67b4316a3efaaa87e7afac15a8b5c2e3841edc6c3ed6550",
- "size_bytes": 50615920
+ "new_version": "1.7.116-alpha",
+ "sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
+ "size_bytes": 51787208
},
{
- "current_version": "1.7.111-alpha",
- "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago-frontend-1.7.111-alpha.tar.gz",
- "name": "archipelago-frontend-1.7.111-alpha.tar.gz",
- "new_version": "1.7.111-alpha",
- "sha256": "3ea73cd8e98312b3b6877e00434363fe575362e7b2b134e03a9004d4e42930c9",
- "size_bytes": 174650727
+ "current_version": "1.7.116-alpha",
+ "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz",
+ "name": "archipelago-frontend-1.7.116-alpha.tar.gz",
+ "new_version": "1.7.116-alpha",
+ "sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
+ "size_bytes": 178062490
}
],
- "release_date": "2026-07-22",
- "signature": "1cc569a121f2cc5560840198115732155db4ee037ea98c4be8cf9ef8a7b227e26dc686a88a779f1e40daaa94b5cb44083b78147f45398a29188bf2834488eb03",
+ "release_date": "2026-07-27",
+ "signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
- "version": "1.7.111-alpha"
+ "version": "1.7.116-alpha"
}
diff --git a/reticulum-daemon/reticulum_daemon.py b/reticulum-daemon/reticulum_daemon.py
index d31c4c73..65e8dc63 100644
--- a/reticulum-daemon/reticulum_daemon.py
+++ b/reticulum-daemon/reticulum_daemon.py
@@ -14,12 +14,13 @@ Security posture (see the plan's "most secure way" section):
RPC (one JSON object per line, both directions):
in : {"cmd":"send","dest_hash":"","content":"…","title":"…","method":"direct|opportunistic"}
{"cmd":"announce"}
+ {"cmd":"set_name","name":"…"}
{"cmd":"status"}
{"cmd":"send_resource","id":"","dest_hash":"","data_b64":"…"}
{"cmd":"shutdown"}
out: {"event":"ready","dest_hash":"","display_name":"…"}
{"event":"recv","source_hash":"","content":"…","title":"…","fields":{…},"app_data":"","rssi":n,"snr":n,"stamp":t}
- {"event":"announce","dest_hash":"","app_data":""}
+ {"event":"announce","dest_hash":"","app_data":"","display_name":"…"|null,"archy_blob":"ARCHY:2:…"|null}
{"event":"delivered","dest_hash":"","state":"delivered|failed","id":""}
{"event":"status","connected":bool,"dest_hash":"","interfaces":[…]}
{"event":"resource_progress","id":"","transferred":n,"total":n}
@@ -227,31 +228,47 @@ class ReticulumDaemon:
if self.delivery_destination is not None:
self.delivery_destination.announce(app_data=self._announce_app_data())
- def _announce_app_data(self) -> bytes:
- """Carry the Archy identity so peers bind this RNS destination onto the
- existing contact, the same way a meshcore/Meshtastic identity advert does.
-
- Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the
- Rust side already parses (``protocol::parse_identity_broadcast``) and
- binds via ``handle_identity_received``/``bind_federation_twins`` — so a
- Reticulum-carried identity merges into the SAME conversation as the
- meshcore/Meshtastic/federation twins of the same Archy node, satisfying
- cross-protocol DM convergence. The keys are the node's real Archipelago
- ed25519/x25519 pubkeys (passed in by the Rust side, which already has
- them) — NOT this daemon's internally-HKDF-derived RNS keys, which exist
- only to make the RNS destination hash deterministic and are never
- themselves treated as an Archy identity.
-
- Falls back to a plain display-name string (undetected as an identity
- blob — no `ARCHY:2:` prefix) if the Archy pubkeys weren't supplied, e.g.
- a dev/selftest run with no `--archy-ed-pubkey-hex`.
- """
+ def _archy_identity_blob(self):
+ """The ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` identity string the Rust
+ side parses (``protocol::parse_identity_broadcast``) and binds via
+ ``handle_identity_received`` — so a Reticulum-carried identity merges
+ into the SAME conversation as the meshcore/Meshtastic/federation twins
+ of the same Archy node. The keys are the node's real Archipelago
+ pubkeys (passed in by the Rust side) — NOT this daemon's
+ internally-HKDF-derived RNS keys, which exist only to make the RNS
+ destination hash deterministic. ``None`` when the pubkeys weren't
+ supplied (dev/selftest run)."""
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
return (
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
f"{self.args.archy_x25519_pubkey_hex}"
).encode("ascii")
- return (self.args.display_name or "").encode("utf-8")
+ return None
+
+ def _announce_app_data(self) -> bytes:
+ """LXMF-standard announce app_data — msgpack ``[display_name,
+ stamp_cost, supported_functionality]`` via the router, so Sideband/
+ NomadNet/MeshChat (and upgraded archy nodes) all see our real display
+ name — with the Archy identity blob appended as an EXTRA list element.
+ Stock clients only read the elements they know ([0]/[1]), so the blob
+ rides along invisibly instead of replacing the name the way the old
+ blob-only app_data did (which left every archy node nameless on RNS).
+ """
+ import RNS.vendor.umsgpack as msgpack
+ app_data = self.router.get_announce_app_data(self.delivery_destination.hash)
+ blob = self._archy_identity_blob()
+ if blob is None:
+ return app_data
+ try:
+ peer_data = msgpack.unpackb(app_data)
+ if not isinstance(peer_data, list):
+ raise ValueError("unexpected announce app_data shape")
+ peer_data.append(blob)
+ return msgpack.packb(peer_data)
+ except Exception:
+ # Never let announce formatting kill announcing entirely — fall
+ # back to the legacy blob-only format (identity binding > name).
+ return blob
# ---- RNS-thread callbacks → asyncio ----
def _on_lxmf_delivery(self, message):
@@ -328,6 +345,15 @@ class ReticulumDaemon:
self._send(req)
elif cmd == "announce":
self.announce()
+ elif cmd == "set_name":
+ # Live rename: update the LXMF delivery destination's display name
+ # (what get_announce_app_data reads) and re-announce immediately so
+ # peers learn the new name without waiting for the next advert tick.
+ name = (req.get("name") or "").strip()
+ if name and self.delivery_destination is not None:
+ self.args.display_name = name
+ self.delivery_destination.display_name = name
+ self.announce()
elif cmd == "status":
self._broadcast(self._status())
elif cmd == "send_resource":
@@ -521,10 +547,41 @@ class _AnnounceHandler:
self.receive_path_responses = True
def received_announce(self, destination_hash, announced_identity, app_data):
+ # Decode what we can here (both the LXMF-standard display name and our
+ # appended ARCHY identity blob — see _announce_app_data) so the Rust
+ # side gets clean typed fields instead of re-implementing msgpack.
+ display_name = None
+ archy_blob = None
+ raw = app_data or b""
+ try:
+ import LXMF
+ display_name = LXMF.display_name_from_app_data(raw)
+ # A legacy blob-only announce is plain ascii, so LXMF's decoder
+ # returns the whole ARCHY identity blob as a "name" — drop it.
+ if display_name and display_name.startswith("ARCHY:"):
+ display_name = None
+ except Exception:
+ display_name = None
+ try:
+ if raw[:1] and ((0x90 <= raw[0] <= 0x9F) or raw[0] == 0xDC):
+ import RNS.vendor.umsgpack as msgpack
+ peer_data = msgpack.unpackb(raw)
+ if isinstance(peer_data, list):
+ for el in peer_data[3:]:
+ if isinstance(el, bytes) and el.startswith(b"ARCHY:"):
+ archy_blob = el.decode("ascii", "ignore")
+ break
+ elif raw.startswith(b"ARCHY:"):
+ # Legacy (pre-upgrade archy node): app_data IS the blob.
+ archy_blob = raw.decode("ascii", "ignore")
+ except Exception:
+ archy_blob = None
self.daemon._emit_threadsafe({
"event": "announce",
"dest_hash": destination_hash.hex(),
- "app_data": (app_data or b"").hex(),
+ "app_data": raw.hex(),
+ "display_name": display_name,
+ "archy_blob": archy_blob,
})
@@ -604,8 +661,30 @@ def main(argv=None) -> int:
if args.selftest:
args.no_radio = True
daemon.bring_up()
+ # Announce app_data round-trip: the LXMF-standard msgpack name must be
+ # decodable by stock clients AND (with archy keys set) the appended
+ # identity blob must survive as an extra list element — this is the
+ # exact wire contract the Rust announce handler and Sideband both
+ # depend on, so verify it here where there's a real router to build it.
+ import LXMF as _LXMF
+ import RNS.vendor.umsgpack as _msgpack
+ args.archy_ed_pubkey_hex = args.archy_ed_pubkey_hex or "ab" * 32
+ args.archy_x25519_pubkey_hex = args.archy_x25519_pubkey_hex or "cd" * 32
+ app_data = daemon._announce_app_data()
+ decoded_name = _LXMF.display_name_from_app_data(app_data)
+ assert decoded_name == args.display_name, (
+ f"announce name round-trip failed: {decoded_name!r} != {args.display_name!r}"
+ )
+ blob_elems = [e for e in _msgpack.unpackb(app_data)[3:]
+ if isinstance(e, bytes) and e.startswith(b"ARCHY:")]
+ assert blob_elems, "identity blob missing from announce app_data"
+ # Live rename: set_name must change what the next announce carries.
+ daemon.delivery_destination.display_name = "selftest-renamed"
+ renamed = _LXMF.display_name_from_app_data(daemon._announce_app_data())
+ assert renamed == "selftest-renamed", f"rename round-trip failed: {renamed!r}"
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
- f"display_name={args.display_name!r} lxmf_router=up")
+ f"display_name={args.display_name!r} lxmf_router=up "
+ f"announce_app_data=verified set_name=verified")
return 0
for sig in (signal.SIGINT, signal.SIGTERM):
diff --git a/scripts/audit-secrets.sh b/scripts/audit-secrets.sh
index 15a6603f..cb1c4823 100755
--- a/scripts/audit-secrets.sh
+++ b/scripts/audit-secrets.sh
@@ -25,7 +25,7 @@ PATTERNS=(
)
# Allowed files (config templates, docs, test fixtures)
-ALLOW_PATTERNS="test|mock|example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets"
+ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with"
main() {
log "=== Secrets Audit ==="
@@ -34,7 +34,7 @@ main() {
# 1. Check for .env files in version control
log "1. Checking for .env files in git..."
local env_files
- env_files=$(cd "$REPO_ROOT" && git ls-files '*.env' '.env*' 2>/dev/null || echo "")
+ env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
if [ -z "$env_files" ]; then
pass "No .env files tracked in git"
else
@@ -69,7 +69,7 @@ main() {
if [ -n "$matches" ]; then
# Filter out false positives (empty strings, variable declarations, etc.)
local real_matches
- real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<' || echo "")
+ real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-Z0-9_]+|TestPassword|password123|entertoexit' || echo "")
if [ -n "$real_matches" ]; then
echo " WARNING: Pattern '$pattern' found:"
echo "$real_matches" | head -5 | sed 's/^/ /'
@@ -96,7 +96,7 @@ main() {
# 5. Check for credential files in repo
log "5. Checking for credential files..."
local cred_files
- cred_files=$(cd "$REPO_ROOT" && git ls-files '*.pem' '*.key' '*macaroon*' 2>/dev/null | grep -v '\.rs$' | grep -v '\.ts$' || echo "")
+ cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts)$' || echo "")
if [ -z "$cred_files" ]; then
pass "No credential files tracked in git"
else
diff --git a/scripts/build-iso-release.sh b/scripts/build-iso-release.sh
new file mode 100755
index 00000000..913673f8
--- /dev/null
+++ b/scripts/build-iso-release.sh
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+# Gated ISO release build — the single command that turns a signed release
+# on `main` into a tested installer ISO.
+#
+# Stages (fail-fast, each logged with timing):
+# 0. preflight — Linux, clean tree on main, version parity across
+# Cargo.toml / package.json / releases/manifest.json /
+# CHANGELOG / git tag, manifest signature present
+# 1. gates — tests/release/run.sh (static + frontend + backend
+# slice), strict catalog drift, FULL cargo test suite
+# 2. artifacts — release binary embeds the version, frontend dist
+# matches, AIUI present (OTA-strip regression guard)
+# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
+# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
+# 5. qemu — headless boot test (skippable with --no-qemu)
+#
+# Usage:
+# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
+#
+# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
+# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
+
+set -u
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO"
+
+SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --skip-gates) SKIP_GATES=1 ;;
+ --no-qemu) NO_QEMU=1 ;;
+ --bundled) UNBUNDLED=0 ;;
+ --rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
+ *) echo "unknown flag: $1" >&2; exit 2 ;;
+ esac
+ shift
+done
+
+[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
+
+PASS=() FAIL=()
+stage() { # stage
+ local name="$1"; shift
+ local t0=$SECONDS
+ echo
+ echo "═══ [$name] $*"
+ if "$@"; then
+ echo "═══ [$name] PASS ($((SECONDS - t0))s)"
+ PASS+=("$name")
+ else
+ local rc=$?
+ echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
+ FAIL+=("$name")
+ summary 1
+ fi
+}
+summary() {
+ echo
+ echo "──────── ISO release build summary ────────"
+ printf 'PASS: %s\n' "${PASS[@]:-none}"
+ [[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
+ exit "${1:-0}"
+}
+
+# ── Stage 0: preflight ───────────────────────────────────────────────
+preflight() {
+ [ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
+
+ local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
+ [ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
+
+ if [ -n "$(git status --porcelain)" ]; then
+ echo "working tree is not clean — release ISOs build from committed state only:"
+ git status --porcelain | head -20
+ return 1
+ fi
+
+ VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
+ local ui_ver manifest_ver
+ ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
+ manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
+ echo " Cargo.toml: $VERSION"
+ echo " package.json: $ui_ver"
+ echo " releases/manifest: $manifest_ver"
+ [ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
+ [ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
+
+ head -5 CHANGELOG.md | grep -qF "v$VERSION" \
+ || { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
+
+ git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
+ || { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
+
+ # The ISO must only ever be cut from a ceremony-signed manifest.
+ python3 - <<'EOF' || return 1
+import json, sys
+m = json.load(open("releases/manifest.json"))
+sig, by = m.get("signature"), m.get("signed_by", "")
+if not sig or not by.startswith("did:key:"):
+ print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
+ sys.exit(1)
+print(f" manifest signed by {by[:32]}…")
+EOF
+
+ echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
+}
+stage "preflight" preflight
+VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
+
+# ── Stage 1: gates ───────────────────────────────────────────────────
+if [ "$SKIP_GATES" = "0" ]; then
+ stage "release-gate-harness" bash tests/release/run.sh
+ stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
+ # Full Rust suite — the release harness only runs a 6-module slice;
+ # ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
+ stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
+ nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
+else
+ echo; echo "═══ [gates] SKIPPED (--skip-gates)"
+fi
+
+# ── Stage 2: artifact verification ───────────────────────────────────
+verify_artifacts() {
+ local bin="core/target/release/archipelago"
+ [ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
+ strings "$bin" | grep -qF "$VERSION" \
+ || { echo "release binary does not embed $VERSION — stale build"; return 1; }
+ echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
+
+ [ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
+ grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
+ || { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
+ echo " frontend dist contains $VERSION"
+
+ # AIUI must ride inside the dist BEFORE packaging or OTA upgrades
+ # silently strip it from nodes in the field.
+ [ -f web/dist/neode-ui/aiui/index.html ] \
+ || { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
+ echo " AIUI present in frontend dist"
+}
+stage "verify-artifacts" verify_artifacts
+
+# ── Stage 3: build the ISO ───────────────────────────────────────────
+build_iso() {
+ local env_args=(
+ UNBUNDLED="$UNBUNDLED"
+ BUILD_FROM_SOURCE=0
+ DEV_SERVER=localhost
+ ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
+ )
+ [ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
+ sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
+}
+stage "build-iso" build_iso
+
+find_iso() {
+ ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
+}
+ISO="$(find_iso)"
+[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
+
+# ── Stage 4: mount-level smoke test ──────────────────────────────────
+stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
+
+# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
+# The ISO's kernel cmdline has no serial console, so the serial-log
+# sanity grep can miss a perfectly healthy boot. Run it, report it,
+# but don't fail an otherwise-green build on it.
+if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
+ echo
+ echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
+ if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
+ echo "═══ [qemu-boot] PASS"
+ PASS+=("qemu-boot")
+ else
+ echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
+ PASS+=("qemu-boot(inconclusive)")
+ fi
+else
+ echo; echo "═══ [qemu-boot] SKIPPED"
+fi
+
+# ── Done ─────────────────────────────────────────────────────────────
+SHA_FILE="$ISO.sha256"
+[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
+
+echo
+echo "════════════════════════════════════════════════════"
+echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
+echo "════════════════════════════════════════════════════"
+echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
+echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
+echo
+echo " Next steps (publisher, offline mnemonic required):"
+echo " 1. scripts/sign-iso-checksums.sh $ISO"
+echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
+echo " v$VERSION Gitea release assets"
+summary 0
diff --git a/scripts/create-release-manifest.sh b/scripts/create-release-manifest.sh
index 96c2ccbe..ed0a62b6 100755
--- a/scripts/create-release-manifest.sh
+++ b/scripts/create-release-manifest.sh
@@ -142,7 +142,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
# SIGPIPE-safe: use awk to read only the first line and exit,
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
# don't kill the whole `set -euo pipefail` script.
- root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
+ root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
case "$root_mode" in
drwxr-xr-x|drwxr-x*x*)
echo " Tarball root perms OK: $root_mode"
diff --git a/scripts/generate-app-catalog.py b/scripts/generate-app-catalog.py
index 6ee7a96e..09445b0b 100644
--- a/scripts/generate-app-catalog.py
+++ b/scripts/generate-app-catalog.py
@@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
return "\n".join(lines)
+def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
+ """Rust constant of catalog launch ports for the fips0 firewall drop-in
+ (core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
+ APP_PORTS overrides (companions/aliases) that have no manifest of their own.
+ """
+ distinct = sorted(set(list(ports.values()) + extra_ports))
+ lines = [
+ "//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
+ "//!",
+ "//! Catalog app launch ports (the web UIs the companion opens by direct",
+ "//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
+ "//! are reachable over the mesh; ports of apps that aren\'t installed have",
+ "//! no listener, so allowing them is inert.",
+ "",
+ "pub const APP_LAUNCH_PORTS: &[u16] = &[",
+ ]
+ lines.extend(f" {port}," for port in distinct)
+ lines.extend(["];", ""])
+ return "\n".join(lines)
+
+
+# Keep in lockstep with APP_PORTS overrides in
+# neode-ui/src/views/appSession/appSessionConfig.ts.
+RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
+
+
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
with path.open("r", encoding="utf-8") as fh:
catalog = json.load(fh)
@@ -178,6 +204,11 @@ def main() -> int:
default=[],
help="Catalog JSON path to update. May be passed multiple times.",
)
+ parser.add_argument(
+ "--rust-app-ports",
+ default="core/archipelago/src/fips/app_ports.rs",
+ help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
+ )
parser.add_argument(
"--app-session-config",
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
@@ -201,6 +232,20 @@ def main() -> int:
print(f"{path}: updated")
else:
print(f"{path}: updated 0 fields")
+ if args.rust_app_ports:
+ ports = {
+ app_id: port
+ for app_id, app in manifests.items()
+ if (port := manifest_launch_port(app))
+ }
+ rust_path = Path(args.rust_app_ports)
+ rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
+ rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
+ if rust_old != rust_content:
+ rust_path.write_text(rust_content, encoding="utf-8")
+ print(f"{rust_path}: updated")
+ else:
+ print(f"{rust_path}: updated 0 fields")
print(f"total_updated={total}")
return 0
diff --git a/scripts/iso-smoke-test.sh b/scripts/iso-smoke-test.sh
new file mode 100755
index 00000000..760e13d8
--- /dev/null
+++ b/scripts/iso-smoke-test.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+# Mount-level smoke test for an Archipelago installer ISO.
+#
+# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
+# payload, and — the check that has bitten before — that the backend
+# binary inside the ISO actually embeds the version the filename claims.
+#
+# Usage:
+# scripts/iso-smoke-test.sh [expected-version]
+#
+# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
+# (loop mount). Exits non-zero on the first hard failure; prints a
+# PASS/FAIL table either way.
+
+set -u
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ISO="${1:-}"
+EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
+
+if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
+ echo "usage: $0 [expected-version]" >&2
+ exit 2
+fi
+
+FAIL=0
+ok() { echo " OK: $*"; }
+bad() { echo " FAIL: $*"; FAIL=1; }
+warn() { echo " WARN: $*"; }
+
+echo "ISO smoke test"
+echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
+echo " Version: $EXPECTED_VERSION (expected)"
+
+# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
+case "$(basename "$ISO")" in
+ *"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
+ *) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
+esac
+
+MNT="$(mktemp -d)"
+INITRD_DIR=""
+cleanup() {
+ sudo umount "$MNT" 2>/dev/null || true
+ rmdir "$MNT" 2>/dev/null || true
+ [ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
+}
+trap cleanup EXIT
+
+if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
+ echo " FAIL: could not loop-mount ISO" >&2
+ exit 1
+fi
+
+# ── Required boot + installer files ──────────────────────────────────
+for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
+ isolinux/isolinux.bin isolinux/isolinux.cfg \
+ boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
+ archipelago/auto-install.sh archipelago/rootfs.tar; do
+ if [ -e "$MNT/$f" ]; then
+ ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
+ else
+ bad "missing $f"
+ fi
+done
+
+# ── GRUB must boot the live system ───────────────────────────────────
+if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
+ ok "grub.cfg has boot=live"
+else
+ bad "grub.cfg missing boot=live"
+fi
+
+# ── initrd must contain live-boot scripts ────────────────────────────
+if command -v unmkinitramfs >/dev/null 2>&1; then
+ INITRD_DIR="$(mktemp -d)"
+ sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
+ if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
+ ok "initrd has live-boot scripts"
+ else
+ bad "initrd missing live-boot scripts"
+ fi
+else
+ warn "unmkinitramfs not installed — skipping initrd live-boot check"
+fi
+
+# ── Backend binary inside the ISO embeds the expected version ────────
+# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
+# shipped and the fleet rejected its fips.yaml on Activate)
+BIN_IN_ISO=""
+if [ -f "$MNT/archipelago/bin/archipelago" ]; then
+ BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
+ if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
+ ok "payload backend binary embeds $EXPECTED_VERSION"
+ else
+ bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
+ fi
+else
+ # Fall back to the copy inside rootfs.tar
+ TMPBIN="$(mktemp -d)"
+ if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
+ usr/local/bin/archipelago 2>/dev/null; then
+ if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
+ ok "rootfs backend binary embeds $EXPECTED_VERSION"
+ else
+ bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
+ fi
+ else
+ bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
+ fi
+ sudo rm -rf "$TMPBIN"
+fi
+
+# ── Frontend payload present ─────────────────────────────────────────
+if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
+ ok "frontend payload (archipelago/web-ui/index.html)"
+ if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
+ ok "AIUI included in frontend payload"
+ else
+ warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
+ fi
+else
+ warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
+fi
+
+echo
+if [ "$FAIL" = "1" ]; then
+ echo "ISO SMOKE TEST: FAILED"
+ exit 1
+fi
+echo "ISO SMOKE TEST: PASSED"
diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh
index 62dfca1f..ec7df3f2 100755
--- a/scripts/run-tests.sh
+++ b/scripts/run-tests.sh
@@ -1,12 +1,15 @@
#!/usr/bin/env bash
#
-# Run all Archipelago tests: frontend (local) + backend (dev server via SSH).
-# Exit 0 only if both pass.
+# Run Archipelago tests.
+#
+# By default this runs frontend tests and local backend Rust tests. Set
+# ARCHIPELAGO_SSH_HOST and ARCHIPELAGO_SSH_KEY to run backend tests on a Linux
+# target instead.
#
set -euo pipefail
-SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
-SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}"
+SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
+SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
@@ -29,34 +32,29 @@ fi
echo ""
-# --- Backend Tests (on dev server) ---
-echo "--- Backend Tests (dev server) ---"
+# --- Backend Tests ---
+if [[ -n "$SSH_HOST" && -n "$SSH_KEY" ]]; then
+ echo "--- Backend Tests (Linux target: $SSH_HOST) ---"
+ echo "Syncing source to target..."
+ rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
+ -e "ssh -i $SSH_KEY" \
+ "$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
-# Sync source to server
-echo "Syncing source to dev server..."
-rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
- -e "ssh -i $SSH_KEY" \
- "$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
-
-# Run tests on server
-if ssh -i "$SSH_KEY" "$SSH_HOST" \
- "source ~/.cargo/env && cd ~/archy/core && cargo test -p archipelago 2>&1"; then
- echo "✅ Backend unit tests PASSED"
- BACKEND_OK=1
+ if ssh -i "$SSH_KEY" "$SSH_HOST" \
+ "source ~/.cargo/env && cd ~/archy/core && cargo test --all-features 2>&1"; then
+ echo "✅ Backend tests PASSED"
+ BACKEND_OK=1
+ else
+ echo "❌ Backend tests FAILED"
+ fi
else
- echo "❌ Backend unit tests FAILED"
-fi
-
-echo ""
-
-# --- Integration Tests ---
-echo "--- Integration Tests (dev server) ---"
-if ssh -i "$SSH_KEY" "$SSH_HOST" \
- "source ~/.cargo/env && cd ~/archy/core && cargo test --test rpc_integration 2>&1"; then
- echo "✅ Integration tests PASSED"
-else
- echo "❌ Integration tests FAILED"
- BACKEND_OK=0
+ echo "--- Backend Tests (local) ---"
+ if (cd "$PROJECT_DIR/core" && cargo test --all-features 2>&1); then
+ echo "✅ Backend tests PASSED"
+ BACKEND_OK=1
+ else
+ echo "❌ Backend tests FAILED"
+ fi
fi
echo ""
diff --git a/scripts/setup-aiui-server.sh b/scripts/setup-aiui-server.sh
index 49ff0dce..cbf3fc4a 100755
--- a/scripts/setup-aiui-server.sh
+++ b/scripts/setup-aiui-server.sh
@@ -24,8 +24,9 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
-# Anthropic API key — used by all servers for AIUI Claude chat
-ANTHROPIC_API_KEY="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"
+# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the
+# caller's environment or scripts/deploy-config.sh; never commit live keys.
+ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
TARGET_HOST="$1"
if [ -z "$TARGET_HOST" ]; then
@@ -34,6 +35,12 @@ if [ -z "$TARGET_HOST" ]; then
exit 1
fi
+if [ -z "$ANTHROPIC_API_KEY" ]; then
+ echo "ERROR: ANTHROPIC_API_KEY must be set in the environment."
+ echo "Example: ANTHROPIC_API_KEY= $0 $TARGET_HOST"
+ exit 1
+fi
+
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
if [ ! -f "$AIUI_DIST/index.html" ]; then
echo "ERROR: AIUI build not found at $AIUI_DIST"
diff --git a/scripts/validate-app-manifest.sh b/scripts/validate-app-manifest.sh
index 9831d735..657957a6 100755
--- a/scripts/validate-app-manifest.sh
+++ b/scripts/validate-app-manifest.sh
@@ -1,25 +1,26 @@
#!/usr/bin/env bash
#
-# validate-app-manifest.sh — Validate a community-submitted app manifest
+# validate-app-manifest.sh - validate an Archipelago app manifest.
#
-# Usage: ./scripts/validate-app-manifest.sh
+# Usage:
+# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
#
-# Checks:
-# 1. Valid YAML syntax
-# 2. Required fields present (id, title, version, image, description)
-# 3. Image from trusted registry (docker.io, ghcr.io, quay.io)
-# 4. No :latest tag (must pin specific version)
-# 5. Resource limits specified (memory, cpu)
-# 6. Security: no privileged mode, no host networking
-# 7. No hardcoded secrets/passwords in environment
-# 8. Port conflicts with existing apps
-#
-# Exit 0 = valid, Exit 1 = issues found
+# This intentionally mirrors the public app contract documented in
+# docs/app-manifest-spec.md: manifests have a top-level `app:` block and are
+# ultimately validated by the Rust parser in core/container/src/manifest.rs.
+# This script is the contributor-friendly preflight; the Rust parser remains
+# canonical.
set -euo pipefail
-if [[ $# -lt 1 ]]; then
- echo "Usage: $0 "
+REPO_AUDIT=0
+if [[ "${1:-}" == "--repo-audit" ]]; then
+ REPO_AUDIT=1
+ shift
+fi
+
+if [[ $# -ne 1 ]]; then
+ echo "Usage: $0 [--repo-audit] "
exit 1
fi
@@ -30,138 +31,235 @@ WARN=0
check() {
local desc="$1" result="$2"
- if [[ "$result" == "pass" ]]; then
- PASS=$((PASS + 1))
- echo " PASS: $desc"
- elif [[ "$result" == "warn" ]]; then
- WARN=$((WARN + 1))
- echo " WARN: $desc"
- else
- FAIL=$((FAIL + 1))
- echo " FAIL: $desc"
- fi
+ case "$result" in
+ pass)
+ PASS=$((PASS + 1))
+ echo " PASS: $desc"
+ ;;
+ warn)
+ WARN=$((WARN + 1))
+ echo " WARN: $desc"
+ ;;
+ *)
+ FAIL=$((FAIL + 1))
+ echo " FAIL: $desc"
+ ;;
+ esac
+}
+
+yaml_eval() {
+ ruby -ryaml -e '
+ path, expr = ARGV
+ data = YAML.load_file(path)
+ app = data.is_a?(Hash) ? data["app"] : nil
+ abort "missing top-level app block" unless app.is_a?(Hash)
+ value = eval(expr)
+ case value
+ when Array
+ puts value.join("\n")
+ when Hash
+ puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n")
+ when NilClass
+ puts ""
+ else
+ puts value
+ end
+ ' "$MANIFEST" "$1"
}
echo "Validating: $MANIFEST"
echo ""
-# 1. File exists and is readable
if [[ ! -f "$MANIFEST" ]]; then
echo " FAIL: File not found: $MANIFEST"
exit 1
fi
check "File exists" "pass"
-# 2. Valid YAML
-if ! python3 -c "import yaml; yaml.safe_load(open('$MANIFEST'))" 2>/dev/null; then
- check "Valid YAML syntax" "fail"
- echo " Cannot continue with invalid YAML"
+if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 1)' "$MANIFEST" 2>/dev/null; then
+ check "Valid YAML with top-level app block" "fail"
+ echo ""
+ echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
+ echo "STATUS: REJECTED - fix failures before resubmitting"
exit 1
fi
-check "Valid YAML syntax" "pass"
+check "Valid YAML with top-level app block" "pass"
-# 3. Required fields
-CONTENT=$(python3 -c "
-import yaml, json
-with open('$MANIFEST') as f:
- d = yaml.safe_load(f)
-print(json.dumps(d))
-" 2>/dev/null)
+APP_ID="$(yaml_eval 'app["id"]')"
+APP_NAME="$(yaml_eval 'app["name"]')"
+APP_VERSION="$(yaml_eval 'app["version"]')"
+APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
+APP_INTERNAL="$(yaml_eval 'app["internal"]')"
+IMAGE="$(yaml_eval '(app["container"] || {})["image"]')"
+BUILD_CONTEXT="$(yaml_eval '(((app["container"] || {})["build"] || {})["context"])')"
+BUILD_TAG="$(yaml_eval '(((app["container"] || {})["build"] || {})["tag"])')"
-for field in id title version description; do
- val=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$field',''))" 2>/dev/null)
- if [[ -n "$val" && "$val" != "None" ]]; then
- check "Required field '$field' present" "pass"
- else
- check "Required field '$field' present" "fail"
- fi
-done
-
-# 4. Image reference
-IMAGE=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('image','') or d.get('docker_image','') or '')" 2>/dev/null)
-if [[ -z "$IMAGE" || "$IMAGE" == "None" ]]; then
- check "Container image specified" "fail"
+if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
+ check "app.id is lowercase kebab-case ($APP_ID)" "pass"
else
- check "Container image specified" "pass"
+ check "app.id is lowercase kebab-case" "fail"
+fi
- # Check trusted registry
+if [[ -n "$APP_NAME" ]]; then
+ check "app.name present" "pass"
+else
+ check "app.name present" "fail"
+fi
+
+if [[ "$APP_VERSION" =~ [0-9] ]]; then
+ check "app.version present and contains a digit" "pass"
+else
+ check "app.version present and contains a digit" "fail"
+fi
+
+if [[ -n "$APP_DESCRIPTION" ]]; then
+ check "app.description present" "pass"
+else
+ check "app.description present" "warn"
+fi
+
+HAS_IMAGE=0
+HAS_BUILD=0
+[[ -n "$IMAGE" ]] && HAS_IMAGE=1
+[[ -n "$BUILD_CONTEXT" || -n "$BUILD_TAG" ]] && HAS_BUILD=1
+
+if [[ "$HAS_IMAGE" -eq 1 && "$HAS_BUILD" -eq 0 ]]; then
+ check "container.image specified" "pass"
+elif [[ "$HAS_IMAGE" -eq 0 && "$HAS_BUILD" -eq 1 ]]; then
+ if [[ -n "$BUILD_CONTEXT" && -n "$BUILD_TAG" ]]; then
+ check "container.build specified with context and tag" "pass"
+ else
+ check "container.build requires context and tag" "fail"
+ fi
+else
+ check "exactly one of container.image or container.build specified" "fail"
+fi
+
+if [[ -n "$IMAGE" ]]; then
TRUSTED=false
- for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
- if echo "$IMAGE" | grep -q "$reg"; then
+ for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000" "localhost/"; do
+ if [[ "$IMAGE" == *"$reg"* ]]; then
TRUSTED=true
break
fi
done
- # Also allow short-form Docker Hub images (no registry prefix)
- if ! echo "$IMAGE" | grep -q "/"; then
- TRUSTED=true # single-name images are Docker Hub official
- fi
- if [[ "$TRUSTED" == "true" ]]; then
- check "Image from trusted registry" "pass"
+ if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
+ check "image registry is recognized" "pass"
else
- check "Image from trusted registry ($IMAGE)" "warn"
+ check "image registry is not in the reviewed list ($IMAGE)" "warn"
fi
- # Check no :latest
- if echo "$IMAGE" | grep -q ":latest$"; then
- check "No :latest tag (pin specific version)" "fail"
- elif ! echo "$IMAGE" | grep -q ":"; then
- check "No version tag specified (should pin version)" "warn"
+ if [[ "$IMAGE" == *":latest" ]]; then
+ if [[ "$APP_INTERNAL" == "true" || "$IMAGE" == localhost/* ]]; then
+ check "internal/local build uses :latest ($IMAGE)" "warn"
+ elif [[ "$REPO_AUDIT" -eq 1 ]]; then
+ check "existing manifest uses :latest and must be pinned before public app submission ($IMAGE)" "warn"
+ else
+ check "image tag is pinned and not :latest ($IMAGE)" "fail"
+ fi
+ elif [[ "$IMAGE" != *:* ]]; then
+ check "image tag is explicit ($IMAGE)" "warn"
else
- check "Version tag pinned" "pass"
+ check "image tag is pinned" "pass"
fi
fi
-# 5. Security checks
-PRIVILEGED=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('privileged', False))" 2>/dev/null)
-if [[ "$PRIVILEGED" == "True" ]]; then
- check "No privileged mode" "fail"
+MEMORY_LIMIT="$(yaml_eval '((app["resources"] || {})["memory_limit"] || (app["resources"] || {})["memory"])')"
+CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (app["resources"] || {})["cpu"])')"
+[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
+[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
+
+READONLY_ROOT="$(yaml_eval '((app["security"] || {})["readonly_root"])')"
+NO_NEW_PRIVS="$(yaml_eval '((app["security"] || {})["no_new_privileges"])')"
+NETWORK_POLICY="$(yaml_eval '((app["security"] || {})["network_policy"])')"
+CONTAINER_NETWORK="$(yaml_eval '((app["container"] || {})["network"])')"
+
+if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
+ check "security.readonly_root true (explicit or Rust default)" "pass"
else
- check "No privileged mode" "pass"
+ check "security.readonly_root true or explicitly justified" "warn"
fi
-HOST_NET=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('host_network', d.get('network_mode','')))" 2>/dev/null)
-if [[ "$HOST_NET" == "host" ]]; then
- check "No host networking" "fail"
+if [[ "$NO_NEW_PRIVS" == "true" || -z "$NO_NEW_PRIVS" ]]; then
+ check "security.no_new_privileges true (explicit or Rust default)" "pass"
+elif [[ "$REPO_AUDIT" -eq 1 ]]; then
+ check "existing manifest disables security.no_new_privileges and needs review" "warn"
else
- check "No host networking" "pass"
+ check "security.no_new_privileges true" "fail"
fi
-# 6. Check for hardcoded secrets in env vars
-ENV_VARS=$(echo "$CONTENT" | python3 -c "
-import sys,json
-d=json.load(sys.stdin)
-env = d.get('environment', d.get('env', {}))
-if isinstance(env, dict):
- for k,v in env.items():
- print(f'{k}={v}')
-elif isinstance(env, list):
- for e in env:
- print(e)
-" 2>/dev/null || echo "")
-
-SECRET_PATTERNS="password|secret|api_key|private_key|token"
-if echo "$ENV_VARS" | grep -iqE "$SECRET_PATTERNS"; then
- check "No hardcoded secrets in environment" "warn"
+if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
+ check "security.network_policy valid" "pass"
else
- check "No hardcoded secrets in environment" "pass"
+ check "security.network_policy valid" "fail"
fi
-# 7. Memory limit
-MEM=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('memory', d.get('mem_limit', d.get('resources',{}).get('memory',''))))" 2>/dev/null)
-if [[ -n "$MEM" && "$MEM" != "None" && "$MEM" != "" ]]; then
- check "Memory limit specified ($MEM)" "pass"
+if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
+ check "container.network does not share another namespace" "fail"
else
- check "Memory limit specified" "warn"
+ check "container.network does not share another namespace" "pass"
+fi
+
+SECRET_ENV="$(yaml_eval '(app["environment"] || [])')"
+if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
+ check "no hardcoded secret-like values in app.environment" "warn"
+else
+ check "no hardcoded secret-like values in app.environment" "pass"
+fi
+
+if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
+ EXPECTED_DIR="$(basename "$(dirname "$MANIFEST")")"
+ if [[ "$EXPECTED_DIR" == "$APP_ID" ]]; then
+ check "app.id matches directory name" "pass"
+ elif [[ "$REPO_AUDIT" -eq 1 ]]; then
+ check "existing manifest app.id differs from directory name ($EXPECTED_DIR)" "warn"
+ else
+ check "app.id matches directory name ($EXPECTED_DIR)" "fail"
+ fi
+fi
+
+PORT_CHECK="$(ruby -ryaml -e '
+ current = ARGV[0]
+ current_id = File.basename(File.dirname(current))
+ ports = {}
+ Dir.glob("apps/*/manifest.yml").sort.each do |path|
+ data = YAML.load_file(path)
+ app = data.is_a?(Hash) ? data["app"] : nil
+ next unless app.is_a?(Hash)
+ id = app["id"] || File.basename(File.dirname(path))
+ next if id == current_id
+ Array(app["ports"]).each do |p|
+ next unless p.is_a?(Hash)
+ proto = p["protocol"] || "tcp"
+ bind = p["bind"] || ""
+ host = p["host"]
+ ports[[host, proto, bind]] = id if host
+ end
+ end
+ data = YAML.load_file(current)
+ app = data["app"]
+ conflicts = []
+ Array(app["ports"]).each do |p|
+ next unless p.is_a?(Hash)
+ key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""]
+ conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key)
+ end
+ puts conflicts.join("\n")
+' "$MANIFEST")"
+if [[ -n "$PORT_CHECK" ]]; then
+ while IFS= read -r conflict; do
+ check "port conflict: $conflict" "warn"
+ done <<< "$PORT_CHECK"
+else
+ check "no duplicate host port bindings" "pass"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
if [[ "$FAIL" -gt 0 ]]; then
- echo "STATUS: REJECTED — fix failures before resubmitting"
+ echo "STATUS: REJECTED - fix failures before resubmitting"
exit 1
-else
- echo "STATUS: APPROVED (with $WARN warnings)"
- exit 0
fi
+
+echo "STATUS: APPROVED (with $WARN warnings)"
diff --git a/tests/mesh/run-mesh-tests.sh b/tests/mesh/run-mesh-tests.sh
new file mode 100755
index 00000000..7911dd8e
--- /dev/null
+++ b/tests/mesh/run-mesh-tests.sh
@@ -0,0 +1,85 @@
+#!/bin/bash
+# Mesh / Reticulum test suite — the "is the mesh stack healthy" gate.
+#
+# Three layers, cheapest first:
+# 1. Rust unit tests (no hardware, ~2s once built)
+# 2. Daemon selftest (full RNS+LXMF bring-up, no radio; also verifies
+# the announce app_data wire contract + set_name)
+# 3. Live-node assertions (optional; needs a running archipelago with a
+# radio — set MESH_TEST_LIVE=1 MESH_TEST_PW=...)
+#
+# Usage:
+# tests/mesh/run-mesh-tests.sh # layers 1+2
+# MESH_TEST_LIVE=1 MESH_TEST_PW='...' tests/mesh/run-mesh-tests.sh
+# MESH_TEST_HOST=100.113.100.55 ... # live-test a remote node
+set -u
+cd "$(dirname "$0")/../.."
+FAIL=0
+ok() { echo "ok - $1"; }
+bad() { echo "not ok - $1"; FAIL=1; }
+
+# ── 1. Rust unit tests ────────────────────────────────────────────────
+RUST_RESULTS=$(cd core && cargo test -p archipelago --bin archipelago mesh 2>&1 | grep "^test result:")
+if [ -n "$RUST_RESULTS" ] && ! echo "$RUST_RESULTS" | grep -vq " 0 failed"; then
+ ok "rust mesh unit tests ($(echo "$RUST_RESULTS" | grep -o '[0-9]* passed' | head -1))"
+else
+ bad "rust mesh unit tests"
+fi
+
+# ── 2. Reticulum daemon selftest (no radio) ───────────────────────────
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+head -c 32 /dev/urandom > "$TMP/key"
+DAEMON=reticulum-daemon/.venv/bin/python
+if [ -x "$DAEMON" ]; then
+ if "$DAEMON" reticulum-daemon/reticulum_daemon.py \
+ --identity-key "$TMP/key" --rns-config "$TMP/rns" \
+ --socket "$TMP/sock" --display-name "SelftestNode" --selftest 2>/dev/null \
+ | grep -q "announce_app_data=verified set_name=verified"; then
+ ok "daemon selftest (announce wire contract + set_name)"
+ else
+ bad "daemon selftest"
+ fi
+else
+ echo "skip - daemon selftest (no venv at $DAEMON)"
+fi
+
+# ── 3. Live node assertions (opt-in) ──────────────────────────────────
+if [ "${MESH_TEST_LIVE:-0}" = "1" ]; then
+ HOST="${MESH_TEST_HOST:-127.0.0.1}"
+ PW="${MESH_TEST_PW:?set MESH_TEST_PW}"
+ # Nodes differ: dev boxes serve plain http on :80, ISO installs https.
+ RPC=""
+ for base in "http://$HOST" "https://$HOST" "http://$HOST:5678"; do
+ code=$(curl -ksS -o /dev/null -w '%{http_code}' -m 5 -X POST "$base/rpc/v1" 2>/dev/null || true)
+ case "$code" in 000|"") continue ;; *) RPC="$base/rpc/v1"; break ;; esac
+ done
+ [ -n "$RPC" ] || { bad "live: no RPC endpoint reachable on $HOST"; echo FAIL; exit 1; }
+ JAR="$TMP/jar"
+ curl -ksS -c "$JAR" -H "Content-Type: application/json" \
+ -d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$PW\"},\"id\":1}" \
+ "$RPC" > "$TMP/login"
+ if grep -q '"error":null' "$TMP/login"; then ok "live: rpc login"; else bad "live: rpc login"; fi
+ call() {
+ local csrf; csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$JAR")
+ curl -ksS -b "$JAR" -c "$JAR" -H "Content-Type: application/json" \
+ -H "X-CSRF-Token: $csrf" \
+ -d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":${2:-{\}},\"id\":2}" \
+ --max-time 60 "$RPC"
+ }
+ ST=$(call mesh.status)
+ echo "$ST" | grep -q '"device_connected":true' \
+ && ok "live: radio connected ($(echo "$ST" | grep -o '"device_type":"[a-z]*"'))" \
+ || bad "live: radio connected"
+ echo "$ST" | grep -q '"self_advert_name":"[^"]' \
+ && ok "live: node has a mesh name" || bad "live: node has a mesh name"
+ call mesh.refresh | grep -q '"refreshed":true' \
+ && ok "live: mesh.refresh" || bad "live: mesh.refresh"
+ call mesh.broadcast | grep -q '"broadcast":true' \
+ && ok "live: mesh.broadcast" || bad "live: mesh.broadcast"
+ # No peer may ever display a raw identity blob as its name.
+ call mesh.peers | grep -q '"advert_name":"ARCHY:' \
+ && bad "live: no ARCHY-blob peer names" || ok "live: no ARCHY-blob peer names"
+fi
+
+[ "$FAIL" = 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }
diff --git a/tests/release/run.sh b/tests/release/run.sh
index cf79e0b6..5c577d33 100755
--- a/tests/release/run.sh
+++ b/tests/release/run.sh
@@ -62,7 +62,7 @@ summary() {
# ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
-stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
+stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
# Every release must surface its CHANGELOG entry in the Settings "What's New"
# modal. The modal hardcodes a block per version and has drifted behind before
# (sat at v1.7.84 while the fleet shipped to v1.7.92). Fail if any CHANGELOG