Compare commits

..
Author SHA1 Message Date
Archipelago 081dab5934 Archipelago — open-source initial import 2026-08-12 10:55:49 +00:00
314 changed files with 2062 additions and 16702 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 10–15 fps and
* takes 60–100 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
* 150–300 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>
-57
View File
@@ -1,62 +1,5 @@
# Changelog # Changelog
## v1.8.4-alpha (2026-08-20)
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
- **The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.
- **While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.
- **"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.
- **A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.
- **Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)
- **A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.
## v1.8.3-alpha (2026-08-14)
- **The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.
- **The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.
- **App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files.
## v1.8.2-alpha (2026-08-13)
- **An app that can't be shown inside the dashboard now becomes a tab app by itself.** A few apps refuse to render inside another page no matter what — they break out with their own code or insist on owning the whole browser window. Opening one used to mean staring at a grey pane. Now the dashboard notices, offers the app in its own tab, and remembers: from then on that app's button opens a tab directly (with the little launch icon that tab apps carry), first click, every time. If a later update makes the app embeddable after all, the dashboard notices that too and goes back to embedding it.
- **The logo emblem got its glossy black paint finish — properly this time.** The circle behind the A on the screensaver, intro, and login now wears a deep wet-paint look: warm light blooming from the top edge, fine grain so the dark tones stay smooth instead of banding, and no more ring border. (An earlier rougher version of this experiment briefly shipped by accident and then vanished depending on which screen you were on — this is the finished, deliberate one, everywhere.)
- **New app icons now match the store's look, on every screen.** Alby Hub and phoenixd arrived with edge-to-edge logos that ignored the breathing room every other app icon has, and the app detail page skipped the icon backdrop entirely. Both icons are re-set on the standard canvas, the detail page now applies the same icon treatment as the store tiles, and app developers get a one-command tool that puts any logo onto the house canvas automatically.
## 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`.
@@ -93,11 +93,10 @@ describe('useAI', () => {
expect(activeModel.value).toBe('echo') expect(activeModel.value).toBe('echo')
}) })
it('lists available providers with models, Routstr first', () => { it('lists available providers with models', () => {
const { availableProviders } = useAI() const { availableProviders } = useAI()
expect(availableProviders.value.length).toBe(4) expect(availableProviders.value.length).toBe(3)
const ids = availableProviders.value.map(p => p.id) const ids = availableProviders.value.map(p => p.id)
expect(ids[0]).toBe('routstr')
expect(ids).toContain('claude') expect(ids).toContain('claude')
expect(ids).toContain('openrouter') expect(ids).toContain('openrouter')
expect(ids).toContain('mock') expect(ids).toContain('mock')
@@ -119,7 +119,7 @@
<Transition name="picker"> <Transition name="picker">
<div <div
v-if="showModelPicker" v-if="showModelPicker"
class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px] max-h-[70vh] overflow-y-auto" class="fixed z-[9999] path-glass-card header-overlay-panel p-3 space-y-3 animate-fade-up-fast shadow-2xl min-w-[220px]"
:style="modelPickerDropdownStyle" :style="modelPickerDropdownStyle"
@click.stop @click.stop
> >
@@ -332,7 +332,7 @@ const modelDisplayName = computed(() => {
}) })
function selectModel(providerId: string, modelId: string) { function selectModel(providerId: string, modelId: string) {
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock') setProvider(providerId as 'claude' | 'openrouter' | 'mock')
setModel(modelId) setModel(modelId)
showModelPicker.value = false showModelPicker.value = false
} }
+5 -118
View File
@@ -13,14 +13,12 @@ import { useCodeContext } from '@/composables/useCodeContext'
import { apiFetch } from '@/utils/api-fetch' import { apiFetch } from '@/utils/api-fetch'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' type Provider = 'claude' | 'openrouter' | 'mock'
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/) // API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
const BASE = import.meta.env.BASE_URL || '/' const BASE = import.meta.env.BASE_URL || '/'
const CLAUDE_PATH = `${BASE}api/claude/v1/messages` const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
const OPENROUTER_PATH = `${BASE}api/openrouter` const OPENROUTER_PATH = `${BASE}api/openrouter`
const ROUTSTR_MODELS_PATH = `${BASE}api/routstr/models`
const ROUTSTR_CHAT_PATH = `${BASE}api/routstr/chat/completions`
import { mockFilms } from '@/mocks/films' import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs' import { mockSongs } from '@/mocks/songs'
@@ -150,41 +148,8 @@ function looksLikeMissingApiKey(err: string): boolean {
) )
} }
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
// The node forwards the live Routstr aggregator's /v1/models; entries carry
// sats_pricing so completions are Cashu-paid against the operator's budget.
const routstrModels = ref<{ id: string; name: string }[]>([])
let routstrModelsFetched = false
async function refreshRoutstrModels() {
if (routstrModelsFetched) return
routstrModelsFetched = true
try {
const res = await apiFetch(ROUTSTR_MODELS_PATH)
if (!res.ok) return
const data = await res.json()
if (Array.isArray(data?.data)) {
routstrModels.value = data.data
.filter((m: Record<string, unknown>) => typeof m.id === 'string')
.map((m: Record<string, unknown>) => ({
id: m.id as string,
name: (m.name as string) || (m.id as string),
}))
}
} catch {
routstrModelsFetched = false // allow a retry on the next send/open
}
}
const availableProviders = computed(() => { const availableProviders = computed(() => {
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{
id: 'routstr',
name: 'Routstr (sats)',
models: routstrModels.value.length > 0
? routstrModels.value
: [{ id: 'routstr-unavailable', name: 'No models — node offline?' }],
},
{ {
id: 'claude', id: 'claude',
name: 'Claude (Max)', name: 'Claude (Max)',
@@ -416,71 +381,6 @@ async function streamOpenRouter(
}, onError, signal) }, onError, signal)
} }
/**
* Routstr: one paid, NON-streaming, OpenAI-shaped completion through the
* node's session-gated `/aiui/api/routstr/` forwarder. The node quotes a
* price from the live catalog, pays with a Cashu token against the
* operator's budget (Settings → System → Routstr AI budget), redeems the
* change, and passes the provider's JSON back. The full answer is emitted
* as a single token — streaming across a paid hop is the planned follow-up.
*/
async function streamRoutstr(
messages: ChatMessage[],
onToken: (text: string) => void,
onError: (err: string) => void,
systemPrompt: string,
signal?: AbortSignal,
): Promise<void> {
const wireMessages = [
{ role: 'system' as const, content: systemPrompt },
...messages.map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })),
]
const res = await apiFetch(ROUTSTR_CHAT_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: activeModel.value,
messages: wireMessages,
stream: false,
}),
signal,
})
const bodyText = await res.text().catch(() => '')
if (!res.ok) {
// The node's refusals carry a plain-language error.message (budget not
// set, budget spent, wallet can't fund) — surface it verbatim.
let msg = `Routstr error ${res.status}`
try {
const parsed = JSON.parse(bodyText)
// Node refusals use {error:{message}}; the upstream provider nests
// its own as {detail:{error:{message}}} or a plain {detail:"..."}.
const detail = parsed?.detail
msg =
parsed?.error?.message ??
detail?.error?.message ??
(typeof detail === 'string' ? detail : undefined) ??
msg
} catch { /* keep the status-only message */ }
onError(msg)
return
}
if (signal?.aborted) return
try {
const parsed = JSON.parse(bodyText)
const text = parsed?.choices?.[0]?.message?.content
if (typeof text === 'string' && text.length > 0) {
onToken(text)
} else {
onError('Routstr returned an empty response')
}
} catch {
onError('Routstr returned a malformed response')
}
}
/** /**
* Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside * Embedded-mode chat delegation (D-01/D-17): when AIUI is running inside
* Archy, the model call, the tool-calling loop, and the model key all live * Archy, the model call, the tool-calling loop, and the model key all live
@@ -719,11 +619,7 @@ export async function streamWithModel(
activeModel.value = model activeModel.value = model
try { try {
if (provider === 'routstr') { if (useArchy().isEmbedded.value) {
// Explicitly chosen Routstr wins even embedded in Archy — the whole
// point of the picker entry is that it is a selection, not a fallback.
await streamRoutstr(history, onToken, onError, 'You are a helpful assistant.', signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to // D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side. // Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal) await streamViaArchy(history, onToken, onError, signal)
@@ -742,9 +638,8 @@ export async function streamWithModel(
export function useAI() { export function useAI() {
const chatStore = useChatStore() const chatStore = useChatStore()
// Fetch Wavlake + Routstr catalogs on first use (non-blocking) // Fetch Wavlake catalog on first use (non-blocking)
refreshWavlakeCatalog() refreshWavlakeCatalog()
refreshRoutstrModels()
function stopGeneration() { function stopGeneration() {
if (currentAbort) { if (currentAbort) {
@@ -817,11 +712,7 @@ export function useAI() {
const genParams = getConversationParams(chatStore) const genParams = getConversationParams(chatStore)
try { try {
if (provider === 'routstr') { if (useArchy().isEmbedded.value) {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to // D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side. // Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal) await streamViaArchy(history, onToken, onError, signal)
@@ -937,11 +828,7 @@ export function useAI() {
const genParams = getConversationParams(chatStore) const genParams = getConversationParams(chatStore)
try { try {
if (provider === 'routstr') { if (useArchy().isEmbedded.value) {
// Explicitly chosen Routstr wins even embedded in Archy — a
// selection, not a fallback.
await streamRoutstr(history, onToken, onError, systemPrompt, signal)
} else if (useArchy().isEmbedded.value) {
// D-17: embedded mode delegates the loop, the tools and the key to // D-17: embedded mode delegates the loop, the tools and the key to
// Archy — provider/model selection here doesn't apply node-side. // Archy — provider/model selection here doesn't apply node-side.
await streamViaArchy(history, onToken, onError, signal) await streamViaArchy(history, onToken, onError, signal)
+12 -36
View File
@@ -73,7 +73,7 @@
"author": "Mempool", "author": "Mempool",
"category": "money", "category": "money",
"tier": "core", "tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1", "dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"repoUrl": "https://github.com/mempool/mempool", "repoUrl": "https://github.com/mempool/mempool",
"requires": [ "requires": [
"bitcoin-knots", "bitcoin-knots",
@@ -193,13 +193,13 @@
{ {
"id": "nostr-rs-relay", "id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)", "title": "Nostr Relay (Rust)",
"version": "0.10.0", "version": "0.8.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.", "description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg", "icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay", "author": "Nostr RS Relay",
"category": "community", "category": "community",
"tier": "recommended", "tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0", "dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay", "repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden", "author": "Vaultwarden",
"category": "data", "category": "data",
"tier": "recommended", "tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine", "dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden", "repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png", "icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint", "author": "Fedimint",
"category": "money", "category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1", "dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint" "repoUrl": "https://github.com/fedimint/fedimint"
}, },
{ {
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png", "icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint", "author": "Fedimint",
"category": "money", "category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1", "dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint", "repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp", "icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin", "author": "Jellyfin",
"category": "data", "category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11", "dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin", "repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png", "icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant", "author": "Home Assistant",
"category": "home", "category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2", "dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core", "repoUrl": "https://github.com/home-assistant/core",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -378,7 +378,7 @@
"icon": "/assets/img/app-icons/pine.svg", "icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago", "author": "Archipelago",
"category": "home", "category": "home",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine", "dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming" "repoUrl": "https://github.com/rhasspy/wyoming"
}, },
{ {
@@ -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": [
@@ -442,7 +442,7 @@
"author": "Portainer", "author": "Portainer",
"category": "development", "category": "development",
"tier": "optional", "tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6", "dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer", "repoUrl": "https://github.com/portainer/portainer",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -464,7 +464,7 @@
"author": "NetBird", "author": "NetBird",
"category": "networking", "category": "networking",
"tier": "recommended", "tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine", "dockerImage": "docker.io/library/nginx:1.27-alpine",
"repoUrl": "https://github.com/netbirdio/netbird", "repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@@ -547,30 +547,6 @@
"/var/lib/archipelago/nextcloud:/var/www/html" "/var/lib/archipelago/nextcloud:/var/www/html"
] ]
} }
},
{
"id": "alby-hub",
"title": "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.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "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.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
} }
] ]
} }
-3
View File
@@ -2,9 +2,6 @@ app:
id: aiui id: aiui
name: AI Assistant name: AI Assistant
version: 0.1.0 version: 0.1.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker. description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store internal: true # System-managed, not shown in App Store
-81
View File
@@ -1,81 +0,0 @@
app:
id: alby-hub
name: Alby Hub
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: getAlby/hub
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.24.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
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-btcpay-db id: archy-btcpay-db
name: BTCPay Postgres name: BTCPay Postgres
version: "15.17" version: "15.17"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres backend for BTCPay and NBXplorer. description: Postgres backend for BTCPay and NBXplorer.
container: container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-mempool-db id: archy-mempool-db
name: Mempool MariaDB name: Mempool MariaDB
version: 11.4.10 version: 11.4.10
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/mariadb
description: MariaDB backend for the mempool explorer stack. description: MariaDB backend for the mempool explorer stack.
container: container:
+1 -7
View File
@@ -2,17 +2,11 @@ app:
id: archy-mempool-web id: archy-mempool-web
name: Mempool Web name: Mempool Web
version: 3.0.1 version: 3.0.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Frontend web UI for mempool explorer. description: Frontend web UI for mempool explorer.
container_name: mempool container_name: mempool
container: container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1 image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
-6
View File
@@ -2,12 +2,6 @@ app:
id: archy-nbxplorer id: archy-nbxplorer
name: NBXplorer name: NBXplorer
version: 2.6.0 version: 2.6.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dgarage/NBXplorer
description: BTCPay blockchain indexer service. description: BTCPay blockchain indexer service.
container: container:
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: bitcoin-core id: bitcoin-core
name: Bitcoin Core name: Bitcoin Core
version: 28.4.0 version: 28.4.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoin/bitcoin
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk. description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core container_name: bitcoin-core
@@ -55,7 +49,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi; fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi fi
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: bitcoin-knots id: bitcoin-knots
name: Bitcoin Knots name: Bitcoin Knots
version: 28.1.0 version: 28.1.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoinknots/bitcoin
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk. description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots container_name: bitcoin-knots
@@ -61,7 +55,7 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi; fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
fi fi
-3
View File
@@ -2,9 +2,6 @@ app:
id: bitcoin-ui id: bitcoin-ui
name: Bitcoin UI name: Bitcoin UI
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: | description: |
Archipelago-native HTTP proxy + static site for interacting with the Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
-3
View File
@@ -2,9 +2,6 @@ app:
id: botfights id: botfights
name: BotFights name: BotFights
version: 1.2.11 version: 1.2.11
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners. description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
category: community category: community
+1 -17
View File
@@ -2,12 +2,6 @@ app:
id: btcpay-server id: btcpay-server
name: BTCPay Server name: BTCPay Server
version: 2.4.2 version: 2.4.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: btcpayserver/btcpayserver
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries. description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container: container:
@@ -52,17 +46,7 @@ app:
container: 49392 container: 49392
protocol: tcp protocol: tcp
bind: 127.0.0.1 bind: 127.0.0.1
# open, not gated: BTCPay has its own account system, and its public auth: gated
# surfaces (checkout/invoice pages, payment buttons, webhooks) must be
# reachable by anonymous payers and machines — a dashboard login in
# front of a checkout link breaks the product. The gate still fronts
# the port; the operator can force the dashboard login back on from
# Settings → BTCPay Server → Access control.
auth: open
auth_rationale: >-
BTCPay enforces its own login for administration, and its checkout,
invoice and webhook endpoints are designed to be reached by
anonymous payers and payment processors.
volumes: volumes:
- type: bind - type: bind
-6
View File
@@ -2,12 +2,6 @@ app:
id: core-lightning id: core-lightning
name: Core Lightning (CLN) name: Core Lightning (CLN)
version: 23.08.2 version: 23.08.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ElementsProject/lightning
description: Lightning Network implementation in C. Lightweight alternative to LND. description: Lightning Network implementation in C. Lightweight alternative to LND.
container: container:
-151
View File
@@ -1,151 +0,0 @@
app:
id: cuprate
name: Cuprate
# Matches the crate's own Cargo.toml version (binaries/cuprated/Cargo.toml).
# Cuprate has no stable release yet — this is explicitly work-in-progress
# software (see upstream README). The image tag below pins the exact
# commit built, since "0.1.0-preview" alone is not reproducible.
version: 0.1.0-preview
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: Cuprate/cuprate
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
# no newer tagged release as of this writing. Re-pin to a tagged release
# once upstream cuts one.
image: source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14
pull_policy: if-not-present
network: archy-net
# The image's own ENTRYPOINT is ["/usr/local/bin/cuprated"]; these are
# appended as its argv, matching the project's own systemd unit
# (cuprated.service) invocation exactly.
custom_args: ["--config-file", "/home/cuprate/Cuprated.toml"]
# The image (FROM scratch) creates uid:gid 1000:1000 for the `cuprate`
# user at build time and runs as it unconditionally (USER 1000:1000,
# no shell to switch users at runtime) — same pattern as
# apps/phoenixd, apps/electrumx, apps/nostr-rs-relay, apps/portainer,
# apps/barkd. The bind-mounted data dir must be owned by that literal
# uid or cuprated dies on a permission error the first time it writes.
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 300Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
# — unlike bitcoin-knots this needs no runtime chown/setuid dance, so it
# can run fully read-only with an empty capability set.
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: isolated
ports:
# P2P. Cuprate's own default listen address is already 0.0.0.0
# (p2p.clear_net.listen_on), so no config override is needed — only the
# host-side port differs from Monero's canonical 18080 because that
# number is already taken on this fleet by lnd's REST port.
- host: 18183
container: 18080
protocol: tcp
auth: none
auth_rationale: >-
Monero p2p gossip. Peers are anonymous by design and speak the Monero wire protocol, not HTTP.
# Unrestricted RPC (full node control) is deliberately NOT published.
# cuprated has no RPC authentication, and for a published port to reach
# it the service would have to bind 0.0.0.0 inside the container — at
# which point every other app can reach it directly on 18081, since
# ports[].bind only restricts the HOST side and podman bridges route to
# each other (verified live 2026-08-22: a peer container on archy-net
# got an unauthenticated get_info, from a *different* network). That is
# unlike bitcoin-knots, whose 0.0.0.0 RPC still demands the rpcuser /
# rpcpassword it writes from generated secrets. So unrestricted RPC is
# left at cuprated's own default — container loopback only, reachable by
# nothing — which is also what upstream intends by refusing a non-local
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login
# would break wallet clients connecting programmatically, same
# reasoning as electrumx's port. The daemon still uses its canonical
# container port 18089, but Penpot already owns host port 18089, so this
# maps the public host port to the free 18090 instead.
- host: 18090
container: 18089
protocol: tcp
auth: none
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
volumes:
- type: bind
source: /var/lib/archipelago/cuprate
target: /home/cuprate
options: [rw]
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
# (canonical 18089), and advertise stays false — this node is not
# opting in to being listed as a public remote node over the p2p
# network, just reachable if someone points a wallet at it directly.
# - rpc.unrestricted.address + the allow-public flag: cuprated's own
# default (127.0.0.1) looks like the obviously-correct choice for a
# port meant to stay loopback-only, but verified live (2026-08-21)
# that a service bound literally to 127.0.0.1 *inside* the container
# is unreachable through the host's published port — connections
# reset regardless of how long the daemon has been up. Binding
# 0.0.0.0 inside and letting ports[].bind: 127.0.0.1 below be the
# actual restriction is the same pattern apps/bitcoin-knots already
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
target_max_memory = 3000000000
[rpc.restricted]
enable = true
overwrite: false
health_check:
type: tcp
# Restricted RPC — the only RPC surface published now.
endpoint: localhost:18090
interval: 30s
timeout: 5s
retries: 3
start_period: 5m
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Cuprate
repo: https://github.com/Cuprate/cuprate
-3
View File
@@ -2,9 +2,6 @@ app:
id: did-wallet id: did-wallet
name: Web5 DID Wallet name: Web5 DID Wallet
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets. description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container: container:
-3
View File
@@ -2,9 +2,6 @@ app:
id: electrs-ui id: electrs-ui
name: Electrs UI name: Electrs UI
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: | description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies nginx inside a container, serves static assets, and proxies
-6
View File
@@ -2,12 +2,6 @@ app:
id: electrumx id: electrumx
name: ElectrumX name: ElectrumX
version: 1.18.0 version: 1.18.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: spesmilo/electrumx
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries. description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container: container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: fedimint-clientd id: fedimint-clientd
name: Fedimint Client name: Fedimint Client
version: 0.8.0 version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint-clientd
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API. description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container: container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: fedimint-gateway id: fedimint-gateway
name: Fedimint Gateway name: Fedimint Gateway
version: 0.10.0 version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Fedimint gateway service with automatic LND-or-LDK backend selection. description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container: container:
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1 image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
entrypoint: ["sh", "-lc"] entrypoint: ["sh", "-lc"]
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: fedimint id: fedimint
name: Fedimint Guardian name: Fedimint Guardian
version: 0.10.0 version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody. description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container: container:
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1 image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
entrypoint: ["sh", "-lc"] entrypoint: ["sh", "-lc"]
-6
View File
@@ -2,12 +2,6 @@ app:
id: filebrowser id: filebrowser
name: File Browser name: File Browser
version: 2.27.0 version: 2.27.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: filebrowser/filebrowser
description: Baseline Archipelago file manager service. description: Baseline Archipelago file manager service.
container: container:
-3
View File
@@ -2,9 +2,6 @@ app:
id: fips-ui id: fips-ui
name: FIPS Mesh name: FIPS Mesh
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: | description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on inside a container with host networking, serves a static dashboard on
+1 -16
View File
@@ -2,12 +2,6 @@ app:
id: gitea id: gitea
name: Gitea name: Gitea
version: "1.23" version: "1.23"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: go-gitea/gitea
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting. description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development category: development
@@ -33,16 +27,7 @@ app:
container: 3000 container: 3000
protocol: tcp protocol: tcp
bind: 127.0.0.1 bind: 127.0.0.1
# open, not gated: Gitea carries a complete login of its own, and git auth: gated
# clients speak HTTP basic-auth — a cookie challenge in front of
# git-over-HTTP breaks every clone/push. The gate still fronts the
# port (iframe header fixes, retry page, Tor); the operator can force
# the dashboard login back on from Settings → Gitea → Access control.
auth: open
auth_rationale: >-
Gitea enforces its own account login on every page and API route;
git clients authenticate with basic-auth/tokens and cannot complete
a browser login challenge.
- host: 2222 - host: 2222
container: 22 container: 22
protocol: tcp protocol: tcp
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: grafana id: grafana
name: Grafana name: Grafana
version: 10.2.0 version: 10.2.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: grafana/grafana
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"
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: homeassistant id: homeassistant
name: Home Assistant name: Home Assistant
version: 2026.7.3 version: 2026.7.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: home-assistant/core
description: Open source home automation platform. Control and monitor your smart home devices. description: Open source home automation platform. Control and monitor your smart home devices.
container: container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2 image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich-redis id: immich-redis
name: Immich Redis name: Immich Redis
version: "7-alpine" version: "7-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: valkey/valkey
description: Valkey (Redis-compatible) cache for Immich. description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references # Container named immich_redis (underscore) to match runtime per-app references
-6
View File
@@ -2,12 +2,6 @@ app:
id: immich id: immich
name: Immich name: Immich
version: "2.7.4" version: "2.7.4"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: immich-app/immich
description: Self-hosted photo and video backup with mobile apps and search. description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title # app_id "immich" = the user-facing launcher (matches the catalog entry's title
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub-api id: indeedhub-api
name: IndeedHub API name: IndeedHub API
version: "1.0.0" version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub backend API (Nostr auth, media, payments). description: IndeedHub backend API (Nostr auth, media, payments).
category: community category: community
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub-ffmpeg id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker name: IndeedHub FFmpeg Worker
version: "1.0.0" version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub background media transcoding worker. description: IndeedHub background media transcoding worker.
category: community category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-postgres id: indeedhub-postgres
name: IndeedHub Postgres name: IndeedHub Postgres
version: "16.13-alpine" version: "16.13-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres database backend for IndeedHub. description: Postgres database backend for IndeedHub.
category: community category: community
-6
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-redis id: indeedhub-redis
name: IndeedHub Redis name: IndeedHub Redis
version: "7.4.8-alpine" version: "7.4.8-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/redis
description: Redis queue/cache backend for IndeedHub. description: Redis queue/cache backend for IndeedHub.
category: community category: community
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: indeedhub-relay id: indeedhub-relay
name: IndeedHub Nostr Relay name: IndeedHub Nostr Relay
version: "0.9.0" version: "0.9.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments. description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
category: community category: community
@@ -17,7 +11,7 @@ app:
container_name: indeedhub-relay container_name: indeedhub-relay
container: container:
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0 image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0
pull_policy: if-not-present pull_policy: if-not-present
network: indeedhub-net network: indeedhub-net
network_aliases: [relay] network_aliases: [relay]
-3
View File
@@ -2,9 +2,6 @@ app:
id: indeedhub id: indeedhub
name: IndeeHub name: IndeeHub
version: "1.0.0" version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity. description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: community category: community
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: jellyfin id: jellyfin
name: Jellyfin name: Jellyfin
version: 10.8.13 version: 10.8.13
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: jellyfin/jellyfin
description: Free media server. Stream movies, music, and photos. description: Free media server. Stream movies, music, and photos.
container: container:
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11 image: source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
-3
View File
@@ -2,9 +2,6 @@ app:
id: lnd-ui id: lnd-ui
name: LND UI name: LND UI
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: | description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched container and serves static assets. LND connection info is fetched
-6
View File
@@ -2,12 +2,6 @@ app:
id: lnd id: lnd
name: LND name: LND
version: 0.18.4 version: 0.18.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: lightningnetwork/lnd
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments. description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container: container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: mempool-api id: mempool-api
name: Mempool API name: Mempool API
version: 3.0.0 version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Backend API for mempool explorer. description: Backend API for mempool explorer.
container: container:
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1 image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or # CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: mempool id: mempool
name: Mempool Explorer name: Mempool Explorer
version: 3.0.0 version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization. description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container: container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1 image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image_signature: cosign://... image_signature: cosign://...
pull_policy: if-not-present pull_policy: if-not-present
-3
View File
@@ -2,9 +2,6 @@ app:
id: morphos-server id: morphos-server
name: MorphOS Server name: MorphOS Server
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server. description: MorphOS server platform. Decentralized application server.
container: container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: netbird-dashboard id: netbird-dashboard
name: NetBird Dashboard name: NetBird Dashboard
version: "2.38.0" version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/dashboard
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy. description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
category: networking category: networking
-6
View File
@@ -2,12 +2,6 @@ app:
id: netbird-server id: netbird-server
name: NetBird Server name: NetBird Server
version: "0.71.2" version: "0.71.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/netbird
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN. description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
category: networking category: networking
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: netbird id: netbird
name: NetBird name: NetBird
version: "2.38.0" version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server. description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
category: networking category: networking
@@ -18,7 +12,7 @@ app:
container_name: netbird container_name: netbird
container: container:
image: docker.io/library/nginx:1.31.3-alpine image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: netbird-net network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a # Self-signed TLS cert materialised before create — the dashboard needs a
-6
View File
@@ -2,12 +2,6 @@ app:
id: nextcloud id: nextcloud
name: Nextcloud name: Nextcloud
version: "29" version: "29"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: nextcloud/server
description: Your own private cloud. File sync, calendars, contacts. description: Your own private cloud. File sync, calendars, contacts.
container: container:
+2 -8
View File
@@ -1,17 +1,11 @@
app: app:
id: nostr-rs-relay id: nostr-rs-relay
name: Nostr Relay (Rust) name: Nostr Relay (Rust)
version: 0.10.0 version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits. description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container: container:
image: scsibug/nostr-rs-relay:0.10.0 image: scsibug/nostr-rs-relay:0.8.9
image_signature: cosign://... image_signature: cosign://...
pull_policy: verify-signature pull_policy: verify-signature
data_uid: "1000:1000" data_uid: "1000:1000"
-81
View File
@@ -1,81 +0,0 @@
app:
id: phoenixd
name: phoenixd
version: 0.9.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ACINQ/phoenixd
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
-6
View File
@@ -2,12 +2,6 @@ app:
id: photoprism id: photoprism
name: PhotoPrism name: PhotoPrism
version: "240915" version: "240915"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: photoprism/photoprism
description: AI-powered photo management with facial recognition. description: AI-powered photo management with facial recognition.
container: container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: pine-openwakeword id: pine-openwakeword
name: Pine Wake Word (openWakeWord) name: Pine Wake Word (openWakeWord)
version: "2.1.0" version: "2.1.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-openwakeword
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image). description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home category: home
+2 -8
View File
@@ -1,13 +1,7 @@
app: app:
id: pine-piper id: pine-piper
name: Pine Piper (TTS) name: Pine Piper (TTS)
version: "2.4.2" version: "2.2.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-piper
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite. description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home category: home
@@ -18,7 +12,7 @@ app:
container_name: pine-piper container_name: pine-piper
container: container:
image: docker.io/rhasspy/wyoming-piper:2.4.2 image: docker.io/rhasspy/wyoming-piper:2.2.2
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
network_aliases: [pine-piper] network_aliases: [pine-piper]
+1 -7
View File
@@ -2,12 +2,6 @@ app:
id: pine id: pine
name: Pine name: Pine
version: "1.3.0" version: "1.3.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else. description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home category: home
@@ -19,7 +13,7 @@ app:
container_name: pine container_name: pine
container: container:
image: docker.io/library/nginx:1.31.3-alpine image: docker.io/library/nginx:1.27-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
network_aliases: [pine] network_aliases: [pine]
+1 -7
View File
@@ -2,17 +2,11 @@ app:
id: portainer id: portainer
name: Portainer name: Portainer
version: 2.19.4 version: 2.19.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: portainer/portainer
description: Container management web UI for the local Podman socket. description: Container management web UI for the local Podman socket.
category: development category: development
container: container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6 image: source.archipelago-foundation.org/lfg2025/portainer:2.39.1
pull_policy: if-not-present pull_policy: if-not-present
data_uid: "1000:1000" data_uid: "1000:1000"
-3
View File
@@ -2,9 +2,6 @@ app:
id: router id: router
name: Mesh Router name: Mesh Router
version: 1.0.0 version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization. description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container: container:
-6
View File
@@ -2,12 +2,6 @@ app:
id: searxng id: searxng
name: SearXNG name: SearXNG
version: 1.0.0 version: 1.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: searxng/searxng
description: Privacy-respecting metasearch engine. Search the web without tracking. description: Privacy-respecting metasearch engine. Search the web without tracking.
container: container:
+2 -8
View File
@@ -1,17 +1,11 @@
app: app:
id: strfry id: strfry
name: Strfry Nostr Relay name: Strfry Nostr Relay
version: 1.1.1 version: 0.9.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: hoytech/strfry
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage. description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container: container:
image: dockurr/strfry:1.1.1 image: dockurr/strfry:1.0.4
image_signature: cosign://... image_signature: cosign://...
pull_policy: verify-signature pull_policy: verify-signature
-6
View File
@@ -2,12 +2,6 @@ app:
id: uptime-kuma id: uptime-kuma
name: Uptime Kuma name: Uptime Kuma
version: 1.23.0 version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: louislam/uptime-kuma
description: Self-hosted uptime monitoring. description: Self-hosted uptime monitoring.
container: container:
+1 -7
View File
@@ -2,16 +2,10 @@ app:
id: vaultwarden id: vaultwarden
name: Vaultwarden name: Vaultwarden
version: 1.30.0 version: 1.30.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dani-garcia/vaultwarden
description: Self-hosted password vault with zero-knowledge encryption. description: Self-hosted password vault with zero-knowledge encryption.
container: container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine
pull_policy: if-not-present pull_policy: if-not-present
network: pasta network: pasta
+58 -398
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.4-alpha" version = "1.7.126-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
@@ -120,7 +120,6 @@ dependencies = [
"blake3", "blake3",
"bs58", "bs58",
"bytes", "bytes",
"cashu",
"chacha20poly1305", "chacha20poly1305",
"chrono", "chrono",
"ciborium", "ciborium",
@@ -187,7 +186,7 @@ dependencies = [
"futures", "futures",
"hex", "hex",
"hyper 0.14.32", "hyper 0.14.32",
"indexmap 2.13.0", "indexmap",
"log", "log",
"reqwest 0.11.27", "reqwest 0.11.27",
"serde", "serde",
@@ -451,8 +450,8 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
dependencies = [ dependencies = [
"bitcoin-internals", "bitcoin-internals 0.3.0",
"bitcoin_hashes", "bitcoin_hashes 0.14.1",
] ]
[[package]] [[package]]
@@ -500,11 +499,11 @@ checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab"
[[package]] [[package]]
name = "bip39" name = "bip39"
version = "2.2.2" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
dependencies = [ dependencies = [
"bitcoin_hashes", "bitcoin_hashes 0.13.0",
"rand 0.8.5", "rand 0.8.5",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
@@ -527,26 +526,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
dependencies = [ dependencies = [
"base58ck", "base58ck",
"base64 0.21.7",
"bech32", "bech32",
"bitcoin-internals", "bitcoin-internals 0.3.0",
"bitcoin-io", "bitcoin-io",
"bitcoin-units", "bitcoin-units",
"bitcoin_hashes", "bitcoin_hashes 0.14.1",
"hex-conservative", "hex-conservative 0.2.2",
"hex_lit", "hex_lit",
"secp256k1", "secp256k1",
"serde",
] ]
[[package]]
name = "bitcoin-internals"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
[[package]] [[package]]
name = "bitcoin-internals" name = "bitcoin-internals"
version = "0.3.0" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitcoin-io" name = "bitcoin-io"
@@ -560,8 +560,17 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
dependencies = [ dependencies = [
"bitcoin-internals", "bitcoin-internals 0.3.0",
"serde", ]
[[package]]
name = "bitcoin_hashes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [
"bitcoin-internals 0.2.0",
"hex-conservative 0.1.2",
] ]
[[package]] [[package]]
@@ -571,7 +580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
dependencies = [ dependencies = [
"bitcoin-io", "bitcoin-io",
"hex-conservative", "hex-conservative 0.2.2",
"serde", "serde",
] ]
@@ -698,32 +707,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "cashu"
version = "0.17.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bd7216af2b980e203d10677076d8c6c5c30610cbdea6549b8a9020cfa0cf47b"
dependencies = [
"bitcoin",
"cbor-diag",
"ciborium",
"lightning",
"lightning-invoice",
"once_cell",
"serde",
"serde_json",
"serde_with",
"strum 0.27.2",
"strum_macros 0.27.2",
"thiserror 2.0.18",
"tracing",
"unicode-normalization",
"url",
"uuid",
"web-time",
"zeroize",
]
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -733,25 +716,6 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "cbor-diag"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
dependencies = [
"bs58",
"chrono",
"data-encoding",
"half",
"nom",
"num-bigint",
"num-rational",
"num-traits",
"separator",
"url",
"uuid",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.54" version = "1.2.54"
@@ -1128,18 +1092,8 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [ dependencies = [
"darling_core 0.20.11", "darling_core",
"darling_macro 0.20.11", "darling_macro",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
] ]
[[package]] [[package]]
@@ -1156,37 +1110,13 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
]
[[package]] [[package]]
name = "darling_macro" name = "darling_macro"
version = "0.20.11" version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core 0.20.11", "darling_core",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote", "quote",
"syn 2.0.114", "syn 2.0.114",
] ]
@@ -1217,37 +1147,6 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -1288,9 +1187,6 @@ name = "deranged"
version = "0.5.8" version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "derive_arbitrary" name = "derive_arbitrary"
@@ -1318,7 +1214,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [ dependencies = [
"darling 0.20.11", "darling",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.114", "syn 2.0.114",
@@ -1418,18 +1314,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "dnssec-prover"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869bf72abc8c654b350aa8d881c5d9957b85e1e1ed6569c10cd68e9f505d5435"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@@ -1890,7 +1774,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"http 0.2.12", "http 0.2.12",
"indexmap 2.13.0", "indexmap",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -1909,7 +1793,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"http 1.4.0", "http 1.4.0",
"indexmap 2.13.0", "indexmap",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -1945,12 +1829,6 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -2009,6 +1887,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
[[package]] [[package]]
name = "hex-conservative" name = "hex-conservative"
version = "0.2.2" version = "0.2.2"
@@ -2507,17 +2391,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.0" version = "2.13.0"
@@ -2634,7 +2507,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"smallvec", "smallvec",
"strum 0.28.0", "strum",
"time", "time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@@ -2722,7 +2595,7 @@ dependencies = [
"rand 0.10.1", "rand 0.10.1",
"rustls 0.23.36", "rustls 0.23.36",
"simple-dns", "simple-dns",
"strum 0.28.0", "strum",
"tokio", "tokio",
"tracing", "tracing",
"url", "url",
@@ -2802,7 +2675,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_bytes", "serde_bytes",
"strum 0.28.0", "strum",
"tokio", "tokio",
"tokio-rustls 0.26.4", "tokio-rustls 0.26.4",
"tokio-util", "tokio-util",
@@ -2892,59 +2765,6 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.21.1" version = "0.21.1"
@@ -3091,55 +2911,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "lightning"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab16d2a714c0b26d7230bd388ac383a30fce231c8927c62752afc0471a36dc6"
dependencies = [
"bech32",
"bitcoin",
"dnssec-prover",
"hashbrown 0.13.2",
"libm",
"lightning-invoice",
"lightning-macros",
"lightning-types",
"possiblyrandom",
]
[[package]]
name = "lightning-invoice"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d83bd798e04ab9eecc8bbef1fa17d3808859bcdc0406bd16c55d51c8834444"
dependencies = [
"bech32",
"bitcoin",
"lightning-types",
"serde",
]
[[package]]
name = "lightning-macros"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "lightning-types"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77c676d4a34cceb2ae3756916e446b4d17f9430a24107e099981f0f9aec77e6"
dependencies = [
"bitcoin",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@@ -3644,7 +3415,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bech32", "bech32",
"bip39", "bip39",
"bitcoin_hashes", "bitcoin_hashes 0.14.1",
"cbc", "cbc",
"chacha20 0.9.1", "chacha20 0.9.1",
"chacha20poly1305", "chacha20poly1305",
@@ -3746,17 +3517,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -4136,7 +3896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap",
"quick-xml", "quick-xml",
"serde", "serde",
"time", "time",
@@ -4174,15 +3934,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "portmapper" name = "portmapper"
version = "0.19.0" version = "0.19.0"
@@ -4222,15 +3973,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "possiblyrandom"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c564dbf654befd49035528299f1208a40508f6e07efb11c163444e304e4484f"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "postcard" name = "postcard"
version = "1.1.3" version = "1.1.3"
@@ -4901,30 +4643,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -4974,7 +4692,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [ dependencies = [
"bitcoin_hashes", "bitcoin_hashes 0.14.1",
"rand 0.8.5", "rand 0.8.5",
"secp256k1-sys", "secp256k1-sys",
"serde", "serde",
@@ -5040,12 +4758,6 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "separator"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -5130,46 +4842,13 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_with"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64 0.22.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.13.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.9.34+deprecated" version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap",
"itoa", "itoa",
"ryu", "ryu",
"serde", "serde",
@@ -5458,31 +5137,13 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.28.0" version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [ dependencies = [
"strum_macros 0.28.0", "strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.114",
] ]
[[package]] [[package]]
@@ -5945,7 +5606,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime 0.6.11", "toml_datetime 0.6.11",
@@ -5959,7 +5620,7 @@ version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.3", "winnow 1.0.3",
@@ -6162,9 +5823,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
version = "0.1.25" version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
dependencies = [ dependencies = [
"tinyvec", "tinyvec",
] ]
@@ -6242,7 +5903,6 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
dependencies = [ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
"js-sys", "js-sys",
"serde_core",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -6420,7 +6080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"indexmap 2.13.0", "indexmap",
"wasm-encoder", "wasm-encoder",
"wasmparser", "wasmparser",
] ]
@@ -6459,7 +6119,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.13.0",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap 2.13.0", "indexmap",
"semver", "semver",
] ]
@@ -7018,7 +6678,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"indexmap 2.13.0", "indexmap",
"prettyplease", "prettyplease",
"syn 2.0.114", "syn 2.0.114",
"wasm-metadata", "wasm-metadata",
@@ -7049,7 +6709,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.13.0", "bitflags 2.13.0",
"indexmap 2.13.0", "indexmap",
"log", "log",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -7068,7 +6728,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"id-arena", "id-arena",
"indexmap 2.13.0", "indexmap",
"log", "log",
"semver", "semver",
"serde", "serde",
@@ -7299,7 +6959,7 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
"displaydoc", "displaydoc",
"flate2", "flate2",
"indexmap 2.13.0", "indexmap",
"memchr", "memchr",
"thiserror 2.0.18", "thiserror 2.0.18",
"zopfli", "zopfli",
+2 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.4-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"
@@ -72,7 +72,7 @@ bs58 = "0.5"
chrono = "0.4" chrono = "0.4"
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation # BIP-39 mnemonic seed generation + BIP-32 HD key derivation
bip39 = { version = "2.1", features = ["rand"] } bip39 = { version = "=2.1.0", features = ["rand"] }
bitcoin = { version = "=0.32.5", features = ["rand-std"] } bitcoin = { version = "=0.32.5", features = ["rand-std"] }
# Configuration # Configuration
@@ -143,7 +143,6 @@ async-trait = "0.1"
iroh = { version = "1", optional = true } iroh = { version = "1", optional = true }
iroh-blobs = { version = "0.103", optional = true } iroh-blobs = { version = "0.103", optional = true }
lofty = "0.24.0" lofty = "0.24.0"
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
[dev-dependencies] [dev-dependencies]
tokio-test = "0.4" tokio-test = "0.4"
-429
View File
@@ -1,429 +0,0 @@
//! CDP input bridge — forwards companion remote input into the local kiosk
//! Chromium as *trusted*, browser-level input via the DevTools protocol.
//!
//! Why this exists: the web relay path (`remote-relay.ts`) synthesizes DOM
//! events in the top document, and synthetic events can never cross into a
//! cross-origin iframe — so on the kiosk, companion taps/keys/scrolls died at
//! the border of every containerized app's frame. CDP `Input.dispatch*`
//! events enter the browser's real input pipeline: they hit-test through any
//! frame, move focus, and insert text exactly like a physical device, which
//! is the only correct way to drive app iframes (tracked in the unified task
//! tracker; supersedes the earlier "no CDP" note in
//! `docs/tv-input-iframe-apps.md`, which was about gamepad *keys* only).
//!
//! The kiosk launcher opens Chromium with `--remote-debugging-port=9222`
//! bound to loopback. This keeper task discovers the page target, holds one
//! WebSocket to it, and reconnects whenever the kiosk restarts (the launcher
//! supervises Chromium in a loop, so the debugger URL changes under us).
//! When the bridge is not connected (non-kiosk installs, kiosk booting),
//! `is_active()` is false and callers fall back to the web relay unchanged.
//!
//! Security: the CDP port is loopback-only and Chromium's default origin
//! check stands (we deliberately do NOT pass `--remote-allow-origins`, so
//! browser pages can't open the debug socket; our raw client sends no
//! Origin header and is accepted).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
const CDP_HTTP: &str = "http://127.0.0.1:9222";
/// Marker whose presence means this node drives a local kiosk display.
const KIOSK_UNIT: &str = "/etc/systemd/system/archipelago-kiosk.service";
/// One relay scroll step ≈ this many CSS pixels (matches remote-relay.ts).
const SCROLL_STEP_PX: f64 = 100.0;
/// Cloneable handle the WS handlers use to feed validated relay JSON into
/// the keeper task.
#[derive(Clone)]
pub struct CdpBridge {
tx: mpsc::Sender<String>,
connected: Arc<AtomicBool>,
}
impl CdpBridge {
/// Spawn the session keeper and return the shared handle.
pub fn spawn() -> Self {
let (tx, rx) = mpsc::channel::<String>(256);
let connected = Arc::new(AtomicBool::new(false));
tokio::spawn(run_keeper(rx, connected.clone()));
Self { tx, connected }
}
/// True only while a live CDP session to the kiosk Chromium exists.
pub fn is_active(&self) -> bool {
self.connected.load(Ordering::Relaxed)
}
/// Queue a validated relay input message (the exact JSON that goes to the
/// broadcast channel) for CDP dispatch. Best-effort: if the keeper is
/// behind or gone the message is dropped — input is transient by nature.
pub fn send(&self, relay_json: &str) {
let _ = self.tx.try_send(relay_json.to_string());
}
}
/// Virtual cursor state, server-side. The web relay keeps this in the kiosk
/// page (`cursorX`/`cursorY`); CDP needs its own copy because trusted mouse
/// events carry absolute viewport coordinates.
struct Cursor {
x: f64,
y: f64,
w: f64,
h: f64,
}
async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>) {
loop {
// Cheap gate: no kiosk unit on this node → nothing to drive. Keep
// draining queued input so the channel never backs up.
if tokio::fs::metadata(KIOSK_UNIT).await.is_err() {
drain_for(&mut rx, Duration::from_secs(60)).await;
continue;
}
let Some(ws_url) = discover_page_target().await else {
// Kiosk configured but Chromium not up (or CDP flag not rolled
// out yet) — retry gently.
drain_for(&mut rx, Duration::from_secs(15)).await;
continue;
};
match drive_session(&ws_url, &mut rx, &connected).await {
Ok(()) => info!("CDP kiosk input session ended cleanly"),
Err(e) => debug!(error = %e, "CDP kiosk input session dropped — will re-discover"),
}
connected.store(false, Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
/// Discard queued input for `d` — used while no kiosk session exists so the
/// bounded channel can't fill with stale events.
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
}
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
/// localhost (the kiosk app) over e.g. devtools/extension targets.
async fn discover_page_target() -> Option<String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build()
.ok()?;
let list: Vec<Value> = client
.get(format!("{CDP_HTTP}/json/list"))
.send()
.await
.ok()?
.json()
.await
.ok()?;
let pages: Vec<&Value> = list
.iter()
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
.collect();
let preferred = pages
.iter()
.find(|t| {
t.get("url")
.and_then(Value::as_str)
.is_some_and(|u| u.contains("localhost") || u.contains("127.0.0.1"))
})
.or_else(|| pages.first());
preferred?
.get("webSocketDebuggerUrl")
.and_then(Value::as_str)
.map(str::to_string)
}
async fn drive_session(
ws_url: &str,
rx: &mut mpsc::Receiver<String>,
connected: &Arc<AtomicBool>,
) -> anyhow::Result<()> {
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
let (mut sink, mut stream) = ws.split();
let mut next_id: u64 = 0;
let mut id = move || {
next_id += 1;
next_id
};
// Viewport size for cursor clamping. Best-effort: fall back to 1080p if
// the metrics call fails — clamping is a nicety, not a correctness need.
sink.send(Message::Text(
json!({"id": id(), "method": "Page.getLayoutMetrics"}).to_string(),
))
.await?;
let (mut vw, mut vh) = (1920.0_f64, 1080.0_f64);
if let Ok(Some(Ok(Message::Text(txt)))) =
tokio::time::timeout(Duration::from_secs(3), stream.next()).await
{
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
if let Some(vp) = v.pointer("/result/cssLayoutViewport") {
vw = vp.get("clientWidth").and_then(Value::as_f64).unwrap_or(vw);
vh = vp.get("clientHeight").and_then(Value::as_f64).unwrap_or(vh);
}
}
}
let mut cursor = Cursor {
x: vw / 2.0,
y: vh / 2.0,
w: vw,
h: vh,
};
connected.store(true, Ordering::Relaxed);
info!(viewport = %format!("{vw}x{vh}"), "CDP kiosk input bridge connected");
loop {
tokio::select! {
cmd = rx.recv() => {
let Some(cmd) = cmd else { return Ok(()) };
for frame in translate(&cmd, &mut cursor, &mut id) {
sink.send(Message::Text(frame.to_string())).await?;
}
}
msg = stream.next() => {
match msg {
// Responses/events — nothing to correlate, but a read
// error or close means Chromium restarted.
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => anyhow::bail!("CDP socket closed"),
}
}
}
}
}
/// Translate one validated relay input message into CDP command frames.
fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Vec<Value> {
let Ok(msg) = serde_json::from_str::<Value>(raw) else {
return vec![];
};
match msg.get("t").and_then(Value::as_str) {
Some("m") => {
let dx = msg.get("x").and_then(Value::as_i64).unwrap_or(0) as f64;
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64;
cursor.x = (cursor.x + dx).clamp(0.0, cursor.w - 1.0);
cursor.y = (cursor.y + dy).clamp(0.0, cursor.h - 1.0);
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
}
Some("c") => {
let b = msg
.get("b")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 3);
let (button, buttons) = match b {
2 => ("middle", 4),
3 => ("right", 2),
_ => ("left", 1),
};
vec![
// Hover first so the press lands on current hit-test state.
mouse_event(id(), "mouseMoved", cursor, "none", 0, 1),
mouse_event(id(), "mousePressed", cursor, button, buttons, 1),
mouse_event(id(), "mouseReleased", cursor, button, 0, 1),
]
}
Some("s") => {
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64 * SCROLL_STEP_PX;
vec![json!({
"id": id(),
"method": "Input.dispatchMouseEvent",
"params": {
"type": "mouseWheel",
"x": cursor.x, "y": cursor.y,
"deltaX": 0.0, "deltaY": dy,
"pointerType": "mouse",
}
})]
}
Some("k") => {
let Some(k) = msg.get("k").and_then(Value::as_str) else {
return vec![];
};
key_events(k, id)
}
_ => vec![],
}
}
fn mouse_event(
id: u64,
kind: &str,
cursor: &Cursor,
button: &str,
buttons: u32,
clicks: u32,
) -> Value {
json!({
"id": id,
"method": "Input.dispatchMouseEvent",
"params": {
"type": kind,
"x": cursor.x, "y": cursor.y,
"button": button,
"buttons": buttons,
"clickCount": if kind == "mousePressed" || kind == "mouseReleased" { clicks } else { 0 },
"pointerType": "mouse",
}
})
}
/// xdotool named key → (DOM key, DOM code, Windows virtual-key code).
fn named_key(k: &str) -> Option<(&'static str, &'static str, i32)> {
Some(match k {
"Return" => ("Enter", "Enter", 13),
"BackSpace" => ("Backspace", "Backspace", 8),
"Escape" => ("Escape", "Escape", 27),
"Tab" => ("Tab", "Tab", 9),
"Delete" => ("Delete", "Delete", 46),
"Up" => ("ArrowUp", "ArrowUp", 38),
"Down" => ("ArrowDown", "ArrowDown", 40),
"Left" => ("ArrowLeft", "ArrowLeft", 37),
"Right" => ("ArrowRight", "ArrowRight", 39),
"Home" => ("Home", "Home", 36),
"End" => ("End", "End", 35),
"Prior" => ("PageUp", "PageUp", 33),
"Next" => ("PageDown", "PageDown", 34),
"F1" => ("F1", "F1", 112),
"F2" => ("F2", "F2", 113),
"F3" => ("F3", "F3", 114),
"F4" => ("F4", "F4", 115),
"F5" => ("F5", "F5", 116),
"F6" => ("F6", "F6", 117),
"F7" => ("F7", "F7", 118),
"F8" => ("F8", "F8", 119),
"F9" => ("F9", "F9", 120),
"F10" => ("F10", "F10", 121),
"F11" => ("F11", "F11", 122),
"F12" => ("F12", "F12", 123),
_ => return None,
})
}
/// xdotool symbol name → printable char (the relay whitelist speaks xdotool).
fn symbol_char(k: &str) -> Option<char> {
Some(match k {
"space" => ' ',
"exclam" => '!',
"at" => '@',
"numbersign" => '#',
"dollar" => '$',
"percent" => '%',
"asciicircum" => '^',
"ampersand" => '&',
"asterisk" => '*',
"parenleft" => '(',
"parenright" => ')',
"underscore" => '_',
"plus" => '+',
"braceleft" => '{',
"braceright" => '}',
"bar" => '|',
"colon" => ':',
"quotedbl" => '"',
"less" => '<',
"greater" => '>',
"question" => '?',
"asciitilde" => '~',
"minus" => '-',
"equal" => '=',
"bracketleft" => '[',
"bracketright" => ']',
"backslash" => '\\',
"semicolon" => ';',
"apostrophe" => '\'',
"grave" => '`',
"comma" => ',',
"period" => '.',
"slash" => '/',
_ => return None,
})
}
/// Build the CDP frame pair (keyDown, keyUp) for one relay key name,
/// including `modifier+base` combos. A keyDown that carries `text` both
/// fires real keydown/keypress AND inserts the character — exactly how a
/// physical keystroke behaves, so games see the key and fields get the text.
fn key_events(k: &str, id: &mut impl FnMut() -> u64) -> Vec<Value> {
let (modifiers, base) = match k.split_once('+') {
Some((m, b)) => (
match m {
"alt" => 1,
"ctrl" => 2,
"super" => 4,
"shift" => 8,
_ => 0,
},
b,
),
None => (0, k),
};
let (key, code, vk, text): (String, Option<&str>, i32, Option<String>) =
if let Some((key, code, vk)) = named_key(base) {
// Enter carries "\r" like a real keyboard so single-line inputs
// submit and textareas newline.
let text = (key == "Enter").then(|| "\r".to_string());
(key.to_string(), Some(code), vk, text)
} else {
let ch = if base.chars().count() == 1 {
base.chars().next()
} else {
symbol_char(base)
};
let Some(mut ch) = ch else {
return vec![];
};
if modifiers == 8 && ch.is_ascii_alphabetic() {
ch = ch.to_ascii_uppercase();
}
let vk = ch.to_ascii_uppercase() as i32;
// Ctrl/Alt/Super chords are shortcuts, not typing — no text.
let text = (modifiers & !8 == 0).then(|| ch.to_string());
(ch.to_string(), None, vk, text)
};
let mut down = json!({
"id": id(),
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyDown",
"key": key,
"modifiers": modifiers,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}
});
if let Some(code) = code {
down["params"]["code"] = json!(code);
}
if let Some(t) = &text {
down["params"]["text"] = json!(t);
down["params"]["unmodifiedText"] = json!(t);
}
let mut up = json!({
"id": id(),
"method": "Input.dispatchKeyEvent",
"params": {
"type": "keyUp",
"key": key,
"modifiers": modifiers,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}
});
if let Some(code) = code {
up["params"]["code"] = json!(code);
}
vec![down, up]
}
-18
View File
@@ -1,5 +1,4 @@
mod blob; mod blob;
mod cdp;
mod content; mod content;
mod dwn; mod dwn;
mod model_proxy; mod model_proxy;
@@ -7,7 +6,6 @@ mod node_message;
mod proxy; mod proxy;
mod remote_input; mod remote_input;
mod remote_relay; mod remote_relay;
mod routstr_proxy;
mod websocket; mod websocket;
use crate::api::rpc::RpcHandler; use crate::api::rpc::RpcHandler;
@@ -52,10 +50,6 @@ pub struct ApiHandler {
/// to the phone's default browser. Lets "open in external browser" apps — /// to the phone's default browser. Lets "open in external browser" apps —
/// which the kiosk can't usefully open itself — launch on the controller. /// which the kiosk can't usefully open itself — launch on the controller.
external_open_tx: broadcast::Sender<String>, external_open_tx: broadcast::Sender<String>,
/// Bridge that dispatches companion input into the local kiosk Chromium
/// as trusted CDP events (reaches inside cross-origin app iframes).
/// Inert (never connects) on nodes without a kiosk.
cdp_bridge: cdp::CdpBridge,
/// Content-addressed blob store for attachments shared over mesh/federation. /// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>, blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities. /// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
@@ -84,7 +78,6 @@ impl ApiHandler {
); );
let (input_relay_tx, _) = broadcast::channel(64); let (input_relay_tx, _) = broadcast::channel(64);
let (external_open_tx, _) = broadcast::channel(16); let (external_open_tx, _) = broadcast::channel(16);
let cdp_bridge = cdp::CdpBridge::spawn();
// Derive a blob-store capability key from the node's Ed25519 signing // Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates // key. SHA-256 domain-separated so rotating the identity rotates
@@ -115,7 +108,6 @@ impl ApiHandler {
session_store, session_store,
input_relay_tx, input_relay_tx,
external_open_tx, external_open_tx,
cdp_bridge,
blob_store, blob_store,
self_pubkey_hex, self_pubkey_hex,
}) })
@@ -409,7 +401,6 @@ impl ApiHandler {
req, req,
self.input_relay_tx.clone(), self.input_relay_tx.clone(),
self.external_open_tx.subscribe(), self.external_open_tx.subscribe(),
self.cdp_bridge.clone(),
) )
.await; .await;
} }
@@ -424,7 +415,6 @@ impl ApiHandler {
req, req,
self.input_relay_tx.subscribe(), self.input_relay_tx.subscribe(),
self.external_open_tx.clone(), self.external_open_tx.clone(),
self.cdp_bridge.clone(),
) )
.await; .await;
} }
@@ -459,14 +449,6 @@ impl ApiHandler {
self.handle_model_proxy(req_with_bytes, p).await self.handle_model_proxy(req_with_bytes, p).await
} }
// AIUI Routstr proxy — the explicit, user-selected Routstr
// provider (model catalog + Cashu-paid completions), same
// session-gate discipline as the model proxy above. D-05: paid
// requests are refused unless the operator has armed a budget.
(_, p) if p.starts_with("/aiui/api/routstr/") => {
self.handle_routstr_proxy(req_with_bytes, p).await
}
// Health — unauthenticated, returns JSON with service status // Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => { (Method::GET, "/health") => {
let recovery_complete = crate::crash_recovery::is_recovery_complete(); let recovery_complete = crate::crash_recovery::is_recovery_complete();
@@ -84,14 +84,14 @@ async fn route_model_proxy(
/// call back into `ApiHandler::is_authenticated` — keeping this small and /// call back into `ApiHandler::is_authenticated` — keeping this small and
/// dependency-free is what makes the 401 behaviour unit-testable without /// dependency-free is what makes the 401 behaviour unit-testable without
/// paying for a full `ApiHandler` in every test. /// paying for a full `ApiHandler` in every test.
pub(super) async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool { async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
match session::extract_session_cookie(headers) { match session::extract_session_cookie(headers) {
Some(token) => session_store.validate(&token).await, Some(token) => session_store.validate(&token).await,
None => false, None => false,
} }
} }
pub(super) fn unauthorized() -> Response<Body> { fn unauthorized() -> Response<Body> {
let body = serde_json::json!({ "error": "Unauthorized" }); let body = serde_json::json!({ "error": "Unauthorized" });
Response::builder() Response::builder()
.status(StatusCode::UNAUTHORIZED) .status(StatusCode::UNAUTHORIZED)
@@ -119,7 +119,7 @@ fn key_not_configured() -> Response<Body> {
/// backends' egress screen never sees the forwarder path — the standalone /// backends' egress screen never sees the forwarder path — the standalone
/// frontend posts FULL history and images straight here — so the forwarder /// frontend posts FULL history and images straight here — so the forwarder
/// screens for itself. 400, plain-language, never naming what matched. /// screens for itself. 400, plain-language, never naming what matched.
pub(super) fn blocked_secret_shaped() -> Response<Body> { fn blocked_secret_shaped() -> Response<Body> {
let body = serde_json::json!({ let body = serde_json::json!({
"error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere." "error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere."
}); });
@@ -134,12 +134,12 @@ pub(super) fn blocked_secret_shaped() -> Response<Body> {
/// the assistant's secret-shape rules (G-B1) with this node's own secrets /// the assistant's secret-shape rules (G-B1) with this node's own secrets
/// as the deny corpus. Returns Some(kind) — kind only, never the value — /// as the deny corpus. Returns Some(kind) — kind only, never the value —
/// when the content must not leave. /// when the content must not leave.
pub(super) async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> { async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
let secrets = crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await; let secrets = crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await;
crate::assistant::egress::scan_secret_shapes(text, &secrets) crate::assistant::egress::scan_secret_shapes(text, &secrets)
} }
pub(super) fn bad_gateway(msg: &str) -> Response<Body> { fn bad_gateway(msg: &str) -> Response<Body> {
let body = serde_json::json!({ "error": msg }); let body = serde_json::json!({ "error": msg });
Response::builder() Response::builder()
.status(StatusCode::BAD_GATEWAY) .status(StatusCode::BAD_GATEWAY)
@@ -331,7 +331,7 @@ async fn forward(
/// same shape as `proxy.rs`'s peer-content Range streamer — so a /// same shape as `proxy.rs`'s peer-content Range streamer — so a
/// token-by-token reply doesn't wait for the full response before the first /// token-by-token reply doesn't wait for the full response before the first
/// byte reaches the browser. /// byte reaches the browser.
pub(super) fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> { fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
let status = resp.status().as_u16(); let status = resp.status().as_u16();
let headers = resp.headers().clone(); let headers = resp.headers().clone();
let mut builder = Response::builder().status(status); let mut builder = Response::builder().status(status);
@@ -212,7 +212,6 @@ impl ApiHandler {
req: Request<hyper::Body>, req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>, relay_tx: broadcast::Sender<String>,
mut external_open_rx: broadcast::Receiver<String>, mut external_open_rx: broadcast::Receiver<String>,
cdp_bridge: super::cdp::CdpBridge,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1 // Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req let player_id: Option<u8> = req
@@ -318,20 +317,9 @@ impl ApiHandler {
} else { } else {
text.clone() text.clone()
}; };
let validation = handle_input(&text).await; let _ = relay_tx.send(relay_text);
let _ = relay_tx.send(relay_text.clone());
// Trusted-input path: while the kiosk CDP
// bridge is live, also dispatch validated
// input into the kiosk Chromium so it
// lands inside cross-origin app iframes
// (the web relay above can't cross that
// boundary; the kiosk subscriber mutes its
// own DOM synthesis — remote_relay.rs).
if matches!(validation, Ok(None)) && cdp_bridge.is_active() {
cdp_bridge.send(&relay_text);
}
match validation { match handle_input(&text).await {
Ok(Some(reply)) => { Ok(Some(reply)) => {
let _ = tx.send(Message::Text(reply)).await; let _ = tx.send(Message::Text(reply)).await;
} }
@@ -21,16 +21,7 @@ impl ApiHandler {
req: Request<hyper::Body>, req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>, mut relay_rx: broadcast::Receiver<String>,
external_open_tx: broadcast::Sender<String>, external_open_tx: broadcast::Sender<String>,
cdp_bridge: super::cdp::CdpBridge,
) -> Result<Response<hyper::Body>> { ) -> Result<Response<hyper::Body>> {
// The kiosk browser self-identifies with ?kiosk=1 so we can suppress
// its DOM-synthesis path while the CDP bridge delivers trusted input
// (otherwise every key/click/scroll would apply twice). A remote
// browser claiming kiosk=1 only mutes its own input — harmless.
let is_kiosk = req
.uri()
.query()
.is_some_and(|q| q.split('&').any(|s| s == "kiosk=1"));
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req) let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?; .map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -69,19 +60,6 @@ impl ApiHandler {
msg = relay_rx.recv() => { msg = relay_rx.recv() => {
match msg { match msg {
Ok(text) => { Ok(text) => {
// Kiosk + live CDP bridge: keys/clicks/
// scrolls arrive as trusted browser input
// via CDP; forward only cursor moves (the
// on-screen cursor is drawn by the page)
// so nothing applies twice.
if is_kiosk && cdp_bridge.is_active() {
let tag = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.get("t").and_then(|t| t.as_str().map(str::to_string)));
if !matches!(tag.as_deref(), Some("m") | Some("o") | Some("p")) {
continue;
}
}
if tx.send(Message::Text(text)).await.is_err() { if tx.send(Message::Text(text)).await.is_err() {
break; break;
} }
@@ -1,535 +0,0 @@
//! Session-gated forwarder for `/aiui/api/routstr/*` — the explicit,
//! user-selected Routstr path (as opposed to `assistant/backends/routstr.rs`,
//! which is the D-04 fallback leg the operator never chooses directly).
//!
//! AIUI's model picker lists Routstr as a first-class provider; selecting one
//! of its models routes chat completions through here. Same discipline as
//! `model_proxy.rs`: auth is re-derived from the request's own session cookie
//! (never trusted to nginx), inbound `authorization`/`cookie` headers are
//! never forwarded, and every outbound body is egress-screened (S3) before it
//! leaves the node.
//!
//! Payment is Cashu, D-05-gated end to end: a request is refused unless the
//! operator has set a non-zero Routstr allowance (Settings → System), the
//! quoted price fits the remaining allowance, and `auto_pay_token` (the ONE
//! budget-capped payment primitive, T-13-89) agrees to build the token. The
//! provider's change (`X-Cashu` / `X-Cashu-Refund` response headers, per
//! docs.routstr.com) is redeemed back into the node wallet and only the net
//! is recorded against the allowance.
//!
//! Upstream is the public Routstr aggregator instance routstr.com itself
//! ships against (verified live 2026-08-14: `/v1/models` serves the full
//! catalog with `sats_pricing`; the canonical `api.routstr.com` host 404s).
//! Making the instance operator-configurable — or sourcing it from the Nostr
//! provider announcements once those carry real endpoint/pricing content —
//! is the planned follow-up, not this file's job.
use super::ApiHandler;
use crate::session::SessionStore;
use anyhow::Result;
use hyper::{Body, Method, Request, Response, StatusCode};
use serde_json::{json, Value};
use std::path::Path;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use super::model_proxy::{
bad_gateway, blocked_secret_shaped, forward_screen, is_authenticated, unauthorized,
};
/// The live public Routstr aggregator (the same instance routstr.com's own
/// frontend queries for `/v1/providers` and `/v1/models`).
const ROUTSTR_INSTANCE: &str = "https://routstr.otrta.me";
/// Generation cap forced onto every forwarded completion — never unbounded
/// (T-13-88), and the completion half of the price quote is arithmetic over
/// exactly this figure.
const MAX_COMPLETION_TOKENS: u64 = 1024;
/// Same round-trip ceiling as `model_proxy.rs`'s Claude/Ollama forwarders.
const FORWARD_TIMEOUT_SECS: u64 = 180;
/// Models-catalog cache TTL — mirrors `backends/routstr.rs`'s provider
/// discovery TTL. The catalog prices every chat request, so it cannot be
/// fetched per-message without doubling latency.
const MODELS_CACHE_TTL: Duration = Duration::from_secs(300);
/// One model's sats-denominated pricing, parsed from the aggregator's
/// `/v1/models` entries (`sats_pricing`). Rates are sats PER TOKEN (fractional
/// floats); `request` is a flat per-request fee in sats. Untrusted input — a
/// missing/garbled field parses as 0.0 and simply prices low, which the
/// remaining-allowance ceiling still caps.
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct SatsPricing {
#[serde(default)]
prompt: f64,
#[serde(default)]
completion: f64,
#[serde(default)]
request: f64,
}
/// Quote a price in whole sats for one completion call: flat request fee +
/// prompt rate × (payload chars / 4, the usual chars-per-token rule of thumb)
/// + completion rate × the forced `MAX_COMPLETION_TOKENS` cap, +20% margin,
/// rounded up, never below 1. Deliberately a pure function so the arithmetic
/// is unit-testable; deliberately conservative because the provider's change
/// comes back as a Cashu refund and is redeemed — overquoting costs nothing
/// but float, underquoting gets the request rejected upstream.
fn estimate_price_sats(pricing: &SatsPricing, prompt_chars: usize, completion_tokens: u64) -> u64 {
let prompt_tokens = (prompt_chars as f64) / 4.0;
let raw = pricing.request
+ pricing.prompt * prompt_tokens
+ pricing.completion * (completion_tokens as f64);
let with_margin = raw * 1.2;
(with_margin.ceil() as u64).max(1)
}
/// Process-lifetime cache of the aggregator's models catalog (same pattern as
/// `backends/routstr.rs`'s `PROVIDER_CACHE`).
static MODELS_CACHE: OnceLock<StdMutex<Option<(Instant, Value)>>> = OnceLock::new();
fn cached_models() -> Option<Value> {
let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
let guard = cache.lock().expect("routstr models cache poisoned");
guard.as_ref().and_then(|(at, models)| {
if at.elapsed() < MODELS_CACHE_TTL {
Some(models.clone())
} else {
None
}
})
}
fn set_cached_models(models: Value) {
let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
*cache.lock().expect("routstr models cache poisoned") = Some((Instant::now(), models));
}
/// Fetch (or serve cached) the aggregator's `/v1/models` catalog.
async fn fetch_models() -> Result<Value> {
if let Some(cached) = cached_models() {
return Ok(cached);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()?;
let url = format!("{ROUTSTR_INSTANCE}/v1/models");
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("routstr models upstream returned HTTP {}", resp.status());
}
let models: Value = resp.json().await?;
set_cached_models(models.clone());
Ok(models)
}
/// Find one model's `sats_pricing` in the catalog by exact id.
fn pricing_for_model(models: &Value, model_id: &str) -> Option<SatsPricing> {
models
.get("data")?
.as_array()?
.iter()
.find(|m| m.get("id").and_then(|v| v.as_str()) == Some(model_id))
.and_then(|m| m.get("sats_pricing"))
.and_then(|sp| serde_json::from_value(sp.clone()).ok())
}
fn json_response(status: StatusCode, body: Value) -> Response<Body> {
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
.unwrap_or_else(|_| Response::new(Body::from("{}")))
}
/// Plain-language refusal naming the UI path that fixes it — never a bare
/// status (RULE: every action needs a UI path).
fn budget_refusal(msg: String) -> Response<Body> {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
json!({ "error": { "message": msg } }),
)
}
impl ApiHandler {
/// Entry point wired into the `/aiui/api/routstr/` arm in `mod.rs` —
/// thin, like `handle_model_proxy`, so the routing/budget logic below is
/// testable without a full `ApiHandler`.
pub(super) async fn handle_routstr_proxy(
&self,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>> {
route_routstr_proxy(&self.session_store, &self.config.data_dir, req, path).await
}
}
async fn route_routstr_proxy(
session_store: &SessionStore,
data_dir: &Path,
req: Request<Body>,
path: &str,
) -> Result<Response<Body>> {
if !is_authenticated(session_store, req.headers()).await {
tracing::warn!("401 routstr proxy {} — session invalid or missing", path);
return Ok(unauthorized());
}
match path.strip_prefix("/aiui/api/routstr/") {
Some("models") if req.method() == Method::GET => forward_models().await,
Some("chat/completions") if req.method() == Method::POST => {
forward_chat(req, data_dir).await
}
_ => Ok(unauthorized()),
}
}
/// GET /aiui/api/routstr/models — the full catalog, passed through so AIUI
/// can render ids/names and show sats pricing. Read-only and unpaid.
async fn forward_models() -> Result<Response<Body>> {
match fetch_models().await {
Ok(models) => Ok(json_response(StatusCode::OK, models)),
Err(e) => {
tracing::warn!("routstr proxy: models upstream failed: {}", e);
Ok(bad_gateway("Routstr model catalog is unreachable"))
}
}
}
/// POST /aiui/api/routstr/chat/completions — one paid, non-streaming,
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
/// offline) → price quote → pay → forward → redeem change → record net.
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
let payload = hyper::body::to_bytes(req.into_body())
.await
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
let payload_str = String::from_utf8_lossy(&payload).to_string();
// S3: the standalone frontend posts full history straight here with no
// assistant loop (and no egress screen) behind it.
if let Some(kind) = forward_screen(&payload_str, data_dir).await {
tracing::error!(
kind,
"routstr proxy: blocked chat forward — secret-shaped content"
);
return Ok(blocked_secret_shaped());
}
let mut body: Value = match serde_json::from_str(&payload_str) {
Ok(v) => v,
Err(_) => {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": "request body is not valid JSON" } }),
));
}
};
let Some(model_id) = body.get("model").and_then(|v| v.as_str()).map(String::from) else {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": "request is missing a model id" } }),
));
};
// D-05 gate, checked before any network I/O: a zero allowance means
// Routstr is refused outright, with the UI path that arms it.
let mut budget = crate::assistant::AssistantBudget::load(data_dir).await;
if budget.allowance_sats == 0 {
return Ok(budget_refusal(
"Routstr is disabled on this node — set a sats budget in Settings → System → \
Routstr AI budget to enable it."
.to_string(),
));
}
let remaining = budget.remaining_sats();
if remaining == 0 {
return Ok(budget_refusal(format!(
"This period's Routstr budget is spent ({} of {} sats). Raise the allowance in \
Settings → System → Routstr AI budget to continue.",
budget.spent_sats, budget.allowance_sats
)));
}
// Price the request from the catalog. An unknown model is a caller bug
// (the dropdown only offers catalog models), not a reason to guess a
// price.
let models = match fetch_models().await {
Ok(m) => m,
Err(e) => {
tracing::warn!("routstr proxy: cannot price request, models fetch failed: {e}");
return Ok(bad_gateway(
"Routstr model catalog is unreachable — cannot price this request",
));
}
};
let Some(pricing) = pricing_for_model(&models, &model_id) else {
return Ok(json_response(
StatusCode::BAD_REQUEST,
json!({ "error": { "message": format!("unknown Routstr model: {model_id}") } }),
));
};
// Force the shape this forwarder actually supports: non-streaming, with
// an explicit, capped generation limit (T-13-88).
let max_tokens = body
.get("max_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(MAX_COMPLETION_TOKENS)
.min(MAX_COMPLETION_TOKENS);
body["stream"] = json!(false);
body["max_tokens"] = json!(max_tokens);
let price_sats = estimate_price_sats(&pricing, payload_str.len(), max_tokens);
if price_sats > remaining {
return Ok(budget_refusal(format!(
"This request quotes ~{price_sats} sats but only {remaining} sats remain in this \
period's Routstr budget (Settings → System → Routstr AI budget)."
)));
}
// Pay via the ONE budget-capped primitive (T-13-89) — same call, same
// mint list as the fallback leg in backends/routstr.rs.
let accepted_mints = crate::wallet::ecash::load_accepted_mints(data_dir)
.await
.map(|m| m.mints)
.unwrap_or_default();
let token = match crate::swarm::payment::auto_pay_token(
data_dir,
&budget.payment_policy(),
&accepted_mints,
price_sats,
)
.await?
{
Some(t) => t,
None => {
return Ok(budget_refusal(format!(
"The node wallet could not fund this request (~{price_sats} sats) — check the \
ecash balance and accepted mints in Settings → Wallet."
)));
}
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS))
.build()?;
let url = format!("{ROUTSTR_INSTANCE}/v1/chat/completions");
let resp = match client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// The token never reached the provider — reclaim it into our own
// wallet so the sats aren't stranded, and record nothing.
match crate::wallet::ecash::receive_token(data_dir, &token).await {
Ok(_) => tracing::info!(
"routstr proxy: upstream send failed ({e}); unsent payment token reclaimed"
),
Err(re) => tracing::warn!(
"routstr proxy: upstream send failed ({e}) AND reclaiming the unsent token \
failed ({re}) — {price_sats} sats may be stranded in the token"
),
}
return Ok(bad_gateway("Routstr provider is unreachable"));
}
};
let status = resp.status();
// Change comes back as a Cashu token header (docs.routstr.com names both
// spellings across versions); redeem it so only the net leaves the
// allowance.
let refund_token = ["x-cashu-refund", "x-cashu"]
.iter()
.find_map(|h| resp.headers().get(*h))
.and_then(|v| v.to_str().ok())
.map(String::from);
let resp_body = resp.bytes().await.unwrap_or_default();
let mut reclaimed = 0u64;
if let Some(refund) = refund_token {
match crate::wallet::ecash::receive_token(data_dir, &refund).await {
Ok(sats) => reclaimed = sats,
Err(e) => tracing::warn!("routstr proxy: redeeming the change token failed: {e}"),
}
} else if !status.is_success() {
// The provider refused the request (e.g. "mint unreachable") and
// sent no change — if it never actually redeemed our token, the
// proofs are still ours to take back. If it DID redeem and then
// failed, this reclaim fails harmlessly and the spend stands.
match crate::wallet::ecash::receive_token(data_dir, &token).await {
Ok(sats) => {
reclaimed = sats;
tracing::info!(
"routstr proxy: upstream refused (HTTP {status}); unredeemed payment token \
reclaimed ({sats} sats)"
);
}
Err(e) => tracing::warn!(
"routstr proxy: upstream refused (HTTP {status}) and the payment token could \
not be reclaimed ({e}) — treating the {price_sats} sats as spent"
),
}
}
let net_sats = price_sats.saturating_sub(reclaimed);
if net_sats > 0 {
if let Err(e) = budget.record_spend(data_dir, net_sats).await {
tracing::warn!(
error = %e,
"routstr proxy: failed to persist the budget spend (the payment itself already happened)"
);
}
}
tracing::info!(
model = %model_id,
quoted = price_sats,
reclaimed,
net = net_sats,
status = %status,
"routstr proxy: forwarded paid chat completion"
);
Ok(Response::builder()
.status(status.as_u16())
.header("Content-Type", "application/json")
.body(Body::from(resp_body))
.unwrap_or_else(|_| Response::new(Body::from("{}"))))
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_store() -> SessionStore {
let path = std::env::temp_dir().join(format!(
"archy-routstr-proxy-test-sessions-{}.json",
rand::RngCore::next_u64(&mut rand::rngs::OsRng)
));
SessionStore::new_for_tests(path)
}
fn req(method: &str, path: &str, cookie: Option<&str>, body: &'static str) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(path);
if let Some(c) = cookie {
builder = builder.header("cookie", format!("session={c}"));
}
builder.body(Body::from(body)).unwrap()
}
#[tokio::test]
async fn models_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req("GET", "/aiui/api/routstr/models", None, "");
let resp = route_routstr_proxy(&store, data_dir.path(), r, "/aiui/api/routstr/models")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn chat_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req("POST", "/aiui/api/routstr/chat/completions", None, "{}");
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
/// D-05: a fresh node (no budget file → zero allowance) refuses the paid
/// path BEFORE any pricing/network I/O — this test runs fully offline.
#[tokio::test]
async fn chat_with_zero_allowance_is_refused_offline() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
r#"{"model":"some-model","messages":[{"role":"user","content":"hi"}]}"#,
);
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let v: Value = serde_json::from_slice(&body).unwrap();
let msg = v["error"]["message"].as_str().unwrap();
assert!(msg.contains("Settings"), "refusal must name the UI path");
}
#[tokio::test]
async fn chat_body_carrying_bip39_is_blocked() {
let store = test_store().await;
let token = store.create().await;
let data_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(data_dir.path().join("secrets")).unwrap();
let r = req(
"POST",
"/aiui/api/routstr/chat/completions",
Some(&token),
r#"{"model":"m","messages":[{"role":"user","content":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"}]}"#,
);
let resp = route_routstr_proxy(
&store,
data_dir.path(),
r,
"/aiui/api/routstr/chat/completions",
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn price_estimate_is_conservative_and_never_zero() {
// A free/garbled pricing entry still quotes at least 1 sat.
assert_eq!(estimate_price_sats(&SatsPricing::default(), 100, 1024), 1);
// Live-catalog-shaped numbers (deepseek-v4-flash, 2026-08-14):
// request 0.001, prompt ~0.000178/tok, completion ~0.000267/tok.
let p = SatsPricing {
prompt: 0.000178,
completion: 0.000267,
request: 0.001,
};
let quote = estimate_price_sats(&p, 4000, 1024);
// ~0.18 + ~0.27 + flat, with margin → rounds up to 1 sat.
assert_eq!(quote, 1);
// A pricier model scales with the prompt.
let expensive = SatsPricing {
prompt: 0.05,
completion: 0.1,
request: 1.0,
};
let quote = estimate_price_sats(&expensive, 40_000, 1024);
assert!(quote >= 600, "quote {quote} should reflect real rates");
}
#[test]
fn pricing_lookup_finds_exact_model_id() {
let models = json!({ "data": [
{ "id": "a-model", "sats_pricing": { "prompt": 0.1, "completion": 0.2, "request": 1.0 } },
{ "id": "other", "sats_pricing": { "prompt": 0.3 } }
]});
let p = pricing_for_model(&models, "a-model").unwrap();
assert_eq!(p.request, 1.0);
assert!(pricing_for_model(&models, "missing").is_none());
}
}
-62
View File
@@ -42,13 +42,6 @@ impl RpcHandler {
"port": g.port, "port": g.port,
"app_id": g.app_id, "app_id": g.app_id,
"app_name": g.app_name, "app_name": g.app_name,
// Is the login challenge active on this port right now
// (manifest default + operator override, resolved)?
"gate_enabled": g.auth_enabled,
// Whether an operator override is recorded, and what the
// manifest would do without it — the UI needs all three
// to render a meaningful toggle.
"override": crate::container::app_gate_config::gate_override(&g.app_id),
}) })
}) })
.collect(); .collect();
@@ -63,59 +56,4 @@ impl RpcHandler {
"exempt": exempt, "exempt": exempt,
})) }))
} }
/// `security.set-app-gate` — the operator's per-app gate toggle.
///
/// Params: `{ id: "<app_id>", enabled: true | false | null }`.
/// `enabled: null` clears the override so the manifest default applies
/// again. Takes effect on the next request (the gate resolves per-request
/// policy from the live port map) — no rebind, no restart.
pub(in crate::api::rpc) async fn handle_set_app_gate(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?
.to_string();
let enabled = match params.get("enabled") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::Bool(b)) => Some(*b),
Some(other) => anyhow::bail!("enabled must be true, false or null, got {other}"),
};
// Only apps the gate actually fronts have a challenge to toggle.
// Writing an override for anything else would sit silently in the
// config doing nothing — reject instead so a typo'd id is loud.
let port_map = self.app_gate.port_map().await;
if !port_map.gated_ports().any(|g| g.app_id == app_id) {
anyhow::bail!(
"'{app_id}' has no gate-fronted ports — nothing to toggle \
(auth: none/local ports are manifest-declared, not runtime-toggled)"
);
}
crate::container::app_gate_config::write_gate_override(&app_id, enabled)
.map_err(|e| anyhow::anyhow!("Failed to persist gate override: {e}"))?;
// Rebuild the port map now so the change is live on the next request
// instead of after the next 60s sweep.
self.app_gate.refresh().await;
let effective: Vec<serde_json::Value> = self
.app_gate
.port_map()
.await
.gated_ports()
.filter(|g| g.app_id == app_id)
.map(|g| serde_json::json!({ "port": g.port, "gate_enabled": g.auth_enabled }))
.collect();
tracing::info!(
app = %app_id,
override_ = ?enabled,
"app gate override updated by operator"
);
Ok(serde_json::json!({ "id": app_id, "override": enabled, "ports": effective }))
}
} }
@@ -344,7 +344,6 @@ impl RpcHandler {
"content.indeehub-projects" => self.handle_content_indeehub_projects().await, "content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"system.settings.get" => self.handle_system_settings_get(params).await, "system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await, "system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await, "system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await, "system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
"bitcoin.relay-update-settings" => { "bitcoin.relay-update-settings" => {
@@ -133,7 +133,6 @@ impl RpcHandler {
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await, "lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await, "lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await, "lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await, "lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await, "lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await, "lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
@@ -266,12 +265,6 @@ impl RpcHandler {
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await, "wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await, "wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await, "wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await, "wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar) // Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await, "wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
@@ -495,7 +488,6 @@ impl RpcHandler {
// System monitoring // System monitoring
"security.app-gate-status" => self.handle_app_gate_status().await, "security.app-gate-status" => self.handle_app_gate_status().await,
"security.set-app-gate" => self.handle_set_app_gate(params).await,
"system.get-hostname" => self.handle_system_get_hostname().await, "system.get-hostname" => self.handle_system_get_hostname().await,
"system.stats" => self.handle_system_stats().await, "system.stats" => self.handle_system_stats().await,
"system.processes" => self.handle_system_processes().await, "system.processes" => self.handle_system_processes().await,
@@ -511,7 +503,6 @@ impl RpcHandler {
"ai.permissions.set" => self.handle_ai_permissions_set(params).await, "ai.permissions.set" => self.handle_ai_permissions_set(params).await,
"system.settings.get" => self.handle_system_settings_get(params).await, "system.settings.get" => self.handle_system_settings_get(params).await,
"system.settings.set" => self.handle_system_settings_set(params).await, "system.settings.set" => self.handle_system_settings_set(params).await,
"system.node-ca.generate" => self.handle_system_node_ca_generate().await,
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await, "system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await, "system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
@@ -696,22 +696,6 @@ impl RpcHandler {
anyhow::bail!("Refusing to peer with self"); anyhow::bail!("Refusing to peer with self");
} }
// Bind the DID to the advertised pubkey. Without this the signature
// check below is self-referential (the caller signs over a pubkey it
// also supplies), so a consistent-but-unrelated keypair would pass.
// The DID-rotation handler already enforces the same invariant.
match identity::did_key_from_pubkey_hex(pubkey) {
Ok(derived) if derived == did => {}
Ok(derived) => {
tracing::warn!(peer_did = %did, derived_did = %derived, "Rejected peer-joined: DID does not match pubkey");
anyhow::bail!("DID does not match pubkey");
}
Err(e) => {
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: invalid pubkey");
anyhow::bail!("Invalid pubkey");
}
}
// Verify ed25519 signature to prevent federation spoofing (H2 security fix) // Verify ed25519 signature to prevent federation spoofing (H2 security fix)
let signature = params.get("signature").and_then(|v| v.as_str()); let signature = params.get("signature").and_then(|v| v.as_str());
match signature { match signature {
@@ -719,16 +703,10 @@ impl RpcHandler {
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey); let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) { match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
Ok(true) => {} Ok(true) => {}
Ok(false) => { _ => {
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature"); tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
anyhow::bail!("Invalid signature"); anyhow::bail!("Invalid signature");
} }
Err(e) => {
// Malformed hex / wrong length — distinguish from a
// genuine mismatch so the log tells us which it was.
tracing::warn!(peer_did = %did, error = %e, "Rejected peer-joined: malformed signature");
anyhow::bail!("Invalid signature");
}
} }
} }
None => { None => {
+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 }))
@@ -607,81 +607,9 @@ impl RpcHandler {
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
// LND returns r_hash base64-encoded; the lookup endpoint the Receive
// flow polls (`lnd.invoicestatus`) wants it hex — hand the UI the
// ready-to-use form.
let r_hash_hex = {
use base64::Engine as _;
body.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default()
};
Ok(serde_json::json!({ Ok(serde_json::json!({
"payment_request": payment_request, "payment_request": payment_request,
"amount_sats": amount_sats, "amount_sats": amount_sats,
"r_hash_hex": r_hash_hex,
}))
}
/// lnd.invoicestatus — is this invoice settled yet? Polled by the wallet's
/// Receive flow so a Lightning payment gets the same "money has arrived"
/// success screen as on-chain (minus the broadcast step: settlement is
/// final). Params: `{ "r_hash_hex": string }`.
pub(in crate::api::rpc) async fn handle_lnd_invoicestatus(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let r_hash_hex = params
.get("r_hash_hex")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'r_hash_hex' parameter"))?;
if r_hash_hex.len() != 64 || !r_hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("r_hash_hex must be 64 hex characters"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{r_hash_hex}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to query invoice")?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice lookup response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Invoice lookup failed: {}", msg));
}
let settled = body
.get("state")
.and_then(|v| v.as_str())
.map(|s| s == "SETTLED")
.unwrap_or_else(|| {
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
let amt_paid_sat = body
.get("amt_paid_sat")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| body.get("amt_paid_sat").and_then(|v| v.as_i64()))
.unwrap_or(0);
Ok(serde_json::json!({
"settled": settled,
"amt_paid_sat": amt_paid_sat,
})) }))
} }
@@ -405,9 +405,17 @@ impl RpcHandler {
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let device_type = svc.shared_state().status.read().await.device_type; let device_type = svc.shared_state().status.read().await.device_type;
// Resource transfer is a native RNS transfer over LoRa — it needs an
// actual radio route to this contact, not just a Reticulum device on
// our end. A federation-only peer with no radio twin fits the size
// and device-type checks but has no dest_prefix to send to; without
// this check the send falls into send_content_resource and fails
// with "Peer is federation-only (no radio twin)" (picture-send,
// 2026-08-07) instead of falling back to the federation path below.
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
&& device_type == crate::mesh::types::DeviceType::Reticulum && device_type == crate::mesh::types::DeviceType::Reticulum
&& bytes.len() <= RETICULUM_RESOURCE_MAX; && bytes.len() <= RETICULUM_RESOURCE_MAX
&& svc.has_radio_route(contact_id).await;
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer { if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
anyhow::bail!( anyhow::bail!(
@@ -492,15 +500,58 @@ impl RpcHandler {
) )
.await? .await?
} else { } else {
svc.send_typed_wire( // Federation-only peers have no radio twin for
contact_id, // send_typed_wire's LoRa dest-prefix resolution — route over
wire, // Tor federation instead, mirroring mesh.send-content's onion
"content_ref", // lookup, or the send fails with "Peer is federation-only (no
&display, // radio twin)" (picture-send from a federation-only contact,
Some(typed_json), // 2026-08-07).
seq, let federation_onion = {
) let state = svc.shared_state();
.await? let peers = state.peers.read().await;
peers
.get(&contact_id)
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
};
let federation_onion = match federation_onion {
Some((Some(pubkey_hex), did)) => {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
nodes
.iter()
.find(|n| n.pubkey == pubkey_hex)
.map(|n| n.onion.clone())
.or_else(|| {
did.as_ref().and_then(|d| {
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
})
})
}
_ => None,
};
if let Some(onion) = federation_onion {
svc.send_typed_wire_via_federation(
contact_id,
&onion,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
}
} }
}; };
@@ -590,6 +641,16 @@ impl RpcHandler {
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1); let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum; let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
// A Reticulum device on our end doesn't mean THIS peer is radio
// reachable — a federation-only contact (no radio twin) has no dest
// prefix for a resource transfer, even though it's small enough and
// our device type qualifies. Without this check the frontend was
// steered into mesh.send-content-inline's resource-transfer path,
// which fails with "Peer is federation-only (no radio twin)"
// (picture-send, 2026-08-07); the tier below now defers to the
// has_tor branches for such peers, which route via mesh.send-content
// (federation) instead.
let has_radio_route = is_reticulum && svc.has_radio_route(contact_id).await;
let (tier, reason) = if size <= MESH_AUTO_MAX { let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh") ("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX { } else if size <= MESH_HARD_MAX {
@@ -598,7 +659,7 @@ impl RpcHandler {
} else { } else {
("auto-mesh", "No Tor path — sending inline over mesh") ("auto-mesh", "No Tor path — sending inline over mesh")
} }
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX { } else if has_radio_route && size <= RETICULUM_RESOURCE_MAX {
( (
"resource-mesh", "resource-mesh",
"Sending directly over LoRa via a Reticulum resource transfer", "Sending directly over LoRa via a Reticulum resource transfer",

Some files were not shown because too many files have changed in this diff Show More