Compare commits

..
1 Commits
Author SHA1 Message Date
Archipelago e3bcd9fca9 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
87 changed files with 1111 additions and 5934 deletions
-93
View File
@@ -1,93 +0,0 @@
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app" applicationId = "com.archipelago.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 47 versionCode = 45
versionName = "0.5.27" versionName = "0.5.25"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -1,40 +1,5 @@
package com.archipelago.app package com.archipelago.app
import android.app.Application import android.app.Application
import android.os.Looper
import android.webkit.WebView
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsNative
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ArchipelagoApp : Application() { class ArchipelagoApp : Application()
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
// Warmups that otherwise land inside the first frame:
// - FipsNative.available dlopens the 7 MB Rust core; referenced from
// composition (NESMenu, mesh auto-start), it blocked the UI thread.
// - The first DataStore read gates the nav graph's start destination;
// parsing it here means the launch gate resolves in the first
// emission instead of waiting on cold disk IO.
warmupScope.launch {
FipsNative.available
runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() }
}
// First WebView construction pays Chromium provider load (~150-400 ms
// cold). Absorb it while the main thread is idle before the kiosk
// needs it, instead of serially after the connection probe.
Looper.getMainLooper().queue.addIdleHandler {
runCatching { WebView(this).destroy() }
false // one-shot
}
}
}
@@ -9,7 +9,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.screens.releaseKioskWebView
import com.archipelago.app.ui.theme.ArchipelagoTheme import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,13 +19,7 @@ class MainActivity : ComponentActivity() {
private val pendingPairUri = MutableStateFlow<String?>(null) private val pendingPairUri = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
// Hold the branded system splash until the nav graph has its launch installSplashScreen()
// state — without this the splash dropped at the first composed frame,
// which was EMPTY (the DataStore read hadn't landed): splash → black
// flash → UI on every launch.
var navReady = false
val splash = installSplashScreen()
splash.setKeepOnScreenCondition { !navReady }
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString pendingPairUri.value = intent?.dataString
@@ -36,7 +29,6 @@ class MainActivity : ComponentActivity() {
AppNavHost( AppNavHost(
pairUri = pairUri, pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null }, onPairUriConsumed = { pendingPairUri.value = null },
onReady = { navReady = true },
) )
} }
} }
@@ -46,14 +38,4 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
pendingPairUri.value = intent.dataString pendingPairUri.value = intent.dataString
} }
override fun onDestroy() {
super.onDestroy()
// Swiped out of recents (or otherwise finished) — let go of the
// retained kiosk WebView so the next launch starts clean. Without
// this the FIPS service keeps the process (and the static WebView)
// alive, and "close the app" no longer restarted it. isFinishing
// keeps config changes (rotation) on the fast reattach path.
if (isFinishing) releaseKioskWebView()
}
} }
@@ -9,7 +9,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
@@ -30,18 +29,6 @@ data class ServerEntry(
/** Label to show in lists — the user-given name, or the address if unnamed. */ /** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address } fun displayName(): String = name.ifBlank { address }
/**
* Is this node reachable over the Archipelago FIPS mesh?
*
* A node that advertised either identity (npub) or a mesh address (ULA)
* came from a FIPS-capable pairing QR. Anything else — a hand-entered LAN
* box, someone else's server behind their own VPN — is a plain HTTP
* target, and the companion must NOT raise its own tunnel for it: Android
* allows exactly one VPN at a time, so doing so would silently take the
* tunnel away from whatever the user actually uses to reach that node.
*/
fun isFipsNode(): Boolean = npub.isNotBlank() || meshIp.isNotBlank()
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */ /** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
private fun urlHost(host: String): String = private fun urlHost(host: String): String =
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
@@ -102,9 +89,9 @@ class ServerPreferences(private val context: Context) {
private val introSeenKey = booleanPreferencesKey("intro_seen") private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen") private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
private fun activeServerFrom(prefs: Preferences): ServerEntry? { val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return null val address = prefs[activeAddressKey] ?: return@map null
return ServerEntry( ServerEntry(
address = address, address = address,
useHttps = prefs[activeHttpsKey] ?: false, useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "", port = prefs[activePortKey] ?: "",
@@ -115,52 +102,19 @@ class ServerPreferences(private val context: Context) {
) )
} }
// distinctUntilChanged on every flow: DataStore emits on EVERY write to the
// file regardless of key, and each spurious emission recomposed whatever
// screen collected it (the kiosk recomposed on gesture-hint writes).
val activeServer: Flow<ServerEntry?> = context.dataStore.data
.map { prefs -> activeServerFrom(prefs) }
.distinctUntilChanged()
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs -> val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
val raw = prefs[savedServersKey] ?: emptySet() val raw = prefs[savedServersKey] ?: emptySet()
// Sorted so set-iteration order can't produce a structurally different raw.mapNotNull { ServerEntry.deserialize(it) }
// list for the same servers (which defeats distinctUntilChanged). }
raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() }
}.distinctUntilChanged()
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs -> val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false prefs[introSeenKey] ?: false
}.distinctUntilChanged() }
/** One-shot flag for the three-finger-hold teaching overlay. */ /** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs -> val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[gestureHintSeenKey] ?: false prefs[gestureHintSeenKey] ?: false
}.distinctUntilChanged() }
/** Everything the nav graph needs to pick a start destination, derived
* from ONE DataStore emission. Collecting introSeen and activeServer as
* two separate flows let them land in different frames — the intro flag
* could resolve first and flash the Connect screen at a paired user
* before the active server arrived. */
data class LaunchState(
val introSeen: Boolean,
val activeServer: ServerEntry?,
/** Every saved node — the launch gate needs the COUNT to decide
* whether to ask which one to connect to. */
val savedServers: List<ServerEntry>,
)
val launchState: Flow<LaunchState> = context.dataStore.data.map { prefs ->
LaunchState(
introSeen = prefs[introSeenKey] ?: false,
activeServer = activeServerFrom(prefs),
savedServers = (prefs[savedServersKey] ?: emptySet())
.mapNotNull { ServerEntry.deserialize(it) }
.sortedBy { it.displayName() },
)
}.distinctUntilChanged()
suspend fun setActiveServer(server: ServerEntry) { suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
@@ -37,7 +37,6 @@ class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null private var warmerJob: Job? = null
private var handoffKickJob: Job? = null
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the // Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
// tunnel's underlying network stays pinned to the interface that was // tunnel's underlying network stays pinned to the interface that was
@@ -205,20 +204,14 @@ class ArchyVpnService : VpnService() {
/** /**
* Track the phone's default network and hand the mesh over to it as the * Track the phone's default network and hand the mesh over to it as the
* phone roams (Wi-Fi ⇄ 5G). Two actions per change: * phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live * 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
* network instead of dying on the one it launched with. * network instead of dying on the one it launched with.
* 2. re-home the mesh — kick the session warmer so discovery + sessions * 2. re-home the mesh — kick the session warmer so discovery + sessions
* rebuild on the new path; the node's own fast-reconnect (1s) redials * rebuild on the new path immediately; the node's own fast-reconnect
* peers over the new route. * (1s) redials peers over the new route.
* onAvailable also fires for the FIRST network, which is how the initial * onAvailable also fires for the FIRST network, which is how the initial
* underlying network gets set. * underlying network gets set.
*
* requestNetwork, NOT registerDefaultNetworkCallback: this app is routed
* through its own TUN, so its "default network" IS the VPN — a default
* callback fires once with our own tunnel and never again on Wi-Fi ⇄ 5G.
* A NetworkRequest's default capabilities include NOT_VPN, so requestNetwork
* tracks the best real transport underneath instead.
*/ */
private fun registerNetworkHandoff() { private fun registerNetworkHandoff() {
if (networkCallback != null) return if (networkCallback != null) return
@@ -243,6 +236,10 @@ class ArchyVpnService : VpnService() {
} }
} }
networkCallback = cb 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) } runCatching { cm.requestNetwork(request, cb) }
} }
@@ -254,22 +251,13 @@ class ArchyVpnService : VpnService() {
runCatching { setUnderlyingNetworks(arrayOf(network)) } runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (changed && FipsNative.isRunning()) { if (changed && FipsNative.isRunning()) {
Log.i(TAG, "network handoff → re-homing mesh on new default network") Log.i(TAG, "network handoff → re-homing mesh on new default network")
// Coalesced, not immediate: marginal Wi-Fi flaps the default // Fresh warmer pass drives immediate rediscovery/session rebuild
// Wi-Fi ⇄ cell in bursts, and an aggressive warmer pass per flip // on the new path instead of waiting out dead-link timeouts.
// meant near-constant session churn — the "reconnects a lot" startSessionWarmer()
// report. The re-pin above still happens on every change; only
// the rediscovery kick waits for the network to hold still.
handoffKickJob?.cancel()
handoffKickJob = scope.launch {
delay(2_000)
if (FipsNative.isRunning()) startSessionWarmer()
}
} }
} }
private fun unregisterNetworkHandoff() { private fun unregisterNetworkHandoff() {
handoffKickJob?.cancel()
handoffKickJob = null
val cm = connectivityManager val cm = connectivityManager
val cb = networkCallback val cb = networkCallback
if (cm != null && cb != null) { if (cm != null && cb != null) {
@@ -3,10 +3,8 @@ package com.archipelago.app.fips
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.VpnService import android.net.VpnService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
/** /**
* Glue between pairing and the mesh: persists the node peer from a scanned * Glue between pairing and the mesh: persists the node peer from a scanned
@@ -38,27 +36,20 @@ object FipsManager {
* No-op on devices without the native lib (non-arm64). * No-op on devices without the native lib (non-arm64).
*/ */
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) { suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
if (info == null) return if (info == null || !FipsNative.available) return
// Every caller reaches this from a Compose scope — i.e. the MAIN val prefs = FipsPreferences(context)
// thread — the instant a pairing QR decodes. Everything below is ensureIdentity(prefs)
// main-hostile: touching FipsNative dlopens the 7 MB mesh core, prefs.upsertNodePeer(info, alias)
// ensureIdentity runs native ed25519 keygen, and VpnService.prepare peersDirty = true
// is a binder round-trip. Left on the UI thread it froze the frame // Restart the mesh with the new peer RIGHT NOW when consent already
// right after the camera got the code, which reads as "the scanner // exists — relying on the consentNeeded collector left a running
// is slow" when the scan itself already succeeded. // mesh on the OLD peer list whenever the collector wasn't active
val consent = withContext(Dispatchers.IO) { // (fresh pairings looked dead until a full app restart).
if (!FipsNative.available) return@withContext null if (VpnService.prepare(context) == null) {
val prefs = FipsPreferences(context) startService(context)
ensureIdentity(prefs) } else {
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).
VpnService.prepare(context) == null
} ?: return
if (consent) startService(context) else _consentNeeded.value = true
} }
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */ /** Generate-once mesh identity. Returns null only if the RNG/native fails. */
@@ -76,17 +67,11 @@ object FipsManager {
* through AppNavHost instead. * through AppNavHost instead.
*/ */
suspend fun autoStartIfReady(context: Context) { suspend fun autoStartIfReady(context: Context) {
// Self-dispatching for the same reason as registerNode: callers reach if (!FipsNative.available) return
// this from Compose scopes, and dlopen + binder must not ride the UI val prefs = FipsPreferences(context)
// thread (the connect path calls it while the scanner is still up). if (prefs.identity() == null || !prefs.hasPeers()) return
val ready = withContext(Dispatchers.IO) { if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
if (!FipsNative.available) return@withContext false startService(context)
val prefs = FipsPreferences(context)
if (prefs.identity() == null || !prefs.hasPeers()) return@withContext false
// consent missing — don't prompt here
VpnService.prepare(context) == null
}
if (ready) startService(context)
} }
fun startService(context: Context) { fun startService(context: Context) {
@@ -1,46 +1,37 @@
package com.archipelago.app.ui.components package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.archipelago.app.R import com.archipelago.app.ui.screens.PixelArtLogo
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceBlack import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/** /**
* Full-screen loader shown while the app is dialing a node. * 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),
* Two faces, because they are two different promises: * instead of an anonymous spinner. The point of the brand: what's loading
* - [mesh] `true` — a FIPS node: the branded "F*CK IPs" screen, because what * is a connection to a cryptographic identity, not an IP.
* is loading really is a connection to a cryptographic identity, not an IP.
* - [mesh] `false` — a plain node reached over the network like anything
* else. No mesh branding at all: claiming the mesh is carrying a connection
* it isn't is worse than an anonymous spinner.
*/ */
@Composable @Composable
fun MeshLoadingScreen( fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
mesh: Boolean = true,
nodeName: String = "",
done: Boolean = false,
) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -48,39 +39,39 @@ fun MeshLoadingScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
// The app's own badge — the same ringed mark as the launcher icon // The brand's circle-container logo (as on the connect screen /
// and the system splash, so launch → splash → this screen is one // web login): pixel-art "a" centered in a black disc.
// continuous identity. Box(
Image( Modifier
painter = painterResource(id = R.drawable.ic_logo), .size(120.dp)
contentDescription = null, .clip(androidx.compose.foundation.shape.CircleShape)
modifier = Modifier.size(112.dp), .background(Color.Black)
) .border(
Spacer(Modifier.height(24.dp)) 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(
text = if (mesh) "F*CK IPS MESH" else "CONNECTING", text = "F*CK IPs MESH",
color = BitcoinOrange, color = BitcoinOrange,
fontSize = 16.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
letterSpacing = 4.sp, letterSpacing = 4.sp,
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
text = when { text = message,
mesh -> "Dialing your node by its key — no IPs harmed" color = TextMuted,
nodeName.isNotBlank() -> "Reaching $nodeName"
else -> "Reaching your node"
},
color = if (done) TextPrimary else TextMuted,
fontSize = 13.sp, fontSize = 13.sp,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
Spacer(Modifier.height(28.dp))
SlidingLoader(
modifier = Modifier.width(220.dp),
done = done,
) )
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = BitcoinOrange)
} }
} }
} }
@@ -30,7 +30,6 @@ import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Keyboard import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -71,7 +70,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.screens.restartCompanionApp
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextMuted
@@ -223,11 +221,7 @@ private fun MenuPanel(
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") { HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
page = HubPage.NODES page = HubPage.NODES
} }
// Mesh oversight only when this session is actually on the if (FipsNative.available) {
// mesh. Offering "FIPS Mesh" while connected to a plain node
// (whose traffic is going nowhere near the tunnel) advertises
// a connection the user doesn't have.
if (FipsNative.available && activeServer?.isFipsNode() == true) {
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS } HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
} }
if (onMeshParty != null) { if (onMeshParty != null) {
@@ -235,36 +229,6 @@ private fun MenuPanel(
} }
// Dark/Classic style lives on the remote/keyboard screen next to // Dark/Classic style lives on the remote/keyboard screen next to
// the settings button — not here. // the settings button — not here.
// Small version chip at the hub's foot — the one place a
// connected user can always check what build they're on.
val hubContext = LocalContext.current
// Restart: the dashboard WebView is retained across
// remote ⇄ dashboard (that's the point), which also means a
// wedged page can't be cleared by leaving the screen. This
// throws the page away and relaunches the app clean — the mesh
// service keeps running.
HubCard(Icons.Default.RestartAlt, "Restart", "Reload the app from scratch") {
onDismiss()
restartCompanionApp(hubContext)
}
val versionLabel = remember {
runCatching {
hubContext.packageManager
.getPackageInfo(hubContext.packageName, 0).versionName
}.getOrNull()?.let { "Companion v$it" } ?: ""
}
if (versionLabel.isNotEmpty()) {
Text(
versionLabel,
color = TextMuted.copy(alpha = 0.6f),
fontSize = 11.sp,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
)
}
} }
HubPage.NODES -> { HubPage.NODES -> {
@@ -1,24 +1,14 @@
package com.archipelago.app.ui.components package com.archipelago.app.ui.components
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.os.Process
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.camera2.interop.Camera2Interop
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -26,26 +16,22 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlashOff
import androidx.compose.material.icons.filled.FlashOn
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -60,15 +46,10 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -78,26 +59,23 @@ import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerQrParser import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.GlobalHistogramBinarizer
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
import com.google.zxing.qrcode.QRCodeReader
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.concurrent.Executors import java.util.concurrent.Executors
/** /**
* Scans the node pairing QR (docs/companion-pairing-qr.md) and reports the * Full-screen camera overlay that scans the node pairing QR
* decoded server entry. Handles the camera permission itself; foreign/invalid * (docs/companion-pairing-qr.md) and reports the decoded server entry.
* codes show a hint in the status strip and scanning continues. * Handles the camera permission itself; foreign/invalid codes show a hint
* * and scanning continues.
* Visually this is the SAME glass modal the web wallet uses (neode-ui's
* WalletScanModal) — scrim, glass card, square preview, orange viewfinder,
* status strip — so pairing from the app and scanning from the web UI look
* like one product rather than two different scanners.
*/ */
@Composable @Composable
fun QrScannerOverlay( fun QrScannerOverlay(
@@ -105,14 +83,28 @@ fun QrScannerOverlay(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onServerScanned: (PairResult.Success) -> Unit, onServerScanned: (PairResult.Success) -> Unit,
) { ) {
val haptics = LocalHapticFeedback.current val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var hintRes by remember { mutableStateOf<Int?>(null) } var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) } var handled by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) { LaunchedEffect(visible) {
if (visible) { if (visible) {
handled = false handled = false
hintRes = null hintRes = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
} }
} }
@@ -124,331 +116,125 @@ fun QrScannerOverlay(
} }
} }
QrGlassModal(
visible = visible,
title = stringResource(R.string.scan_node_qr),
status = hintRes?.let { stringResource(it) to true },
idleHint = stringResource(R.string.scan_qr_hint),
permissionRationale = stringResource(R.string.camera_permission_needed),
onDismiss = onDismiss,
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
// Confirm the hit in the hand — the eye is still on the
// code, not on the screen.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onServerScanned(result)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
}
/**
* The shared native scanner shell — one visual contract for every camera the
* app opens (pairing, wallet), mirroring neode-ui's WalletScanModal so the
* native and web scanners are indistinguishable:
* - black/60 scrim, dismiss on tap-outside
* - glass card (rounded 24, white/10 hairline) capped at 420dp
* - square preview with the 62% orange viewfinder and a darkened surround
* - a status strip that carries hints and errors
* - an optional footer (the wallet's "Upload image")
*/
@Composable
internal fun QrGlassModal(
visible: Boolean,
title: String,
// message + isError; null falls back to [idleHint].
status: Pair<String, Boolean>?,
idleHint: String,
permissionRationale: String,
onDismiss: () -> Unit,
onDecoded: (String) -> Unit,
footer: @Composable (() -> Unit)? = null,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var torchOn by remember { mutableStateOf(false) }
var hasTorch by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
} else {
torchOn = false
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() } BackHandler { onDismiss() }
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f)) .background(Color.Black),
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) { ) {
Column( if (hasPermission) {
Modifier CameraQrPreview(
.padding(16.dp) onDecoded = { text ->
.widthIn(max = 420.dp) if (!handled) {
.fillMaxWidth() when (val result = ServerQrParser.parse(text)) {
.clip(RoundedCornerShape(24.dp)) is PairResult.Success -> {
.background(Color(0xF212151C)) handled = true
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp)) onServerScanned(result)
.clickable( }
interactionSource = remember { MutableInteractionSource() }, is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
indication = null, is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
onClick = {}, // swallow — only the scrim dismisses
)
.padding(24.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
}
Spacer(Modifier.height(8.dp))
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.4f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = onDecoded,
torchOn = torchOn,
onTorchAvailable = { hasTorch = it },
)
// Viewfinder — 62% of the preview, matching the web
// modal's .scan-viewfinder, and matching the ROI the
// decoder actually reads (QR_ROI_FRACTION).
Box(
Modifier
.fillMaxSize(QR_ROI_FRACTION)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
if (hasTorch) {
IconButton(
onClick = { torchOn = !torchOn },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.45f)),
) {
Icon(
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
stringResource(
if (torchOn) R.string.torch_off else R.string.torch_on,
),
tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f),
)
} }
} }
} else { },
Column( )
Modifier.padding(horizontal = 24.dp), // Aim frame
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = permissionRationale,
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
Box( Box(
Modifier Modifier
.fillMaxWidth() .align(Alignment.Center)
.clip(RoundedCornerShape(8.dp)) .size(260.dp)
.background(Color.White.copy(alpha = 0.05f)) .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
.padding(12.dp) )
.defaultMinSize(minHeight = 24.dp), } else {
contentAlignment = Alignment.Center, Column(
Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text( Text(
text = status?.first?.takeIf { it.isNotBlank() } ?: idleHint, text = stringResource(R.string.camera_permission_needed),
style = MaterialTheme.typography.bodySmall, color = TextPrimary,
color = if (status?.second == true) { style = MaterialTheme.typography.bodyLarge,
Color(0xFFF87171) textAlign = TextAlign.Center,
} else { )
Color.White.copy(alpha = 0.6f) GlassButton(
}, text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
// Top bar: title + close
Row(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_node_qr),
color = TextPrimary,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
}
}
// Bottom hints
Column(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
hintRes?.let { res ->
Text(
text = stringResource(res),
color = BitcoinOrange,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
}
if (hasPermission) {
Text(
text = stringResource(R.string.scan_qr_hint),
color = TextMuted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
if (footer != null) {
Spacer(Modifier.height(16.dp))
footer()
}
} }
} }
} }
} }
/** /** Shared by the pairing scanner and the wallet scan modal. */
* Warm the CameraX provider and the ZXing decode path before the user ever
* asks for a scan, so opening the scanner doesn't pay provider init + class
* loading on the critical path. Does NOT open the camera: no permission is
* needed, no LED lights up, nothing is recorded — [ProcessCameraProvider]
* init is process-wide and cached, and the synthetic decode below just walks
* a blank 32x32 frame to class-load the binarizer/detector.
*
* Called once per process from the kiosk WebView (first page load) and by the
* page via `ArchipelagoQr.prewarm()`.
*/
internal fun prewarmQrScanner(context: Context) {
if (!qrPrewarmed.compareAndSet(false, true)) return
val app = context.applicationContext
runCatching { ProcessCameraProvider.getInstance(app) }
// Off the UI thread: the first decode attempt loads a dozen ZXing classes.
Executors.newSingleThreadExecutor().let { exec ->
exec.execute {
runCatching {
val blank = ByteArray(32 * 32)
val reader = MultiFormatReader().apply {
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
}
val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false)
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
}
}
exec.shutdown()
}
}
private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false)
/**
* Fraction of the preview's shorter edge that both the on-screen viewfinder
* and the decoder's region of interest use. Keeping them identical is the
* point: the user aims at the box, and the box is exactly what gets decoded.
*/
internal const val QR_ROI_FRACTION = 0.62f
/**
* Shared by the pairing scanner and the wallet scan modal.
*
* [torchOn] drives the flash; [onTorchAvailable] reports whether this camera
* has one at all (the caller only draws its toggle when it does).
*
* ## Why this looks the way it does
*
* The previous version hunted: a scheduled tick alternated the optical zoom
* between 1x and 1.5x and re-fired `startFocusAndMetering(...disableAutoCancel())`
* every 2 seconds. Both are camera-hostile:
*
* - Every zoom step restarts AE/AF convergence, so the sensor spends the
* seconds right after it delivering soft frames — precisely the frames the
* decoder needs to be sharp. The visible symptom is the "zooms in and out
* and takes ages" report.
* - `disableAutoCancel()` leaves AF **locked** at whatever it converged on
* instead of handing the lens back to continuous AF, so a re-aim never
* refocused on its own; the next timer tick then kicked off another full
* sweep from a locked position — a lens that hunts forever.
*
* A stock camera app does neither. It leaves CameraX's continuous AF alone,
* refocuses on tap, and never touches zoom. This does the same, with one
* concession to the "hand-held QR is a static scene" case: if nothing has
* decoded for a few seconds, ONE auto-cancelling focus nudge is issued (and
* then not again for a while), which re-arms continuous AF instead of
* fighting it.
*/
@Composable @Composable
internal fun CameraQrPreview( internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
onDecoded: (String) -> Unit,
torchOn: Boolean = false,
onTorchAvailable: (Boolean) -> Unit = {},
) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded) val currentOnDecoded by rememberUpdatedState(onDecoded)
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
val previewView = remember { val previewView = remember {
PreviewView(context).apply { PreviewView(context).apply {
scaleType = PreviewView.ScaleType.FILL_CENTER scaleType = PreviewView.ScaleType.FILL_CENTER
// TextureView, not the SurfaceView default: SurfaceView punches a // TextureView, not the SurfaceView default: SurfaceView punches a
// hole in the window, which black-flashes inside Compose fades and // hole in the window, which black-flashes inside Compose fades and
// ignores rounded-corner clipping (the glass modal). // ignores rounded-corner clipping (wallet modal).
implementationMode = PreviewView.ImplementationMode.COMPATIBLE implementationMode = PreviewView.ImplementationMode.COMPATIBLE
} }
} }
// Set by the analyzer on every decode; the focus nudge below reads it to
// tell "nothing in view" from "reading fine, leave the camera alone".
val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
// A tap-to-focus wins over the periodic centre AF for a few seconds.
val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
DisposableEffect(Unit) { DisposableEffect(Unit) {
// Analysis runs at display priority: the decode thread competes with val analysisExecutor = Executors.newSingleThreadExecutor()
// the FIPS mesh service's native workers in this same process, and a
// background-priority analyzer is exactly how a sharp, well-framed
// code still takes seconds to land.
val analysisExecutor = Executors.newSingleThreadExecutor { r ->
Thread {
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
r.run()
}.apply { name = "qr-analyzer" }
}
val mainExecutor = ContextCompat.getMainExecutor(context) val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context) val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null var provider: ProcessCameraProvider? = null
@@ -457,18 +243,15 @@ internal fun CameraQrPreview(
providerFuture.addListener({ providerFuture.addListener({
val p = providerFuture.get() val p = providerFuture.get()
provider = p provider = p
val previewBuilder = Preview.Builder() val preview = Preview.Builder().build().also {
tuneForBarcodes(previewBuilder, context)
val preview = previewBuilder.build().also {
it.setSurfaceProvider(previewView.surfaceProvider) it.setSurfaceProvider(previewView.surfaceProvider)
} }
// Dense Lightning-invoice QRs need BOTH enough pixels per module and // Dense Lightning-invoice QRs need BOTH enough pixels per module and
// sharp focus. 1280x720 left dense invoices undecodable while sparse // sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
// address QRs still read — the "scanner doesn't pick up invoices" // lens, which won't focus close) left dense invoices undecodable
// report. 1920x1080 roughly doubles module resolution. The analyzer // while sparse address QRs still read — the "scanner doesn't pick up
// never binarizes the full 2 MP: it reads the centre ROI at this // invoices" report. 1920x1080 roughly doubles module resolution so a
// resolution (for dense codes) and the whole frame at half of it // QR held at the camera's actual focus distance still resolves.
// (for coverage), so the big frame costs little.
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder() val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1920, 1080)) .setTargetResolution(android.util.Size(1920, 1080))
@@ -477,47 +260,25 @@ internal fun CameraQrPreview(
.also { .also {
it.setAnalyzer( it.setAnalyzer(
analysisExecutor, analysisExecutor,
QrCodeAnalyzer { text -> QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
lastDecodeAt.set(System.currentTimeMillis())
mainExecutor.execute { currentOnDecoded(text) }
},
) )
} }
try { try {
p.unbindAll() p.unbindAll()
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
camera = cam // Force a centre autofocus on a repeating tick. A hand-held QR is
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit()) // a static scene, so continuous-AF often never retriggers and the
// Start the clock at bind time so the nudge below waits for the // lens sits at its resting (far) focus — fatal for dense codes.
// user to actually aim before it does anything. // A normalized centre point works before the view is measured.
lastDecodeAt.set(System.currentTimeMillis()) val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
// Centre point, normalized — valid before the view is measured. .createPoint(0.5f, 0.5f)
val point = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f) val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
// A one-shot AF action puts the lens in AUTO — i.e. LOCKED — point,
// until it auto-cancels. The default 5s lock is far too long androidx.camera.core.FocusMeteringAction.FLAG_AF,
// here: it spans exactly the window where the user is swinging ).disableAutoCancel().build()
// the phone towards the code, and a locked lens cannot follow
// them. Hand control back after 1s so CONTINUOUS_PICTURE (set
// explicitly in tuneForBarcodes) does the real work, which is
// what actually tracks a moving aim.
val focusAction = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
.setAutoCancelDuration(1, java.util.concurrent.TimeUnit.SECONDS)
.build()
var lastNudgeAt = 0L
focusScheduler.scheduleWithFixedDelay({ focusScheduler.scheduleWithFixedDelay({
val now = System.currentTimeMillis() runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
// The nudge only exists for the one case continuous AF }, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
// genuinely misses: the phone held perfectly still on a
// code while the lens sits at its resting focus, with no
// scene change to trigger a sweep.
if (now - lastDecodeAt.get() > 2_000 &&
now - lastNudgeAt > 3_000 &&
now - lastTapFocusAt.get() > 3_000
) {
lastNudgeAt = now
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
}
}, 1, 1, java.util.concurrent.TimeUnit.SECONDS)
} catch (_: Exception) { } catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually. // Camera unavailable — the user can dismiss and enter details manually.
} }
@@ -525,251 +286,66 @@ internal fun CameraQrPreview(
onDispose { onDispose {
focusScheduler.shutdownNow() focusScheduler.shutdownNow()
runCatching { camera?.cameraControl?.enableTorch(false) }
camera = null
provider?.unbindAll() provider?.unbindAll()
analysisExecutor.shutdown() analysisExecutor.shutdown()
} }
} }
// Torch follows the caller's state (and switches off when the view goes). AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
LaunchedEffect(camera, torchOn) {
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
}
AndroidView(
factory = { previewView },
modifier = Modifier
.fillMaxSize()
// Tap-to-focus: the ROI assumes the code is centred; a tap lets the
// user point at one that isn't, or re-trigger AF the instant
// they've framed it.
.pointerInput(camera) {
detectTapGestures { offset ->
val cam = camera ?: return@detectTapGestures
val factory = previewView.meteringPointFactory
val action = FocusMeteringAction.Builder(
factory.createPoint(offset.x, offset.y),
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE,
).build()
lastTapFocusAt.set(System.currentTimeMillis())
runCatching { cam.cameraControl.startFocusAndMetering(action) }
}
},
)
} }
/** /** ZXing-based QR decoder over the camera's Y (luminance) plane. */
* Configure the capture session the way a dedicated barcode scanner does,
* rather than the way a photo app does.
*
* The single most valuable knob is **CONTROL_AE_TARGET_FPS_RANGE**. Left
* alone, auto-exposure indoors happily drops the sensor to 1015 fps and
* takes 60100 ms exposures — every hand-held frame is then motion-blurred,
* and a blurred QR is not a slow decode, it is *no* decode. The user waves
* the phone about waiting for a lock that cannot happen. Pinning the lower
* bound of the AE range as high as the device allows caps exposure time
* (~33 ms at 30 fps), so frames come out sharp; AE compensates with gain
* instead, and ZXing tolerates noise far better than it tolerates blur.
* (Dark rooms get grainier as a result — that is what the torch button is
* for, and grainy-but-sharp still decodes where smooth-but-smeared never
* does.)
*
* CONTINUOUS_PICTURE is set explicitly so that when a tap-to-focus action
* expires, CameraX restores continuous AF rather than whatever the device
* defaults to; FAST noise/edge processing shaves ISP latency per frame.
*
* All of it is best-effort — an OEM that rejects a key just keeps its default.
*/
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
private fun tuneForBarcodes(builder: Preview.Builder, context: Context) {
runCatching {
val ext = Camera2Interop.Extender(builder)
ext.setCaptureRequestOption(
CaptureRequest.CONTROL_AF_MODE,
CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
)
ext.setCaptureRequestOption(
CaptureRequest.NOISE_REDUCTION_MODE,
CameraMetadata.NOISE_REDUCTION_MODE_FAST,
)
ext.setCaptureRequestOption(
CaptureRequest.EDGE_MODE,
CameraMetadata.EDGE_MODE_FAST,
)
highestSteadyFpsRange(context)?.let {
ext.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
}
}
}
/**
* The back camera's AE range with the highest floor, ignoring anything that
* runs past 30 fps (those are the high-speed/slow-motion modes, which cost
* light for frames we do not need).
*/
private fun highestSteadyFpsRange(context: Context): android.util.Range<Int>? = runCatching {
val manager = context.getSystemService(CameraManager::class.java) ?: return@runCatching null
val backId = manager.cameraIdList.firstOrNull { id ->
manager.getCameraCharacteristics(id)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: return@runCatching null
manager.getCameraCharacteristics(backId)
.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
?.filter { it.upper <= 30 }
?.maxWithOrNull(compareBy({ it.lower }, { it.upper }))
}.getOrNull()
/**
* ZXing decoder over the camera's Y (luminance) plane.
*
* ## The rule this class exists to obey
*
* **Every frame costs the same, and every frame sees the whole scene.**
*
* That sounds obvious; the previous version violated both halves and produced
* a scanner with a very specific failure: it locked on instantly if the code
* was already in view when the camera opened, but crawled if you opened it
* and then moved to the code. The cause was an escalation ladder — each frame
* that failed to decode unlocked progressively more expensive searches, up to
* a TRY_HARDER pass over the full 2 MP frame plus an inverted retry, easily
* 150300 ms of work.
*
* So the moment the user began hunting for the code, the analyzer dropped from
* ~30 attempts per second to ~4, each one on a motion-blurred frame. By the
* time they framed the code and held still, the pipeline was busy grinding
* through an exhaustive search of an old, blurry frame. Escalating on failure
* is exactly backwards: failure means the user is still aiming, which is when
* the scanner must be at its *fastest*, not its most thorough.
*
* ## What runs now, on every single frame
*
* 1. **Centre ROI at full resolution** ([QR_ROI_FRACTION], ~0.45 MP). Full
* sensor detail, so dense Lightning invoices keep their pixels-per-module.
* 2. **The whole frame at half resolution** (~0.5 MP). This is what fixes the
* "move to the code" case: coverage is no longer limited to the viewfinder
* box on the fast path, so a code that is merely *near* the middle decodes
* immediately instead of waiting for a slow tier to come around. A code
* big enough to be off-centre is big enough to survive the 2x downscale.
* 3. **One alternating second binarizer** — GlobalHistogram over the ROI on
* even frames, over the half-frame on odd ones. Hybrid is tuned for
* shadowed paper; most codes this app scans are on a *screen* (the node's
* pairing popup, another phone's wallet) where a global threshold is both
* cheaper and more reliable. Alternating keeps the per-frame budget flat.
*
* Two rare extras, both bounded so they can never dent the loop above: an
* inverted ROI pass every 8th frame (light-on-dark codes), and one TRY_HARDER
* pass over the half-frame at most once a second (skewed/damaged codes).
*
* Steady-state that is ~35 ms per frame — around 27 attempts per second, and
* it does not degrade the longer the user hunts.
*
* Buffers are allocated once and reused: the original path allocated a fresh
* ~2 MB array per frame, 60 MB/s of garbage at 30 fps, with GC pauses landing
* mid-decode.
*/
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer { private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
// QRCodeReader directly rather than MultiFormatReader: with a single private val reader = MultiFormatReader().apply {
// format in play the dispatch and per-call state reset are pure overhead. setHints(
private val reader = QRCodeReader() mapOf(
private val plainHints = mapOf<DecodeHintType, Any>( DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), // Screen-displayed QRs come with moiré, glare, and soft focus at
) // close range — the exhaustive search is worth the milliseconds.
private val hardHints = mapOf<DecodeHintType, Any>( DecodeHintType.TRY_HARDER to true,
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), )
DecodeHintType.TRY_HARDER to true,
)
private var roiBuffer = ByteArray(0)
private var halfBuffer = ByteArray(0)
private var frame = 0L
private var lastHardAt = 0L
private fun read(
source: PlanarYUVLuminanceSource,
global: Boolean = false,
hard: Boolean = false,
inverted: Boolean = false,
): String? {
val src = if (inverted) source.invert() else source
val bitmap = BinaryBitmap(
if (global) GlobalHistogramBinarizer(src) else HybridBinarizer(src),
) )
return runCatching {
reader.decode(bitmap, if (hard) hardHints else plainHints).text
}.getOrNull().also { reader.reset() }
} }
private var lastAttempt = 0L
override fun analyze(image: ImageProxy) { override fun analyze(image: ImageProxy) {
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
// retry) pegs a core when run at camera rate, and that CPU contention
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
// frames skipped here are simply dropped, so decodes stay current.
val now = System.currentTimeMillis()
if (now - lastAttempt < 140) {
image.close()
return
}
lastAttempt = now
try { try {
val plane = image.planes[0] val plane = image.planes[0]
val buffer = plane.buffer val buffer = plane.buffer
val stride = plane.rowStride // Copy into a rowStride-wide array; the last row of the plane buffer
// YUV_420_888 permits an interleaved Y plane. Rare, but a device // may be short of the full stride, so the tail stays zero-padded.
// that does it would otherwise hand the decoder pure noise. val data = ByteArray(plane.rowStride * image.height)
val pixelStride = plane.pixelStride buffer.get(data, 0, minOf(buffer.remaining(), data.size))
val width = image.width val source = PlanarYUVLuminanceSource(
val height = image.height data, plane.rowStride, image.height,
frame++ 0, 0, image.width, image.height,
false,
buffer.rewind() )
val available = buffer.remaining() val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
// ── 1. Centre ROI, full resolution ────────────────────────────── } catch (_: NotFoundException) {
val side = (minOf(width, height) * QR_ROI_FRACTION).toInt().coerceAtLeast(1) // Dark-themed pages can render light-on-dark QRs — retry inverted.
val left = (width - side) / 2 reader.reset()
val top = (height - side) / 2 reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
if (roiBuffer.size != side * side) roiBuffer = ByteArray(side * side)
for (row in 0 until side) {
val srcPos = (top + row) * stride + left * pixelStride
if (srcPos + side * pixelStride > available) break
if (pixelStride == 1) {
buffer.position(srcPos)
buffer.get(roiBuffer, row * side, side)
} else {
val dst = row * side
for (col in 0 until side) {
roiBuffer[dst + col] = buffer.get(srcPos + col * pixelStride)
}
}
}
val roi = PlanarYUVLuminanceSource(roiBuffer, side, side, 0, 0, side, side, false)
read(roi)?.let { onDecoded(it); return }
// ── 2. Whole frame, half resolution ─────────────────────────────
val hw = width / 2
val hh = height / 2
if (halfBuffer.size != hw * hh) halfBuffer = ByteArray(hw * hh)
var truncated = false
for (row in 0 until hh) {
val srcRow = row * 2 * stride
val dst = row * hw
for (col in 0 until hw) {
val srcPos = srcRow + col * 2 * pixelStride
if (srcPos >= available) { truncated = true; break }
halfBuffer[dst + col] = buffer.get(srcPos)
}
if (truncated) break
}
val half = PlanarYUVLuminanceSource(halfBuffer, hw, hh, 0, 0, hw, hh, false)
read(half)?.let { onDecoded(it); return }
// ── 3. Alternating second binarizer ─────────────────────────────
val second = if (frame % 2 == 0L) roi else half
read(second, global = true)?.let { onDecoded(it); return }
// ── Bounded extras ──────────────────────────────────────────────
if (frame % 8 == 0L) {
read(roi, inverted = true)?.let { onDecoded(it); return }
}
val now = System.currentTimeMillis()
if (now - lastHardAt >= 1_000) {
lastHardAt = now
read(half, hard = true)?.let { onDecoded(it); return }
} }
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
} catch (_: Exception) { } catch (_: Exception) {
// Malformed frame; skip it. // Malformed frame; skip it.
} finally { } finally {
reader.reset()
image.close() image.close()
} }
} }
@@ -1,119 +0,0 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.BitcoinOrange
/** green-400 — the same "done" colour the web install overlay lands on. */
private val DoneGreen = Color(0xFF4ADE80)
/**
* The Archipelago loading bar: a stripe that runs side to side inside a dim
* track and lands as a solid green bar when the work completes.
*
* This is a direct port of the platform's install-progress overlay
* (neode-ui SystemUpdate.vue `.install-overlay-bar-anim`): a third-width
* orange stripe on a white/10 track, 1.8s ease-in-out, going full green on
* success. Using the same loader natively is what makes the companion feel
* like the same product as the node UI rather than a stock Android app.
*
* @param done finished successfully — the bar fills solid green.
* @param stalled waiting on the user / something external — the bar parks
* half-full in a dimmed orange instead of animating, so it
* reads as "this needs you", not "still working".
*/
@Composable
fun SlidingLoader(
modifier: Modifier = Modifier,
done: Boolean = false,
stalled: Boolean = false,
height: Dp = 8.dp,
) {
val doneProgress by animateFloatAsState(
targetValue = if (done) 1f else 0f,
animationSpec = tween(320),
label = "loaderDone",
)
BoxWithConstraints(
modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(percent = 50))
.background(Color.White.copy(alpha = 0.10f)),
) {
val trackWidth = maxWidth
val stripeWidth = trackWidth / 3
val stripePx = with(LocalDensity.current) { stripeWidth.toPx() }
if (doneProgress < 1f) {
if (stalled) {
Box(
Modifier
.fillMaxWidth(0.5f)
.fillMaxHeight()
.clip(RoundedCornerShape(percent = 50))
.background(BitcoinOrange.copy(alpha = 0.6f)),
)
} else {
// Keyframes copied from the web overlay: -100% → 120% → 300%
// of the STRIPE's own width, which is what gives the bar its
// fast sweep out and lazy re-entry.
val transition = rememberInfiniteTransition(label = "loaderSlide")
val offset by transition.animateFloat(
initialValue = -1f,
targetValue = 3f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1800
(-1f) at 0
1.2f at 900
3f at 1800
},
repeatMode = RepeatMode.Restart,
),
label = "loaderOffset",
)
Box(
Modifier
.fillMaxWidth(1f / 3f)
.fillMaxHeight()
.graphicsLayer { translationX = offset * stripePx }
.clip(RoundedCornerShape(percent = 50))
.background(BitcoinOrange),
)
}
}
if (doneProgress > 0f) {
Box(
Modifier
.fillMaxWidth()
.fillMaxHeight()
.graphicsLayer { alpha = doneProgress }
.background(DoneGreen),
)
}
}
}
@@ -1,26 +1,58 @@
package com.archipelago.app.ui.components package com.archipelago.app.ui.components
import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.dp
import androidx.core.content.ContextCompat
import com.archipelago.app.R import com.archipelago.app.R
import com.archipelago.app.ui.screens.GlassButton import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType import com.google.zxing.DecodeHintType
@@ -30,10 +62,10 @@ import com.google.zxing.RGBLuminanceSource
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
/** /**
* Native replacement for the web wallet's scan pane — the shared [QrGlassModal] * Native replacement for the web wallet's scan pane — same visual design as
* shell (same visual design as neode-ui's WalletScanModal) with the camera and * neode-ui's WalletScanModal (dark glass card, square preview, orange
* decoding running natively, so the preview doesn't lag the way getUserMedia * viewfinder, status strip) but the camera and decoding run natively, so the
* does inside a WebView. * preview doesn't lag the way getUserMedia does inside a WebView.
* *
* Decoded text is handed back to the page ([onDecoded]) which does all the * Decoded text is handed back to the page ([onDecoded]) which does all the
* detection/spend logic; the page in turn streams status lines (animated-QR * detection/spend logic; the page in turn streams status lines (animated-QR
@@ -48,7 +80,15 @@ fun WalletQrScannerModal(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val haptics = LocalHapticFeedback.current var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
// Local error from a failed image upload; a fresh web status replaces it. // Local error from a failed image upload; a fresh web status replaces it.
var uploadError by remember { mutableStateOf<String?>(null) } var uploadError by remember { mutableStateOf<String?>(null) }
@@ -67,50 +107,155 @@ fun WalletQrScannerModal(
} }
} }
LaunchedEffect(visible) { if (visible) uploadError = null }
LaunchedEffect(status) { if (status != null) uploadError = null }
// Throttle repeat frames: a static QR decodes many times a second but the
// page only needs one; animated QRs still stream because each frame's
// text differs.
var lastText by remember { mutableStateOf("") }
var lastSentAt by remember { mutableStateOf(0L) }
LaunchedEffect(visible) { LaunchedEffect(visible) {
if (visible) { if (visible) {
lastText = "" uploadError = null
lastSentAt = 0L val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
} }
} }
LaunchedEffect(status) { if (status != null) uploadError = null }
QrGlassModal( AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
visible = visible, BackHandler { onDismiss() }
title = stringResource(R.string.scan_to_send), Box(
status = uploadError?.let { it to true } ?: status, Modifier
idleHint = stringResource(R.string.scan_wallet_hint), .fillMaxSize()
permissionRationale = stringResource(R.string.camera_permission_needed), .background(Color.Black.copy(alpha = 0.6f))
onDismiss = onDismiss, .clickable(
onDecoded = { text -> interactionSource = remember { MutableInteractionSource() },
val now = System.currentTimeMillis() indication = null,
if (text != lastText || now - lastSentAt > 250) { onClick = onDismiss,
// Buzz on the FIRST hit only: an animated QR streams a new ),
// frame every few ms, and one buzz each would be a drill in contentAlignment = Alignment.Center,
// the hand. ) {
if (lastText.isEmpty()) { Column(
haptics.performHapticFeedback(HapticFeedbackType.LongPress) Modifier
.padding(16.dp)
.widthIn(max = 420.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF212151C))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}, // swallow — only the scrim dismisses
)
.padding(24.dp),
) {
// Header — mirrors the web modal's title row
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_to_send),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
)
IconButton(onClick = onDismiss) {
Icon(
Icons.Default.Close,
stringResource(R.string.close),
tint = Color.White.copy(alpha = 0.7f),
)
}
} }
lastText = text
lastSentAt = now Spacer(Modifier.height(8.dp))
onDecoded(text)
// Square camera preview with the orange viewfinder
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.4f))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
if (hasPermission) {
// Throttle repeat frames: a static QR decodes ~20x/s but
// the page only needs one; animated QRs still stream
// because each frame's text differs.
var lastText by remember { mutableStateOf("") }
var lastSentAt by remember { mutableStateOf(0L) }
CameraQrPreview(onDecoded = { text ->
val now = System.currentTimeMillis()
if (text != lastText || now - lastSentAt > 250) {
lastText = text
lastSentAt = now
onDecoded(text)
}
})
Box(
Modifier
.fillMaxSize(0.62f)
.border(
2.dp,
BitcoinOrange.copy(alpha = 0.85f),
RoundedCornerShape(16.dp),
),
)
} else {
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
// Status strip — same slot the web modal uses for hints/errors
val message = uploadError ?: status?.first
val isError = uploadError != null || status?.second == true
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(12.dp)
.defaultMinSize(minHeight = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message?.takeIf { it.isNotBlank() }
?: stringResource(R.string.scan_wallet_hint),
style = MaterialTheme.typography.bodySmall,
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
GlassButton(
text = stringResource(R.string.upload_qr_image),
onClick = { imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
} }
}, }
footer = { }
GlassButton(
text = stringResource(R.string.upload_qr_image),
onClick = { imagePicker.launch("image/*") },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
},
)
} }
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */ /** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
@@ -24,18 +24,14 @@ import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.screens.FlareScreen import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.NodePickerScreen
import com.archipelago.app.ui.screens.PartyScreen import com.archipelago.app.ui.screens.PartyScreen
import com.archipelago.app.ui.screens.RemoteInputScreen import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen import com.archipelago.app.ui.screens.WebViewScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object Routes { object Routes {
const val INTRO = "intro" const val INTRO = "intro"
const val NODE_PICKER = "node_picker"
const val SERVER_CONNECT = "server_connect" const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view" const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input" const val REMOTE_INPUT = "remote_input"
@@ -43,38 +39,18 @@ object Routes {
const val FLARE = "flare" const val FLARE = "flare"
} }
/**
* Process-scoped "have we already asked which node?" flag.
*
* The picker is a COLD-START question: opening the app fresh (or after the
* mesh service and its process were killed) is exactly when the user may want
* a different node than last time. An Activity recreation inside a live
* process — rotation, theme change — must not re-ask, and neither must a
* simple return from the background, so the flag lives with the process
* rather than in saved state.
*/
private object LaunchGate {
@Volatile
var nodeChoiceMade: Boolean = false
}
@Composable @Composable
fun AppNavHost( fun AppNavHost(
pairUri: String? = null, pairUri: String? = null,
onPairUriConsumed: () -> Unit = {}, onPairUriConsumed: () -> Unit = {},
onReady: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val prefs = remember { ServerPreferences(context) } val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController() val navController = rememberNavController()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// One combined emission — introSeen and activeServer resolving in separate val introSeen by prefs.introSeen.collectAsState(initial = null)
// frames used to flash the Connect screen at paired users on launch. val activeServer by prefs.activeServer.collectAsState(initial = null)
val launchState by prefs.launchState.collectAsState(initial = null)
val introSeen = launchState?.introSeen
val activeServer = launchState?.activeServer
val savedServers = launchState?.savedServers ?: emptyList()
// Pairing entry from a deep link that carried no password — prefills the // Pairing entry from a deep link that carried no password — prefills the
// connect form so the user lands on the password prompt for that server. // connect form so the user lands on the password prompt for that server.
@@ -103,30 +79,12 @@ fun AppNavHost(
} }
} }
if (introSeen == null) return // Paired + previously consented → the mesh comes back silently on launch.
LaunchedEffect(Unit) {
// Ask which node when the user keeps more than one and this is a cold FipsManager.autoStartIfReady(context)
// start. Anything else (single node, mid-process Activity recreation,
// a pairing deep link) goes straight through as before.
val needsNodeChoice = introSeen == true &&
!LaunchGate.nodeChoiceMade &&
savedServers.size > 1
// Paired + previously consented → the mesh comes back silently on launch,
// but ONLY once the session's node is known to be a FIPS node. Bringing
// the tunnel up before that took Android's single VPN slot away from
// whatever the user uses to reach a non-mesh node. Off the main
// dispatcher: this path dlopens the 7 MB fips core and does a binder
// round-trip (VpnService.prepare).
LaunchedEffect(needsNodeChoice, activeServer?.npub, activeServer?.meshIp) {
if (needsNodeChoice) return@LaunchedEffect
if (activeServer?.isFipsNode() != true) return@LaunchedEffect
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
} }
// Launch state resolved — MainActivity holds the system splash until now, if (introSeen == null) return
// so the first visible frame is the real UI, never a black gap.
LaunchedEffect(Unit) { onReady() }
// Declared after the introSeen gate so it can't fire before the NavHost // Declared after the introSeen gate so it can't fire before the NavHost
// below has set the nav graph; pairUri stays pending until consumed here. // below has set the nav graph; pairUri stays pending until consumed here.
@@ -160,7 +118,6 @@ fun AppNavHost(
val startDestination = when { val startDestination = when {
introSeen == false -> Routes.INTRO introSeen == false -> Routes.INTRO
needsNodeChoice -> Routes.NODE_PICKER
activeServer != null -> Routes.WEB_VIEW activeServer != null -> Routes.WEB_VIEW
else -> Routes.SERVER_CONNECT else -> Routes.SERVER_CONNECT
} }
@@ -169,37 +126,6 @@ fun AppNavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
) { ) {
composable(Routes.NODE_PICKER) {
NodePickerScreen(
servers = savedServers,
lastActive = activeServer,
onPick = { server ->
LaunchGate.nodeChoiceMade = true
scope.launch {
prefs.setActiveServer(server)
// The mesh follows the choice, and ONLY the choice.
// A non-mesh node gets the tunnel taken down: Android
// hands out one VPN slot, and holding it hostage is
// what broke reaching nodes behind a different VPN.
withContext(Dispatchers.IO) {
if (server.isFipsNode()) {
FipsManager.autoStartIfReady(context)
} else {
FipsManager.stopService(context)
}
}
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
}
},
onAddNode = {
LaunchGate.nodeChoiceMade = true
navController.navigate(Routes.SERVER_CONNECT)
},
)
}
composable(Routes.INTRO) { composable(Routes.INTRO) {
IntroScreen( IntroScreen(
onMeshParty = { onMeshParty = {
@@ -107,11 +107,7 @@ fun FlareScreen(onBack: () -> Unit) {
} }
val peer = peers.firstOrNull { it.npub == selectedNpub } val peer = peers.firstOrNull { it.npub == selectedNpub }
// derivedStateOf: filtering inline re-ran over the whole store on every val messages = allMessages.filter { it.peerNpub == selectedNpub }
// recomposition — including one per keystroke in the composer.
val messages by remember(selectedNpub) {
androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } }
}
val listState = rememberLazyListState() val listState = rememberLazyListState()
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
@@ -309,13 +305,7 @@ private fun MessageBubble(msg: FlareMessage) {
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
) { ) {
if (msg.photoPath.isNotBlank()) { if (msg.photoPath.isNotBlank()) {
// Decoded off-main and downsampled to the bubble width — val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
// full-size decode in remember{} ran on the UI thread mid-
// scroll and held ~8 MB per visible photo (OOM territory).
var bmp by remember(msg.photoPath) { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(msg.photoPath) {
bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) }
}
bmp?.let { bmp?.let {
Image( Image(
bitmap = it.asImageBitmap(), bitmap = it.asImageBitmap(),
@@ -346,19 +336,6 @@ private fun MessageBubble(msg: FlareMessage) {
} }
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */ /** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the
* bubble renders at ~300 dp, so the stored 1600 px original is 25× the
* pixels needed. Blocking — call on IO. */
private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
} catch (_: Exception) {
null
}
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? = private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
@@ -37,7 +37,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@@ -66,10 +65,9 @@ fun IntroScreen(
var showContent by remember { mutableStateOf(false) } var showContent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// Content fades in WITH the logo, not after it — the serial logoAlpha.animateTo(1f, animationSpec = tween(800))
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s. delay(300)
showContent = true showContent = true
logoAlpha.animateTo(1f, animationSpec = tween(450))
} }
Box( Box(
@@ -113,9 +111,7 @@ fun IntroScreen(
contentDescription = "Archipelago", contentDescription = "Archipelago",
modifier = Modifier modifier = Modifier
.size(160.dp) .size(160.dp)
// graphicsLayer defers the alpha read to the draw phase — .alpha(logoAlpha.value),
// .alpha(value) recomposed the whole screen per frame.
.graphicsLayer { alpha = logoAlpha.value },
) )
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
@@ -1,226 +0,0 @@
package com.archipelago.app.ui.screens
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.WindowInsets
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.SuccessGreen
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/**
* "Which node?" — shown at launch when more than one node is saved.
*
* The companion used to dive straight back into whichever node was last
* active, which is wrong the moment a user keeps more than one: they arrive
* somewhere they didn't choose, and (worse) the FIPS tunnel came up before
* anyone said which network this session belongs to. Picking first makes the
* choice explicit and lets the mesh stay down for nodes that aren't on it.
*
* [onPick] carries the entry; the caller decides what the mesh does about it.
*/
@Composable
fun NodePickerScreen(
servers: List<ServerEntry>,
lastActive: ServerEntry?,
onPick: (ServerEntry) -> Unit,
onAddNode: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.65f),
Color.Black.copy(alpha = 0.5f),
Color.Black.copy(alpha = 0.85f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
) {
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier.size(88.dp),
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.pick_node_title),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.pick_node_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
servers.forEach { server ->
NodeCard(
server = server,
isLast = lastActive?.sameNode(server) == true,
onClick = { onPick(server) },
)
}
Spacer(Modifier.height(8.dp))
GlassButton(
text = stringResource(R.string.pick_node_add),
onClick = onAddNode,
modifier = Modifier.fillMaxWidth().height(52.dp),
)
}
}
}
@Composable
private fun NodeCard(
server: ServerEntry,
isLast: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.08f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(
1.dp,
if (isLast) BitcoinOrange.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.1f),
RoundedCornerShape(14.dp),
)
.clickable { onClick() }
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = server.displayName(),
style = MaterialTheme.typography.titleMedium,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(
text = secondary,
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// The one thing that actually changes behaviour on this screen: a mesh
// node brings the FIPS tunnel up, a plain one deliberately does not.
if (server.isFipsNode()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = BitcoinOrange,
)
Spacer(Modifier.width(4.dp))
Text(
text = "FIPS",
color = BitcoinOrange,
fontSize = 11.sp,
letterSpacing = 1.sp,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
@@ -123,12 +123,9 @@ fun PartyScreen(
name = prefs.partyName() name = prefs.partyName()
// The hotspot/WiFi address can change while this screen is open // The hotspot/WiFi address can change while this screen is open
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh. // (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
// Tight only at first (the hotspot-flip window); interface walks
// allocate, so back off once the screen has been open a while.
var round = 0
while (true) { while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() } localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(if (round++ < 10) 3_000 else 30_000) delay(3_000)
} }
} }
@@ -141,16 +138,7 @@ fun PartyScreen(
port = PartyQr.PARTY_UDP_PORT, port = PartyQr.PARTY_UDP_PORT,
) )
} }
// QR encode + bitmap fill off the composition: done in remember{} it ran val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
// on the UI thread PER KEYSTROKE of the name field (the payload embeds the
// name) — a ZXing encode plus a megabyte-plus allocation per character.
// The 250 ms delay is a free debounce via coroutine cancellation.
var qrBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(qrPayload) {
if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect }
if (qrBitmap != null) delay(250)
qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) }
}
BackHandler { BackHandler {
when { when {
@@ -349,12 +337,7 @@ fun PartyScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Encoded off-main; done in remember{} it dropped the val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
// overlay's first fade-in frame.
var dlQr by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
LaunchedEffect(Unit) {
dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) }
}
dlQr?.let { bmp -> dlQr?.let { bmp ->
Box( Box(
Modifier Modifier
@@ -381,7 +364,7 @@ fun PartyScreen(
"…or send the APK file directly", "…or send the APK file directly",
color = BitcoinOrange, color = BitcoinOrange,
fontSize = 13.sp, fontSize = 13.sp,
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp), modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp)) Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
@@ -490,9 +473,8 @@ fun PartyScreen(
} }
} }
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the /** Render a QR payload as a bitmap (dark modules on white). */
* 240.dp display size at any density; 640 was a third more pixels for nothing. */ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
val matrix = QRCodeWriter().encode( val matrix = QRCodeWriter().encode(
payload, payload,
BarcodeFormat.QR_CODE, BarcodeFormat.QR_CODE,
@@ -512,23 +494,16 @@ private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
} }
/** Share this install's own APK via the system share sheet — a nearby friend /** 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). * gets the companion with no internet at all (Quick Share / Bluetooth). */
* The ~27 MB copy runs on IO — inline in the click handler it froze the UI private fun shareCompanionApk(context: android.content.Context) {
* for seconds (ANR territory on slow flash). Copied once per install; the
* cached file is reused while its size still matches the source. */
private suspend fun shareCompanionApk(context: android.content.Context) {
try { try {
val uri = withContext(Dispatchers.IO) { val src = java.io.File(context.applicationInfo.sourceDir)
val src = java.io.File(context.applicationInfo.sourceDir) val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() } val out = java.io.File(dir, "archipelago-companion.apk")
val out = java.io.File(dir, "archipelago-companion.apk") src.copyTo(out, overwrite = true)
if (!out.exists() || out.length() != src.length()) { val uri = androidx.core.content.FileProvider.getUriForFile(
src.copyTo(out, overwrite = true) context, "${context.packageName}.fileprovider", out,
} )
androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", out,
)
}
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive" type = "application/vnd.android.package-archive"
putExtra(android.content.Intent.EXTRA_STREAM, uri) putExtra(android.content.Intent.EXTRA_STREAM, uri)
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -75,7 +76,6 @@ import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.fips.FipsManager import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.components.MeshLoadingScreen import com.archipelago.app.ui.components.MeshLoadingScreen
import com.archipelago.app.ui.components.SlidingLoader
import com.archipelago.app.ui.components.QrScannerOverlay import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed import com.archipelago.app.ui.theme.ErrorRed
@@ -86,7 +86,6 @@ import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -109,20 +108,6 @@ fun ServerConnectScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
// Warm the mesh tunnel the moment the screen appears — starting it only
// after the LAN probe failed put full tunnel bring-up + session discovery
// inside the user's wait. By connect-tap time it's usually already up.
//
// Only when there is actually a mesh node to warm for, though: raising the
// tunnel on a phone whose saved nodes are all plain HTTP boxes takes
// Android's single VPN slot for nothing.
LaunchedEffect(savedServers.any { it.isFipsNode() }) {
if (savedServers.none { it.isFipsNode() }) return@LaunchedEffect
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
}
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") } var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") } var port by remember { mutableStateOf("") }
@@ -136,13 +121,8 @@ fun ServerConnectScreen(
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing. // Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
var manualMode by remember { mutableStateOf(false) } var manualMode by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) } var showScanner by remember { mutableStateOf(false) }
// Is the connect currently running aimed at a mesh node? Drives whether
// the loader wears the FIPS brand — see MeshLoadingScreen. val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
var connectingOverMesh by remember { mutableStateOf(false) }
var connectingName by remember { mutableStateOf("") }
// Brief green landing on the loader before the kiosk takes over, matching
// the platform's install overlay.
var connectSucceeded by remember { mutableStateOf(false) }
fun clearForm() { fun clearForm() {
name = "" name = ""
@@ -191,60 +171,40 @@ fun ServerConnectScreen(
} }
isConnecting = true isConnecting = true
errorMessage = null errorMessage = null
connectingOverMesh = server.isFipsNode()
connectingName = server.displayName()
connectSucceeded = false
scope.launch { scope.launch {
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain var reachable = testConnection(server)
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
// started (the off-LAN QR-pairing case, exactly where speed shows). // LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
// The scanned IP was only ever a dial hint; the node's real // node. The scanned IP was only ever a dial hint; the node's real
// identity is its npub and its ULA is reachable from anywhere over // identity is its npub and its ULA is reachable from anywhere over
// the mesh. Mesh discovery + first session can take 15s+ through // the mesh. Bring the tunnel up and probe the ULA before failing.
// the public tree (per node diagnosis), and on a if (!reachable && server.meshIp.isNotBlank()) {
// first-ever pairing the VPN consent dialog is on screen at the
// same time — so the mesh side keeps probing inside its budget
// while the tunnel (already started at screen entry, and kicked
// again here) warms up underneath.
val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let {
FipsManager.autoStartIfReady(context) FipsManager.autoStartIfReady(context)
server.copy(address = it, useHttps = false, port = "") val meshServer = server.copy(
} address = server.meshIp,
val reachable = kotlinx.coroutines.coroutineScope { useHttps = false,
val lan = async { testConnection(server, timeoutMs = 4_000) } port = "",
val mesh = async { )
if (meshServer == null) return@async false // Mesh discovery + first session can take 15s+ through the
val deadline = System.currentTimeMillis() + 45_000 // public tree (per node diagnosis), and on a
var ok = false // first-ever pairing the VPN consent dialog is on screen at
while (!ok && System.currentTimeMillis() < deadline) { // the same time — so probe patiently inside a 60s budget with
ok = testConnection(meshServer, timeoutMs = 8_000) // per-attempt timeouts wide enough to ride out TCP
if (!ok) delay(2000) // retransmit backoff. The VPN service pre-warms the session
} // in parallel (ArchyVpnService.startSessionWarmer).
ok val deadline = System.currentTimeMillis() + 60_000
} while (!reachable && System.currentTimeMillis() < deadline) {
val first = kotlinx.coroutines.selects.select<Boolean> { reachable = testConnection(meshServer, timeoutMs = 15_000)
lan.onAwait { it } if (!reachable) delay(3000)
mesh.onAwait { it }
}
if (first) {
lan.cancel(); mesh.cancel()
true
} else {
// One side gave up — the verdict is whatever the other says.
if (lan.isCompleted) mesh.await() else lan.await()
} }
} }
isConnecting = false
if (reachable) { if (reachable) {
// Land the loader green before handing over, so the last thing
// seen is "done", not a bar cut mid-sweep.
connectSucceeded = true
prefs.setActiveServer(server) prefs.setActiveServer(server)
delay(320)
isConnecting = false
onConnected(server.toUrl()) onConnected(server.toUrl())
} else { } else {
isConnecting = false
errorMessage = context.getString(R.string.connection_failed) errorMessage = context.getString(R.string.connection_failed)
} }
} }
@@ -333,7 +293,7 @@ fun ServerConnectScreen(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = if (editingServer != null) stringResource(R.string.edit_server_title) else stringResource(R.string.connect_to_node), text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
color = TextPrimary, color = TextPrimary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -617,9 +577,10 @@ fun ServerConnectScreen(
} }
if (isConnecting) { if (isConnecting) {
SlidingLoader( CircularProgressIndicator(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.size(24.dp),
done = connectSucceeded, color = Color.White.copy(alpha = 0.6f),
strokeWidth = 2.dp,
) )
} }
@@ -656,11 +617,7 @@ fun ServerConnectScreen(
// establishing (LAN probe → tunnel up → ULA probe can take a while). // establishing (LAN probe → tunnel up → ULA probe can take a while).
// The small inline spinner stays for context; this owns the screen. // The small inline spinner stays for context; this owns the screen.
if (isConnecting) { if (isConnecting) {
MeshLoadingScreen( MeshLoadingScreen()
mesh = connectingOverMesh,
nodeName = connectingName,
done = connectSucceeded,
)
} }
} }
} }
@@ -729,17 +686,6 @@ private fun sanitizeAddress(input: String): String {
.trimEnd('/') .trimEnd('/')
} }
// Built once — the connect loop probed up to 20 times, and each attempt was
// paying a fresh SSLContext + SecureRandom init.
private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. /** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more * [timeoutMs] is per-phase (connect / read) — mesh probes need far more
* patience than LAN ones (first session through the tree can take 15s+). */ * patience than LAN ones (first session through the tree can take 15s+). */
@@ -751,7 +697,14 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000):
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs) // Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
if (connection is HttpsURLConnection) { if (connection is HttpsURLConnection) {
connection.sslSocketFactory = trustAllSslFactory val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
connection.sslSocketFactory = sc.socketFactory
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
} }
File diff suppressed because it is too large Load Diff
@@ -2,95 +2,56 @@ package com.archipelago.app.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.archipelago.app.R
/**
* The platform's brand face. neode-ui sets `font-archipelago: Montserrat` and
* uses it for every heading, title and button label, with body copy left to
* `Avenir Next, system-ui` — which on Android resolves to the system sans
* anyway. Mirroring that split exactly is what makes companion text read as
* the same product as the node UI.
*
* Montserrat is SIL OFL 1.1 (see Android/MONTSERRAT-OFL.txt); the files are
* the ones already vendored for the web UI, so both halves ship the same
* outlines.
*/
val Montserrat = FontFamily(
Font(R.font.montserrat_medium, FontWeight.Medium),
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
Font(R.font.montserrat_bold, FontWeight.Bold),
Font(R.font.montserrat_extrabold, FontWeight.ExtraBold),
)
val Typography = Typography( val Typography = Typography(
// ── Display / headings: Montserrat, tight and heavy like the web hero
// copy (the platform sets tracking negative on its big type).
displayLarge = TextStyle( displayLarge = TextStyle(
fontFamily = Montserrat, fontWeight = FontWeight.Bold,
fontWeight = FontWeight.ExtraBold,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 40.sp, lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
),
headlineLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = (-0.5).sp, letterSpacing = (-0.5).sp,
), ),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineMedium = TextStyle( headlineMedium = TextStyle(
fontFamily = Montserrat, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Bold,
fontSize = 24.sp, fontSize = 24.sp,
lineHeight = 32.sp, lineHeight = 32.sp,
letterSpacing = (-0.4).sp,
), ),
titleLarge = TextStyle( titleLarge = TextStyle(
fontFamily = Montserrat, fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp, fontSize = 20.sp,
lineHeight = 28.sp, lineHeight = 28.sp,
letterSpacing = (-0.2).sp,
), ),
titleMedium = TextStyle( titleMedium = TextStyle(
fontFamily = Montserrat, fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.15.sp,
), ),
// ── Body: system sans, exactly as the web falls back to.
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.2.sp, letterSpacing = 0.5.sp,
), ),
bodyMedium = TextStyle( bodyMedium = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp, letterSpacing = 0.25.sp,
), ),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.sp,
),
// ── Buttons / labels: Montserrat again, matching .glass-button.
labelLarge = TextStyle( labelLarge = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp, letterSpacing = 0.1.sp,
), ),
labelMedium = TextStyle( labelMedium = TextStyle(
fontFamily = Montserrat,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -1,52 +1,36 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- System splash icon — deliberately the SAME mark as the adaptive launcher <!-- Archipelago pixel-art "A" for splash screen -->
icon (ic_launcher_background.xml): dark disc + metallic ring + white
Archipelago grid. Tapping the icon and watching the splash should show
one badge, not two different logos.
Geometry is copied from the launcher: the Android 12 splash draws its icon
on a 288dp canvas whose inner 2/3 is the safe area — the same 0.667 ratio
the adaptive-icon mask uses — so the launcher's 0.65 (ring) / 0.55 (grid)
group scales land identically here. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp"
android:width="288dp" android:height="108dp"
android:height="288dp" android:viewportWidth="1024"
android:viewportWidth="752" android:viewportHeight="1024">
android:viewportHeight="752">
<!-- Dark disc + gradient ring (#000 -> #666), matching logo.svg -->
<group <group
android:pivotX="376" android:pivotX="512"
android:pivotY="376" android:pivotY="512"
android:scaleX="0.65"
android:scaleY="0.65">
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0.000976562">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
</group>
<!-- White Archipelago grid -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.55" android:scaleX="0.55"
android:scaleY="0.55"> android:scaleY="0.55">
<path
android:fillColor="#FFFFFF" <path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" /> <path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
</group> </group>
</vector> </vector>
Binary file not shown.
Binary file not shown.
@@ -49,12 +49,4 @@
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string> <string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
<string name="upload_qr_image">Upload image</string> <string name="upload_qr_image">Upload image</string>
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string> <string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
<string name="torch_on">Turn on the torch</string>
<string name="torch_off">Turn off the torch</string>
<!-- Launch node picker (more than one node saved) -->
<string name="pick_node_title">Which node?</string>
<string name="pick_node_hint">Choose the Archipelago this session connects to. The FIPS mesh only comes up for mesh nodes.</string>
<string name="pick_node_add">Add another node</string>
<string name="connect_to_node">Connect to your node</string>
</resources> </resources>
-34
View File
@@ -1,39 +1,5 @@
# Changelog # Changelog
## v1.8.1-alpha (2026-08-13)
- **Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page — correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.
- **The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine — settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate — invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.
- **New Settings → Display → Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets.
## v1.8.0-alpha (2026-08-12)
- **Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.
- **Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".
- **Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.
- **The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.
- **No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.
- **The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.
- **The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.
## v1.7.129-alpha (2026-08-10)
- **Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.
- **Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" — and a helper must be orphaned for a sustained period before it is touched.
- **A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.
- **The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.
- **An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).
## v1.7.128-alpha (2026-08-10)
- **The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays — nothing holds its key anymore — so the "Discoverable nodes" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off — or factory-resetting the node — actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.
- **You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name — it travels inside the public announcement, so other nodes' discovery lists show "Dorian's basement node" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.
- **The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address — which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.
- **The seed screen stops flashing while the node starts.** During first boot, the lock icon and "server starting" text blinked in and out every few seconds while the node came up — each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.
- **A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about "the authenticated system.factory-reset". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node.
## v1.7.127-alpha (2026-08-09) ## v1.7.127-alpha (2026-08-09)
- **Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`. - **Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.
+1 -1
View File
@@ -390,7 +390,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0", "dockerImage": "grafana/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana", "repoUrl": "https://github.com/grafana/grafana",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
-75
View File
@@ -1,75 +0,0 @@
app:
id: alby-hub
name: Alby Hub
version: 1.23.0
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
category: money
container:
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0
pull_policy: if-not-present
dependencies:
- storage: 1Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 8187
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/alby-hub
target: /data
options: [rw]
environment:
- WORK_DIR=/data
- PORT=8080
# LDK peers are dialed outbound-only in v1; no inbound p2p port is
# advertised, so no extra port mapping is needed for payments to work.
- LOG_LEVEL=info
health_check:
type: http
endpoint: http://localhost:8080
path: /
interval: 30s
timeout: 5s
retries: 5
interfaces:
main:
name: Web UI
description: Alby Hub wallet interface
type: ui
port: 8187
protocol: http
metadata:
icon: /assets/img/app-icons/alby-hub.svg
repo: https://github.com/getAlby/hub
tier: optional
launch:
# Embedded: the gate neutralizes Alby Hub's X-Frame-Options: DENY on
# proxied responses. Nodes older than the gate fix show a blocked
# frame — flip to true only if targeting such nodes.
open_in_new_tab: false
features:
- Self-custodial Lightning node (LDK) with a friendly wallet UI
- Connect wallets and apps via Nostr Wallet Connect (NWC)
- Per-app budgets and isolated sub-wallets
- Works with the Alby browser extension and mobile app
+1 -1
View File
@@ -5,7 +5,7 @@ app:
description: Analytics and monitoring platform. Visualize metrics and create dashboards. description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container: container:
image: source.archipelago-foundation.org/lfg2025/grafana:10.2.0 image: grafana/grafana:10.2.0
image_signature: cosign://... image_signature: cosign://...
pull_policy: if-not-present pull_policy: if-not-present
data_uid: "472:472" data_uid: "472:472"
-75
View File
@@ -1,75 +0,0 @@
app:
id: phoenixd
name: phoenixd
version: 0.9.0
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
category: money
container:
# Image entrypoint already runs with --agree-to-terms-of-service and
# --http-bind-ip 0.0.0.0, as user "phoenix"; no custom args needed.
image: source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0
pull_policy: if-not-present
# The image runs as user phoenix (1000:1000); the datadir bind source
# must be chowned to that identity or phoenixd dies on
# "Failed to open /data/phoenix.conf with Permission denied".
data_uid: "1000:1000"
dependencies:
- storage: 500Mi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 9740
container: 9740
protocol: tcp
bind: 127.0.0.1
auth: none
auth_rationale: >-
Loopback-only JSON API, not a web page. Every request is
authenticated by the http password phoenixd generates in its own
data directory on first run; the app gate's browser login page
would break the API clients this port exists for.
volumes:
# The wallet seed (seed.dat) and phoenix.conf live here. This directory
# must survive reinstall/migration like any other app data dir —
# losing it means losing funds.
# Target is /data (via PHOENIX_DATADIR below), NOT the image's default
# /phoenix/.phoenix: the orchestrator treats any bind path containing a
# dot as a file mount and skips creating its source directory, so a
# hidden-dir target never gets its host dir and the unit crash-loops.
- type: bind
source: /var/lib/archipelago/phoenixd
target: /data
options: [rw]
environment:
- PHOENIX_DATADIR=/data
health_check:
type: tcp
endpoint: localhost:9740
interval: 30s
timeout: 5s
retries: 5
metadata:
icon: /assets/img/app-icons/phoenixd.svg
repo: https://github.com/ACINQ/phoenixd
tier: optional
features:
- Ultra-light Lightning node — no bitcoin node required
- Automated channel and liquidity management (fees apply)
- Simple HTTP API + websockets for payments
- Backed by the team behind the Phoenix mobile wallet
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.0-alpha" version = "1.7.126-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.0-alpha" version = "1.7.127-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
+4 -45
View File
@@ -21,7 +21,7 @@ use anyhow::{Context, Result};
use nostr_sdk::FromBech32; use nostr_sdk::FromBech32;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE; const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level /// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
/// config file is read once at boot and is OFF by default; this state file /// config file is read once at boot and is OFF by default; this state file
@@ -32,9 +32,6 @@ use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE;
struct NostrDiscoveryState { struct NostrDiscoveryState {
#[serde(default)] #[serde(default)]
enabled: bool, enabled: bool,
/// Operator-chosen display name carried in the presence event.
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
} }
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState { async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
@@ -58,16 +55,10 @@ async fn save_discovery_state(
} }
impl RpcHandler { impl RpcHandler {
/// Read the current runtime discoverability flag. Also returns the npub /// Read the current runtime discoverability flag.
/// this node publishes as (the discoverability UI shows it — that npub,
/// not the onion, is what's actually visible on the relays). Load-only:
/// null until discovery keys exist.
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> { pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
let state = load_discovery_state(&self.config.data_dir).await; let state = load_discovery_state(&self.config.data_dir).await;
let npub = nostr_handshake::own_npub(&self.config.data_dir.join("identity")) Ok(serde_json::json!({ "enabled": state.enabled }))
.await
.unwrap_or(None);
Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub, "name": state.name }))
} }
/// Set the runtime discoverability flag. If turning ON, publish presence /// Set the runtime discoverability flag. If turning ON, publish presence
@@ -87,22 +78,7 @@ impl RpcHandler {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?; .ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
// Optional display name. Absent param = keep the stored name (so a save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
// plain off/on toggle doesn't forget it); present-but-empty clears it.
let prior = load_discovery_state(&self.config.data_dir).await;
let name = match params.get("name") {
Some(v) => v.as_str().and_then(nostr_handshake::clean_display_name),
None => prior.name,
};
save_discovery_state(
&self.config.data_dir,
&NostrDiscoveryState {
enabled,
name: name.clone(),
},
)
.await?;
if enabled && !self.config.nostr_relays.is_empty() { if enabled && !self.config.nostr_relays.is_empty() {
let (data, _) = self.state_manager.get_snapshot().await; let (data, _) = self.state_manager.get_snapshot().await;
@@ -112,13 +88,11 @@ impl RpcHandler {
let version = data.server_info.version.clone(); let version = data.server_info.version.clone();
let relays = self.handshake_relays().await; let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone(); let tor_proxy = self.config.nostr_tor_proxy.clone();
let publish_name = name.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence( if let Err(e) = nostr_handshake::publish_presence(
&identity_dir, &identity_dir,
&did, &did,
&version, &version,
publish_name.as_deref(),
&relays, &relays,
tor_proxy.as_deref(), tor_proxy.as_deref(),
) )
@@ -127,21 +101,6 @@ impl RpcHandler {
tracing::warn!("Initial presence publish failed: {}", e); tracing::warn!("Initial presence publish failed: {}", e);
} }
}); });
} else if !enabled {
// Switching off: overwrite our presence with an empty tombstone so
// the node disappears from other nodes' discovery lists now, not
// at the next TTL expiry.
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) =
nostr_handshake::publish_tombstone(&identity_dir, &relays, tor_proxy.as_deref())
.await
{
tracing::warn!("Presence tombstone publish failed: {}", e);
}
});
} }
Ok(serde_json::json!({ "enabled": enabled })) Ok(serde_json::json!({ "enabled": enabled }))
@@ -64,12 +64,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be", "must be",
"cannot", "cannot",
"Password", "Password",
// OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already
// running" text the update UI matches on to join an in-flight apply
// instead of showing a false failure. Every such message starts "Update".
"Update",
// The federation escalation sentinel. "Password" above does NOT cover // The federation escalation sentinel. "Password" above does NOT cover
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS — // it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
// so the frontend's isPasswordRequired() never saw it and the // so the frontend's isPasswordRequired() never saw it and the
+13 -75
View File
@@ -921,29 +921,6 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Password Incorrect")); return Err(anyhow::anyhow!("Password Incorrect"));
} }
// Overwrite our Nostr presence with a tombstone BEFORE the wipe: the
// discovery keys die with the identity dir, and once they're gone the
// stale presence event can never be replaced by anyone — it would
// list this dead install to the whole network until relays expire it.
// Best-effort with a hard cap so a dead relay can't stall the reset.
{
let identity_dir = self.config.data_dir.join("identity");
let relays = crate::nostr_relays::merged_relay_list(
&self.config.data_dir,
&self.config.nostr_relays,
)
.await;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(15),
crate::nostr_handshake::publish_tombstone(
&identity_dir,
&relays,
self.config.nostr_tor_proxy.as_deref(),
),
)
.await;
}
tracing::warn!("Factory reset initiated — wiping ALL user data and containers"); tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir; let data_dir = &self.config.data_dir;
@@ -1268,14 +1245,7 @@ impl RpcHandler {
} else { } else {
"auto" "auto"
}; };
let graphics = if conf.contains("KIOSK_GRAPHICS=performance") { Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
"performance"
} else if conf.contains("KIOSK_GRAPHICS=quality") {
"quality"
} else {
"auto"
};
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset, "graphics": graphics }))
} }
/// system.kiosk-display.set — Write the kiosk display preset and restart /// system.kiosk-display.set — Write the kiosk display preset and restart
@@ -1286,56 +1256,24 @@ impl RpcHandler {
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let preset = params.get("preset").and_then(|v| v.as_str()); let preset = params
let graphics = params.get("graphics").and_then(|v| v.as_str()); .get("preset")
if preset.is_none() && graphics.is_none() { .and_then(|v| v.as_str())
anyhow::bail!("Missing preset or graphics"); .ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
}
// The conf carries two independent settings (display scale preset + let conf = match preset {
// graphics tier). A set of one must not clobber the other, so the
// half not being changed is carried over from the file as-is.
let existing = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
.await
.unwrap_or_default();
let display_part = match preset {
// Resolution-derived default: 4K -> 2.0 (1920-wide layout), // Resolution-derived default: 4K -> 2.0 (1920-wide layout),
// 1080p TV -> 1.5, laptop panels -> 1.0. // 1080p TV -> 1.5, laptop panels -> 1.0.
Some("auto") => String::new(), "auto" => String::new(),
// Biggest UI: every panel targets a 1280-wide layout. // Biggest UI: every panel targets a 1280-wide layout.
Some("large") => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(), "large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
// Full-HD layout on any panel that can carry it. // Full-HD layout on any panel that can carry it.
Some("balanced") => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(), "balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
// No scaling: native CSS viewport, most content, smallest UI. // No scaling: native CSS viewport, most content, smallest UI.
Some("native") => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(), "native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
Some(other) => anyhow::bail!("Unknown display preset: {other}"), other => anyhow::bail!("Unknown display preset: {other}"),
None => existing
.lines()
.filter(|l| l.starts_with("ARCHIPELAGO_KIOSK_"))
.map(|l| format!("{l}\n"))
.collect(),
}; };
let graphics_part = match graphics {
// Auto: the launcher classifies the hardware itself (CPU/iGPU
// generation) — legacy boxes keep the choppy-audio-safe flags,
// modern iGPUs get GPU rasterization.
Some("auto") => String::new(),
// Force the conservative legacy flag set (troubleshooting).
Some("performance") => "KIOSK_GRAPHICS=performance\n".to_string(),
// Force the modern flag set even on unclassified hardware.
Some("quality") => "KIOSK_GRAPHICS=quality\n".to_string(),
Some(other) => anyhow::bail!("Unknown graphics mode: {other}"),
None => existing
.lines()
.find(|l| l.starts_with("KIOSK_GRAPHICS="))
.map(|l| format!("{l}\n"))
.unwrap_or_default(),
};
let conf = format!("{display_part}{graphics_part}");
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?; host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
if conf.is_empty() { if conf.is_empty() {
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await; let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
@@ -1371,8 +1309,8 @@ impl RpcHandler {
]) ])
.await; .await;
info!(?preset, ?graphics, "Kiosk display settings applied"); info!(preset, "Kiosk display preset applied");
Ok(serde_json::json!({ "preset": preset, "graphics": graphics, "applied": true })) Ok(serde_json::json!({ "preset": preset, "applied": true }))
} }
} }
+9 -143
View File
@@ -446,7 +446,7 @@ async fn proxy_to_app(
.to_string(); .to_string();
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() { let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
Ok(uri) => uri, Ok(uri) => uri,
Err(_) => return app_down_page(app), Err(_) => return bad_gateway(),
}; };
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
@@ -501,7 +501,7 @@ async fn proxy_to_app(
let client = hyper::Client::new(); let client = hyper::Client::new();
let mut upstream_resp = match client.request(upstream_req).await { let mut upstream_resp = match client.request(upstream_req).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(_) => return app_down_page(app), Err(_) => return bad_gateway(),
}; };
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS { if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
if let Some(client_upgrade) = client_upgrade { if let Some(client_upgrade) = client_upgrade {
@@ -520,60 +520,8 @@ async fn proxy_to_app(
let client = hyper::Client::new(); let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await { match client.request(Request::from_parts(parts, body)).await {
Ok(mut resp) => { Ok(resp) => resp,
neutralize_frame_blocking(resp.headers_mut()); Err(_) => bad_gateway(),
resp
}
Err(_) => app_down_page(app),
}
}
/// Make gate-proxied app responses embeddable by the dashboard's My Apps
/// iframe. Apps that were never designed for framing ship
/// `X-Frame-Options: DENY` (Alby Hub) or a CSP `frame-ancestors` directive,
/// and either one makes the embedded app session a dead grey pane — the
/// historical workaround was a bespoke per-app nginx proxy (gitea), which is
/// exactly the per-app patching the manifest platform exists to delete.
///
/// Framing protection exists to stop a FOREIGN origin from framing an authed
/// page and clickjacking it. Behind the gate that threat model is already
/// handled the way the gate's own pages handle it: every proxied request is
/// authenticated by the gate first, and the gate's own responses declare
/// `frame-ancestors 'self' http://*:* https://*:*` (see `page()`) because the
/// dashboard is reached by LAN IP, mDNS name, and onion alike. Upstream
/// X-Frame-Options is dropped entirely; only the `frame-ancestors` directive
/// is removed from the app's CSP — the rest of the app's policy (script-src,
/// connect-src, …) is the app's business and passes through untouched.
fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) {
headers.remove("x-frame-options");
let Some(csp) = headers.get("content-security-policy") else {
return;
};
let Ok(raw) = csp.to_str() else {
return;
};
if !raw.to_ascii_lowercase().contains("frame-ancestors") {
return;
}
let kept: Vec<&str> = raw
.split(';')
.map(str::trim)
.filter(|d| !d.to_ascii_lowercase().starts_with("frame-ancestors") && !d.is_empty())
.collect();
if kept.is_empty() {
headers.remove("content-security-policy");
return;
}
match header::HeaderValue::from_str(&kept.join("; ")) {
Ok(v) => {
headers.insert("content-security-policy", v);
}
Err(_) => {
// Unrepresentable after filtering — fail open for framing but
// closed for the policy: better to drop a mangled CSP than to
// serve one we rewrote incorrectly.
headers.remove("content-security-policy");
}
} }
} }
@@ -635,32 +583,11 @@ fn redirect_to_app() -> Response<Body> {
.expect("static response builds") .expect("static response builds")
} }
/// Served when the app behind the gate does not answer on loopback. fn bad_gateway() -> Response<Body> {
/// Response::builder()
/// A real page rather than the bare string `app is not responding`: the gate .status(StatusCode::BAD_GATEWAY)
/// answers on the app's own port, so this text IS the app as far as the .body(Body::from("app is not responding"))
/// operator can tell, and the raw string read as the node itself being broken .expect("static response builds")
/// (reported against Gitea on a fleet node, 2026-08-10 — the actual fault was
/// a ghost container crash-looping the app). Name the app, say the node is
/// fine, and retry on our own: an app that is restarting comes back without
/// the user knowing to reload. Status stays 502 so machine clients still see
/// an upstream failure rather than a success with HTML in it.
fn app_down_page(app: &GatedPort) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>{name} is not responding</h1>
<p class="sub">The app is not answering right now it may be stopped or still
starting. This page retries automatically. If it does not recover, open the
dashboard and check {name} under My Apps.</p>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
);
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
// Header-based refresh, not <meta> or script: page()'s CSP allows no
// script, and the header keeps the retry out of the document entirely.
resp.headers_mut()
.insert("Refresh", header::HeaderValue::from_static("5"));
resp
} }
fn not_found() -> Response<Body> { fn not_found() -> Response<Body> {
@@ -1124,50 +1051,6 @@ mod tests {
/// 2026-08-05). It must still be uncacheable, and still refuse to be /// 2026-08-05). It must still be uncacheable, and still refuse to be
/// framed by a foreign origin, which `frame-ancestors` expresses and /// framed by a foreign origin, which `frame-ancestors` expresses and
/// `X-Frame-Options` cannot. /// `X-Frame-Options` cannot.
/// Upstream frame-blocking must not survive the proxy: X-Frame-Options
/// goes away entirely, CSP loses ONLY its frame-ancestors directive —
/// the app's remaining policy must pass through byte-preserving in
/// content (Alby Hub's DENY + strict CSP was the real-world case,
/// archi-dev-box 2026-08-12).
#[test]
fn proxied_responses_lose_frame_blocking_but_keep_the_apps_csp() {
let mut headers = hyper::HeaderMap::new();
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert(
"content-security-policy",
"default-src 'self'; frame-ancestors 'none'; img-src 'self' https://cdn.example"
.parse()
.unwrap(),
);
neutralize_frame_blocking(&mut headers);
assert!(!headers.contains_key("x-frame-options"));
let csp = headers["content-security-policy"].to_str().unwrap();
assert!(!csp.contains("frame-ancestors"));
assert!(csp.contains("default-src 'self'"));
assert!(csp.contains("img-src 'self' https://cdn.example"));
// CSP that is ONLY a frame-ancestors directive disappears entirely.
let mut only = hyper::HeaderMap::new();
only.insert(
"content-security-policy",
"frame-ancestors 'self'".parse().unwrap(),
);
neutralize_frame_blocking(&mut only);
assert!(!only.contains_key("content-security-policy"));
// No frame directives at all → CSP untouched.
let mut plain = hyper::HeaderMap::new();
plain.insert(
"content-security-policy",
"default-src 'self'".parse().unwrap(),
);
neutralize_frame_blocking(&mut plain);
assert_eq!(
plain["content-security-policy"].to_str().unwrap(),
"default-src 'self'"
);
}
#[test] #[test]
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() { fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED); let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
@@ -1182,23 +1065,6 @@ mod tests {
assert!(csp.contains("form-action 'self'")); assert!(csp.contains("form-action 'self'"));
} }
/// A dead upstream must render as a page that names the app and retries,
/// not the bare string "app is not responding" — that string standing
/// alone on the app's own port read as the node being broken (Gitea on a
/// fleet node, 2026-08-10). The 502 status must survive so machine
/// clients still see an upstream failure.
#[tokio::test]
async fn a_dead_app_gets_a_named_retrying_page_not_a_bare_string() {
let resp = app_down_page(&app());
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
assert_eq!(resp.headers()["Refresh"], "5");
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Strfry Relay is not responding"));
assert!(html.contains("<html"), "must be a page, not a bare string");
}
/// The login page must render entirely from the gate's own origin: the /// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background or logo that 404s leaves /// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art. /// a black page rather than the dashboard's art.
@@ -107,7 +107,6 @@ impl BootReconciler {
let companion_handle = if self.companion_stage { let companion_handle = if self.companion_stage {
let orchestrator = self.orchestrator.clone(); let orchestrator = self.orchestrator.clone();
let interval = self.interval; let interval = self.interval;
let data_dir = orchestrator.data_dir().to_path_buf();
Some(tokio::spawn(async move { Some(tokio::spawn(async move {
let mut failure_rounds: u32 = 0; let mut failure_rounds: u32 = 0;
loop { loop {
@@ -129,48 +128,34 @@ impl BootReconciler {
continue; continue;
}; };
let failures = crate::container::companion::reconcile(&installed).await; let failures = crate::container::companion::reconcile(&installed).await;
// Reaper, RE-WIRED 2026-08-10 — driven by the DURABLE // `reap_orphans` is deliberately NOT called here. It is
// installed-apps registry, never by runtime inference. // implemented and tested, and it must stay unwired until a
// DURABLE record of "this app is installed" exists.
// //
// History: this call was unwired on 2026-08-08 after it // Proven harmful on archi-dev-box 2026-08-08: it removed
// removed archy-bitcoin-ui and archy-lnd-ui for apps that // archy-bitcoin-ui (36 minutes of no Bitcoin UI, until the
// WERE installed. Not a logic error — the inputs lied: // operator reinstalled the backend) and archy-lnd-ui, both
// `installed_app_ids` infers installation from runtime // for apps that ARE installed. It was not a logic error —
// state (containers present + running-containers.json), // it did exactly what it was told. The inputs lied: the
// and the clean-exit vanishing bug falsified both signals // backends' containers were missing because of the
// at once. The unwire commit set the re-wire bar: a // clean-exit vanishing bug, and both had already aged out
// durable record of "this app is installed". // of running-containers.json, which only ever records what
// is CURRENTLY RUNNING. So container-presence and
// installation-evidence, the two independent signals the
// reaper trusts, were false at the same time and for the
// same underlying reason.
// //
// That record now exists — installed-apps.json, written on // Reaping turns one lost app into two, which is strictly
// install, cleared on deliberate uninstall, backfilled at // worse than the orphan it cleans up. Leaving an orphan
// boot from demonstrably-present containers, and immune to // costs a stale UI tile; reaping a live app's companion
// container absence by construction (89b03c47 holds // costs the operator a working screen. Until "installed"
// entries while a container is gone). A vanished backend // can be answered without inferring it from runtime state,
// no longer looks uninstalled, so the failure mode that // absence is not evidence of uninstallation.
// burned archi-dev-box cannot recur through this path.
// //
// `None` = the registry could not be read (missing or // The provisioning half above is the actual fix for
// corrupt) — which is "I could not look", NOT "nothing is // "fedimint installs but does not work" and stands on its
// installed". The reaper stays idle in that case; the // own: a companion is never stood up for an app nobody
// runtime-derived `installed` set above is deliberately // installed, so no NEW orphans are created.
// NOT used as a fallback (it is exactly the input class
// that caused the 2026-08-08 incident). ORPHAN_GRACE still
// applies on top: a companion must be orphaned for the
// full grace period before it is touched.
if let Some(durable) =
crate::crash_recovery::load_installed_apps_if_recorded(&data_dir).await
{
let durable: Vec<String> = durable.into_iter().collect();
for (companion, err) in
crate::container::companion::reap_orphans(&durable).await
{
tracing::warn!(
companion = %companion,
error = %err,
"companion reap failed"
);
}
}
for (companion, err) in &failures { for (companion, err) in &failures {
tracing::warn!( tracing::warn!(
companion = %companion, companion = %companion,
+2 -6
View File
@@ -682,12 +682,8 @@ fn due_after_grace(
/// Stop and remove any companion whose backend app is not installed. /// Stop and remove any companion whose backend app is not installed.
/// ///
/// ⚠️ WIRED (2026-08-10) to exactly one caller — the boot reconciler's /// ⚠️ NOT WIRED, ON PURPOSE. Do not call this from the reconciler until a
/// companion loop — and ONLY behind the durable installed-apps registry /// DURABLE record of "this app is installed" exists to drive it.
/// (`crash_recovery::load_installed_apps_if_recorded`). That satisfies the
/// bar the 2026-08-08 unwire set: a DURABLE record of "this app is
/// installed" drives it, never runtime inference. Do not add callers fed
/// from runtime state; the history below is why.
/// ///
/// It ran on archi-dev-box on 2026-08-08 and removed two companions whose /// It ran on archi-dev-box on 2026-08-08 and removed two companions whose
/// backends were installed — archy-bitcoin-ui (36 minutes of no Bitcoin UI) /// backends were installed — archy-bitcoin-ui (36 minutes of no Bitcoin UI)
@@ -1039,9 +1039,7 @@ async fn repair_manifest_host_ports_after_stability(
container = %name, container = %name,
"host listener disappeared after startup; restarting container" "host listener disappeared after startup; restarting container"
); );
if uses_pasta_network(manifest) && !quadlet::unit_exists(name).await { if uses_pasta_network(manifest) {
// Legacy (pre-quadlet) pasta app: no unit owns it, so a transient
// scope keeps its networking's cgroup independent of the daemon.
podman_user_scope(&["restart", name]) podman_user_scope(&["restart", name])
.await .await
.with_context(|| format!("podman restart {name}"))?; .with_context(|| format!("podman restart {name}"))?;
@@ -1087,16 +1085,9 @@ async fn start_container_scoped_if_pasta(
name: &str, name: &str,
) -> Result<()> { ) -> Result<()> {
if uses_pasta_network(manifest) { if uses_pasta_network(manifest) {
// Quadlet-managed pasta app: the unit owns the cgroup and the // Rootless pasta/conmon inherit the cgroup of the process that starts
// container is rendered --rm — bare `podman start` would fight // them. Starting through archipelago.service lets backend restarts kill
// systemd over it. Restart-through-the-unit starts a stopped one. // app networking; a transient user scope keeps app daemons independent.
if quadlet::unit_exists(name).await {
return quadlet::restart_service(&format!("{name}.service")).await;
}
// Legacy pasta app: rootless pasta/conmon inherit the cgroup of the
// process that starts them. Starting through archipelago.service lets
// backend restarts kill app networking; a transient user scope keeps
// app daemons independent.
podman_user_scope(&["start", name]).await podman_user_scope(&["start", name]).await
} else { } else {
runtime.start_container(name).await runtime.start_container(name).await
@@ -1109,9 +1100,6 @@ async fn restart_container_scoped_if_pasta(
name: &str, name: &str,
) -> Result<()> { ) -> Result<()> {
if uses_pasta_network(manifest) { if uses_pasta_network(manifest) {
if quadlet::unit_exists(name).await {
return quadlet::restart_service(&format!("{name}.service")).await;
}
podman_user_scope(&["restart", name]).await podman_user_scope(&["restart", name]).await
} else { } else {
let _ = runtime.stop_container(name).await; let _ = runtime.stop_container(name).await;
@@ -1515,10 +1503,6 @@ impl ProdContainerOrchestrator {
self.data_dir = data_dir; self.data_dir = data_dir;
} }
pub fn data_dir(&self) -> &std::path::Path {
&self.data_dir
}
#[cfg(test)] #[cfg(test)]
pub fn set_lnd_paths(&mut self, paths: lnd::EnsurePaths) { pub fn set_lnd_paths(&mut self, paths: lnd::EnsurePaths) {
self.lnd_paths = paths; self.lnd_paths = paths;
@@ -2279,15 +2263,7 @@ impl ProdContainerOrchestrator {
// after proving the container exists. Boot reconciliation must // after proving the container exists. Boot reconciliation must
// not create every catalog app just because a Quadlet unit is // not create every catalog app just because a Quadlet unit is
// absent. // absent.
// if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
// Pasta apps included since 2026-08-10: the old exclusion
// paired with the transient-scope machinery (daemon-started
// pasta died with the daemon's cgroup). A quadlet unit gives
// pasta the same independence with systemd supervision on top
// — Restart=always + RestartSec=10, which also spaces restarts
// past pasta's port teardown. The scoped start/restart helpers
// now defer to the unit whenever one exists.
if self.use_quadlet_backends {
if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? { if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? {
return Ok(action); return Ok(action);
} }
@@ -2559,7 +2535,10 @@ impl ProdContainerOrchestrator {
// lost the container record after a crash/reboot. Sync the unit // lost the container record after a crash/reboot. Sync the unit
// bytes first (clears stale Notify=healthy/nc probes), then ask // bytes first (clears stale Notify=healthy/nc probes), then ask
// user systemd to start the generated service. // user systemd to start the generated service.
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? { if self.use_quadlet_backends
&& !uses_pasta_network(&resolved_manifest)
&& self.quadlet_unit_exists(&name).await?
{
self.prepare_for_start(&resolved_manifest).await?; self.prepare_for_start(&resolved_manifest).await?;
self.sync_quadlet_unit(lm, &name).await?; self.sync_quadlet_unit(lm, &name).await?;
self.ensure_resolved_source_available(lm).await?; self.ensure_resolved_source_available(lm).await?;
@@ -2742,13 +2721,11 @@ impl ProdContainerOrchestrator {
self.prepare_for_start(&resolved_manifest).await?; self.prepare_for_start(&resolved_manifest).await?;
self.ensure_container_network(&resolved_manifest).await?; self.ensure_container_network(&resolved_manifest).await?;
if self.use_quadlet_backends { if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
// Phase 3.2 path: declarative .container unit + systemctl. // Phase 3.2 path: declarative .container unit + systemctl.
// Containers parented under user.slice instead of // Containers parented under user.slice instead of
// archipelago.service's cgroup → no FM3 cascade SIGKILL on // archipelago.service's cgroup → no FM3 cascade SIGKILL on
// archipelago restart. Pasta apps included since 2026-08-10 — // archipelago restart.
// the unit gives them the same cgroup independence the transient
// scopes provided, plus Restart=always supervision.
self.install_via_quadlet(&resolved_manifest, &name).await?; self.install_via_quadlet(&resolved_manifest, &name).await?;
} else { } else {
self.remove_quadlet_unit_if_present(&name).await?; self.remove_quadlet_unit_if_present(&name).await?;
-13
View File
@@ -661,19 +661,6 @@ fn parse_memory_mib(raw: &str) -> Option<u32> {
num_part.trim().parse::<u32>().ok()?.checked_mul(mul) num_part.trim().parse::<u32>().ok()?.checked_mul(mul)
} }
/// Does a quadlet `.container` unit exist for this container name?
/// Errors count as "unknown" and return false — callers use this to decide
/// whether systemd owns the container, and claiming ownership on an
/// unreadable answer would route lifecycle ops around a live unit.
pub async fn unit_exists(name: &str) -> bool {
let Ok(dir) = unit_dir().await else {
return false;
};
tokio::fs::try_exists(dir.join(format!("{name}.container")))
.await
.unwrap_or(false)
}
/// Resolve the per-user quadlet dir under $HOME. Created if missing. /// Resolve the per-user quadlet dir under $HOME. Created if missing.
pub async fn unit_dir() -> Result<PathBuf> { pub async fn unit_dir() -> Result<PathBuf> {
let home = std::env::var_os("HOME") let home = std::env::var_os("HOME")
@@ -120,13 +120,6 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
config config
.registries .registries
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST)); .retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
// And the release server's own bare-IP twin (146.59.87.168:3000 — the
// same host as source.archipelago-foundation.org): older defaults listed
// both, so the registry UI showed one server twice. Bare-IP origins were
// retired 2026-08-11; the named entry stays and covers the same pulls.
config
.registries
.retain(|r| !r.url.contains("146.59.87.168"));
let mut changed = config.registries.len() != before; let mut changed = config.registries.len() != before;
// Migrate: any default registry URL that isn't already in the // Migrate: any default registry URL that isn't already in the
-38
View File
@@ -204,19 +204,6 @@ pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<S
} }
} }
/// Like `load_installed_apps`, but keeps "no record" distinguishable from
/// "empty record". The companion reaper must only ever run on `Some`:
/// "I could not look" and "nothing is installed" both come back as an empty
/// set from the lossy loader, yet they demand opposite behaviour — the
/// distinction has to survive to the caller (see `reap_orphans`' contract).
pub async fn load_installed_apps_if_recorded(
data_dir: &Path,
) -> Option<std::collections::HashSet<String>> {
let path = data_dir.join(INSTALLED_APPS_FILE);
let content = fs::read_to_string(&path).await.ok()?;
serde_json::from_str(&content).ok()
}
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) { async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE); let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) { if let Ok(json) = serde_json::to_string_pretty(installed) {
@@ -1206,31 +1193,6 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
#[tokio::test]
async fn if_recorded_distinguishes_no_record_from_empty_record() {
let tmp = TempDir::new().unwrap();
// No file: the reaper must see "could not look", never "empty".
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
// Corrupt file: same — refuse to answer rather than guess.
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "{ not json")
.await
.unwrap();
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
// A real (even empty) record answers.
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "[]")
.await
.unwrap();
assert_eq!(
load_installed_apps_if_recorded(tmp.path()).await,
Some(std::collections::HashSet::new())
);
mark_installed(tmp.path(), "bitcoin-knots").await;
assert!(load_installed_apps_if_recorded(tmp.path())
.await
.unwrap()
.contains("bitcoin-knots"));
}
#[tokio::test] #[tokio::test]
async fn installed_record_survives_and_forgets_on_uninstall() { async fn installed_record_survives_and_forgets_on_uninstall() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
+5 -125
View File
@@ -35,59 +35,6 @@ use tracing::warn;
const NOSTR_SECRET_FILE: &str = "nostr_secret"; const NOSTR_SECRET_FILE: &str = "nostr_secret";
/// Runtime discoverability override written by the `nostr.set-discovery` RPC.
/// Lives here (not api/rpc) so the server's heartbeat can honour the same
/// state the toggle writes.
pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json";
/// How long a presence event stays valid. Published as a NIP-40 expiration
/// tag AND enforced client-side in `discover` (relay NIP-40 support varies).
/// Must be comfortably longer than the re-publish heartbeat (12h in
/// server.rs) so a node that misses one heartbeat doesn't vanish: 48h
/// tolerates three misses.
pub const PRESENCE_TTL_SECS: u64 = 48 * 3600;
/// Read the runtime discovery override and the operator-chosen display name.
/// Enabled `None` means the toggle has never been used on this node —
/// callers fall back to the config flag.
pub async fn discovery_overrides(data_dir: &Path) -> (Option<bool>, Option<String>) {
let Ok(raw) = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)).await else {
return (None, None);
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return (None, None);
};
let enabled = v.get("enabled").and_then(|e| e.as_bool());
let name = v
.get("name")
.and_then(|n| n.as_str())
.and_then(clean_display_name);
(enabled, name)
}
/// Display names travel in a PUBLIC relay event and come back from untrusted
/// peers — normalise both directions: single line, control chars stripped,
/// hard length cap, empty collapses to None.
pub fn clean_display_name(raw: &str) -> Option<String> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_control())
.take(32)
.collect::<String>()
.trim()
.to_string();
(!cleaned.is_empty()).then_some(cleaned)
}
/// This node's own published npub (bech32), if discovery keys exist.
/// Load-only: never mints keys on a read.
pub async fn own_npub(identity_dir: &Path) -> Result<Option<String>> {
Ok(load_nostr_keys(identity_dir)
.await?
.map(|k| k.public_key().to_bech32().unwrap_or_default())
.filter(|s| !s.is_empty()))
}
/// Message types exchanged inside NIP-44 encrypted DMs (kind 4). /// Message types exchanged inside NIP-44 encrypted DMs (kind 4).
/// ///
/// Note: NONE of these variants carry an onion address. The onion is only /// Note: NONE of these variants carry an onion address. The onion is only
@@ -183,7 +130,6 @@ pub async fn publish_presence(
identity_dir: &Path, identity_dir: &Path,
did: &str, did: &str,
version: &str, version: &str,
name: Option<&str>,
relays: &[String], relays: &[String],
tor_proxy: Option<&str>, tor_proxy: Option<&str>,
) -> Result<()> { ) -> Result<()> {
@@ -199,20 +145,14 @@ pub async fn publish_presence(
let nostr_npub = keys.public_key().to_bech32().unwrap_or_default(); let nostr_npub = keys.public_key().to_bech32().unwrap_or_default();
let client = build_client(keys, tor_proxy)?; let client = build_client(keys, tor_proxy)?;
let mut fields = serde_json::json!({ let content = serde_json::json!({
"did": did, "did": did,
"nostr_pubkey": nostr_pubkey, "nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub, "nostr_npub": nostr_npub,
"version": version, "version": version,
// No onion address — exchanged only via encrypted DM // No onion address — exchanged only via encrypted DM
}); })
// Operator-chosen display name (optional, already normalised). Public by .to_string();
// construction: it exists to label this node in other nodes' discovery
// lists, so only ever include what clean_display_name lets through.
if let Some(n) = name.and_then(clean_display_name) {
fields["name"] = serde_json::Value::String(n);
}
let content = fields.to_string();
for url in relays { for url in relays {
let _ = client.add_relay(url).await; let _ = client.add_relay(url).await;
@@ -224,13 +164,8 @@ pub async fn publish_presence(
warn!("Nostr relay connection timed out after 10s, continuing anyway"); warn!("Nostr relay connection timed out after 10s, continuing anyway");
} }
// NIP-40 expiration: relays that honour it garbage-collect the event if let builder =
// this node stops heartbeating (reinstall, decommission, long outage). EventBuilder::new(Kind::Custom(30078), content).tag(Tag::identifier("archipelago-node"));
// `discover` enforces the same window client-side for relays that don't.
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), content)
.tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires));
let _ = client.send_event_builder(builder).await; let _ = client.send_event_builder(builder).await;
client.disconnect().await; client.disconnect().await;
@@ -241,43 +176,6 @@ pub async fn publish_presence(
Ok(()) Ok(())
} }
/// Overwrite this node's presence with an empty tombstone (NIP-33: same
/// author + kind + d-tag replaces). Called when discovery is switched off
/// and — critically — during factory-reset BEFORE the keys are wiped: once
/// the secret is gone, nothing can ever replace the stale event.
pub async fn publish_tombstone(
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<()> {
if relays.is_empty() {
return Ok(());
}
let Some(keys) = load_nostr_keys(identity_dir).await? else {
return Ok(()); // never published — nothing to tombstone
};
let client = build_client(keys, tor_proxy)?;
for url in relays {
let _ = client.add_relay(url).await;
}
if tokio::time::timeout(Duration::from_secs(10), client.connect())
.await
.is_err()
{
warn!("Nostr relay connection timed out after 10s, continuing anyway");
}
// Tombstone also expires: after TTL the relay may drop it entirely,
// which is the desired end state (nothing left to list).
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
.tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires));
let _ = client.send_event_builder(builder).await;
client.disconnect().await;
tracing::info!("🔒 Published presence tombstone to {} relays", relays.len());
Ok(())
}
/// Discover other Archipelago nodes (presence-only — no onion addresses). /// Discover other Archipelago nodes (presence-only — no onion addresses).
/// Returns Nostr pubkeys and DIDs of discoverable nodes. /// Returns Nostr pubkeys and DIDs of discoverable nodes.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -288,9 +186,6 @@ pub struct DiscoverableNode {
pub nostr_npub: String, pub nostr_npub: String,
pub did: String, pub did: String,
pub version: String, pub version: String,
/// Operator-chosen display name from the presence event. Untrusted peer
/// input — normalised through `clean_display_name` on the way in.
pub name: Option<String>,
} }
pub async fn discover_nodes( pub async fn discover_nodes(
@@ -326,17 +221,7 @@ pub async fn discover_nodes(
client.disconnect().await; client.disconnect().await;
let mut nodes = Vec::new(); let mut nodes = Vec::new();
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS));
for event in events { for event in events {
// Client-side staleness enforcement: pre-TTL events (and events from
// relays that ignore NIP-40) would otherwise list dead installs
// forever — every reinstall mints a new key, so the old author can
// never replace its own event.
if event.created_at < stale_cutoff {
continue;
}
// A tombstone ("{}" content) parses but yields no pubkey — the
// nostr_pubkey.is_empty() guard below already drops it.
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) { if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
let nostr_pubkey = content let nostr_pubkey = content
.get("nostr_pubkey") .get("nostr_pubkey")
@@ -365,16 +250,11 @@ pub async fn discover_nodes(
.ok() .ok()
.and_then(|pk| pk.to_bech32().ok()) .and_then(|pk| pk.to_bech32().ok())
.unwrap_or_default(); .unwrap_or_default();
let name = content
.get("name")
.and_then(|v| v.as_str())
.and_then(clean_display_name);
nodes.push(DiscoverableNode { nodes.push(DiscoverableNode {
nostr_pubkey, nostr_pubkey,
nostr_npub, nostr_npub,
did, did,
version, version,
name,
}); });
} }
} }
+13 -36
View File
@@ -212,15 +212,7 @@ impl Server {
// Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address). // Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address).
// Onion addresses are exchanged privately via NIP-44 encrypted DMs. // Onion addresses are exchanged privately via NIP-44 encrypted DMs.
// if config.nostr_discovery_enabled && !config.nostr_relays.is_empty() {
// This is a heartbeat, not a one-shot: presence events carry a NIP-40
// expiration of PRESENCE_TTL_SECS, so a node that stops re-publishing
// ages out of discovery instead of lingering forever. First tick runs
// immediately (preserving the old startup-publish behaviour); the
// runtime toggle (nostr.set-discovery) is re-read every tick, so a
// node switched on via the UI heartbeats too — not just ones with the
// config flag baked in.
{
let identity_dir = config.data_dir.join("identity"); let identity_dir = config.data_dir.join("identity");
let did = let did =
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default(); identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
@@ -229,36 +221,21 @@ impl Server {
// where handshake peers actually read (2026-07-22 unification). // where handshake peers actually read (2026-07-22 unification).
let data_dir_for_relays = config.data_dir.clone(); let data_dir_for_relays = config.data_dir.clone();
let config_relays = config.nostr_relays.clone(); let config_relays = config.nostr_relays.clone();
let config_flag = config.nostr_discovery_enabled;
let tor_proxy = config.nostr_tor_proxy.clone(); let tor_proxy = config.nostr_tor_proxy.clone();
tokio::spawn(async move { tokio::spawn(async move {
const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3 let relays =
loop { crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
let (enabled_override, display_name) =
nostr_handshake::discovery_overrides(&data_dir_for_relays).await;
let enabled = enabled_override.unwrap_or(config_flag);
if enabled {
let relays = crate::nostr_relays::merged_relay_list(
&data_dir_for_relays,
&config_relays,
)
.await; .await;
if !relays.is_empty() { if let Err(e) = nostr_handshake::publish_presence(
if let Err(e) = nostr_handshake::publish_presence( &identity_dir,
&identity_dir, &did,
&did, &version,
&version, &relays,
display_name.as_deref(), tor_proxy.as_deref(),
&relays, )
tor_proxy.as_deref(), .await
) {
.await tracing::debug!("Nostr presence publish (non-fatal): {}", e);
{
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(HEARTBEAT_SECS)).await;
} }
}); });
} }
+64 -112
View File
@@ -84,9 +84,11 @@ fn is_newer(candidate: &str, current: &str) -> bool {
const DEFAULT_UPDATE_MANIFEST_URL: &str = const DEFAULT_UPDATE_MANIFEST_URL: &str =
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json"; "https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
// The previous IP-based origin (http://146.59.87.168:3000/…) was an automatic /// The previous IP-based origin, kept as an automatic fallback so a node
// DNS/TLS-broken fallback until 2026-08-11, when bare-IP origins were retired /// whose DNS or TLS is broken still updates. Dropped from the mirror list
// from the update registry. `load_mirrors` strips it from saved lists by host. /// once the fleet has moved.
const LEGACY_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json"; const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json"; const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
/// Marker written by apply_update() just before the service restart and /// Marker written by apply_update() just before the service restart and
@@ -129,11 +131,20 @@ fn default_mirrors() -> Vec<UpdateMirror> {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(), url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Archipelago Foundation".to_string(), label: "Archipelago Foundation".to_string(),
}, },
// The bare-IP plain-HTTP twin of the entry above was retired as a // NOT a second server — the SAME host as the entry above, reached by
// default on 2026-08-11 (operator decision: no bare-IP origins in the // IP over plain HTTP instead of by name over TLS. It buys nothing if
// update registry). Its DNS/TLS-broken recovery value is accepted as // the origin is down; what it recovers is a node whose **DNS is
// lost; the manifest signature was always what protected the update, // broken or whose clock is wrong**, either of which fails TLS while
// never the transport. `load_mirrors` strips it from saved lists. // plain HTTP still works. Safe because the manifest carries an Ed25519
// signature verified against the pinned release-root anchor, so
// transport integrity is not what protects the update.
//
// Labelled explicitly so the UI cannot imply redundancy it doesn't
// provide. Real redundancy needs a mirror on a different host.
UpdateMirror {
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
label: "Same server, no DNS/TLS".to_string(),
},
] ]
} }
@@ -171,16 +182,8 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
// ever served a stale manifest as the secondary mirror. // ever served a stale manifest as the secondary mirror.
// Exception to the usual "explicit removals stick" rule: the user never // Exception to the usual "explicit removals stick" rule: the user never
// chose to add these — they were defaults. // chose to add these — they were defaults.
// - 146.59.87.168: the release server's own bare-IP HTTP twin, retired
// as a default 2026-08-11 — same host as the named origin, so it
// provided no redundancy, only a plain-HTTP path the operator no
// longer wants advertised in the update registry.
let before = list.len(); let before = list.len();
list.retain(|m| { list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
!m.url.contains("23.182.128.160")
&& !m.url.contains("git.tx1138.com")
&& !m.url.contains("146.59.87.168")
});
let mut changed = list.len() != before; let mut changed = list.len() != before;
// Merge in any default URLs the saved config is missing. // Merge in any default URLs the saved config is missing.
@@ -213,14 +216,21 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
for mirror in list.iter_mut() { for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL { if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Archipelago Foundation".to_string(); mirror.label = "Archipelago Foundation".to_string();
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
// Rewritten on every load, so relabelling here reaches nodes that
// already have the old "Direct (fallback)" text saved in their
// update-mirrors.json — the merge below matches on URL, never on
// label, so without this a renamed default would never propagate.
mirror.label = "Same server, no DNS/TLS".to_string();
} }
} }
// Named origin first, anything the operator added after that. Ordering // Named origin first, its same-host IP fallback second, anything the
// matters: the list is tried in order, so a stale entry sitting first // operator added after that. Ordering matters: the list is tried in order,
// costs a timeout on every check. // so a stale entry sitting first costs a timeout on every check.
list.sort_by_key(|m| match m.url.as_str() { list.sort_by_key(|m| match m.url.as_str() {
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0, u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
_ => 1, u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
_ => 2,
}); });
} }
@@ -1013,7 +1023,7 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
/// partially-corrupt resume still fails cleanly. /// partially-corrupt resume still fails cleanly.
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> { pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| { let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("Update already in progress — another download or apply is already running") anyhow::anyhow!("another update operation (download or apply) is already running")
})?; })?;
let mut state = load_state(data_dir).await?; let mut state = load_state(data_dir).await?;
if state.available_update.is_none() { if state.available_update.is_none() {
@@ -1406,8 +1416,8 @@ async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest)
.unwrap_or(0); .unwrap_or(0);
if len != component.size_bytes { if len != component.size_bytes {
anyhow::bail!( anyhow::bail!(
"Update staging is inconsistent: component {} is {} bytes but the manifest says {} — \ "staged component {} is {} bytes but the manifest says {} — \
re-download before applying (incomplete or concurrently-rewritten download)", refusing to apply (incomplete or concurrently-rewritten download)",
component.name, component.name,
len, len,
component.size_bytes component.size_bytes
@@ -1519,11 +1529,11 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions. /// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
pub async fn apply_update(data_dir: &Path) -> Result<()> { pub async fn apply_update(data_dir: &Path) -> Result<()> {
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| { let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
anyhow::anyhow!("Update already in progress — another download or apply is already running") anyhow::anyhow!("another update operation (download or apply) is already running")
})?; })?;
let staging_dir = data_dir.join("update-staging"); let staging_dir = data_dir.join("update-staging");
if !staging_dir.exists() { if !staging_dir.exists() {
anyhow::bail!("Update not staged — download it first, then apply."); anyhow::bail!("No staged update found. Download first.");
} }
// Gate 1: the completion marker is written only after EVERY component // Gate 1: the completion marker is written only after EVERY component
@@ -1531,7 +1541,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
// or in-flight download — exactly what got installed on .198. // or in-flight download — exactly what got installed on .198.
if !has_staged_update(data_dir).await { if !has_staged_update(data_dir).await {
anyhow::bail!( anyhow::bail!(
"Update download was incomplete (no completion marker) — download the update again before applying" "Staged update is incomplete (no completion marker) — download the update again before applying"
); );
} }
@@ -1540,7 +1550,9 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
.await? .await?
.available_update .available_update
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!("Update manifest missing from state — re-download the update") anyhow::anyhow!(
"no update manifest in state to verify staged files against — re-download the update"
)
})?; })?;
verify_staged_components(&staging_dir, &manifest).await?; verify_staged_components(&staging_dir, &manifest).await?;
@@ -1576,83 +1588,41 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
info!("Current binary backed up"); info!("Current binary backed up");
} }
// Apply staged components in a DETERMINISTIC order, binary LAST. // Apply staged components
// read_dir order is filesystem-arbitrary, and each component used to be let mut entries = fs::read_dir(&staging_dir)
// consumed destructively — so a mid-apply failure could leave staging .await
// half-emptied and un-reappliable (Gate 2 re-verifies EVERY manifest .context("Failed to read staging dir")?;
// component against staging, so a missing one wedges every retry:
// "doesn't apply, still says install, can never apply again"). Two
// guards against that now: (a) nothing is removed from staging here —
// the binary is copied, not moved (see its block) — so a failed apply
// is always retryable from the same staged files; (b) the binary, the
// one component whose swap changes what runs after restart, is applied
// only after the frontend/runtime succeed, so a frontend failure never
// leaves a new binary staged to run against an old frontend on the next
// restart.
let mut names: Vec<String> = Vec::new();
{
let mut entries = fs::read_dir(&staging_dir)
.await
.context("Failed to read staging dir")?;
while let Some(entry) = entries.next_entry().await? {
names.push(entry.file_name().to_string_lossy().to_string());
}
}
names.sort_by_key(|n| match n.as_str() {
"archipelago" => 2, // binary last
n if n.contains("runtime") && n.ends_with(".tar.gz") => 1,
_ => 0, // frontend and everything else first
});
for name in &names { while let Some(entry) = entries.next_entry().await? {
let name = name.as_str(); let name = entry.file_name().to_string_lossy().to_string();
let src = staging_dir.join(name); let src = entry.path();
match name { match name.as_str() {
"archipelago" => { "archipelago" => {
// Three constraints this block works around: // Two namespace gotchas this block works around:
// 1. We're running FROM /usr/local/bin/archipelago, so // 1. We're running FROM /usr/local/bin/archipelago, so
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY. // `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
// rename() over a busy destination is fine. // Use `mv`, which is atomic rename() and tolerates a
// busy destination.
// 2. archipelago.service sets ProtectSystem=strict, so // 2. archipelago.service sets ProtectSystem=strict, so
// even `sudo mv` into /usr/local/bin/ fails EROFS — // even `sudo mv` into /usr/local/bin/ fails EROFS —
// sudo inherits the service's mount namespace. Route // sudo inherits the service's mount namespace. Route
// through host_sudo (systemd-run transient unit with // the rename through systemd-run so it runs in a
// default protections). // transient unit with default protections.
// 3. The staged binary must SURVIVE this so a later
// component's failure leaves the apply retryable. So we
// COPY the staged file to a sibling temp in the target
// dir, then atomic-rename the temp over the target —
// the staging copy is never moved. (mv'ing the staged
// file itself was the wedging bug: binary applied, then
// frontend fails, staging now missing the binary, every
// retry fails re-verification forever.)
let staged = src.to_string_lossy().to_string(); let staged = src.to_string_lossy().to_string();
let tmp = format!( let _ = host_sudo(&["chmod", "0755", &staged]).await;
"/usr/local/bin/.archipelago.new.{}", let _ = host_sudo(&["chown", "root:root", &staged]).await;
chrono::Utc::now().timestamp_millis() let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
);
let cp = host_sudo(&["cp", "-f", &staged, &tmp])
.await
.with_context(|| format!("Failed to copy staged binary for {}", name))?;
if !cp.success() {
let _ = host_sudo(&["rm", "-f", &tmp]).await;
anyhow::bail!("copy of staged binary failed for {}", name);
}
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
.await .await
.with_context(|| format!("Failed to spawn mv for {}", name))?; .with_context(|| format!("Failed to spawn mv for {}", name))?;
if !status.success() { if !status.success() {
let _ = host_sudo(&["rm", "-f", &tmp]).await;
anyhow::bail!( anyhow::bail!(
"mv into /usr/local/bin failed for {} (exit {:?})", "mv into /usr/local/bin failed for {} (exit {:?})",
name, name,
status.code() status.code()
); );
} }
info!(name = %name, "Backend binary applied (staging preserved)"); info!(name = %name, "Backend binary applied");
} }
_ if name.contains("frontend") && name.ends_with(".tar.gz") => { _ if name.contains("frontend") && name.ends_with(".tar.gz") => {
// Tarball contents are the *inside* of web-ui/ (root entries // Tarball contents are the *inside* of web-ui/ (root entries
@@ -2458,10 +2428,10 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() { async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap(); let list = load_mirrors(dir.path()).await.unwrap();
// The named https origin is the ONLY default since 2026-08-11: // The named origin leads, its IP fallback follows. A node with broken
// bare-IP origins were retired from the update registry (the IP // DNS or a wrong clock (both break TLS) must still have a way to
// twin bought DNS/TLS-broken recovery, deliberately given up). // update; the signature is what makes either source trustworthy.
assert_eq!(list.len(), 1); assert_eq!(list.len(), 2);
assert!( assert!(
list[0] list[0]
.url .url
@@ -2469,14 +2439,11 @@ mod tests {
"the named origin must be primary, got {}", "the named origin must be primary, got {}",
list[0].url list[0].url
); );
assert!(list[1].url.contains("146.59.87.168"));
assert!( assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")), !list.iter().any(|m| m.url.contains("git.tx1138.com")),
"tx1138 was retired as a release server and must not be a default mirror" "tx1138 was retired as a release server and must not be a default mirror"
); );
assert!(
!list.iter().any(|m| m.url.contains("146.59.87.168")),
"bare-IP origins were retired 2026-08-11 and must not be defaults"
);
} }
#[tokio::test] #[tokio::test]
@@ -2504,11 +2471,7 @@ mod tests {
"retired tx1138 mirror should be stripped on load; got {:?}", "retired tx1138 mirror should be stripped on load; got {:?}",
list list
); );
assert!( assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
!list.iter().any(|m| m.url.contains("146.59.87.168")),
"the bare-IP twin is retired too and must be stripped on load; got {:?}",
list
);
} }
#[tokio::test] #[tokio::test]
@@ -2778,20 +2741,9 @@ mod tests {
save_state(dir.path(), &state).await.unwrap(); save_state(dir.path(), &state).await.unwrap();
let err = apply_update(dir.path()).await.unwrap_err(); let err = apply_update(dir.path()).await.unwrap_err();
assert!( assert!(
err.to_string().contains("re-download before applying"), err.to_string().contains("refusing to apply"),
"got: {err:#}" "got: {err:#}"
); );
// Resilience: a refused apply must leave the update still available and
// still staged, so the user can re-download and retry — never a wedge.
let loaded = load_state(dir.path()).await.unwrap();
assert!(
loaded.available_update.is_some(),
"a refused apply must not clear the available update"
);
assert!(
loaded.update_in_progress,
"a refused apply must leave the staged-update flag set for retry"
);
} }
#[tokio::test] #[tokio::test]
+13 -98
View File
@@ -130,38 +130,7 @@ app:
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives. Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
### Iframe embedding — the rules Use `metadata.launch.open_in_new_tab: true` when the app UI is known to reject iframe embedding with headers such as `X-Frame-Options` or restrictive CSP. The frontend app-session metadata is generated from this flag during release work.
The dashboard opens apps in an **embedded frame** (My Apps → app session) by
default. Whether that works is decided by HTTP headers, not by wishes, so
know the mechanics:
- Browsers refuse to render a page in an iframe when the response carries
`X-Frame-Options: DENY`/`SAMEORIGIN` (the dashboard and the app are
different origins — different port at minimum) or a CSP `frame-ancestors`
directive that excludes the dashboard's origin.
- Many upstream apps ship exactly those headers (Alby Hub sends
`X-Frame-Options: DENY`). In a normal deployment that is correct hardening;
behind Archipelago's app gate the clickjacking threat those headers address
is already handled — every proxied request is authenticated by the gate
first.
- Therefore **the gate neutralizes frame blocking on proxied responses**: it
removes `X-Frame-Options` and strips only the `frame-ancestors` directive
from the app's CSP. The rest of the app's CSP (script-src, connect-src, …)
passes through untouched — the gate never weakens the app's own content
policy, only its framing policy. You do not need a bespoke reverse proxy,
header patches, or app config to be embeddable.
Set `metadata.launch.open_in_new_tab: true` only when embedding is broken by
things headers can't fix — the app frame-busts in JavaScript, requires being
the top-level origin (OAuth redirect flows, WebAuthn), or sets
`SameSite=Strict` session cookies that never accompany framed requests. Test
in the real embedded app session, **not** a plain browser tab: tabs don't
enforce framing headers, so a tab proves nothing about the iframe.
(Historical note: before the gate handled this, embeddable-but-blocking apps
each carried a hand-built nginx strip proxy — gitea's port-3000 proxy is the
surviving example. Do not copy that pattern for new apps.)
### Launch Interfaces ### Launch Interfaces
@@ -437,73 +406,19 @@ curl http://localhost:8180/health
podman logs my-app podman logs my-app
``` ```
### On an Archipelago Node (before your app is in the catalog) ### On an Archipelago Node
The App Store lists **signed-catalog apps and Nostr-discovered apps only** 1. Install via the marketplace UI or RPC:
a manifest on the node's disk never appears in the store by itself. That is ```bash
deliberate: the store is a trust surface. But the orchestrator installs from curl -b cookies.txt -X POST http://archipelago.local/rpc/v1 \
disk manifests just fine, so you can test the complete install/run/uninstall -d '{"method":"package.install","params":{"id":"my-app","dockerImage":"docker.io/myorg/my-app:1.0.0"}}'
lifecycle on your own node before your app is published anywhere. ```
2. Verify the container is running:
**1. Stage the manifest where it survives reboots.** ```bash
curl -b cookies.txt -X POST http://archipelago.local/rpc/v1 \
`/opt/archipelago/apps/` is *rebuilt on every backend start* from the runtime -d '{"method":"container-list"}'
payload that ships inside the frontend bundle ```
(`/opt/archipelago/web-ui/archipelago-runtime/apps/`). If you copy your 3. Check the UI. The app's detail page is `http://archipelago.local/dashboard/apps/my-app`; the embedded launch surface is `http://archipelago.local/dashboard/app-session/my-app`
manifest only into `/opt/archipelago/apps/`, the next restart silently deletes
it. Stage into the payload directory instead — the boot sync then promotes it
for you:
```bash
sudo mkdir -p /opt/archipelago/web-ui/archipelago-runtime/apps/my-app
sudo cp apps/my-app/manifest.yml /opt/archipelago/web-ui/archipelago-runtime/apps/my-app/
sudo systemctl restart archipelago # manifests are loaded at startup
```
Watch `journalctl -u archipelago` after the restart — the orchestrator
validates every manifest on load and tells you about problems immediately
(for example a host-port collision with another installed app).
**2. Install over JSON-RPC.**
The repo ships the same session helper the release lifecycle gate uses.
Three things to know before using it: it needs `jq`; it reuses a cached
session from `/tmp/archy-rpc-session-<uid>` unless `ARCHY_FORCE_LOGIN=1` is
set (a stale cache fails every call quietly); and it sets `set -euo pipefail`,
so run it inside a script or subshell — sourcing it into your interactive
shell makes the first failed step kill the whole chain without printing
anything.
```bash
bash <<'EOF'
export ARCHY_PASSWORD='<your dashboard password>' ARCHY_FORCE_LOGIN=1
# Stock nodes serve HTTPS on 443; dev boxes behind plain nginx use:
# export ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http
source tests/lifecycle/lib/rpc.bash
rpc_login && echo "login ok"
# Both fields are required: `dockerImage` is normally supplied by the App
# Store from the signed catalog — pre-catalog, you pass your manifest's
# image yourself (it must match, and must come from a trusted registry).
rpc_call package.install '{"id":"my-app","dockerImage":"docker.io/myorg/my-app:1.0.0"}'
EOF
```
**3. Verify the lifecycle, not just the install:**
```bash
rpc_call package.status '{"id":"my-app"}' # state + health (run inside the same subshell pattern)
podman ps --filter name=my-app # container is up
rpc_call package.stop '{"id":"my-app"}' # …and start, restart
sudo systemctl restart archipelago # app must survive this
rpc_call package.uninstall '{"id":"my-app","preserve_data":true}'
rpc_call package.install '{"id":"my-app"}' # data still there?
```
The app's detail page is `https://<node>/dashboard/apps/my-app`; a gated web
UI is reachable through the app gate on its manifest port once running.
Only after this loop is green does the app belong in a catalog submission —
catalog inclusion is what makes it appear in the App Store.
### Validate Manifest ### Validate Manifest
-134
View File
@@ -1,134 +0,0 @@
# Companion QR decoder — the zxing-cpp option (deferred)
*2026-08-11. Status: **NOT actioned.** Held as the next lever if the tuned
ZXing-Java pipeline proves insufficient in field testing. Companion-only —
touches `Android/` and nothing else.*
Related: [`qr-scanner-snappiness-handover.md`](qr-scanner-snappiness-handover.md)
(web + native survey, 2026-07-29), [`companion-pairing-qr.md`](companion-pairing-qr.md)
(the payload being scanned).
## Where we actually landed first
Before reaching for a new decoder, the native scanner
(`Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt`)
was rebuilt around one rule:
> **Every frame costs the same, and every frame sees the whole scene.**
Per frame: centre ROI at full resolution (dense invoices keep their
pixels-per-module) + the whole frame at half resolution (coverage) + one
alternating `GlobalHistogramBinarizer` pass. Bounded extras only: an inverted
ROI every 8th frame, one `TRY_HARDER` pass over the *half*-frame at most once
a second.
Two bugs were fixed on the way, both worth remembering because they are easy
to reintroduce:
1. **Escalation-on-failure is backwards.** An earlier version unlocked
progressively more expensive searches on each frame that missed, ending in
a `TRY_HARDER` pass over the full 2 MP frame (150300 ms). The result was a
scanner that locked on instantly when the code was already in view at open,
and crawled when the user opened the camera and then moved to the code —
because hunting collapsed the rate from ~30 attempts/sec to ~4, each on a
motion-blurred frame. Failure means the user is still aiming, which is when
the scanner must be *fastest*, not most thorough.
2. **A one-shot `startFocusAndMetering` locks the lens.** It puts AF in AUTO
until auto-cancel; the 5 s default spans exactly the window where the user
is swinging the phone toward the code, and a locked lens cannot follow.
Auto-cancel is now 1 s so `CONTROL_AF_MODE_CONTINUOUS_PICTURE` does the
tracking.
Plus `CONTROL_AE_TARGET_FPS_RANGE` pinned to the highest floor the back camera
offers at ≤30 fps, which caps exposure (~33 ms) and kills the motion blur that
indoor auto-exposure otherwise bakes into every hand-held frame.
That combination tested better on device (2026-08-11). This document covers
what to do **if it is still not good enough.**
## The remaining structural limit
The decoder engine itself. ZXing's Java implementation is both the slow part
and the picky part — most relevantly, it rejects perspective-skewed codes
outright, which is much of what the sensor sees while the user is moving. No
amount of frame budgeting fixes a decoder that will not accept the frame.
## The candidate: zxing-cpp
`io.github.zxing-cpp:android` — the maintained C++ rewrite of ZXing with an
official Android/Kotlin wrapper.
**Why it clears the project's dependency bar** (`~/.claude/CLAUDE.md`):
Apache-2.0, established OSS, fully on-device, no telemetry, no Play Services,
no account or network dependency. This is the distinguishing point against
**ML Kit**, which is the other fast option and is disqualified: it is
proprietary and Play-Services-backed.
**What it buys:**
- Roughly 510× faster than ZXing-Java on the same frames.
- Materially better on the cases that actually fail here: perspective/rotation
(`tryRotate`, and its detector handles warp rather than rejecting it),
blur, low contrast, damaged codes.
- Built-in inversion handling (`tryInvert`), removing our alternating
inverted-ROI pass.
- Accepts an `ImageProxy` directly, so the manual Y-plane crop/copy machinery
in `QrCodeAnalyzer` can largely be deleted — including the reused
`roiBuffer`/`halfBuffer` and the `pixelStride` handling.
**Costs / risks:**
- New native dependency. APK grows ~12 MB — limited because the app is
already arm64-only (`abiFilters += "arm64-v8a"`), so only one ABI ships.
- Adds a native attack/maintenance surface next to the existing Rust FIPS
core. Pin the version exactly, per project rules.
- The tuned camera work above (AE FPS floor, AF auto-cancel, flat per-frame
budget) stays relevant regardless — a faster decoder does not fix a blurred
or out-of-focus frame. Do **not** rip that out as part of this change.
## Integration sketch
> ⚠️ Coordinates and API surface below are from memory and were **not**
> verified against Maven Central — the machine this was written on had no
> network. Confirm the current artifact version and wrapper API on the first
> online Gradle sync before trusting the snippet.
`Android/app/build.gradle.kts`:
```kotlin
// Replaces com.google.zxing:core for the live-camera path.
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
```
`QrCodeAnalyzer` collapses to roughly:
```kotlin
private val reader = BarcodeReader().apply {
options = BarcodeReader.Options(
formats = setOf(BarcodeFormat.QR_CODE),
tryHarder = true,
tryRotate = true,
tryInvert = true,
)
}
override fun analyze(image: ImageProxy) {
try {
reader.read(image).firstOrNull()?.text?.let(onDecoded)
} finally {
image.close()
}
}
```
Keep `com.google.zxing:core` for now regardless: the still-image path
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
`prewarmQrScanner` both use it, and neither is on the hot path.
## Decision trigger
Action this only if field testing shows the current pipeline still failing the
**move-to-the-code** case — open the scanner pointing at nothing, then bring it
to a QR at a normal hand-held distance. If that reads within about a second in
ordinary room light, the Java decoder is doing its job and this stays on the
shelf.
@@ -408,10 +408,6 @@ DOCKERFILE_HEAD
xorg \ xorg \
xdotool \ xdotool \
chromium \ chromium \
mesa-va-drivers \
intel-media-va-driver \
i965-va-driver \
vainfo \
pipewire \ pipewire \
pipewire-pulse \ pipewire-pulse \
pipewire-alsa \ pipewire-alsa \
@@ -1,28 +1,5 @@
#!/bin/bash #!/bin/bash
# TearFree BEFORE X starts: bare Xorg with the stock modesetting driver has
# no vsync and no compositor, so video page-flips land mid-scanout — visible
# tearing on every kiosk (reported 2026-08-11, "really bad" on IndeedHub
# playback). The modesetting driver's TearFree option double-buffers the
# scanout at the driver level: no compositor needed, one frame of latency,
# no interaction with the 2026-06-28 choppy-audio GPU decisions. Written
# here (idempotently) rather than baked into the image so existing kiosk
# nodes pick it up through the launcher's own OTA path (bootstrap.rs
# reinstalls this script on every node).
XORG_CONF_DIR=/etc/X11/xorg.conf.d
XORG_TEARFREE="$XORG_CONF_DIR/20-archipelago-kiosk-tearfree.conf"
mkdir -p "$XORG_CONF_DIR"
if [ ! -f "$XORG_TEARFREE" ] || ! grep -q TearFree "$XORG_TEARFREE"; then
cat > "$XORG_TEARFREE" <<'EOF'
# Written by archipelago-kiosk-launcher — vsynced scanout for kiosk video.
Section "Device"
Identifier "Archipelago Kiosk GPU"
Driver "modesetting"
Option "TearFree" "true"
EndSection
EOF
fi
# Start a dedicated X server for the attached kiosk display. # Start a dedicated X server for the attached kiosk display.
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty & /usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
XPID=$! XPID=$!
@@ -172,71 +149,16 @@ xset s noblank 2>/dev/null || true
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
sleep 1 sleep 1
# ── Graphics tier ──────────────────────────────────────────────────────── # GPU vs headless (#36, choppy-audio incident 2026-06-28). --enable-gpu-rasterization
# One flag set does not fit all kiosk hardware. The June-2026 choppy-audio # spins a dedicated GPU process at 55-92% CPU even on real GPU hardware (Intel HD 5500)
# incident (#36) proved HD 5500-era boxes melt with GPU rasterization on # because under X11 it falls back to software compositing anyway — that CPU
# (under X11 they fall back to software compositing while a GPU process # starvation is what caused choppy HDMI audio. --in-process-gpu avoids the
# burns 55-92% CPU) — but holding MODERN iGPUs to those same defensive # separate process; GpuRasterization is also disabled via --disable-features below.
# flags (single raster thread, raster ban) froze framework-pt outright on # On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
# the animated network map at 4K (2026-08-13). So: two tiers.
#
# legacy — the proven HD 5500 tuning: --in-process-gpu, ONE raster
# thread, GpuRasterization banned via --disable-features.
# modern — --in-process-gpu, Chromium's default raster threads,
# GpuRasterization allowed.
#
# KIOSK_GRAPHICS in /etc/archipelago/kiosk-display.conf overrides:
# auto (default) | performance (force legacy) | quality (force modern)
# Set from Settings → Display; sourced with the rest of the conf above.
#
# Auto classifies by CPU model string — transparent and greppable, and the
# iGPU generation tracks the CPU generation on this hardware. Rules:
# * "NNth Gen Intel" → only stamped on gen 10+ model names → modern
# * AMD Ryzen → modern
# * Intel iN-NNNNN (5 dig)→ gen 10+ desktop → modern
# * Intel iN-8xxx/9xxx → gen 8/9 → modern
# * anything else → legacy (fail conservative: slow-but-stable)
detect_graphics_tier() {
case "${KIOSK_GRAPHICS:-auto}" in
performance) echo legacy; return ;;
quality) echo modern; return ;;
esac
_cpu=$(grep -m1 '^model name' /proc/cpuinfo 2>/dev/null || true)
case "$_cpu" in
*"Gen Intel"*) echo modern; return ;;
*AMD*Ryzen*) echo modern; return ;;
esac
_num=$(printf '%s' "$_cpu" | grep -oE 'i[3579]-[0-9]{4,5}' | head -1 | cut -d- -f2)
case "$_num" in
[0-9][0-9][0-9][0-9][0-9]) echo modern; return ;; # 5 digits = gen 10+
[89][0-9][0-9][0-9]) echo modern; return ;; # gen 8/9
esac
echo legacy
}
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
GRAPHICS_TIER=$(detect_graphics_tier) GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
if [ "$GRAPHICS_TIER" = "modern" ]; then
GPU_FLAGS="--in-process-gpu"
EXTRA_DISABLED_FEATURES=""
else
GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
EXTRA_DISABLED_FEATURES=",GpuRasterization"
fi
# Hardware VIDEO DECODE (VA-API) on GPU hardware — both tiers. Decode
# offload REDUCES the CPU pressure that caused the choppy-audio
# incident, it doesn't re-create it. Falls back silently to software
# decode when the platform lacks a va driver — never a black player.
# IgnoreDriverChecks: older Intel gens (HD 5500-era kiosks) are wrongly
# blocklisted upstream.
ENABLE_FEATURES="OverlayScrollbar,VaapiVideoDecodeLinuxGL,VaapiIgnoreDriverChecks"
echo "archipelago-kiosk: graphics tier=$GRAPHICS_TIER (KIOSK_GRAPHICS=${KIOSK_GRAPHICS:-auto})"
else else
# GPU-less / headless server (no /dev/dri): no GPU at all.
GRAPHICS_TIER=headless
GPU_FLAGS="--disable-gpu --num-raster-threads=1" GPU_FLAGS="--disable-gpu --num-raster-threads=1"
EXTRA_DISABLED_FEATURES=",GpuRasterization"
ENABLE_FEATURES="OverlayScrollbar"
fi fi
ARCHIPELAGO_UID=$(id -u archipelago) ARCHIPELAGO_UID=$(id -u archipelago)
@@ -271,8 +193,8 @@ while true; do
--disable-translate \ --disable-translate \
--no-first-run \ --no-first-run \
--check-for-update-interval=31536000 \ --check-for-update-interval=31536000 \
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled${EXTRA_DISABLED_FEATURES} \ --disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
--enable-features=$ENABLE_FEATURES \ --enable-features=OverlayScrollbar \
--disable-session-crashed-bubble \ --disable-session-crashed-bubble \
--disable-save-password-bubble \ --disable-save-password-bubble \
--disable-suggestions-service \ --disable-suggestions-service \
+2 -9
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.0-alpha", "version": "1.7.127-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.0-alpha", "version": "1.7.127-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
@@ -16,7 +16,6 @@
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
@@ -7294,12 +7293,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+1 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.0-alpha", "version": "1.7.127-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
@@ -33,7 +33,6 @@
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8000 8000">
<path d="M1404.94034,2404.75143c.19948.39893.416.79745.63896,1.19934-.21965-.3998-.4293-.7996-.63896-1.19934ZM1398.36987,2392.55825c.01.02001.01999.03996.01999.04996.01999.02001.03998.04996.04998.06997-.01999-.03996-.04998-.07997-.06997-.11993ZM7957.22848,6407.80899c-75.3022-453.82005-341.31246-1051.68618-631.76608-1536.83905-217.58172,280.41299-443.70125,543.12568-695.66263,796.78364,151.96083,272.22676,381.52012,831.33939,371.78586,1131.07073-3.34955,103.13742-84.59871,186.38404-187.48323,193.03587-323.69068,20.92769-761.15088-169.80941-1126.96417-352.01125-274.08918,250.36976-539.73074,478.97192-815.02811,690.20321,570.85548,338.56749,1305.54247,669.74202,1904.46573,668.08293,872.77622,46.26671,1378.68446-776.20339,1180.65263-1590.32607ZM5249.15884,5683.56479c1101.91285-1101.01171,2353.03599-2527.31198,2708.11113-4090.5697,253.93721-1273.80049-760.34667-1869.98142-1895.16006-1469.66567,211.65045,278.23428,364.22738,589.33979,457.73054,914.98744,86.80988-20.20216,196.93112-34.80914,289.99476-31.50156,103.33929,3.67279,186.93541,86.38156,191.60732,189.77353,13.96581,309.07109-235.73659,902.8025-402.1382,1193.8406,0,.01-.01.02001-.01.03001l-.04998.07991c-624.44753,1143.09071-1591.89389,2208.42396-2600.29743,3077.06633l-.22964-.19984c-263.64443,226.46298-526.16056,433.98687-804.2439,626.56893.39938.34966.79882.69956,1.19826,1.04946-509.31622,353.04862-1419.46673,925.33606-2012.13405,893.44541-103.82534-5.58671-186.23795-90.1864-188.67838-194.22674-6.97909-297.53161,215.6413-843.71786,363.83869-1111.11806-237.05325-259.94423-464.71032-529.69327-686.31643-814.38364-1228.33988,1900.32534-772.83666,3911.20697,1772.96127,2814.86279,1004.95316-454.63593,1968.00538-1208.05697,2803.81609-2000.0392ZM2528.2177,4000.38756c-468.17105-551.20508-806.89093-1020.88409-1125.16461-1599.13417h-.01c-.00197-.02709-.02111-.04517-.02999-.07997h-.01c-.00225-.03154-.04191-.05163-.03986-.08992,0,0-.01,0-.01-.01-1.46642-2.7001-3.08021-5.72568-4.5634-8.46529.01999.02001.03998.04996.04998.06997-165.01013-287.23702-415.64692-882.63022-403.53149-1193.46936,4.07951-104.66591,88.1281-188.31126,192.71288-192.04153,92.1143-3.28547,200.69379,10.46969,286.62767,30.61985,93.58312-325.69743,246.26999-636.85321,458.06014-915.09749C797.66386-275.84563-214.1867,319.25478,39.32792,1592.99819c208.75964,1126.24494,1140.55524,2354.14531,1865.30175,3206.20191-1.24817,1.76903,1.23818-1.76903,0,0,177.91975,202.67638,363.17881,406.52227,558.17368,604.08153,267.29912-191.50259,530.82373-396.10806,795.10723-617.7438-259.10108-257.46588-503.73334-520.6384-729.69288-785.15026ZM1420.59738,2433.86513c.84886,1.56913,1.71746,3.17821,2.5963,4.82725-.85885-1.58908-1.72745-3.19816-2.5963-4.82725ZM1457.40366,2502.26682c.92857,1.699,1.83716,3.39806,2.73599,5.07711-.89883-1.64904-1.80742-3.3481-2.73599-5.07711ZM1403.01311,2401.17342c-.01-.01-.01987-.02995-.02986-.03996.01.01.01987.02995.01987.03996.01999.03001.02999.04996.03998.06997-.01-.02001-.01999-.03996-.02999-.06997ZM1404.94034,2404.75143c.19948.39893.416.79745.63896,1.19934-.21965-.3998-.4293-.7996-.63896-1.19934Z" fill="#ffe480"/>
<path d="M2762.56314,2987.43935c-683.41752-683.42045-683.41752-1791.46134,0-2474.87781,683.41752-683.41755,1791.45841-683.41755,2474.87885,0,683.41068,683.41648,683.41068,1791.45737,0,2474.87781l-1237.44089,1237.44089-1237.43796-1237.44089Z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 94 94" id="vector" xmlns="http://www.w3.org/2000/svg">
<g id="group" transform="matrix(1, 0, 0, 1, -6.999915, -7)">
<path id="path" d="M 30 81 C 23 80 21.63 71.49 29.16 45.87 C 35 26 46.18 26 58 26 C 73.17 26 81.61 29.18 83.79 35.73 C 84.243 36.883 84.39 38.135 84.214 39.362 C 84.039 40.589 83.548 41.75 82.79 42.73 C 82.613 42.96 82.419 43.178 82.21 43.38 C 85.1 45.31 86.88 48.02 86.52 51.9 C 86.13 56.26 83.16 58.55 78.52 59.66 C 78.849 60.396 79.013 61.194 79 62 C 78.988 63.199 78.695 64.378 78.147 65.444 C 77.598 66.51 76.808 67.433 75.84 68.14 C 70.09 72.76 60 75 46 73 C 43 72.57 41 75 39 77 C 36.62 79.38 34.24 81.61 30 81 Z" fill="#25A5FF"/>
<path id="path_1" d="M 58 30 C 66 30 78 31 80 37 C 82.24 43.71 69 43 59 42 C 68 43 83.18 44 82.5 51.5 C 82 57 72 57 55 56 C 63 57 75 58 75 62 C 75 68.28 58.74 71 47.67 69.31 C 39 68 38.06 76.77 31 75 C 27 74 28 63 33 47 C 38.29 30.09 46.49 30 58 30 Z" fill="#ffffff" stroke="#25A5FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -19,7 +19,7 @@
"author": "Bitcoin Knots", "author": "Bitcoin Knots",
"category": "money", "category": "money",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210", "dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin" "repoUrl": "https://github.com/bitcoinknots/bitcoin"
}, },
{ {
@@ -390,7 +390,7 @@
"author": "Grafana Labs", "author": "Grafana Labs",
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0", "dockerImage": "grafana/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana", "repoUrl": "https://github.com/grafana/grafana",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
Binary file not shown.
@@ -1,4 +0,0 @@
{
"versionName": "0.5.27",
"versionCode": 47
}
-34
View File
@@ -1,39 +1,5 @@
<template> <template>
<div id="app"> <div id="app">
<!-- KAMMERGUT GLOSS TEST (2026-08-12): SVG paint filter lifted verbatim
from plan-b's Kammergut wordmark (#paintGloss). Consumed by the
.logo-gradient-border::after override in style.css. Revert by
deleting this svg block + that css block (one commit). -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<defs>
<filter id="paintGloss" x="-12%" y="-30%" width="124%" height="160%" color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="2.5" result="dilated"/>
<feGaussianBlur in="dilated" stdDeviation="6" result="puddle"/>
<feFlood flood-color="#fff4dc" flood-opacity="0.85" result="puddleColor"/>
<feComposite in="puddleColor" in2="puddle" operator="in" result="puddleHi"/>
<feFlood flood-color="#1A1A18" flood-opacity="0.18" result="puddleShadowColor"/>
<feComposite in="puddleShadowColor" in2="puddle" operator="in" result="puddleShadow"/>
<feOffset in="puddleShadow" dx="0" dy="3" result="puddleShadowOff"/>
<feGaussianBlur in="SourceAlpha" stdDeviation="1.4" result="bump"/>
<feSpecularLighting in="bump" surfaceScale="4" specularConstant="0.9"
specularExponent="40" lighting-color="#fff2d4" result="spec2">
<fePointLight x="600" y="-200" z="380"/>
</feSpecularLighting>
<feComposite in="spec2" in2="SourceAlpha" operator="in" result="specClip2"/>
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="dsBlur"/>
<feOffset in="dsBlur" dx="0" dy="6" result="dsOff"/>
<feComponentTransfer in="dsOff" result="dsFinal">
<feFuncA type="linear" slope="0.45"/>
</feComponentTransfer>
<feMerge>
<feMergeNode in="dsFinal"/>
<feMergeNode in="SourceGraphic"/>
<feMergeNode in="specClip2"/>
</feMerge>
</filter>
</defs>
</svg>
<!-- Splash Screen (only on first visit) --> <!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" /> <SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
+3 -6
View File
@@ -884,16 +884,14 @@ class RPCClient {
// `handshake.poll` queues inbound requests into the federation pending // `handshake.poll` queues inbound requests into the federation pending
// inbox for manual approval (it does NOT auto-accept). // inbox for manual approval (it does NOT auto-accept).
async nostrDiscoveryStatus(): Promise<{ enabled: boolean; npub?: string | null; name?: string | null }> { async nostrDiscoveryStatus(): Promise<{ enabled: boolean }> {
return this.call({ method: 'nostr.discovery-status', params: {} }) return this.call({ method: 'nostr.discovery-status', params: {} })
} }
async nostrSetDiscovery(enabled: boolean, name?: string): Promise<{ enabled: boolean }> { async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> {
// `name` omitted = backend keeps the stored display name; empty string
// clears it. Only sent when the caller explicitly provides it.
return this.call({ return this.call({
method: 'nostr.set-discovery', method: 'nostr.set-discovery',
params: name === undefined ? { enabled } : { enabled, name }, params: { enabled },
timeout: 30000, timeout: 30000,
}) })
} }
@@ -904,7 +902,6 @@ class RPCClient {
nostr_npub: string nostr_npub: string
did: string did: string
version: string version: string
name?: string | null
}> }>
}> { }> {
return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 }) return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 })
@@ -353,10 +353,6 @@ function openInNewTab() {
// (so the tap silently no-ops). The native bridge is reliable; fall back to // (so the tap silently no-ops). The native bridge is reliable; fall back to
// window.open in a plain mobile browser. // window.open in a plain mobile browser.
const native = (window as any).ArchipelagoNative const native = (window as any).ArchipelagoNative
if (native && typeof native.openInAppEx === 'function' && store.title) {
native.openInAppEx(store.url, '', store.title)
return
}
if (native && typeof native.openInApp === 'function') { if (native && typeof native.openInApp === 'function') {
native.openInApp(store.url) native.openInApp(store.url)
return return
-5
View File
@@ -54,7 +54,6 @@ import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useModalKeyboard } from '@/composables/useModalKeyboard' import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock' import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
import { useModalHistory } from '@/composables/useModalHistory'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
show: boolean show: boolean
@@ -106,10 +105,6 @@ function close() {
useModalKeyboard(modalRef, computed(() => props.show), close) useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show)) useBodyScrollLock(computed(() => props.show))
// Browser/mouse/gesture Back closes the modal instead of navigating the
// router out from under it the native-app behaviour kiosk and mobile
// browsers expect (the companion webview already provides it natively).
useModalHistory(computed(() => props.show), close)
</script> </script>
<style scoped> <style scoped>
@@ -71,11 +71,6 @@
I've installed it I've installed it
</button> </button>
</div> </div>
<!-- Which version the Download button installs read from the
metadata staged beside the APK; absent file, absent note -->
<p v-if="companionVersion" class="text-xs text-white/40 text-center mt-3">
Version {{ companionVersion.versionName }}<template v-if="companionVersion.versionCode"> (build {{ companionVersion.versionCode }})</template>
</p>
</div> </div>
<!-- Screen 2: pair the app with this node --> <!-- Screen 2: pair the app with this node -->
@@ -149,30 +144,13 @@ import { rpcClient } from '@/api/rpc-client'
const STORAGE_KEY = 'neode_companion_intro_seen' const STORAGE_KEY = 'neode_companion_intro_seen'
// Absolute URL so the QR works when scanned by a phone (a relative path has no // Absolute URL so the QR works when scanned by a phone (a relative path has no
// host to resolve). Points at the companion APK on the release server's https // host to resolve). Points at the companion APK hosted on the 146 release server
// domain (bare-IP origins retired 2026-08-11; /packages/ is proxied to the // (publicly reachable) rather than the local node's /packages copy.
// same package host that previously answered on the IP).
// The demo serves the APK from its own public origin instead, so the QR never // The demo serves the APK from its own public origin instead, so the QR never
// exposes the release-server address. // exposes the release-server address.
const DEFAULT_DOWNLOAD_URL = IS_DEMO const DEFAULT_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/archipelago-companion.apk` ? `${window.location.origin}/packages/archipelago-companion.apk`
: 'https://source.archipelago-foundation.org/packages/archipelago-companion.apk' : 'http://146.59.87.168:2100/packages/archipelago-companion.apk'
// Version note for the download step. Read from the node's own copy of the
// metadata (ships in the frontend beside the APK at /packages/), written by
// publish-companion-apk.sh from the same gradle config that built the APK.
// Best-effort: no file, no note.
const companionVersion = ref<{ versionName: string; versionCode: number } | null>(null)
async function loadCompanionVersion() {
try {
const res = await fetch('/packages/archipelago-companion.json', { cache: 'no-store' })
if (!res.ok) return
const meta = await res.json()
if (meta && typeof meta.versionName === 'string' && meta.versionName) {
companionVersion.value = { versionName: meta.versionName, versionCode: Number(meta.versionCode) || 0 }
}
} catch { /* metadata is a nicety — the download works without it */ }
}
// Deep-link scheme the companion app registers; carries the server entry the // Deep-link scheme the companion app registers; carries the server entry the
// app should create (see docs/companion-pairing-qr.md for the contract). // app should create (see docs/companion-pairing-qr.md for the contract).
@@ -260,7 +238,6 @@ watch(companionIntroRequested, (requested) => {
watch(visible, async (isVisible) => { watch(visible, async (isVisible) => {
if (!isVisible) return if (!isVisible) return
if (!companionVersion.value) void loadCompanionVersion()
// Generate large and let CSS scale down at 112px source a ~45-module QR // Generate large and let CSS scale down at 112px source a ~45-module QR
// is 2.5px/module, which camera decoders (the companion app included) // is 2.5px/module, which camera decoders (the companion app included)
// routinely fail on. 512px keeps every module crisp. // routinely fail on. 512px keeps every module crisp.
+15 -117
View File
@@ -31,50 +31,19 @@
<!-- On-chain --> <!-- On-chain -->
<div v-if="receiveMethod === 'onchain'"> <div v-if="receiveMethod === 'onchain'">
<!-- Payment detected: the QR did its job show the outcome --> <div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
<div v-if="paymentSeen" class="mb-3 p-6 bg-white/5 rounded-lg text-center"> {{ note }}
<div class="flex justify-center mb-4"> </div>
<div <div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
class="w-16 h-16 rounded-full flex items-center justify-center" <canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
:class="paymentSeen.confirmations > 0 ? 'bg-green-500/15' : 'bg-orange-500/15 animate-pulse'" <p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
> <p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<!-- Check once confirmed, clock while in the mempool --> <CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
<svg v-if="paymentSeen.confirmations > 0" class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> </div>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> <div v-else class="mb-3 text-center">
</svg> <p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<svg v-else class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> <p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">
{{ paymentSeen.confirmations > 0 ? t('receiveBitcoin.paymentConfirmed') : t('receiveBitcoin.paymentBroadcast') }}
</p>
<p v-if="paymentSeen.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ paymentSeen.amountSats.toLocaleString() }} sats
</p>
<p v-if="paymentSeen.confirmations === 0" class="text-sm text-white/50 mb-3 max-w-md mx-auto">
{{ t('receiveBitcoin.paymentBroadcastHint') }}
</p>
<p class="text-xs text-white/50 mb-1">{{ t('receiveBitcoin.transactionId') }}</p>
<p class="text-xs font-mono text-white/80" :title="paymentSeen.txid">{{ midTxid(paymentSeen.txid) }}</p>
<CopyButton :value="paymentSeen.txid" :label="t('common.copy')" class="mt-2" />
</div> </div>
<template v-else>
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</template>
</div> </div>
<!-- Ark --> <!-- Ark -->
@@ -101,11 +70,7 @@
<div v-if="error" class="mb-3 alert-error">{{ error }}</div> <div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<!-- Once the payment is seen there is nothing left to do here --> <div class="flex gap-3">
<div v-if="paymentSeen" class="flex">
<button @click="close" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium">{{ t('common.done') }}</button>
</div>
<div v-else class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button> <button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2"> <button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -121,7 +86,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, watch, onUnmounted } from 'vue' import { ref, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
@@ -142,11 +107,7 @@ const props = defineProps<{
const emit = defineEmits<{ close: []; received: []; scan: [] }>() const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => { watch(() => props.show, (open) => {
if (!open) { if (!open) return
stopWatchingPayment()
return
}
paymentSeen.value = null
// Blank slate on every open: a leftover amount/memo/token or a previous // Blank slate on every open: a leftover amount/memo/token or a previous
// invoice quietly carrying into a new receive flow is exactly the stale- // invoice quietly carrying into a new receive flow is exactly the stale-
// state class the operator flagged on the send modal (2026-08-05). // state class the operator flagged on the send modal (2026-08-05).
@@ -179,65 +140,6 @@ const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false) const processing = ref(false)
const error = ref('') const error = ref('')
// On-chain payment detection
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
// transaction paying it is this receive no baseline bookkeeping needed.
// Poll while the QR is showing; flip to the success view on first sight
// (0-conf, clock), keep polling gently until the first confirmation
// upgrades it to a check, then stop.
const paymentSeen = ref<null | { txid: string; amountSats: number; confirmations: number }>(null)
let watchTimer: ReturnType<typeof setInterval> | null = null
function midTxid(txid: string): string {
return txid.length > 24 ? `${txid.slice(0, 10)}${txid.slice(-10)}` : txid
}
function stopWatchingPayment() {
if (watchTimer) {
clearInterval(watchTimer)
watchTimer = null
}
}
function startWatchingPayment() {
stopWatchingPayment()
watchTimer = setInterval(() => void checkForPayment(), 5000)
}
async function checkForPayment() {
if (!props.show || !onchainAddress.value) {
stopWatchingPayment()
return
}
try {
const res = await rpcClient.call<{
transactions: Array<{
tx_hash: string
amount: number
num_confirmations: number
dest_addresses: string[]
direction: string
}>
}>({ method: 'lnd.gettransactions' })
const hit = (res.transactions || []).find(
(tx) => tx.direction === 'incoming' && (tx.dest_addresses || []).includes(onchainAddress.value),
)
if (!hit) return
const firstSighting = !paymentSeen.value
paymentSeen.value = {
txid: hit.tx_hash,
amountSats: hit.amount,
confirmations: hit.num_confirmations,
}
if (firstSighting) emit('received')
if (hit.num_confirmations > 0) stopWatchingPayment()
} catch {
// Transient poll failure (daemon busy, LND mid-restart) keep watching.
}
}
onUnmounted(stopWatchingPayment)
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') { async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
if (!canvas || !data) return if (!canvas || !data) return
try { try {
@@ -251,8 +153,6 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
} }
function close() { function close() {
stopWatchingPayment()
paymentSeen.value = null
invoiceResult.value = '' invoiceResult.value = ''
onchainAddress.value = '' onchainAddress.value = ''
arkAddress.value = '' arkAddress.value = ''
@@ -284,8 +184,6 @@ async function receive() {
throw new Error('LND did not return a Bitcoin address') throw new Error('LND did not return a Bitcoin address')
} }
onchainAddress.value = res.address onchainAddress.value = res.address
paymentSeen.value = null
startWatchingPayment()
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:')) nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else if (receiveMethod.value === 'ark') { } else if (receiveMethod.value === 'ark') {
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' }) const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
+3 -16
View File
@@ -23,11 +23,11 @@
</button> </button>
</div> </div>
<div v-if="transactions.length === 0" class="flex items-center justify-center py-12"> <div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p> <p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
</div> </div>
<div v-else-if="filteredTransactions.length === 0" class="flex items-center justify-center py-12"> <div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p> <p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div> </div>
@@ -68,7 +68,7 @@
class="text-sm font-medium" class="text-sm font-medium"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'" :class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
> >
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats {{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
</span> </span>
<span <span
v-if="isOnchain(tx)" v-if="isOnchain(tx)"
@@ -91,7 +91,6 @@
</div> </div>
<div class="flex items-center gap-2 mt-0.5"> <div class="flex items-center gap-2 mt-0.5">
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p> <p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span> <span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div> </div>
</div> </div>
@@ -170,18 +169,6 @@ function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain' return !tx.kind || tx.kind === 'onchain'
} }
function feeFor(tx: WalletTransaction): number {
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
}
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
* itself is broken out on its own tag. Incoming rows are untouched. */
function displayAmount(tx: WalletTransaction): number {
const gross = Math.abs(tx.amount_sats)
const fee = feeFor(tx)
return fee > 0 && gross > fee ? gross - fee : gross
}
function kindLabel(tx: WalletTransaction): string { function kindLabel(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Lightning' if (tx.kind === 'lightning') return '⚡ Lightning'
if (tx.kind === 'cashu') return 'Cashu' if (tx.kind === 'cashu') return 'Cashu'
@@ -12,7 +12,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for // live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in // `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation // Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of // in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of
// this plan's scope). This file therefore only covers the Leaflet map's // this plan's scope). This file therefore only covers the Leaflet map's
// activate/deactivate lifecycle — the D3-specific truths from the plan are // activate/deactivate lifecycle — the D3-specific truths from the plan are
// vacuously satisfied (there is nothing to leak). // vacuously satisfied (there is nothing to leak).
@@ -0,0 +1,182 @@
<template>
<div ref="containerRef" class="network-map-container">
<svg ref="svgRef" class="w-full h-full"></svg>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
did: string
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
app_count: number
is_self: boolean
}
interface MapLink {
source: string
target: string
}
const props = defineProps<{
nodes: MapNode[]
links: MapLink[]
}>()
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
type SimNode = MapNode & d3.SimulationNodeDatum
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
case 'observer': return '#fb923c'
case 'untrusted': return '#ef4444'
default: return '#9ca3af'
}
}
function nodeRadius(n: MapNode): number {
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
const container = containerRef.value!
const width = container.clientWidth
const height = container.clientHeight
svg.attr('viewBox', `0 0 ${width} ${height}`)
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
// Center the self-node
const selfNode = simNodes.find(n => n.is_self)
if (selfNode) {
selfNode.fx = width / 2
selfNode.fy = height / 2
}
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
const g = svg.append('g')
// Links
const link = g.append('g')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
})
.attr('stroke-width', 2)
.attr('stroke-dasharray', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
})
// Node groups
const node = g.append('g')
.selectAll<SVGGElement, SimNode>('g')
.data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) simulation!.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation!.alphaTarget(0)
if (!d.is_self) { d.fx = null; d.fy = null }
})
)
// Node circles
node.append('circle')
.attr('r', d => nodeRadius(d))
.attr('fill', d => trustColor(d.trust_level))
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
// Node labels
node.append('text')
.text(d => d.label)
.attr('dy', d => nodeRadius(d) + 14)
.attr('text-anchor', 'middle')
.attr('fill', 'rgba(255,255,255,0.7)')
.attr('font-size', '11px')
.attr('font-family', "'Avenir Next', sans-serif")
// Tooltip
node.append('title')
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimNode).x!)
.attr('y1', d => (d.source as SimNode).y!)
.attr('x2', d => (d.target as SimNode).x!)
.attr('y2', d => (d.target as SimNode).y!)
node.attr('transform', d => `translate(${d.x},${d.y})`)
})
}
onMounted(() => {
render()
resizeObserver = new ResizeObserver(() => render())
if (containerRef.value) resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
simulation?.stop()
resizeObserver?.disconnect()
})
watch(graphSignature, () => render())
</script>
<style scoped>
.network-map-container {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
min-height: 400px;
width: 100%;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,82 +0,0 @@
// Back/forward integration for modals (kiosk, remote browsers, mobile).
//
// Without this, the browser's Back control (mouse side-button on kiosk,
// gesture on mobile, toolbar button in a remote browser) navigates the
// ROUTER while a modal is open — at best closing the whole screen under a
// dialog, at worst leaving the app. The native-app expectation, and what
// the companion webview already provides, is: Back closes the topmost
// dialog first.
//
// Mechanics: opening a modal pushes one history entry (same URL, a depth
// marker in state — router keys are preserved by spreading the existing
// state). A popstate that lands BELOW our depth means the user pressed
// Back over an open modal: close the topmost one. A UI-side close (X,
// backdrop, Esc) consumes its own entry with history.back() so Back never
// needs pressing twice — guarded by the depth marker so it can never eat
// a router entry. One module-level stack serves every BaseModal instance,
// so stacked modals close one per Back, top first.
import { watch, type Ref } from 'vue'
type Entry = { close: () => void }
const stack: Entry[] = []
// Set when a popstate initiated the close: the history entry is already
// gone, so the close-side cleanup must not call history.back() again.
let poppedClose = false
let listening = false
function modalDepth(state: unknown): number {
return (state as { __archyModal?: number } | null)?.__archyModal ?? 0
}
function ensureListener() {
if (listening || typeof window === 'undefined') return
listening = true
window.addEventListener('popstate', (e) => {
// Landed at a depth below the open-modal count → this Back was aimed
// at the topmost modal. One entry per Back press: close exactly one.
// (A popstate at or above our depth is someone else's navigation —
// e.g. our own cleanup back, or a forward — leave it alone.)
if (modalDepth(e.state) < stack.length) {
const top = stack[stack.length - 1]
if (top) {
poppedClose = true
top.close()
}
}
})
}
/** Call from a modal component with its visibility and close trigger. */
export function useModalHistory(show: Ref<boolean>, close: () => void) {
ensureListener()
const entry: Entry = { close }
watch(show, (open, was) => {
if (open === was) return
if (open) {
stack.push(entry)
try {
// Preserve vue-router's own keys in state — clobbering them breaks
// its scroll restoration and position tracking.
window.history.pushState(
{ ...(window.history.state ?? {}), __archyModal: stack.length },
'',
)
} catch { /* history can throw in exotic embeds — modal still works */ }
} else {
const wasTop = stack[stack.length - 1] === entry
const i = stack.indexOf(entry)
if (i >= 0) stack.splice(i, 1)
if (poppedClose) {
poppedClose = false
return
}
// UI-side close of the top modal: consume the entry we pushed, but
// only if it is still the current one (a route change after opening
// moves history past it — backing out then would eat a real entry).
if (wasTop && modalDepth(window.history.state) > stack.length) {
try { window.history.back() } catch { /* same guard as above */ }
}
}
})
}
-6
View File
@@ -443,7 +443,6 @@
"nodeVisibility": "Node Visibility", "nodeVisibility": "Node Visibility",
"nodeVisibilityDesc": "Control how other nodes can discover you", "nodeVisibilityDesc": "Control how other nodes can discover you",
"yourTorAddress": "Your Tor address", "yourTorAddress": "Your Tor address",
"yourNodeNpub": "Your node's npub",
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.", "discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.", "noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
"noRequests": "No pending connection requests.", "noRequests": "No pending connection requests.",
@@ -494,7 +493,6 @@
"failedToUpdatePrice": "Failed to update price", "failedToUpdatePrice": "Failed to update price",
"failedToConnectPeer": "Failed to connect to peer", "failedToConnectPeer": "Failed to connect to peer",
"onionAddressCopied": "Onion address copied", "onionAddressCopied": "Onion address copied",
"npubCopied": "npub copied",
"streamUrlCopied": "Stream URL copied", "streamUrlCopied": "Stream URL copied",
"playerError": "Unable to load media. The content may only be accessible over Tor.", "playerError": "Unable to load media. The content may only be accessible over Tor.",
"connectionAccepted": "Connection accepted", "connectionAccepted": "Connection accepted",
@@ -764,10 +762,6 @@
"memoPlaceholder": "Payment for...", "memoPlaceholder": "Payment for...",
"invoiceShareLabel": "Invoice (share with sender):", "invoiceShareLabel": "Invoice (share with sender):",
"yourBitcoinAddress": "Your Bitcoin address:", "yourBitcoinAddress": "Your Bitcoin address:",
"paymentBroadcast": "Payment on its way",
"paymentBroadcastHint": "The transaction has been broadcast and is waiting for its first confirmation. You can close this — the funds arrive on their own.",
"paymentConfirmed": "Payment confirmed",
"transactionId": "Transaction ID",
"pasteEcashToken": "Paste ecash token", "pasteEcashToken": "Paste ecash token",
"processing": "Processing...", "processing": "Processing...",
"generateAddress": "Generate Address", "generateAddress": "Generate Address",
-6
View File
@@ -441,7 +441,6 @@
"nodeVisibility": "Visibilidad del nodo", "nodeVisibility": "Visibilidad del nodo",
"nodeVisibilityDesc": "Controle c\u00f3mo otros nodos pueden descubrirle", "nodeVisibilityDesc": "Controle c\u00f3mo otros nodos pueden descubrirle",
"yourTorAddress": "Su direcci\u00f3n Tor", "yourTorAddress": "Su direcci\u00f3n Tor",
"yourNodeNpub": "El npub de su nodo",
"discoverableWarning": "Hacer su nodo descubrible permite que otros usuarios de Archipelago le encuentren y se conecten con usted.", "discoverableWarning": "Hacer su nodo descubrible permite que otros usuarios de Archipelago le encuentren y se conecten con usted.",
"noPeers": "A\u00fan no hay pares. Agregue un par manualmente o use Descubrir para encontrar nodos en Nostr.", "noPeers": "A\u00fan no hay pares. Agregue un par manualmente o use Descubrir para encontrar nodos en Nostr.",
"noRequests": "No hay solicitudes de conexi\u00f3n pendientes.", "noRequests": "No hay solicitudes de conexi\u00f3n pendientes.",
@@ -492,7 +491,6 @@
"failedToUpdatePrice": "Error al actualizar precio", "failedToUpdatePrice": "Error al actualizar precio",
"failedToConnectPeer": "Error al conectar con el par", "failedToConnectPeer": "Error al conectar con el par",
"onionAddressCopied": "Direcci\u00f3n onion copiada", "onionAddressCopied": "Direcci\u00f3n onion copiada",
"npubCopied": "npub copiado",
"streamUrlCopied": "URL de transmisi\u00f3n copiada", "streamUrlCopied": "URL de transmisi\u00f3n copiada",
"playerError": "No se pudo cargar el contenido multimedia. Es posible que solo sea accesible a trav\u00e9s de Tor.", "playerError": "No se pudo cargar el contenido multimedia. Es posible que solo sea accesible a trav\u00e9s de Tor.",
"connectionAccepted": "Conexi\u00f3n aceptada", "connectionAccepted": "Conexi\u00f3n aceptada",
@@ -751,10 +749,6 @@
"memoPlaceholder": "Pago por...", "memoPlaceholder": "Pago por...",
"invoiceShareLabel": "Factura (compartir con el remitente):", "invoiceShareLabel": "Factura (compartir con el remitente):",
"yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:", "yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:",
"paymentBroadcast": "Pago en camino",
"paymentBroadcastHint": "La transacci\u00f3n se ha difundido y espera su primera confirmaci\u00f3n. Puede cerrar esta ventana \u2014 los fondos llegar\u00e1n solos.",
"paymentConfirmed": "Pago confirmado",
"transactionId": "ID de transacci\u00f3n",
"pasteEcashToken": "Pegar token Ecash", "pasteEcashToken": "Pegar token Ecash",
"processing": "Procesando...", "processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n", "generateAddress": "Generar direcci\u00f3n",
+5 -17
View File
@@ -3,10 +3,9 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { recordAppLaunch } from '@/utils/appUsage' import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay' import { requestExternalOpen } from '@/api/remote-relay'
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal' import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig' import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
/** /**
@@ -201,17 +200,6 @@ export interface NostrConsentRequest {
reject: () => void reject: () => void
} }
/** App identity (catalog icon + display name) for the companion's native
* branded loader. Undefined when the app isn't in package-data. */
function launchMeta(appId: string): InAppLaunchMeta | undefined {
const pkg = useAppStore().data?.['package-data']?.[appId]
if (!pkg) return undefined
return {
iconUrl: resolveAppIcon(appId, pkg),
name: pkg.manifest?.title || appId,
}
}
export const useAppLauncherStore = defineStore('appLauncher', () => { export const useAppLauncherStore = defineStore('appLauncher', () => {
const isOpen = ref(false) const isOpen = ref(false)
const url = ref('') const url = ref('')
@@ -237,7 +225,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl) const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) { if (launchUrl) {
openInAppOrNewTab(launchUrl, launchMeta(appId)) openInAppOrNewTab(launchUrl)
return return
} }
} }
@@ -247,7 +235,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
if (IS_DEMO && isDemoExternal(appId)) { if (IS_DEMO && isDemoExternal(appId)) {
const ext = demoAppUrl(appId) const ext = demoAppUrl(appId)
if (ext) { if (ext) {
if (mobile) openInAppOrNewTab(ext, launchMeta(appId)) if (mobile) openInAppOrNewTab(ext)
else openExternal(ext) else openExternal(ext)
return return
} }
@@ -260,7 +248,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) { if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
const launchUrl = directAppUrl(appId) const launchUrl = directAppUrl(appId)
if (launchUrl) { if (launchUrl) {
if (mobile) openInAppOrNewTab(launchUrl, launchMeta(appId)) if (mobile) openInAppOrNewTab(launchUrl)
else openExternal(launchUrl) else openExternal(launchUrl)
return return
} }
@@ -339,7 +327,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Companion app: never fall through to the iframe overlay — hand the URL // Companion app: never fall through to the iframe overlay — hand the URL
// to the native in-app WebView instead (see openSession). // to the native in-app WebView instead (see openSession).
if (!IS_DEMO && isCompanionApp()) { if (!IS_DEMO && isCompanionApp()) {
openInAppOrNewTab(launchUrl, resolvedId ? launchMeta(resolvedId) : undefined) openInAppOrNewTab(launchUrl)
return return
} }
-75
View File
@@ -1128,16 +1128,6 @@ html.controller-nav [data-controller-container]:focus {
z-index: 0; z-index: 0;
} }
/* KAMMERGUT GLOSS TEST (2026-08-12) black gloss paint on the badge's
inner circle, lifted from plan-b's Kammergut wordmark (.paint-3d
gradient + #paintGloss filter, defs injected in App.vue). Revert:
delete this block + the svg defs block in App.vue (one commit). */
.logo-gradient-border::after {
background: linear-gradient(180deg, #2a2a26 0%, #1a1a18 45%, #060604 100%);
filter: url(#paintGloss);
-webkit-filter: url(#paintGloss);
}
.logo-gradient-border img, .logo-gradient-border img,
.logo-gradient-border svg { .logo-gradient-border svg {
border-radius: 9999px; border-radius: 9999px;
@@ -1476,24 +1466,6 @@ html.kiosk-safe-area #app {
overflow: hidden; overflow: hidden;
} }
/* Horizontal filter-pill rail: single row, swipes sideways on narrow
screens, no visible scrollbar pills never wrap or squish. */
.pill-rail {
display: flex;
gap: 0.375rem;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.pill-rail::-webkit-scrollbar {
display: none;
}
.pill-rail > * {
flex: 0 0 auto;
white-space: nowrap;
}
/* Custom scrollbar for glass containers */ /* Custom scrollbar for glass containers */
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 10px; width: 10px;
@@ -3177,50 +3149,3 @@ select {
select::-ms-expand { select::-ms-expand {
display: none; display: none;
} }
/* =========================================================================
Federation 3D node map fill-to-bottom layout
When the map stage is on screen, the dashboard scroll panel switches from
a scrolling document to a column that hands all remaining height to the
map, killing the big bottom margin on every form factor. List view (no
.node-map-stage in the DOM) is untouched, and browsers without :has()
gracefully fall back to the old scrolling behaviour via the stage's
min-height.
========================================================================= */
.dashboard-scroll-panel:has(.node-map-stage) {
display: flex;
flex-direction: column;
/* Desktop: trim the 6rem .mobile-scroll-pad breathing room to a slim edge */
padding-bottom: 1rem;
}
/* The routed view stretches; DashboardRouterView tags it .view-container
(with Tailwind's flex-none, which this outranks on specificity). */
.dashboard-scroll-panel:has(.node-map-stage) > .view-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* The wrapper's bottom scroll spacer is dead weight in a filled column */
.dashboard-scroll-panel:has(.node-map-stage) > div[aria-hidden="true"] {
display: none;
}
/* Mobile/tablet: fill down to the tab bar (+ audio player / safe area),
not under it the bar is viewport-fixed and would cover the map. */
@media (max-width: 920px) {
.dashboard-scroll-panel:has(.node-map-stage) {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
}
}
/* Pages with the floating mobile back button (.mobile-scroll-pad-back) keep
its full clearance under the filled map so the stage never slides beneath
the button. */
@media (max-width: 920px) {
.dashboard-scroll-panel.mobile-scroll-pad-back:has(.node-map-stage) {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
}
}
-71
View File
@@ -1,71 +0,0 @@
/**
* Design-system-aware GSAP setup the single place animation code pulls
* timing, easing, and colour tokens from, so every GSAP-driven surface moves
* (and is coloured) like the rest of the glass UI instead of inventing its
* own physics per component.
*
* Usage: `import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'`
* never `import gsap from 'gsap'` directly, or the shared defaults are lost.
*/
import { gsap } from 'gsap'
/** Colour tokens mirrored from style.css / tailwind.config.js. The UI is
* dark-only (style.css pins `color-scheme: dark`), so these are constants,
* not theme-dependent lookups. */
export const motionTokens = {
color: {
/** Brand accent the orange used for focus glows and highlights
* (tailwind orange-400, e.g. `.glass-button:focus-visible`). */
accent: '#fb923c',
/** Trust-level palette — matches NodeList / trust badges. */
trusted: '#4ade80',
observer: '#fb923c',
untrusted: '#ef4444',
neutral: '#9ca3af',
/** Pending/attention — inbound peer requests awaiting a decision. */
pending: '#facc15',
/** Text/line opacities on the dark glass ground. */
textPrimary: 'rgba(255, 255, 255, 0.95)',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textFaint: 'rgba(255, 255, 255, 0.45)',
line: 'rgba(255, 255, 255, 0.18)',
lineFaint: 'rgba(255, 255, 255, 0.08)',
glassDark: 'rgba(0, 0, 0, 0.35)',
glassDarker: 'rgba(0, 0, 0, 0.6)',
},
/** Durations (seconds) align with the CSS transitions already shipped
* (modal 0.3s, press feedback 0.1s). */
duration: {
fast: 0.18,
base: 0.3,
slow: 0.6,
/** Scene-setting intros (map fly-in, hero moments). */
cinematic: 1.4,
},
ease: {
/** Default UI ease — matches the snappy glass feel. */
out: 'power3.out',
inOut: 'power2.inOut',
/** Playful overshoot for elements "arriving" (node pop-ins). */
arrive: 'back.out(1.6)',
/** Springy attention pulse. */
pulse: 'sine.inOut',
},
} as const
// Shared defaults: any tween that doesn't say otherwise moves like the rest
// of the design system.
gsap.defaults({
ease: motionTokens.ease.out,
duration: motionTokens.duration.base,
})
/** Live reduced-motion check. Query at animation-build time (not module
* scope) so OS-level toggles apply without a reload. Callers should skip
* intros / idle loops and jump to end state when this is true. */
export function prefersReducedMotion(): boolean {
return typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
}
export { gsap }
+1 -16
View File
@@ -12,15 +12,6 @@
interface ArchipelagoNativeBridge { interface ArchipelagoNativeBridge {
openExternal?: (url: string) => void openExternal?: (url: string) => void
openInApp?: (url: string) => void openInApp?: (url: string) => void
/** Richer launch (companion 0.5.26): catalog icon + display name drive the
* native branded loader instead of the site favicon. */
openInAppEx?: (url: string, iconUrl: string, name: string) => void
}
/** Optional app identity for the native loading screen. */
export interface InAppLaunchMeta {
iconUrl?: string
name?: string
} }
function nativeBridge(): ArchipelagoNativeBridge | undefined { function nativeBridge(): ArchipelagoNativeBridge | undefined {
@@ -55,15 +46,9 @@ export function openExternalUrl(url: string): void {
* inside Archipelago with the native back/forward/reload/close controls. * inside Archipelago with the native back/forward/reload/close controls.
* - Plain mobile browser (PWA): open directly in a new browser tab. * - Plain mobile browser (PWA): open directly in a new browser tab.
*/ */
export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void { export function openInAppOrNewTab(url: string): void {
if (!url) return if (!url) return
const native = nativeBridge() const native = nativeBridge()
if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) {
// Absolutize the icon path so the native shell can fetch it directly.
const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : ''
native.openInAppEx(url, icon, meta.name ?? '')
return
}
if (native && typeof native.openInApp === 'function') { if (native && typeof native.openInApp === 'function') {
native.openInApp(url) native.openInApp(url)
return return
+7 -55
View File
@@ -1,8 +1,5 @@
<template> <template>
<!-- Map view: no pb-6 the .dashboard-scroll-panel:has(.node-map-stage) <div class="pb-6">
rules turn this view into a column that hands remaining height to the
map, so bottom padding would just re-create the dead margin. -->
<div :class="mapActive ? undefined : 'pb-6'">
<FederationHeader <FederationHeader
:self-did="selfDid" :self-did="selfDid"
:server-name="appStore.serverName" :server-name="appStore.serverName"
@@ -19,9 +16,7 @@
/> />
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) --> <!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
<!-- md:self-start: in map view the root is a flex column, and stretch <div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
alignment would otherwise pull the pill full-width on desktop -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto md:self-start">
<button <button
v-for="tab in viewTabs" v-for="tab in viewTabs"
:key="tab.id" :key="tab.id"
@@ -35,25 +30,9 @@
</button> </button>
</div> </div>
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where <!-- Network Map View -->
vertical space belongs to the map (desktop keeps the header card) --> <div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
<DidCardMobile <NetworkMap :nodes="mapNodes" :links="mapLinks" />
v-if="!mapActive"
:self-did="selfDid"
:server-name="appStore.serverName"
@rotate="showRotateModal = true"
/>
<!-- Network Map View fills all remaining height to the bottom edge -->
<div v-if="mapActive" class="flex-1 min-h-0">
<NetworkMap3D
:nodes="mapNodes"
:links="mapLinks"
:requests="mapRequests"
@select="onMapSelect"
@approve="approvePending"
@reject="rejectPending"
/>
</div> </div>
<template v-if="activeView === 'list'"> <template v-if="activeView === 'list'">
@@ -264,9 +243,8 @@ import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport' import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync' import { useSyncStore } from '@/stores/sync'
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue' import NetworkMap from '@/components/federation/NetworkMap.vue'
import FederationHeader from './federation/FederationHeader.vue' import FederationHeader from './federation/FederationHeader.vue'
import DidCardMobile from './federation/DidCardMobile.vue'
import RotateDidModal from './federation/RotateDidModal.vue' import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.vue' import QuickActions from './federation/QuickActions.vue'
import NodeList from './federation/NodeList.vue' import NodeList from './federation/NodeList.vue'
@@ -330,22 +308,7 @@ function setView(id: ViewId) {
localStorage.setItem('federation-view', id) localStorage.setItem('federation-view', id)
} }
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0) const selfDid = ref('')
/** Map click-through: tapping a peer opens the same detail modal as the list
* view. Tapping the self node is a no-op (its actions live in the header). */
function onMapSelect(did: string) {
const node = nodes.value.find(n => n.did === did)
if (node) selectedNode.value = node
}
/** Seeded from the cached DID so the map's centre node (and its links) exist
* on the very first frame; the authoritative fetch in onMounted refreshes it
* and re-caches. Without this the intro raced the RPC and often played with
* no centre. */
const selfDid = ref<string>((() => {
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
})())
const mapNodes = computed(() => { const mapNodes = computed(() => {
const result = [] const result = []
@@ -380,16 +343,6 @@ const mapLinks = computed(() => {
})) }))
}) })
/** Inbound pending requests for the map blinking yellow nodes the user can
* accept/reject in place (same RPCs as the pending panel). */
const mapRequests = computed(() => pendingRequests.value
.filter(r => !r.outbound && r.state === 'pending')
.map(r => ({
id: r.id,
label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}`,
message: r.message,
})))
const dwnStatusRes = useCachedResource<DwnStatus>({ const dwnStatusRes = useCachedResource<DwnStatus>({
key: 'federation.dwn-status', key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }), fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
@@ -825,7 +778,6 @@ onMounted(async () => {
try { try {
const result = await rpcClient.getNodeDid() const result = await rpcClient.getNodeDid()
selfDid.value = result.did selfDid.value = result.did
try { localStorage.setItem('neode_did', result.did) } catch { /* private mode */ }
} catch { } catch {
// Self DID not available // Self DID not available
} }
+3 -13
View File
@@ -259,11 +259,9 @@ async function generateSeed() {
loading.value = false loading.value = false
waitingForServer.value = false waitingForServer.value = false
} catch (err) { } catch (err) {
loading.value = false
if (isServerStartingError(err)) { if (isServerStartingError(err)) {
// Backend not ready yet keep waiting, retry silently. `loading` stays // Backend not ready yet keep waiting, retry silently.
// true through the whole retry loop: dropping it here unmounts the lock
// icon and status text for the 4s between attempts, which reads as the
// screen flashing in and out (reported on a live install test).
if (!waitingForServer.value) { if (!waitingForServer.value) {
waitingForServer.value = true waitingForServer.value = true
startElapsedTimer() startElapsedTimer()
@@ -272,16 +270,8 @@ async function generateSeed() {
} else { } else {
// Genuine failure stop the silent loop and surface it with a manual retry. // Genuine failure stop the silent loop and surface it with a manual retry.
stopTimers() stopTimers()
loading.value = false
waitingForServer.value = false waitingForServer.value = false
const raw = err instanceof Error ? err.message : 'Failed to generate seed' errorMessage.value = err instanceof Error ? err.message : 'Failed to generate seed'
// The backend's provisioned-guard refusal is precise but written for
// developers ("authenticated system.factory-reset"). Operators hit it
// when a node that already has an identity lands on this screen
// translate it into what they can actually do about it.
errorMessage.value = raw.startsWith('Not supported: this node is already provisioned')
? 'This node already has an identity, so a new seed cannot be created. Sign in normally — or to start this node over, run a factory reset from Settings first.'
: raw
} }
} }
} }
+1 -12
View File
@@ -1087,18 +1087,7 @@ async function applyUpdate() {
} }
return return
} }
// Surface the backend's actual, actionable message when it gave one showStatus(t('systemUpdate.applyFailed'), true)
// ("Update download was incomplete download again", etc.) instead of a
// generic dead end. The staged files are preserved across a failed apply,
// so re-download stays available and a retry is always possible.
const detail = errorMessage(e)
const actionable = /^Update\b/.test(detail)
showStatus(actionable ? detail : t('systemUpdate.applyFailed'), true)
// A staging inconsistency means the download is the thing to redo drop
// the downloaded flag so the button offers Download again, not Apply.
if (/incomplete|not staged|re-download|inconsistent/i.test(detail)) {
downloaded.value = false
}
if (import.meta.env.DEV) console.warn('Apply failed', e) if (import.meta.env.DEV) console.warn('Apply failed', e)
applying.value = false applying.value = false
} }
@@ -6,7 +6,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for // live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in // `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree; the only D3 force simulation belongs to // Mesh.vue's component tree; the only D3 force simulation belongs to
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This // NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
// file therefore only covers the six cached fetch groups (Task 1) and the // file therefore only covers the six cached fetch groups (Task 1) and the
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the // Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
// D3-specific truths are vacuously satisfied (there is nothing to leak). // D3-specific truths are vacuously satisfied (there is nothing to leak).
@@ -1,39 +0,0 @@
<template>
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
Federation.vue (not in the header) and is hidden by the parent on the
Network Map tab, where vertical space belongs to the map. -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'
const props = defineProps<{
selfDid: string
serverName: string
}>()
defineEmits<{
rotate: []
}>()
const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid))
function handleCopy() {
if (props.selfDid) {
safeClipboardWrite(props.selfDid)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
}
</script>
@@ -18,8 +18,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by <!-- Mobile: DID below title -->
Federation.vue below the view tabs (hidden on the map tab). --> <div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</div> </div>
</template> </template>
@@ -362,64 +362,6 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.1-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.1-alpha</span>
<span class="text-xs text-white/40">August 13, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Apps that refused to open inside the dashboard now embed like everything else.** Some apps ship browser headers that forbid being shown inside another page correct hardening on the open web, but inside Archipelago it produced a dead grey pane when you opened them from My Apps (Alby Hub was the first to hit it). The app gate, which already checks your login on every request to an app, now removes just those framing headers on the way through; each app's own content-security rules pass through untouched. No more per-app proxy workarounds.</p>
<p>**The network map no longer freezes kiosk TVs.** The animated federation map at 4K was too much for the deliberately conservative graphics settings the on-screen display used on every machine settings chosen years back to stop audio crackle on much older hardware. Two fixes: on kiosk screens the map now opens in its flat 2D view (the 3D globe is one tap away, and remembered) and animates at half rate invisible from the couch, half the work. And the display itself now recognizes what machine it runs on: older kiosk boxes keep the proven careful settings, modern ones finally get real GPU rendering.</p>
<p>**New Settings Display Graphics choice for the on-screen display.** Auto (recommended) picks the right rendering mode for the machine by itself; Compatibility forces the most conservative mode if a screen ever stutters, tears, or crackles; Quality forces full GPU rendering on hardware the automatic detection doesn't recognize. Changing it restarts the on-screen display, like the size presets.</p>
</div>
</div>
<!-- v1.8.0-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.0-alpha</span>
<span class="text-xs text-white/40">August 12, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Archipelago is now open source.** The full source code of the node you are running the orchestrator, the dashboard, the app platform, the mesh, the release tooling is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.</p>
<p>**Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".</p>
<p>**Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion IndeedHub films especially showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware smoother playback that also leaves more headroom for audio, not less.</p>
<p>**The Back button finally does what you expect.** Pressing Back the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.</p>
<p>**No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice once by its proper name, once as a raw http://146 address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin which was always the same machine.</p>
<p>**The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.</p>
<p>**The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.</p>
</div>
</div>
<!-- v1.7.129-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.129-alpha</span>
<span class="text-xs text-white/40">August 10, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Every app is now supervised the same way the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.</p>
<p>**Leftover companion screens are cleaned up again driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" and a helper must be orphaned for a sustained period before it is touched.</p>
<p>**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.</p>
<p>**The federation map became a real map.** The network view is now a 3D orbital scene of your federation nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro the scene updates in place.</p>
<p>**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).</p>
</div>
</div>
<!-- v1.7.128-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.128-alpha</span>
<span class="text-xs text-white/40">August 10, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays nothing holds its key anymore so the "Discoverable nodes" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off or factory-resetting the node actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.</p>
<p>**You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name it travels inside the public announcement, so other nodes' discovery lists show "Dorian's basement node" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.</p>
<p>**The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.</p>
<p>**The seed screen stops flashing while the node starts.** During first boot, the lock icon and "server starting" text blinked in and out every few seconds while the node came up each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.</p>
<p>**A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about "the authenticated system.factory-reset". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node.</p>
</div>
</div>
<!-- v1.7.127-alpha --> <!-- v1.7.127-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
@@ -6,7 +6,6 @@ import { rpcClient } from '@/api/rpc-client'
// have a kiosk display (has_kiosk from the backend). // have a kiosk display (has_kiosk from the backend).
const hasKiosk = ref(false) const hasKiosk = ref(false)
const preset = ref('auto') const preset = ref('auto')
const graphics = ref('auto')
const applying = ref(false) const applying = ref(false)
const error = ref('') const error = ref('')
@@ -33,35 +32,11 @@ const presets = [
}, },
] ]
// Graphics tier: which browser rendering flags the on-screen display runs
// with. Auto classifies the hardware (older kiosk boxes keep the proven
// conservative flags; modern chips get GPU rendering); the two overrides
// exist for troubleshooting and unclassified hardware.
const graphicsModes = [
{
id: 'auto',
label: 'Auto (recommended)',
description: 'Detect this machines graphics hardware and pick the right rendering mode for it.',
},
{
id: 'performance',
label: 'Compatibility',
description: 'Most conservative rendering — use if the screen stutters, tears, or the audio crackles.',
},
{
id: 'quality',
label: 'Quality',
description: 'Full GPU rendering — smoothest animations on capable hardware. If unsure, use Auto.',
},
]
onMounted(async () => { onMounted(async () => {
try { try {
const res = await rpcClient.call<{ has_kiosk: boolean; preset: string; graphics?: string }>({ method: 'system.kiosk-display.get' }) const res = await rpcClient.call<{ has_kiosk: boolean; preset: string }>({ method: 'system.kiosk-display.get' })
hasKiosk.value = res.has_kiosk hasKiosk.value = res.has_kiosk
preset.value = res.preset preset.value = res.preset
// Older backend without the graphics field: hide nothing, default Auto.
graphics.value = res.graphics ?? 'auto'
} catch { /* backend without the RPC — leave the section hidden */ } } catch { /* backend without the RPC — leave the section hidden */ }
}) })
@@ -80,22 +55,6 @@ async function apply(id: string) {
applying.value = false applying.value = false
} }
} }
async function applyGraphics(id: string) {
if (applying.value || id === graphics.value) return
applying.value = true
error.value = ''
const prev = graphics.value
graphics.value = id
try {
await rpcClient.call({ method: 'system.kiosk-display.set', params: { graphics: id }, timeout: 20000 })
} catch (e: unknown) {
graphics.value = prev
error.value = e instanceof Error ? e.message : 'Failed to apply graphics setting'
} finally {
applying.value = false
}
}
</script> </script>
<template> <template>
@@ -118,25 +77,6 @@ async function applyGraphics(id: string) {
<p class="text-sm text-white/60">{{ p.description }}</p> <p class="text-sm text-white/60">{{ p.description }}</p>
</button> </button>
</div> </div>
<h3 class="text-lg font-semibold text-white/96 mt-8 mb-2">Graphics</h3>
<p class="text-sm text-white/60 mb-6">
How the on-screen display uses this machine&rsquo;s graphics hardware. Changing this restarts the on-screen display.
</p>
<div data-controller-container tabindex="0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
v-for="g in graphicsModes"
:key="g.id"
:disabled="applying"
@click="applyGraphics(g.id)"
class="path-option-card text-left p-5 disabled:opacity-60"
:class="{ 'path-option-card--selected': graphics === g.id }"
>
<div class="font-medium text-white/90 mb-1">{{ g.label }}</div>
<p class="text-sm text-white/60">{{ g.description }}</p>
</button>
</div>
<div v-if="error" class="mt-4 alert-error text-sm">{{ error }}</div> <div v-if="error" class="mt-4 alert-error text-sm">{{ error }}</div>
</div> </div>
</template> </template>
+15 -91
View File
@@ -49,18 +49,14 @@
/> />
</div> </div>
<!-- The node's published npub (shown when discoverable) this, not the <!-- Onion address (shown when public) -->
onion, is what the presence event actually posts to the relays --> <div v-if="discoverEnabled && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
<div v-if="discoverEnabled && nodeNpub" class="mt-4 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourNodeNpub') }}</p> <p class="text-xs text-white/50 mb-1">{{ t('web5.yourTorAddress') }}</p>
<p v-if="nodeName" class="text-sm text-white/90 truncate mb-0.5">{{ nodeName }}</p> <p class="text-xs font-mono text-white/80 truncate" :title="nodeOnionAddress">{{ nodeOnionAddress }}</p>
<!-- Middle-ellipsis, never CSS truncate: the tail is the part a
human compares against another listing, so it must stay visible -->
<p class="text-xs font-mono text-white/80 truncate" :title="nodeNpub">{{ midNpub(nodeNpub) }}</p>
</div> </div>
<button @click="copyNpub" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy"> <button @click="copyOnionAddress" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg> </svg>
@@ -86,8 +82,7 @@
class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3" class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3"
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm text-white truncate">{{ node.name || shortNpub(node.nostr_npub) }}</div> <div class="text-sm text-white truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div v-if="node.name" class="text-[11px] text-white/50 font-mono truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div> <div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div> <div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div> </div>
@@ -119,32 +114,6 @@
@send="confirmPeerRequest" @send="confirmPeerRequest"
@cancel="requestModalTarget = null" @cancel="requestModalTarget = null"
/> />
<!-- Name prompt on the way to discoverable: the announcement is public,
so the name travels with it. Blank is fine npub-only listing. -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showNameModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="cancelNameModal">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-2">Name your node</h3>
<p class="text-sm text-white/60 mb-4">Other nodes will see this name next to your npub in their discovery list. It's public. Leave blank to list as npub only.</p>
<input
v-model="nameInput"
type="text"
maxlength="32"
placeholder="e.g. Dorian's basement node"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
@keyup.enter="confirmNameModal"
/>
<div class="flex gap-3">
<button @click="cancelNameModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="confirmNameModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">Turn on discovery</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div> </div>
</template> </template>
@@ -168,22 +137,16 @@ const emit = defineEmits<{
}>() }>()
const nodeVisibility = ref<VisibilityLevel>('hidden') const nodeVisibility = ref<VisibilityLevel>('hidden')
const nodeNpub = ref<string | null>(null) const nodeOnionAddress = ref<string | null>(null)
const nodeName = ref<string | null>(null)
const visibilityLoading = ref(false) const visibilityLoading = ref(false)
const settingVisibility = ref(false) const settingVisibility = ref(false)
const discoverEnabled = ref(false) const discoverEnabled = ref(false)
// Name-prompt state: turning discovery ON routes through a small dialog so
// the operator can (optionally) name the node before it announces itself.
const showNameModal = ref(false)
const nameInput = ref('')
interface DiscoverableNode { interface DiscoverableNode {
nostr_pubkey: string nostr_pubkey: string
nostr_npub: string nostr_npub: string
did: string did: string
version: string version: string
name?: string | null
} }
const discoveredNodes = ref<DiscoverableNode[]>([]) const discoveredNodes = ref<DiscoverableNode[]>([])
@@ -191,12 +154,6 @@ const discovering = ref(false)
const requestingPeer = ref<string | null>(null) const requestingPeer = ref<string | null>(null)
const requestedPeers = ref(new Set<string>()) const requestedPeers = ref(new Set<string>())
/** Own-npub display: keep the start and the FULL tail visible, ellipsis in
* the middle. (shortNpub below stays as-is it formats the discovered list.) */
function midNpub(npub: string): string {
return npub.length > 24 ? `${npub.slice(0, 12)}${npub.slice(-10)}` : npub
}
function shortNpub(npub: string): string { function shortNpub(npub: string): string {
if (!npub) return 'unknown' if (!npub) return 'unknown'
return npub.length > 21 ? `${npub.slice(0, 12)}${npub.slice(-6)}` : npub return npub.length > 21 ? `${npub.slice(0, 12)}${npub.slice(-6)}` : npub
@@ -216,9 +173,8 @@ async function loadVisibility() {
.catch(() => null), .catch(() => null),
]) ])
discoverEnabled.value = !!disc.enabled discoverEnabled.value = !!disc.enabled
nodeNpub.value = disc.npub || null
nodeName.value = disc.name || null
nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden' nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden'
nodeOnionAddress.value = vis?.onion_address || vis?.tor_address || null
if (discoverEnabled.value) void discoverNodes() if (discoverEnabled.value) void discoverNodes()
} catch { } catch {
discoverEnabled.value = false discoverEnabled.value = false
@@ -229,33 +185,10 @@ async function loadVisibility() {
async function toggleDiscoverable(enabled: boolean) { async function toggleDiscoverable(enabled: boolean) {
if (settingVisibility.value) return if (settingVisibility.value) return
if (enabled) {
// Turning ON goes through the name dialog: the node is about to announce
// itself publicly, and this is the natural moment to (optionally) name it.
nameInput.value = nodeName.value || ''
showNameModal.value = true
return
}
await applyDiscovery(false)
}
function cancelNameModal() {
showNameModal.value = false
// The switch never actually flipped server-side; snap the UI back.
discoverEnabled.value = false
}
async function confirmNameModal() {
showNameModal.value = false
// Send exactly what's in the box: text sets the name, blank clears it.
await applyDiscovery(true, nameInput.value.trim())
}
async function applyDiscovery(enabled: boolean, name?: string) {
settingVisibility.value = true settingVisibility.value = true
try { try {
// Public means public: the switch drives nostr presence publishing. // Public means public: the switch drives nostr presence publishing.
const res = await rpcClient.nostrSetDiscovery(enabled, name) const res = await rpcClient.nostrSetDiscovery(enabled)
discoverEnabled.value = !!res.enabled discoverEnabled.value = !!res.enabled
// Keep the legacy visibility string in sync (cosmetic; best-effort). // Keep the legacy visibility string in sync (cosmetic; best-effort).
const level: VisibilityLevel = enabled ? 'public' : 'hidden' const level: VisibilityLevel = enabled ? 'public' : 'hidden'
@@ -264,17 +197,8 @@ async function applyDiscovery(enabled: boolean, name?: string) {
.then(() => { nodeVisibility.value = level }) .then(() => { nodeVisibility.value = level })
.catch(() => {}) .catch(() => {})
emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery') emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery')
if (enabled) { if (enabled) void discoverNodes()
if (name !== undefined) nodeName.value = name || null else discoveredNodes.value = []
// Re-read status so the npub/name shown reflect post-enable state
// without a page reload.
rpcClient.nostrDiscoveryStatus()
.then((s) => { nodeNpub.value = s.npub || null; nodeName.value = s.name || null })
.catch(() => {})
void discoverNodes()
} else {
discoveredNodes.value = []
}
} catch { } catch {
emit('toast', t('web5.failedToUpdateVisibility')) emit('toast', t('web5.failedToUpdateVisibility'))
} finally { } finally {
@@ -321,10 +245,10 @@ async function requestToPeer(node: DiscoverableNode, message?: string) {
} }
} }
function copyNpub() { function copyOnionAddress() {
if (!nodeNpub.value) return if (!nodeOnionAddress.value) return
safeClipboardWrite(nodeNpub.value) safeClipboardWrite(nodeOnionAddress.value)
emit('toast', t('web5.npubCopied')) emit('toast', t('web5.onionAddressCopied'))
} }
defineExpose({ loadVisibility }) defineExpose({ loadVisibility })
+23 -21
View File
@@ -1,33 +1,35 @@
{ {
"changelog": [ "changelog": [
"**Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.", "**Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.",
"**Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on \"Install\", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do (\"download the update again\"), and offers Download again instead of a dead \"Install\" button, rather than a generic \"it failed\".", "**Tor now tells you the truth, heals itself, and the Restart button really restarts it.** Three nodes ran for days with Tor completely dead while the dashboard said \"Connected\" — the indicator was reading a leftover address file, not the daemon, and the restart button reported success without checking. The cause was a configuration line Tor can never bind on our systems; a node could re-break itself from a single settings change. The node now refuses to write that line, checks Tor with a real connection instead of a leftover file, repairs its own Tor configuration at every start, and the Restart button only claims success once Tor is actually answering. Onion addresses that had silently never been published (BTCPay's included) come back with it.",
"**Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.", "**Inviting another node as Trusted works again — on every node.** Generating a Trusted invite, or promoting a peer from the dropdown, silently failed everywhere: the security prompt that asks for your node password could never appear, because the message requesting it was being scrubbed out of the reply on its way to your browser. The prompt now opens, and if a trust change fails, the error appears inside the window you are looking at instead of hidden behind it.",
"**The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.", "**The mempool explorer actually connects now.** The page loaded but sat empty forever. Three separate causes stacked up: the block index had spent days rebuilding without anything saying so, and then two different layers of the node's plumbing were dropping the live-data connection the page depends on — so everything reported healthy while your screen showed nothing. All three are fixed, and the node's own health checks now test the real connection a browser makes, so this cannot pass unnoticed again.",
"**No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.", "**Apps no longer vanish after stopping cleanly.** A stopped app's container is deleted by design, but the restart policy meant an app that exited cleanly was never brought back — it simply disappeared until reinstalled. Backends now restart in every case, the node remembers what you have installed so a missing app is recreated rather than forgotten, and this release repairs the incorrect policy on apps installed by earlier versions.",
"**The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.", "**Your Bitcoin node will not silently change software versions anymore.** \"Latest\" previously meant different things in different places — one path installed a newer build that deliberately halts until you make a network-rules decision, which froze one node's sync at a fixed block while it reported itself fully synced. Bitcoin Knots is now pinned to an explicit, known-good version; changing it is a decision you make, never a side effect of an update.",
"**The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast." "**Smaller fixes:** the AI data-access settings now say plainly which categories the assistant can see but not act on; the transactions window's tab bar is transparent glass instead of a black block; BTCPay logins no longer fail with a server error when the node is under heavy load right at that moment.",
"**You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again.",
"**Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.0-alpha", "current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.0-alpha", "new_version": "1.7.127-alpha",
"sha256": "fd99219ea11ffebc92b83154e1058911ebe72c357c80085310c78aba9714a6b5", "sha256": "19c5f4573e49ba5a1339a358f5d422da4c3dbf68fba3391a62588049a52da207",
"size_bytes": 59369392 "size_bytes": 59282264
}, },
{ {
"current_version": "1.8.0-alpha", "current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago-frontend-1.8.0-alpha.tar.gz", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago-frontend-1.7.127-alpha.tar.gz",
"name": "archipelago-frontend-1.8.0-alpha.tar.gz", "name": "archipelago-frontend-1.7.127-alpha.tar.gz",
"new_version": "1.8.0-alpha", "new_version": "1.7.127-alpha",
"sha256": "f6bbf7b3f78a00dd7051abef805c943cfa9a58f624e09e94b6bee6c9b2f22902", "sha256": "bedd662105e53ce800caa610cc099a47d7f0786af5fec601169a8906af760244",
"size_bytes": 97608790 "size_bytes": 95433702
} }
], ],
"release_date": "2026-08-12", "release_date": "2026-08-09",
"signature": "be3e3c6f8ed6b8d573a6329bf546ec1aa739214cd391272ce3b2f0a0ef6b10a4a4c4f9dfa4b9b32dad88326c9feff908fbdb3d1b86d055e5cf876f67f195c60b", "signature": "dc418fc08b2b0e288ab0f4b307562d966774d8b5ee73789229d6bef627f61013510054a89d8e314676464a55bcb631e1d1a63e0de394b2d152abd42c90f4120f",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.0-alpha" "version": "1.7.127-alpha"
} }
+23 -21
View File
@@ -1,33 +1,35 @@
{ {
"changelog": [ "changelog": [
"**Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.", "**Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.",
"**Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on \"Install\", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do (\"download the update again\"), and offers Download again instead of a dead \"Install\" button, rather than a generic \"it failed\".", "**Tor now tells you the truth, heals itself, and the Restart button really restarts it.** Three nodes ran for days with Tor completely dead while the dashboard said \"Connected\" — the indicator was reading a leftover address file, not the daemon, and the restart button reported success without checking. The cause was a configuration line Tor can never bind on our systems; a node could re-break itself from a single settings change. The node now refuses to write that line, checks Tor with a real connection instead of a leftover file, repairs its own Tor configuration at every start, and the Restart button only claims success once Tor is actually answering. Onion addresses that had silently never been published (BTCPay's included) come back with it.",
"**Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.", "**Inviting another node as Trusted works again — on every node.** Generating a Trusted invite, or promoting a peer from the dropdown, silently failed everywhere: the security prompt that asks for your node password could never appear, because the message requesting it was being scrubbed out of the reply on its way to your browser. The prompt now opens, and if a trust change fails, the error appears inside the window you are looking at instead of hidden behind it.",
"**The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.", "**The mempool explorer actually connects now.** The page loaded but sat empty forever. Three separate causes stacked up: the block index had spent days rebuilding without anything saying so, and then two different layers of the node's plumbing were dropping the live-data connection the page depends on — so everything reported healthy while your screen showed nothing. All three are fixed, and the node's own health checks now test the real connection a browser makes, so this cannot pass unnoticed again.",
"**No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.", "**Apps no longer vanish after stopping cleanly.** A stopped app's container is deleted by design, but the restart policy meant an app that exited cleanly was never brought back — it simply disappeared until reinstalled. Backends now restart in every case, the node remembers what you have installed so a missing app is recreated rather than forgotten, and this release repairs the incorrect policy on apps installed by earlier versions.",
"**The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.", "**Your Bitcoin node will not silently change software versions anymore.** \"Latest\" previously meant different things in different places — one path installed a newer build that deliberately halts until you make a network-rules decision, which froze one node's sync at a fixed block while it reported itself fully synced. Bitcoin Knots is now pinned to an explicit, known-good version; changing it is a decision you make, never a side effect of an update.",
"**The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast." "**Smaller fixes:** the AI data-access settings now say plainly which categories the assistant can see but not act on; the transactions window's tab bar is transparent glass instead of a black block; BTCPay logins no longer fail with a server error when the node is under heavy load right at that moment.",
"**You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again.",
"**Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.0-alpha", "current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.0-alpha", "new_version": "1.7.127-alpha",
"sha256": "fd99219ea11ffebc92b83154e1058911ebe72c357c80085310c78aba9714a6b5", "sha256": "19c5f4573e49ba5a1339a358f5d422da4c3dbf68fba3391a62588049a52da207",
"size_bytes": 59369392 "size_bytes": 59282264
}, },
{ {
"current_version": "1.8.0-alpha", "current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago-frontend-1.8.0-alpha.tar.gz", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago-frontend-1.7.127-alpha.tar.gz",
"name": "archipelago-frontend-1.8.0-alpha.tar.gz", "name": "archipelago-frontend-1.7.127-alpha.tar.gz",
"new_version": "1.8.0-alpha", "new_version": "1.7.127-alpha",
"sha256": "f6bbf7b3f78a00dd7051abef805c943cfa9a58f624e09e94b6bee6c9b2f22902", "sha256": "bedd662105e53ce800caa610cc099a47d7f0786af5fec601169a8906af760244",
"size_bytes": 97608790 "size_bytes": 95433702
} }
], ],
"release_date": "2026-08-12", "release_date": "2026-08-09",
"signature": "be3e3c6f8ed6b8d573a6329bf546ec1aa739214cd391272ce3b2f0a0ef6b10a4a4c4f9dfa4b9b32dad88326c9feff908fbdb3d1b86d055e5cf876f67f195c60b", "signature": "dc418fc08b2b0e288ab0f4b307562d966774d8b5ee73789229d6bef627f61013510054a89d8e314676464a55bcb631e1d1a63e0de394b2d152abd42c90f4120f",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.0-alpha" "version": "1.7.127-alpha"
} }
-16
View File
@@ -158,16 +158,6 @@ cd "$PROJECT_ROOT/neode-ui"
npm run build 2>&1 | tail -3 npm run build 2>&1 | tail -3
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
# bakes it from demo/aiui independently, but build-iso-release.sh's
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
# consecutive releases (.127, .129) because this fold-in was manual.
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
echo " AIUI folded into web/dist from demo/aiui"
fi
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale # npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
# dist would ship with a perfectly valid sha256. Require the freshly built # dist would ship with a perfectly valid sha256. Require the freshly built
# bundle to embed the version we just bumped to before it gets packaged. # bundle to embed the version we just bumped to before it gets packaged.
@@ -274,18 +264,12 @@ fi
echo "[7/8] Committing version bump..." echo "[7/8] Committing version bump..."
git -C "$PROJECT_ROOT" add \ git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \ core/archipelago/Cargo.toml \
core/Cargo.lock \
neode-ui/package.json \ neode-ui/package.json \
neode-ui/package-lock.json \ neode-ui/package-lock.json \
neode-ui/public/catalog.json \
CHANGELOG.md \ CHANGELOG.md \
releases/manifest.json \ releases/manifest.json \
release-manifest.json \ release-manifest.json \
2>/dev/null || true 2>/dev/null || true
# Cargo.lock (rewritten by the release build after the version bump) and
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}" git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
-14
View File
@@ -85,20 +85,6 @@ echo "publish-companion-apk: verified v1 + v2 + v3 signatures." >&2
mkdir -p "$(dirname "$DEST")" mkdir -p "$(dirname "$DEST")"
cp "$SIGNED" "$DEST" cp "$SIGNED" "$DEST"
# Version metadata beside the APK: the dashboard's companion overlay reads
# this to show which version the Download button installs. Extracted from
# the gradle config that just built the APK, so it can never drift from it.
META="${DEST%.apk}.json"
V_NAME="$(sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' Android/app/build.gradle.kts | head -1)"
V_CODE="$(sed -n 's/^[[:space:]]*versionCode = \([0-9]*\).*/\1/p' Android/app/build.gradle.kts | head -1)"
if [ -n "$V_NAME" ] && [ -n "$V_CODE" ]; then
printf '{\n "versionName": "%s",\n "versionCode": %s\n}\n' "$V_NAME" "$V_CODE" > "$META"
git add "$META"
echo "publish-companion-apk: staged $META (v$V_NAME, build $V_CODE)" >&2
else
echo "publish-companion-apk: WARNING could not extract version from build.gradle.kts — $META not updated" >&2
fi
# Drop the legacy zipped artifact so the served download is the raw APK only. # Drop the legacy zipped artifact so the served download is the raw APK only.
if [ -f "$OLD_ZIP" ]; then if [ -f "$OLD_ZIP" ]; then
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP" git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"