Compare commits
63
Commits
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
package com.archipelago.app
|
||||
|
||||
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,6 +9,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.screens.releaseKioskWebView
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -19,7 +20,13 @@ class MainActivity : ComponentActivity() {
|
||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
// Hold the branded system splash until the nav graph has its launch
|
||||
// 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()
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingPairUri.value = intent?.dataString
|
||||
@@ -29,6 +36,7 @@ class MainActivity : ComponentActivity() {
|
||||
AppNavHost(
|
||||
pairUri = pairUri,
|
||||
onPairUriConsumed = { pendingPairUri.value = null },
|
||||
onReady = { navReady = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,4 +46,14 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
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,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
|
||||
@@ -29,6 +30,18 @@ data class ServerEntry(
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
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. */
|
||||
private fun urlHost(host: String): String =
|
||||
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
@@ -89,9 +102,9 @@ class ServerPreferences(private val context: Context) {
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||
val address = prefs[activeAddressKey] ?: return@map null
|
||||
ServerEntry(
|
||||
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
|
||||
val address = prefs[activeAddressKey] ?: return null
|
||||
return ServerEntry(
|
||||
address = address,
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
@@ -102,19 +115,52 @@ 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 raw = prefs[savedServersKey] ?: emptySet()
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }
|
||||
}
|
||||
// Sorted so set-iteration order can't produce a structurally different
|
||||
// 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 ->
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
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) {
|
||||
context.dataStore.edit { prefs ->
|
||||
|
||||
@@ -37,6 +37,7 @@ class ArchyVpnService : VpnService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var warmerJob: Job? = null
|
||||
private var handoffKickJob: Job? = null
|
||||
|
||||
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
||||
// tunnel's underlying network stays pinned to the interface that was
|
||||
@@ -204,14 +205,20 @@ class ArchyVpnService : VpnService() {
|
||||
|
||||
/**
|
||||
* Track the phone's default network and hand the mesh over to it as the
|
||||
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
|
||||
* phone roams (Wi-Fi ⇄ 5G). Two actions per change:
|
||||
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
|
||||
* network instead of dying on the one it launched with.
|
||||
* 2. re-home the mesh — kick the session warmer so discovery + sessions
|
||||
* rebuild on the new path immediately; the node's own fast-reconnect
|
||||
* (1s) redials peers over the new route.
|
||||
* rebuild on the new path; the node's own fast-reconnect (1s) redials
|
||||
* peers over the new route.
|
||||
* onAvailable also fires for the FIRST network, which is how the initial
|
||||
* underlying network gets set.
|
||||
*
|
||||
* 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() {
|
||||
if (networkCallback != null) return
|
||||
@@ -236,10 +243,6 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -251,13 +254,22 @@ class ArchyVpnService : VpnService() {
|
||||
runCatching { setUnderlyingNetworks(arrayOf(network)) }
|
||||
if (changed && FipsNative.isRunning()) {
|
||||
Log.i(TAG, "network handoff → re-homing mesh on new default network")
|
||||
// Fresh warmer pass drives immediate rediscovery/session rebuild
|
||||
// on the new path instead of waiting out dead-link timeouts.
|
||||
startSessionWarmer()
|
||||
// Coalesced, not immediate: marginal Wi-Fi flaps the default
|
||||
// Wi-Fi ⇄ cell in bursts, and an aggressive warmer pass per flip
|
||||
// meant near-constant session churn — the "reconnects a lot"
|
||||
// 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() {
|
||||
handoffKickJob?.cancel()
|
||||
handoffKickJob = null
|
||||
val cm = connectivityManager
|
||||
val cb = networkCallback
|
||||
if (cm != null && cb != null) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.archipelago.app.fips
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Glue between pairing and the mesh: persists the node peer from a scanned
|
||||
@@ -36,20 +38,27 @@ object FipsManager {
|
||||
* No-op on devices without the native lib (non-arm64).
|
||||
*/
|
||||
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
|
||||
if (info == null || !FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||
// exists — relying on the consentNeeded collector left a running
|
||||
// mesh on the OLD peer list whenever the collector wasn't active
|
||||
// (fresh pairings looked dead until a full app restart).
|
||||
if (VpnService.prepare(context) == null) {
|
||||
startService(context)
|
||||
} else {
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
if (info == null) return
|
||||
// Every caller reaches this from a Compose scope — i.e. the MAIN
|
||||
// thread — the instant a pairing QR decodes. Everything below is
|
||||
// main-hostile: touching FipsNative dlopens the 7 MB mesh core,
|
||||
// ensureIdentity runs native ed25519 keygen, and VpnService.prepare
|
||||
// is a binder round-trip. Left on the UI thread it froze the frame
|
||||
// right after the camera got the code, which reads as "the scanner
|
||||
// is slow" when the scan itself already succeeded.
|
||||
val consent = withContext(Dispatchers.IO) {
|
||||
if (!FipsNative.available) return@withContext null
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
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. */
|
||||
@@ -67,11 +76,17 @@ object FipsManager {
|
||||
* through AppNavHost instead.
|
||||
*/
|
||||
suspend fun autoStartIfReady(context: Context) {
|
||||
if (!FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return
|
||||
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
|
||||
startService(context)
|
||||
// Self-dispatching for the same reason as registerNode: callers reach
|
||||
// this from Compose scopes, and dlopen + binder must not ride the UI
|
||||
// thread (the connect path calls it while the scanner is still up).
|
||||
val ready = withContext(Dispatchers.IO) {
|
||||
if (!FipsNative.available) return@withContext false
|
||||
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) {
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.screens.PixelArtLogo
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/**
|
||||
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
|
||||
* dialing the node over the mesh (relaunch race, post-scan first connect),
|
||||
* instead of an anonymous spinner. The point of the brand: what's loading
|
||||
* is a connection to a cryptographic identity, not an IP.
|
||||
* Full-screen loader shown while the app is dialing a node.
|
||||
*
|
||||
* Two faces, because they are two different promises:
|
||||
* - [mesh] `true` — a FIPS node: the branded "F*CK IPs" screen, because what
|
||||
* 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
|
||||
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
|
||||
fun MeshLoadingScreen(
|
||||
mesh: Boolean = true,
|
||||
nodeName: String = "",
|
||||
done: Boolean = false,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -39,39 +48,39 @@ fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// The brand's circle-container logo (as on the connect screen /
|
||||
// web login): pixel-art "a" centered in a black disc.
|
||||
Box(
|
||||
Modifier
|
||||
.size(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color.Black)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f),
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PixelArtLogo(Modifier.size(64.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
// The app's own badge — the same ringed mark as the launcher icon
|
||||
// and the system splash, so launch → splash → this screen is one
|
||||
// continuous identity.
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(112.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "F*CK IPs MESH",
|
||||
text = if (mesh) "F*CK IPS MESH" else "CONNECTING",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 18.sp,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = message,
|
||||
color = TextMuted,
|
||||
text = when {
|
||||
mesh -> "Dialing your node by its key — no IPs harmed"
|
||||
nodeName.isNotBlank() -> "Reaching $nodeName"
|
||||
else -> "Reaching your node"
|
||||
},
|
||||
color = if (done) TextPrimary else TextMuted,
|
||||
fontSize = 13.sp,
|
||||
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,6 +30,7 @@ import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -70,6 +71,7 @@ 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.screens.restartCompanionApp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
@@ -221,7 +223,11 @@ private fun MenuPanel(
|
||||
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||
page = HubPage.NODES
|
||||
}
|
||||
if (FipsNative.available) {
|
||||
// Mesh oversight only when this session is actually on the
|
||||
// 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 }
|
||||
}
|
||||
if (onMeshParty != null) {
|
||||
@@ -229,6 +235,36 @@ private fun MenuPanel(
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// 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 -> {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
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.rememberLauncherForActivityResult
|
||||
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.FocusMeteringAction
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -16,22 +26,26 @@ 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.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.windowInsetsPadding
|
||||
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.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,10 +60,15 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
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.viewinterop.AndroidView
|
||||
@@ -59,23 +78,26 @@ import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
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.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.GlobalHistogramBinarizer
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Full-screen camera overlay that scans the node pairing QR
|
||||
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
|
||||
* Handles the camera permission itself; foreign/invalid codes show a hint
|
||||
* and scanning continues.
|
||||
* Scans the node pairing QR (docs/companion-pairing-qr.md) and reports the
|
||||
* decoded server entry. Handles the camera permission itself; foreign/invalid
|
||||
* codes show a hint in the status strip 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
|
||||
fun QrScannerOverlay(
|
||||
@@ -83,28 +105,14 @@ fun QrScannerOverlay(
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||
var handled by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
handled = false
|
||||
hintRes = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,125 +124,331 @@ 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()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// Aim frame
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
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,
|
||||
.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),
|
||||
) {
|
||||
hintRes?.let { res ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = BitcoinOrange,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasPermission) {
|
||||
|
||||
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),
|
||||
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(
|
||||
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 = stringResource(R.string.scan_qr_hint),
|
||||
color = TextMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = status?.first?.takeIf { it.isNotBlank() } ?: idleHint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (status?.second == true) {
|
||||
Color(0xFFF87171)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.6f)
|
||||
},
|
||||
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
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
internal fun CameraQrPreview(
|
||||
onDecoded: (String) -> Unit,
|
||||
torchOn: Boolean = false,
|
||||
onTorchAvailable: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
|
||||
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||
// hole in the window, which black-flashes inside Compose fades and
|
||||
// ignores rounded-corner clipping (wallet modal).
|
||||
// ignores rounded-corner clipping (the glass modal).
|
||||
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) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
// Analysis runs at display priority: the decode thread competes with
|
||||
// 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 providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
@@ -243,15 +457,18 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
provider = p
|
||||
val preview = Preview.Builder().build().also {
|
||||
val previewBuilder = Preview.Builder()
|
||||
tuneForBarcodes(previewBuilder, context)
|
||||
val preview = previewBuilder.build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
||||
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
||||
// lens, which won't focus close) left dense invoices undecodable
|
||||
// while sparse address QRs still read — the "scanner doesn't pick up
|
||||
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
||||
// QR held at the camera's actual focus distance still resolves.
|
||||
// sharp focus. 1280x720 left dense invoices undecodable while sparse
|
||||
// address QRs still read — the "scanner doesn't pick up invoices"
|
||||
// report. 1920x1080 roughly doubles module resolution. The analyzer
|
||||
// never binarizes the full 2 MP: it reads the centre ROI at this
|
||||
// resolution (for dense codes) and the whole frame at half of it
|
||||
// (for coverage), so the big frame costs little.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1920, 1080))
|
||||
@@ -260,25 +477,47 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
analysisExecutor,
|
||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||
QrCodeAnalyzer { text ->
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
mainExecutor.execute { currentOnDecoded(text) }
|
||||
},
|
||||
)
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||
// a static scene, so continuous-AF often never retriggers and the
|
||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||
// A normalized centre point works before the view is measured.
|
||||
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
||||
.createPoint(0.5f, 0.5f)
|
||||
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
||||
point,
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||
).disableAutoCancel().build()
|
||||
camera = cam
|
||||
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit())
|
||||
// Start the clock at bind time so the nudge below waits for the
|
||||
// user to actually aim before it does anything.
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
// Centre point, normalized — valid before the view is measured.
|
||||
val point = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
|
||||
// A one-shot AF action puts the lens in AUTO — i.e. LOCKED —
|
||||
// until it auto-cancels. The default 5s lock is far too long
|
||||
// here: it spans exactly the window where the user is swinging
|
||||
// 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({
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
val now = System.currentTimeMillis()
|
||||
// The nudge only exists for the one case continuous AF
|
||||
// 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) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
@@ -286,66 +525,251 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
|
||||
onDispose {
|
||||
focusScheduler.shutdownNow()
|
||||
runCatching { camera?.cameraControl?.enableTorch(false) }
|
||||
camera = null
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
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.
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
// Torch follows the caller's state (and switches off when the view goes).
|
||||
LaunchedEffect(camera, torchOn) {
|
||||
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
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) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// QRCodeReader directly rather than MultiFormatReader: with a single
|
||||
// format in play the dispatch and per-call state reset are pure overhead.
|
||||
private val reader = QRCodeReader()
|
||||
private val plainHints = mapOf<DecodeHintType, Any>(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
)
|
||||
private val hardHints = mapOf<DecodeHintType, Any>(
|
||||
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() }
|
||||
}
|
||||
|
||||
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 {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
// Copy into a rowStride-wide array; the last row of the plane buffer
|
||||
// may be short of the full stride, so the tail stays zero-padded.
|
||||
val data = ByteArray(plane.rowStride * image.height)
|
||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
val stride = plane.rowStride
|
||||
// YUV_420_888 permits an interleaved Y plane. Rare, but a device
|
||||
// that does it would otherwise hand the decoder pure noise.
|
||||
val pixelStride = plane.pixelStride
|
||||
val width = image.width
|
||||
val height = image.height
|
||||
frame++
|
||||
|
||||
buffer.rewind()
|
||||
val available = buffer.remaining()
|
||||
|
||||
// ── 1. Centre ROI, full resolution ──────────────────────────────
|
||||
val side = (minOf(width, height) * QR_ROI_FRACTION).toInt().coerceAtLeast(1)
|
||||
val left = (width - side) / 2
|
||||
val top = (height - side) / 2
|
||||
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) {
|
||||
// Malformed frame; skip it.
|
||||
} finally {
|
||||
reader.reset()
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-192
@@ -1,58 +1,26 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.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.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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
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.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
@@ -62,10 +30,10 @@ import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
|
||||
/**
|
||||
* Native replacement for the web wallet's scan pane — same visual design as
|
||||
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||
* Native replacement for the web wallet's scan pane — the shared [QrGlassModal]
|
||||
* shell (same visual design as neode-ui's WalletScanModal) with the camera and
|
||||
* decoding running natively, so the preview doesn't lag the way getUserMedia
|
||||
* does inside a WebView.
|
||||
*
|
||||
* 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
|
||||
@@ -80,15 +48,7 @@ fun WalletQrScannerModal(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
// Local error from a failed image upload; a fresh web status replaces it.
|
||||
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -107,155 +67,50 @@ fun WalletQrScannerModal(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
uploadError = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(visible) { if (visible) uploadError = null }
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
// 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) {
|
||||
if (visible) {
|
||||
lastText = ""
|
||||
lastSentAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
QrGlassModal(
|
||||
visible = visible,
|
||||
title = stringResource(R.string.scan_to_send),
|
||||
status = uploadError?.let { it to true } ?: status,
|
||||
idleHint = stringResource(R.string.scan_wallet_hint),
|
||||
permissionRationale = stringResource(R.string.camera_permission_needed),
|
||||
onDismiss = onDismiss,
|
||||
onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
// 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
|
||||
// the hand.
|
||||
if (lastText.isEmpty()) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
},
|
||||
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. */
|
||||
|
||||
@@ -24,14 +24,18 @@ import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||
import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object Routes {
|
||||
const val INTRO = "intro"
|
||||
const val NODE_PICKER = "node_picker"
|
||||
const val SERVER_CONNECT = "server_connect"
|
||||
const val WEB_VIEW = "web_view"
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
@@ -39,18 +43,38 @@ object Routes {
|
||||
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
|
||||
fun AppNavHost(
|
||||
pairUri: String? = null,
|
||||
onPairUriConsumed: () -> Unit = {},
|
||||
onReady: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
// One combined emission — introSeen and activeServer resolving in separate
|
||||
// frames used to flash the Connect screen at paired users on launch.
|
||||
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
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
@@ -79,12 +103,30 @@ fun AppNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
if (introSeen == null) return
|
||||
|
||||
// Ask which node when the user keeps more than one and this is a cold
|
||||
// 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) }
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
// Launch state resolved — MainActivity holds the system splash until now,
|
||||
// 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
|
||||
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||
@@ -118,6 +160,7 @@ fun AppNavHost(
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
needsNodeChoice -> Routes.NODE_PICKER
|
||||
activeServer != null -> Routes.WEB_VIEW
|
||||
else -> Routes.SERVER_CONNECT
|
||||
}
|
||||
@@ -126,6 +169,37 @@ fun AppNavHost(
|
||||
navController = navController,
|
||||
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) {
|
||||
IntroScreen(
|
||||
onMeshParty = {
|
||||
|
||||
@@ -107,7 +107,11 @@ fun FlareScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||
// derivedStateOf: filtering inline re-ran over the whole store on every
|
||||
// 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()
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
@@ -305,7 +309,13 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (msg.photoPath.isNotBlank()) {
|
||||
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||
// Decoded off-main and downsampled to the bubble width —
|
||||
// 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 {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
@@ -336,6 +346,19 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
}
|
||||
|
||||
/** 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? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -65,9 +66,10 @@ fun IntroScreen(
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(800))
|
||||
delay(300)
|
||||
// Content fades in WITH the logo, not after it — the serial
|
||||
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
|
||||
showContent = true
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(450))
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -111,7 +113,9 @@ fun IntroScreen(
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.size(160.dp)
|
||||
.alpha(logoAlpha.value),
|
||||
// graphicsLayer defers the alpha read to the draw phase —
|
||||
// .alpha(value) recomposed the whole screen per frame.
|
||||
.graphicsLayer { alpha = logoAlpha.value },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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,9 +123,12 @@ fun PartyScreen(
|
||||
name = prefs.partyName()
|
||||
// The hotspot/WiFi address can change while this screen is open
|
||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
||||
// 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) {
|
||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
||||
delay(3_000)
|
||||
delay(if (round++ < 10) 3_000 else 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +141,16 @@ fun PartyScreen(
|
||||
port = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
}
|
||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||
// QR encode + bitmap fill off the composition: done in remember{} it ran
|
||||
// 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 {
|
||||
when {
|
||||
@@ -337,7 +349,12 @@ fun PartyScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||
// Encoded off-main; done in remember{} it dropped the
|
||||
// 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 ->
|
||||
Box(
|
||||
Modifier
|
||||
@@ -364,7 +381,7 @@ fun PartyScreen(
|
||||
"…or send the APK file directly",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||
@@ -473,8 +490,9 @@ fun PartyScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a QR payload as a bitmap (dark modules on white). */
|
||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
|
||||
* 240.dp display size at any density; 640 was a third more pixels for nothing. */
|
||||
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
|
||||
val matrix = QRCodeWriter().encode(
|
||||
payload,
|
||||
BarcodeFormat.QR_CODE,
|
||||
@@ -494,16 +512,23 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
}
|
||||
|
||||
/** Share this install's own APK via the system share sheet — a nearby friend
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth). */
|
||||
private fun shareCompanionApk(context: android.content.Context) {
|
||||
* 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
|
||||
* 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 {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
src.copyTo(out, overwrite = true)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
if (!out.exists() || out.length() != src.length()) {
|
||||
src.copyTo(out, overwrite = true)
|
||||
}
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
}
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -76,6 +75,7 @@ import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.SlidingLoader
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -86,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -108,6 +109,20 @@ fun ServerConnectScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
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 address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
@@ -121,8 +136,13 @@ fun ServerConnectScreen(
|
||||
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
|
||||
var manualMode by remember { mutableStateOf(false) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
// Is the connect currently running aimed at a mesh node? Drives whether
|
||||
// the loader wears the FIPS brand — see MeshLoadingScreen.
|
||||
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() {
|
||||
name = ""
|
||||
@@ -171,40 +191,60 @@ fun ServerConnectScreen(
|
||||
}
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
connectingOverMesh = server.isFipsNode()
|
||||
connectingName = server.displayName()
|
||||
connectSucceeded = false
|
||||
|
||||
scope.launch {
|
||||
var reachable = testConnection(server)
|
||||
|
||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||
// node. The scanned IP was only ever a dial hint; the node's real
|
||||
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain
|
||||
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
|
||||
// started (the off-LAN QR-pairing case, exactly where speed shows).
|
||||
// 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
|
||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||
if (!reachable && server.meshIp.isNotBlank()) {
|
||||
// the mesh. Mesh discovery + first session can take 15s+ through
|
||||
// the public tree (per node diagnosis), and on a
|
||||
// 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)
|
||||
val meshServer = server.copy(
|
||||
address = server.meshIp,
|
||||
useHttps = false,
|
||||
port = "",
|
||||
)
|
||||
// Mesh discovery + first session can take 15s+ through the
|
||||
// public tree (per node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at
|
||||
// the same time — so probe patiently inside a 60s budget with
|
||||
// per-attempt timeouts wide enough to ride out TCP
|
||||
// retransmit backoff. The VPN service pre-warms the session
|
||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
||||
if (!reachable) delay(3000)
|
||||
server.copy(address = it, useHttps = false, port = "")
|
||||
}
|
||||
val reachable = kotlinx.coroutines.coroutineScope {
|
||||
val lan = async { testConnection(server, timeoutMs = 4_000) }
|
||||
val mesh = async {
|
||||
if (meshServer == null) return@async false
|
||||
val deadline = System.currentTimeMillis() + 45_000
|
||||
var ok = false
|
||||
while (!ok && System.currentTimeMillis() < deadline) {
|
||||
ok = testConnection(meshServer, timeoutMs = 8_000)
|
||||
if (!ok) delay(2000)
|
||||
}
|
||||
ok
|
||||
}
|
||||
val first = kotlinx.coroutines.selects.select<Boolean> {
|
||||
lan.onAwait { it }
|
||||
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) {
|
||||
// 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)
|
||||
delay(320)
|
||||
isConnecting = false
|
||||
onConnected(server.toUrl())
|
||||
} else {
|
||||
isConnecting = false
|
||||
errorMessage = context.getString(R.string.connection_failed)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +333,7 @@ fun ServerConnectScreen(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else stringResource(R.string.connect_to_node),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -577,10 +617,9 @@ fun ServerConnectScreen(
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
strokeWidth = 2.dp,
|
||||
SlidingLoader(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
done = connectSucceeded,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -617,7 +656,11 @@ fun ServerConnectScreen(
|
||||
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||
// The small inline spinner stays for context; this owns the screen.
|
||||
if (isConnecting) {
|
||||
MeshLoadingScreen()
|
||||
MeshLoadingScreen(
|
||||
mesh = connectingOverMesh,
|
||||
nodeName = connectingName,
|
||||
done = connectSucceeded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,6 +729,17 @@ private fun sanitizeAddress(input: String): String {
|
||||
.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.
|
||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
||||
@@ -697,14 +751,7 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000):
|
||||
|
||||
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
|
||||
if (connection is HttpsURLConnection) {
|
||||
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.sslSocketFactory = trustAllSslFactory
|
||||
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,56 +2,95 @@ package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
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.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(
|
||||
// ── Display / headings: Montserrat, tight and heavy like the web hero
|
||||
// copy (the platform sets tracking negative on its big type).
|
||||
displayLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = (-0.4).sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = (-0.2).sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
// ── Body: system sans, exactly as the web falls back to.
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
),
|
||||
// ── Buttons / labels: Montserrat again, matching .glass-button.
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" for splash screen -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<!-- System splash icon — deliberately the SAME mark as the adaptive launcher
|
||||
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"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="288dp"
|
||||
android:height="288dp"
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<!-- Dark disc + gradient ring (#000 -> #666), matching logo.svg -->
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
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:scaleY="0.55">
|
||||
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<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" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
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" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -49,4 +49,12 @@
|
||||
<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="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>
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.4-alpha (draft — date set at cut)
|
||||
|
||||
- **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)
|
||||
|
||||
- **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,10 +93,11 @@ describe('useAI', () => {
|
||||
expect(activeModel.value).toBe('echo')
|
||||
})
|
||||
|
||||
it('lists available providers with models', () => {
|
||||
it('lists available providers with models, Routstr first', () => {
|
||||
const { availableProviders } = useAI()
|
||||
expect(availableProviders.value.length).toBe(3)
|
||||
expect(availableProviders.value.length).toBe(4)
|
||||
const ids = availableProviders.value.map(p => p.id)
|
||||
expect(ids[0]).toBe('routstr')
|
||||
expect(ids).toContain('claude')
|
||||
expect(ids).toContain('openrouter')
|
||||
expect(ids).toContain('mock')
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<Transition name="picker">
|
||||
<div
|
||||
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]"
|
||||
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"
|
||||
:style="modelPickerDropdownStyle"
|
||||
@click.stop
|
||||
>
|
||||
@@ -332,7 +332,7 @@ const modelDisplayName = computed(() => {
|
||||
})
|
||||
|
||||
function selectModel(providerId: string, modelId: string) {
|
||||
setProvider(providerId as 'claude' | 'openrouter' | 'mock')
|
||||
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
|
||||
setModel(modelId)
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import { useCodeContext } from '@/composables/useCodeContext'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
type Provider = 'claude' | 'openrouter' | 'mock'
|
||||
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
|
||||
|
||||
// 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 CLAUDE_PATH = `${BASE}api/claude/v1/messages`
|
||||
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 { mockSongs } from '@/mocks/songs'
|
||||
@@ -148,8 +150,41 @@ 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 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',
|
||||
name: 'Claude (Max)',
|
||||
@@ -381,6 +416,71 @@ async function streamOpenRouter(
|
||||
}, 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
|
||||
* Archy, the model call, the tool-calling loop, and the model key all live
|
||||
@@ -619,7 +719,11 @@ export async function streamWithModel(
|
||||
activeModel.value = model
|
||||
|
||||
try {
|
||||
if (useArchy().isEmbedded.value) {
|
||||
if (provider === 'routstr') {
|
||||
// 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
|
||||
// Archy — provider/model selection here doesn't apply node-side.
|
||||
await streamViaArchy(history, onToken, onError, signal)
|
||||
@@ -638,8 +742,9 @@ export async function streamWithModel(
|
||||
export function useAI() {
|
||||
const chatStore = useChatStore()
|
||||
|
||||
// Fetch Wavlake catalog on first use (non-blocking)
|
||||
// Fetch Wavlake + Routstr catalogs on first use (non-blocking)
|
||||
refreshWavlakeCatalog()
|
||||
refreshRoutstrModels()
|
||||
|
||||
function stopGeneration() {
|
||||
if (currentAbort) {
|
||||
@@ -712,7 +817,11 @@ export function useAI() {
|
||||
const genParams = getConversationParams(chatStore)
|
||||
|
||||
try {
|
||||
if (useArchy().isEmbedded.value) {
|
||||
if (provider === 'routstr') {
|
||||
// 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
|
||||
// Archy — provider/model selection here doesn't apply node-side.
|
||||
await streamViaArchy(history, onToken, onError, signal)
|
||||
@@ -828,7 +937,11 @@ export function useAI() {
|
||||
const genParams = getConversationParams(chatStore)
|
||||
|
||||
try {
|
||||
if (useArchy().isEmbedded.value) {
|
||||
if (provider === 'routstr') {
|
||||
// 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
|
||||
// Archy — provider/model selection here doesn't apply node-side.
|
||||
await streamViaArchy(history, onToken, onError, signal)
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"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 \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
@@ -390,7 +390,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "grafana/grafana:10.2.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -459,7 +459,7 @@
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "2.38.0",
|
||||
"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 \u2014 a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
@@ -547,6 +547,30 @@
|
||||
"/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 \u2014 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.23.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 \u2014 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
app:
|
||||
id: alby-hub
|
||||
name: Alby Hub
|
||||
version: 1.23.0
|
||||
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
|
||||
category: money
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
- host: 8187
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/alby-hub
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- WORK_DIR=/data
|
||||
- PORT=8080
|
||||
# LDK peers are dialed outbound-only in v1; no inbound p2p port is
|
||||
# advertised, so no extra port mapping is needed for payments to work.
|
||||
- LOG_LEVEL=info
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:8080
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Alby Hub wallet interface
|
||||
type: ui
|
||||
port: 8187
|
||||
protocol: http
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/alby-hub.svg
|
||||
repo: https://github.com/getAlby/hub
|
||||
tier: optional
|
||||
launch:
|
||||
# Embedded: the gate neutralizes Alby Hub's X-Frame-Options: DENY on
|
||||
# proxied responses. Nodes older than the gate fix show a blocked
|
||||
# frame — flip to true only if targeting such nodes.
|
||||
open_in_new_tab: false
|
||||
features:
|
||||
- Self-custodial Lightning node (LDK) with a friendly wallet UI
|
||||
- Connect wallets and apps via Nostr Wallet Connect (NWC)
|
||||
- Per-app budgets and isolated sub-wallets
|
||||
- Works with the Alby browser extension and mobile app
|
||||
@@ -49,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";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
fi
|
||||
|
||||
@@ -55,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";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
fi
|
||||
|
||||
@@ -46,7 +46,17 @@ app:
|
||||
container: 49392
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# open, not gated: BTCPay has its own account system, and its public
|
||||
# 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:
|
||||
- type: bind
|
||||
|
||||
+10
-1
@@ -27,7 +27,16 @@ app:
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# open, not gated: Gitea carries a complete login of its own, and git
|
||||
# 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
|
||||
container: 22
|
||||
protocol: tcp
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
|
||||
|
||||
container:
|
||||
image: grafana/grafana:10.2.0
|
||||
image: source.archipelago-foundation.org/lfg2025/grafana:10.2.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
data_uid: "472:472"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
app:
|
||||
id: phoenixd
|
||||
name: phoenixd
|
||||
version: 0.9.0
|
||||
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
|
||||
category: money
|
||||
|
||||
container:
|
||||
# Image entrypoint already runs with --agree-to-terms-of-service and
|
||||
# --http-bind-ip 0.0.0.0, as user "phoenix"; no custom args needed.
|
||||
image: source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0
|
||||
pull_policy: if-not-present
|
||||
# The image runs as user phoenix (1000:1000); the datadir bind source
|
||||
# must be chowned to that identity or phoenixd dies on
|
||||
# "Failed to open /data/phoenix.conf with Permission denied".
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 500Mi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
- host: 9740
|
||||
container: 9740
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Loopback-only JSON API, not a web page. Every request is
|
||||
authenticated by the http password phoenixd generates in its own
|
||||
data directory on first run; the app gate's browser login page
|
||||
would break the API clients this port exists for.
|
||||
|
||||
volumes:
|
||||
# The wallet seed (seed.dat) and phoenix.conf live here. This directory
|
||||
# must survive reinstall/migration like any other app data dir —
|
||||
# losing it means losing funds.
|
||||
# Target is /data (via PHOENIX_DATADIR below), NOT the image's default
|
||||
# /phoenix/.phoenix: the orchestrator treats any bind path containing a
|
||||
# dot as a file mount and skips creating its source directory, so a
|
||||
# hidden-dir target never gets its host dir and the unit crash-loops.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/phoenixd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- PHOENIX_DATADIR=/data
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:9740
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/phoenixd.svg
|
||||
repo: https://github.com/ACINQ/phoenixd
|
||||
tier: optional
|
||||
features:
|
||||
- Ultra-light Lightning node — no bitcoin node required
|
||||
- Automated channel and liquidity management (fees apply)
|
||||
- Simple HTTP API + websockets for payments
|
||||
- Backed by the team behind the Phoenix mobile wallet
|
||||
Generated
+398
-58
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.126-alpha"
|
||||
version = "1.8.3-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -120,6 +120,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"bs58",
|
||||
"bytes",
|
||||
"cashu",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"ciborium",
|
||||
@@ -186,7 +187,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hyper 0.14.32",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
@@ -450,8 +451,8 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
|
||||
dependencies = [
|
||||
"bitcoin-internals 0.3.0",
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"bitcoin-internals",
|
||||
"bitcoin_hashes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -499,11 +500,11 @@ checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab"
|
||||
|
||||
[[package]]
|
||||
name = "bip39"
|
||||
version = "2.1.0"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
|
||||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes 0.13.0",
|
||||
"bitcoin_hashes",
|
||||
"rand 0.8.5",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
@@ -526,27 +527,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026"
|
||||
dependencies = [
|
||||
"base58ck",
|
||||
"base64 0.21.7",
|
||||
"bech32",
|
||||
"bitcoin-internals 0.3.0",
|
||||
"bitcoin-internals",
|
||||
"bitcoin-io",
|
||||
"bitcoin-units",
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"hex-conservative 0.2.2",
|
||||
"bitcoin_hashes",
|
||||
"hex-conservative",
|
||||
"hex_lit",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-internals"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-internals"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin-io"
|
||||
@@ -560,17 +560,8 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
|
||||
dependencies = [
|
||||
"bitcoin-internals 0.3.0",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"bitcoin-internals",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -580,7 +571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b"
|
||||
dependencies = [
|
||||
"bitcoin-io",
|
||||
"hex-conservative 0.2.2",
|
||||
"hex-conservative",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -707,6 +698,32 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
@@ -716,6 +733,25 @@ dependencies = [
|
||||
"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]]
|
||||
name = "cc"
|
||||
version = "1.2.54"
|
||||
@@ -1092,8 +1128,18 @@ version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
@@ -1110,13 +1156,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"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",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
@@ -1147,6 +1217,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1187,6 +1288,9 @@ name = "deranged"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
@@ -1214,7 +1318,7 @@ version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
@@ -1314,6 +1418,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
@@ -1774,7 +1890,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1793,7 +1909,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.4.0",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -1829,6 +1945,12 @@ dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1887,12 +2009,6 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.2"
|
||||
@@ -2391,6 +2507,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -2507,7 +2634,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"strum",
|
||||
"strum 0.28.0",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2595,7 +2722,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"rustls 0.23.36",
|
||||
"simple-dns",
|
||||
"strum",
|
||||
"strum 0.28.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -2675,7 +2802,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"strum",
|
||||
"strum 0.28.0",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
@@ -2765,6 +2892,59 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2911,6 +3091,55 @@ dependencies = [
|
||||
"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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -3415,7 +3644,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bech32",
|
||||
"bip39",
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"bitcoin_hashes",
|
||||
"cbc",
|
||||
"chacha20 0.9.1",
|
||||
"chacha20poly1305",
|
||||
@@ -3517,6 +3746,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3896,7 +4136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -3934,6 +4174,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "portmapper"
|
||||
version = "0.19.0"
|
||||
@@ -3973,6 +4222,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
@@ -4643,6 +4901,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -4692,7 +4974,7 @@ version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"bitcoin_hashes 0.14.1",
|
||||
"bitcoin_hashes",
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys",
|
||||
"serde",
|
||||
@@ -4758,6 +5040,12 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "separator"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -4842,13 +5130,46 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@@ -5137,13 +5458,31 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
"strum_macros 0.28.0",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
@@ -5606,7 +5945,7 @@ version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -5620,7 +5959,7 @@ version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.3",
|
||||
@@ -5823,9 +6162,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.22"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
@@ -5903,6 +6242,7 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -6080,7 +6420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
@@ -6119,7 +6459,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"semver",
|
||||
]
|
||||
|
||||
@@ -6678,7 +7018,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"prettyplease",
|
||||
"syn 2.0.114",
|
||||
"wasm-metadata",
|
||||
@@ -6709,7 +7049,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.0",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
@@ -6728,7 +7068,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -6959,7 +7299,7 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.127-alpha"
|
||||
version = "1.8.3-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
@@ -72,7 +72,7 @@ bs58 = "0.5"
|
||||
chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "=2.1.0", features = ["rand"] }
|
||||
bip39 = { version = "2.1", features = ["rand"] }
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
@@ -143,6 +143,7 @@ async-trait = "0.1"
|
||||
iroh = { version = "1", optional = true }
|
||||
iroh-blobs = { version = "0.103", optional = true }
|
||||
lofty = "0.24.0"
|
||||
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
//! 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]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod blob;
|
||||
mod cdp;
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod model_proxy;
|
||||
@@ -6,6 +7,7 @@ mod node_message;
|
||||
mod proxy;
|
||||
mod remote_input;
|
||||
mod remote_relay;
|
||||
mod routstr_proxy;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
@@ -50,6 +52,10 @@ pub struct ApiHandler {
|
||||
/// to the phone's default browser. Lets "open in external browser" apps —
|
||||
/// which the kiosk can't usefully open itself — launch on the controller.
|
||||
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.
|
||||
blob_store: Arc<BlobStore>,
|
||||
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||
@@ -78,6 +84,7 @@ impl ApiHandler {
|
||||
);
|
||||
let (input_relay_tx, _) = broadcast::channel(64);
|
||||
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
|
||||
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||
@@ -108,6 +115,7 @@ impl ApiHandler {
|
||||
session_store,
|
||||
input_relay_tx,
|
||||
external_open_tx,
|
||||
cdp_bridge,
|
||||
blob_store,
|
||||
self_pubkey_hex,
|
||||
})
|
||||
@@ -401,6 +409,7 @@ impl ApiHandler {
|
||||
req,
|
||||
self.input_relay_tx.clone(),
|
||||
self.external_open_tx.subscribe(),
|
||||
self.cdp_bridge.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -415,6 +424,7 @@ impl ApiHandler {
|
||||
req,
|
||||
self.input_relay_tx.subscribe(),
|
||||
self.external_open_tx.clone(),
|
||||
self.cdp_bridge.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -449,6 +459,14 @@ impl ApiHandler {
|
||||
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
|
||||
(Method::GET, "/health") => {
|
||||
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
|
||||
/// dependency-free is what makes the 401 behaviour unit-testable without
|
||||
/// paying for a full `ApiHandler` in every test.
|
||||
async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
|
||||
pub(super) async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
Some(token) => session_store.validate(&token).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response<Body> {
|
||||
pub(super) fn unauthorized() -> Response<Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
@@ -119,7 +119,7 @@ fn key_not_configured() -> Response<Body> {
|
||||
/// backends' egress screen never sees the forwarder path — the standalone
|
||||
/// frontend posts FULL history and images straight here — so the forwarder
|
||||
/// screens for itself. 400, plain-language, never naming what matched.
|
||||
fn blocked_secret_shaped() -> Response<Body> {
|
||||
pub(super) fn blocked_secret_shaped() -> Response<Body> {
|
||||
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."
|
||||
});
|
||||
@@ -134,12 +134,12 @@ fn blocked_secret_shaped() -> Response<Body> {
|
||||
/// 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 —
|
||||
/// when the content must not leave.
|
||||
async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
|
||||
pub(super) 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;
|
||||
crate::assistant::egress::scan_secret_shapes(text, &secrets)
|
||||
}
|
||||
|
||||
fn bad_gateway(msg: &str) -> Response<Body> {
|
||||
pub(super) fn bad_gateway(msg: &str) -> Response<Body> {
|
||||
let body = serde_json::json!({ "error": msg });
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
@@ -331,7 +331,7 @@ async fn forward(
|
||||
/// 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
|
||||
/// byte reaches the browser.
|
||||
fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
|
||||
pub(super) fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let mut builder = Response::builder().status(status);
|
||||
|
||||
@@ -212,6 +212,7 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
mut external_open_rx: broadcast::Receiver<String>,
|
||||
cdp_bridge: super::cdp::CdpBridge,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req
|
||||
@@ -317,9 +318,20 @@ impl ApiHandler {
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
let _ = relay_tx.send(relay_text);
|
||||
let validation = handle_input(&text).await;
|
||||
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 handle_input(&text).await {
|
||||
match validation {
|
||||
Ok(Some(reply)) => {
|
||||
let _ = tx.send(Message::Text(reply)).await;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
mut relay_rx: broadcast::Receiver<String>,
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
cdp_bridge: super::cdp::CdpBridge,
|
||||
) -> 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)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
@@ -60,6 +69,19 @@ impl ApiHandler {
|
||||
msg = relay_rx.recv() => {
|
||||
match msg {
|
||||
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() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,13 @@ impl RpcHandler {
|
||||
"port": g.port,
|
||||
"app_id": g.app_id,
|
||||
"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();
|
||||
@@ -56,4 +63,59 @@ impl RpcHandler {
|
||||
"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,6 +344,7 @@ impl RpcHandler {
|
||||
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
||||
"system.settings.get" => self.handle_system_settings_get(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.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
"bitcoin.relay-update-settings" => {
|
||||
|
||||
@@ -133,6 +133,7 @@ impl RpcHandler {
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(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.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
@@ -265,6 +266,11 @@ impl RpcHandler {
|
||||
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
|
||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).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.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||
// Fedimint ecash (via fedimint-clientd sidecar)
|
||||
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
|
||||
@@ -488,6 +494,7 @@ impl RpcHandler {
|
||||
|
||||
// System monitoring
|
||||
"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.stats" => self.handle_system_stats().await,
|
||||
"system.processes" => self.handle_system_processes().await,
|
||||
@@ -503,6 +510,7 @@ impl RpcHandler {
|
||||
"ai.permissions.set" => self.handle_ai_permissions_set(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(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.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
|
||||
|
||||
@@ -696,6 +696,22 @@ impl RpcHandler {
|
||||
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)
|
||||
let signature = params.get("signature").and_then(|v| v.as_str());
|
||||
match signature {
|
||||
@@ -703,10 +719,16 @@ impl RpcHandler {
|
||||
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
|
||||
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
Ok(false) => {
|
||||
tracing::warn!(peer_did = %did, "Rejected peer-joined: 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 => {
|
||||
|
||||
@@ -21,7 +21,7 @@ use anyhow::{Context, Result};
|
||||
use nostr_sdk::FromBech32;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||
use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE;
|
||||
|
||||
/// 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
|
||||
@@ -32,6 +32,9 @@ const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||
struct NostrDiscoveryState {
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -55,10 +58,16 @@ async fn save_discovery_state(
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Read the current runtime discoverability flag.
|
||||
/// Read the current runtime discoverability flag. Also returns the npub
|
||||
/// 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> {
|
||||
let state = load_discovery_state(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({ "enabled": state.enabled }))
|
||||
let npub = nostr_handshake::own_npub(&self.config.data_dir.join("identity"))
|
||||
.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
|
||||
@@ -78,7 +87,22 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||
|
||||
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
|
||||
// Optional display name. Absent param = keep the stored name (so a
|
||||
// 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() {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
@@ -88,11 +112,13 @@ impl RpcHandler {
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.handshake_relays().await;
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let publish_name = name.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&version,
|
||||
publish_name.as_deref(),
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
@@ -101,6 +127,21 @@ impl RpcHandler {
|
||||
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 }))
|
||||
|
||||
@@ -607,9 +607,77 @@ impl RpcHandler {
|
||||
.unwrap_or("")
|
||||
.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!({
|
||||
"payment_request": payment_request,
|
||||
"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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,12 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
// OTA apply/download errors are all operator-actionable ("download it
|
||||
// again", "download first") — sanitizing them to "Operation failed"
|
||||
// left users stuck with no idea what to do, and hid the "already
|
||||
// running" text the update UI matches on to join an in-flight apply
|
||||
// instead of showing a false failure. Every such message starts "Update".
|
||||
"Update",
|
||||
// The federation escalation sentinel. "Password" above does NOT cover
|
||||
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
|
||||
// so the frontend's isPasswordRequired() never saw it and the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::router as net_router;
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use archipelago_openwrt::{
|
||||
detect,
|
||||
router::Router,
|
||||
@@ -38,7 +38,16 @@ impl RpcHandler {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await;
|
||||
// scan_subnet is `async` in name only: up to 255 SEQUENTIAL blocking
|
||||
// TCP probes at 500ms each (~2 min on a /24 that silently drops),
|
||||
// plus a blocking SSH verify per candidate. Inline, one click of
|
||||
// "scan for routers" held a tokio worker for that whole time.
|
||||
let routers = tokio::task::spawn_blocking(move || {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password))
|
||||
})
|
||||
.await
|
||||
.context("openwrt scan task")?;
|
||||
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
|
||||
|
||||
Ok(serde_json::json!({ "routers": ips }))
|
||||
@@ -87,8 +96,80 @@ impl RpcHandler {
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
// The SSH session is blocking (ssh2 over std TcpStream). Run it on the
|
||||
// blocking pool: inline it used to park a tokio worker for the whole
|
||||
// exchange, and against an unreachable router (the gateway that stayed
|
||||
// behind after a node moved networks) the periodic dashboard poll
|
||||
// stalled unrelated RPCs for tens of seconds — long enough that TOTP
|
||||
// codes expired in flight (framework-pt, 2026-08-15).
|
||||
let status = {
|
||||
let host = host.clone();
|
||||
let ssh_user = ssh_user.clone();
|
||||
let ssh_password = ssh_password.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<serde_json::Value> {
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
// System info
|
||||
let release = router
|
||||
.run_ok("cat /etc/openwrt_release")
|
||||
.unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
let uptime_secs: u64 = router
|
||||
.run_ok("cat /proc/uptime")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
|
||||
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let tollgate = if tollgate_installed {
|
||||
serde_json::json!({
|
||||
"installed": true,
|
||||
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
|
||||
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
|
||||
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
};
|
||||
|
||||
// WiFi interfaces
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
let wan_status = wan::get_wan_status(&router);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
"uptime_secs": uptime_secs,
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
"wan": wan_status,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.context("openwrt status task")??
|
||||
};
|
||||
|
||||
// Persist the connection so other views (e.g. the Home dashboard's
|
||||
// Network tile) can poll `openwrt.get-status` with no params instead
|
||||
@@ -107,62 +188,7 @@ impl RpcHandler {
|
||||
.await;
|
||||
}
|
||||
|
||||
// System info
|
||||
let release = router
|
||||
.run_ok("cat /etc/openwrt_release")
|
||||
.unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
let uptime_secs: u64 = router
|
||||
.run_ok("cat /proc/uptime")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
|
||||
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
|
||||
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
|
||||
// *package* name, never an on-disk filename.
|
||||
let tollgate_installed = router
|
||||
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
|
||||
test -f /usr/bin/tollgate-wrt 2>/dev/null")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let tollgate = if tollgate_installed {
|
||||
serde_json::json!({
|
||||
"installed": true,
|
||||
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
|
||||
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
|
||||
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
};
|
||||
|
||||
// WiFi interfaces
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
let wan_status = wan::get_wan_status(&router);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
"uptime_secs": uptime_secs,
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
"wan": wan_status,
|
||||
}))
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
||||
@@ -228,9 +254,21 @@ impl RpcHandler {
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
};
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
tollgate::provision(&router, &config).await?;
|
||||
// Blocking SSH session, and provision runs `opkg install` over it —
|
||||
// minutes of held worker if the router stalls mid-exchange.
|
||||
{
|
||||
let host = host.clone();
|
||||
let ssh_user = ssh_user.clone();
|
||||
let ssh_password = ssh_password.clone();
|
||||
let config = config.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
tokio::runtime::Handle::current().block_on(tollgate::provision(&router, &config))
|
||||
})
|
||||
.await
|
||||
.context("openwrt provision task")??;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
@@ -279,10 +317,20 @@ impl RpcHandler {
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let networks = wifi_scan::scan_networks(&router)?;
|
||||
// A radio scan is seconds of SSH round-trips even on a healthy
|
||||
// router; keep it off the runtime.
|
||||
let networks = {
|
||||
let host = host.clone();
|
||||
let ssh_user = ssh_user.clone();
|
||||
let ssh_password = ssh_password.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<Vec<wifi_scan::ScannedNetwork>> {
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
wifi_scan::scan_networks(&router)
|
||||
})
|
||||
.await
|
||||
.context("openwrt wifi scan task")??
|
||||
};
|
||||
let result: Vec<serde_json::Value> = networks
|
||||
.iter()
|
||||
.map(|n| {
|
||||
@@ -357,9 +405,6 @@ impl RpcHandler {
|
||||
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
|
||||
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let config = wan::WispConfig {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
@@ -368,7 +413,20 @@ impl RpcHandler {
|
||||
dhcp_limit,
|
||||
masq,
|
||||
};
|
||||
wan::configure_wisp(&router, &config)?;
|
||||
// Reconfiguring WAN drops and re-establishes the router's uplink, so
|
||||
// the SSH exchange can stall for its full timeout budget mid-command.
|
||||
{
|
||||
let host = host.clone();
|
||||
let ssh_user = ssh_user.clone();
|
||||
let ssh_password = ssh_password.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
wan::configure_wisp(&router, &config)
|
||||
})
|
||||
.await
|
||||
.context("openwrt configure-wan task")??;
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||
}
|
||||
|
||||
@@ -2525,7 +2525,7 @@ async fn wait_for_adopted_container(package_id: &str, container_name: &str) -> R
|
||||
// bitcoin_data_volume_gb removed with write_bitcoin_conf: it only fed that
|
||||
// function's volume-aware `prune=` line, which bitcoind never read either
|
||||
// (see remove_stale_bitcoin_conf). The manifest's shell entrypoint already
|
||||
// computes DISK_GB_VALUE and hardcodes -prune=550 on small volumes — a
|
||||
// computes DISK_GB_VALUE and hardcodes -prune=50000 on small volumes — a
|
||||
// real volume-aware prune fix belongs there, not in a conf file nothing
|
||||
// reads. Tracked as follow-up in bitcoin-conf-crash-patch.md.
|
||||
|
||||
|
||||
@@ -1405,6 +1405,14 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
|
||||
_ => {}
|
||||
}
|
||||
// Reap this app's ghost containers before anything tries to start it.
|
||||
// A ghost (process tree alive, podman record gone) still owns the
|
||||
// published port and the data-dir file locks, so the replacement either
|
||||
// fails to bind (`address already in use`) or starts and dies on the
|
||||
// lock — and `Restart=always` loops it there forever. Ordered before
|
||||
// the port cleanup below: killing the owner is what actually frees the
|
||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||
cleanup_runtime_host_ports(container_name).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,18 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
||||
.parse()
|
||||
.context("Invalid mnemonic in memory")?;
|
||||
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
|
||||
|
||||
// Establish the ecash wallet's NUT-13 phrase here too — this is the last
|
||||
// moment the master seed exists in plaintext during onboarding, and the
|
||||
// ecash wallet needs its own phrase on disk to mint restorable proofs
|
||||
// without a password prompt on every background swap. Best-effort: a node
|
||||
// that fails here still onboards, mints valid coins, and can establish the
|
||||
// phrase later from Settings → Back up ecash.
|
||||
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
if let Err(e) = crate::wallet::nut13::establish_from_master(data_dir, &master).await {
|
||||
tracing::warn!("Could not establish the ecash wallet phrase at onboarding: {e:#}");
|
||||
}
|
||||
|
||||
*state = None;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -176,6 +188,29 @@ pub(in crate::api::rpc) async fn restore_node_identity_from_words(
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Push the on-disk node identity's pubkey into the live `server_info`
|
||||
/// snapshot. `seed.generate` / `seed.restore` rewrite `identity/node_key`,
|
||||
/// but `server_info.pubkey` was only seeded at boot — until the next
|
||||
/// restart every federation peer-joined advertised the stale boot key
|
||||
/// while signing with the new seed-derived key, so receivers rejected it
|
||||
/// ("Invalid signature") on every 90s heal tick (2026-08-16). Mirrors the
|
||||
/// DID-rotation handler, which already does this refresh.
|
||||
async fn refresh_server_pubkey_from_disk(&self) {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let identity = match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Could not reload node identity after seed write — server_info.pubkey stays stale until restart");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||
if data.server_info.pubkey != identity.pubkey_hex() {
|
||||
data.server_info.pubkey = identity.pubkey_hex();
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||
/// Returns the words for the user to write down.
|
||||
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||
@@ -242,6 +277,10 @@ impl RpcHandler {
|
||||
// Initialize identity index at 0.
|
||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||
|
||||
// The node key on disk just changed — keep the live snapshot's pubkey
|
||||
// in lockstep (see refresh_server_pubkey_from_disk for why).
|
||||
self.refresh_server_pubkey_from_disk().await;
|
||||
|
||||
// fips_key is now on disk — auto-activate FIPS so the user doesn't
|
||||
// have to hit a manual Start button. Detached task;
|
||||
// the onboarding RPC returns immediately.
|
||||
@@ -350,7 +389,11 @@ impl RpcHandler {
|
||||
)
|
||||
.context("Invalid words array")?;
|
||||
|
||||
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words).await
|
||||
let result =
|
||||
restore_node_identity_from_words(&self.config.data_dir, &self.auth_manager, &words)
|
||||
.await?;
|
||||
self.refresh_server_pubkey_from_disk().await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Encrypt and save the mnemonic to disk for convenience backup.
|
||||
|
||||
@@ -921,6 +921,29 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
|
||||
// Overwrite our Nostr presence with a tombstone BEFORE the wipe: the
|
||||
// discovery keys die with the identity dir, and once they're gone the
|
||||
// stale presence event can never be replaced by anyone — it would
|
||||
// list this dead install to the whole network until relays expire it.
|
||||
// Best-effort with a hard cap so a dead relay can't stall the reset.
|
||||
{
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = crate::nostr_relays::merged_relay_list(
|
||||
&self.config.data_dir,
|
||||
&self.config.nostr_relays,
|
||||
)
|
||||
.await;
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
crate::nostr_handshake::publish_tombstone(
|
||||
&identity_dir,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
|
||||
|
||||
let data_dir = &self.config.data_dir;
|
||||
@@ -1225,6 +1248,30 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// system.node-ca.generate — run the node's (idempotent) CA setup so the
|
||||
/// dashboard can offer certificate generation as a button. WebUI rule:
|
||||
/// users must never be pointed at a terminal; the script reuses an
|
||||
/// existing CA and only reissues the leaf, so re-running is safe.
|
||||
pub(in crate::api::rpc) async fn handle_system_node_ca_generate(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let script = "/opt/archipelago/scripts/setup-node-ca.sh";
|
||||
if tokio::fs::metadata(script).await.is_err() {
|
||||
anyhow::bail!(
|
||||
"The certificate setup script is not on this node yet — it arrives with the next update."
|
||||
);
|
||||
}
|
||||
let status = host_sudo(&["/usr/bin/bash", script]).await?;
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"Certificate generation failed (exit {:?}) — see the node log for detail",
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
info!("Node CA generated/reissued via dashboard");
|
||||
Ok(serde_json::json!({ "generated": true }))
|
||||
}
|
||||
|
||||
/// system.kiosk-display.get — Current kiosk display preset + whether this
|
||||
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
|
||||
@@ -1245,7 +1292,14 @@ impl RpcHandler {
|
||||
} else {
|
||||
"auto"
|
||||
};
|
||||
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
|
||||
let graphics = if conf.contains("KIOSK_GRAPHICS=performance") {
|
||||
"performance"
|
||||
} else if conf.contains("KIOSK_GRAPHICS=quality") {
|
||||
"quality"
|
||||
} else {
|
||||
"auto"
|
||||
};
|
||||
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset, "graphics": graphics }))
|
||||
}
|
||||
|
||||
/// system.kiosk-display.set — Write the kiosk display preset and restart
|
||||
@@ -1256,24 +1310,56 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let preset = params
|
||||
.get("preset")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
|
||||
let preset = params.get("preset").and_then(|v| v.as_str());
|
||||
let graphics = params.get("graphics").and_then(|v| v.as_str());
|
||||
if preset.is_none() && graphics.is_none() {
|
||||
anyhow::bail!("Missing preset or graphics");
|
||||
}
|
||||
|
||||
let conf = match preset {
|
||||
// The conf carries two independent settings (display scale preset +
|
||||
// graphics tier). A set of one must not clobber the other, so the
|
||||
// half not being changed is carried over from the file as-is.
|
||||
let existing = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let display_part = match preset {
|
||||
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
|
||||
// 1080p TV -> 1.5, laptop panels -> 1.0.
|
||||
"auto" => String::new(),
|
||||
Some("auto") => String::new(),
|
||||
// Biggest UI: every panel targets a 1280-wide layout.
|
||||
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
|
||||
Some("large") => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
|
||||
// Full-HD layout on any panel that can carry it.
|
||||
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
|
||||
Some("balanced") => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
|
||||
// No scaling: native CSS viewport, most content, smallest UI.
|
||||
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
|
||||
other => anyhow::bail!("Unknown display preset: {other}"),
|
||||
Some("native") => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
|
||||
Some(other) => anyhow::bail!("Unknown display preset: {other}"),
|
||||
None => existing
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("ARCHIPELAGO_KIOSK_"))
|
||||
.map(|l| format!("{l}\n"))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let graphics_part = match graphics {
|
||||
// Auto: the launcher classifies the hardware itself (CPU/iGPU
|
||||
// generation) — legacy boxes keep the choppy-audio-safe flags,
|
||||
// modern iGPUs get GPU rasterization.
|
||||
Some("auto") => String::new(),
|
||||
// Force the conservative legacy flag set (troubleshooting).
|
||||
Some("performance") => "KIOSK_GRAPHICS=performance\n".to_string(),
|
||||
// Force the modern flag set even on unclassified hardware.
|
||||
Some("quality") => "KIOSK_GRAPHICS=quality\n".to_string(),
|
||||
Some(other) => anyhow::bail!("Unknown graphics mode: {other}"),
|
||||
None => existing
|
||||
.lines()
|
||||
.find(|l| l.starts_with("KIOSK_GRAPHICS="))
|
||||
.map(|l| format!("{l}\n"))
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let conf = format!("{display_part}{graphics_part}");
|
||||
|
||||
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
|
||||
if conf.is_empty() {
|
||||
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
|
||||
@@ -1309,8 +1395,8 @@ impl RpcHandler {
|
||||
])
|
||||
.await;
|
||||
|
||||
info!(preset, "Kiosk display preset applied");
|
||||
Ok(serde_json::json!({ "preset": preset, "applied": true }))
|
||||
info!(?preset, ?graphics, "Kiosk display settings applied");
|
||||
Ok(serde_json::json!({ "preset": preset, "graphics": graphics, "applied": true }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,51 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-network` — which ecash network this node is on, and the
|
||||
/// balance sitting in the *other* one so the UI can say what switching
|
||||
/// would reveal rather than appearing to lose money.
|
||||
pub(super) async fn handle_wallet_ecash_network(&self) -> Result<serde_json::Value> {
|
||||
let current = ecash::load_network(&self.config.data_dir).await;
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
"network": current,
|
||||
"is_test": current.is_test(),
|
||||
"mint_url": wallet.mint_url,
|
||||
"balance_sats": wallet.balance(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-set-network` — switch between real and test ecash.
|
||||
///
|
||||
/// Each network keeps its own wallet file, so this never moves, merges or
|
||||
/// deletes coins: switching away parks the current balance and switching
|
||||
/// back finds it exactly as it was.
|
||||
pub(super) async fn handle_wallet_ecash_set_network(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let requested = params
|
||||
.get("network")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing network ('mainnet' or 'testnet')"))?;
|
||||
let network = match requested {
|
||||
"mainnet" => ecash::EcashNetwork::Mainnet,
|
||||
"testnet" => ecash::EcashNetwork::Testnet,
|
||||
other => anyhow::bail!("Unknown ecash network '{other}' — use 'mainnet' or 'testnet'"),
|
||||
};
|
||||
|
||||
ecash::save_network(&self.config.data_dir, network).await?;
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
tracing::info!(?network, "ecash network switched by operator");
|
||||
Ok(serde_json::json!({
|
||||
"network": network,
|
||||
"is_test": network.is_test(),
|
||||
"mint_url": wallet.mint_url,
|
||||
"balance_sats": wallet.balance(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_ecash_mint(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -161,10 +206,17 @@ impl RpcHandler {
|
||||
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||
// sidecar). Detect by prefix and route accordingly.
|
||||
if is_cashu_token(token) {
|
||||
// Which mint issued it, for the success screen. Ecash leaves no
|
||||
// public trace once redeemed, so the mint URL is the only thing a
|
||||
// person can quote later if the payment is ever disputed.
|
||||
let mint_url = crate::wallet::cashu::CashuToken::deserialize(token)
|
||||
.ok()
|
||||
.and_then(|t| t.mint_urls().first().map(|m| m.to_string()));
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "cashu",
|
||||
"mint_url": mint_url,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -194,6 +246,131 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-seed-status` — whether this wallet has a NUT-13 phrase
|
||||
/// yet, and therefore whether its coins can be restored at all.
|
||||
///
|
||||
/// Deliberately says nothing secret. `active: false` is the honest answer
|
||||
/// for a node that predates NUT-13: its existing proofs live in exactly one
|
||||
/// file and nothing can bring them back, which the UI needs to be able to
|
||||
/// say plainly rather than implying a backup exists.
|
||||
pub(super) async fn handle_wallet_ecash_seed_status(&self) -> Result<serde_json::Value> {
|
||||
let data_dir = &self.config.data_dir;
|
||||
let active = crate::wallet::nut13::seed_exists(data_dir);
|
||||
let source = match crate::wallet::nut13::load_seed(data_dir).await {
|
||||
Ok(Some(seed)) => Some(seed.source()),
|
||||
_ => None,
|
||||
};
|
||||
// Whether the node has an encrypted master seed decides whether the
|
||||
// "set up" path can derive from it, which is what the operator is
|
||||
// promised: your node's 24 words already cover your ecash.
|
||||
Ok(serde_json::json!({
|
||||
"active": active,
|
||||
"source": source,
|
||||
"can_activate": crate::seed::seed_exists(data_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-seed-reveal` — show the ecash wallet's 24 words, and
|
||||
/// establish them from the node's master seed if this is the first time.
|
||||
///
|
||||
/// Gated exactly like `seed.reveal` and `lnd.seed-reveal`: authenticated
|
||||
/// session, password re-verification, TOTP when enabled. The words are
|
||||
/// returned to the caller only and never logged.
|
||||
///
|
||||
/// Reveal doubles as activation because the master seed is encrypted at
|
||||
/// rest: this password prompt is the only moment the node can legitimately
|
||||
/// open it, so it is also the only moment the ecash phrase can be derived
|
||||
/// from it. A node that has never been here mints valid but unrecoverable
|
||||
/// proofs; one visit fixes that for every proof minted afterwards.
|
||||
pub(super) async fn handle_wallet_ecash_seed_reveal(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
use zeroize::Zeroize;
|
||||
|
||||
let params = params.unwrap_or_default();
|
||||
let data_dir = &self.config.data_dir;
|
||||
|
||||
let mut password = self.verify_reveal_auth(¶ms, "the ecash seed").await?;
|
||||
|
||||
// Already established: just open it. No master seed needed, so this
|
||||
// still works on a node whose backup passphrase has been forgotten.
|
||||
if let Some(seed) = crate::wallet::nut13::load_seed(data_dir).await? {
|
||||
password.zeroize();
|
||||
let words = seed.words();
|
||||
return Ok(serde_json::json!({
|
||||
"words": words,
|
||||
"word_count": words.len(),
|
||||
"source": seed.source(),
|
||||
"newly_activated": false,
|
||||
}));
|
||||
}
|
||||
|
||||
if !crate::seed::seed_exists(data_dir) {
|
||||
password.zeroize();
|
||||
anyhow::bail!(
|
||||
"This node has no encrypted seed backup, so an ecash recovery \
|
||||
phrase cannot be derived from it."
|
||||
);
|
||||
}
|
||||
|
||||
// The backup passphrase may differ from the login password — same
|
||||
// fallback `seed.reveal` uses.
|
||||
let passphrase = params
|
||||
.get("passphrase")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| password.clone());
|
||||
let master = crate::seed::load_seed_encrypted(data_dir, &passphrase).await;
|
||||
password.zeroize();
|
||||
let mnemonic = master.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Could not decrypt the saved seed. If you set a separate backup \
|
||||
passphrase during setup, enter that passphrase."
|
||||
)
|
||||
})?;
|
||||
let master = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||
let seed = crate::wallet::nut13::establish_from_master(data_dir, &master).await?;
|
||||
|
||||
let words = seed.words();
|
||||
Ok(serde_json::json!({
|
||||
"words": words,
|
||||
"word_count": words.len(),
|
||||
"source": seed.source(),
|
||||
"newly_activated": true,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
|
||||
/// phrase by asking a mint which re-derived secrets it has signed.
|
||||
///
|
||||
/// Defaults to the wallet's own mint; `mint_url` targets another one, for
|
||||
/// a wallet whose coins were spread across mints.
|
||||
pub(super) async fn handle_wallet_ecash_restore(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let mint_url = match params.get("mint_url").and_then(|v| v.as_str()) {
|
||||
Some(url) if !url.trim().is_empty() => url.trim().to_string(),
|
||||
_ => {
|
||||
crate::wallet::ecash::load_wallet(&self.config.data_dir)
|
||||
.await?
|
||||
.mint_url
|
||||
}
|
||||
};
|
||||
|
||||
let outcome =
|
||||
crate::wallet::ecash::restore_from_seed(&self.config.data_dir, &mint_url).await?;
|
||||
Ok(serde_json::json!({
|
||||
"mint_url": mint_url,
|
||||
"recovered_sats": outcome.recovered_sats,
|
||||
"recovered_proofs": outcome.recovered_proofs,
|
||||
"already_spent": outcome.already_spent,
|
||||
"keysets_scanned": outcome.keysets_scanned,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -40,6 +40,15 @@ pub struct GatedPort {
|
||||
/// companion UIs proxy that cookie to the daemon's authenticated
|
||||
/// endpoints; for every other app the gate strips its own credential.
|
||||
pub session_passthrough: bool,
|
||||
/// Does the gate challenge for the dashboard login on this port?
|
||||
///
|
||||
/// Default comes from the manifest (`auth: gated`/undeclared → true,
|
||||
/// `auth: open` → false); the operator's runtime override
|
||||
/// (`app_gate_config`, Settings → app → App gate) wins over both.
|
||||
/// False does NOT release the port — the gate keeps binding and
|
||||
/// proxying (frame-header fixes, app-down page, Tor upstream); it just
|
||||
/// forwards every request to the app's own authentication.
|
||||
pub auth_enabled: bool,
|
||||
}
|
||||
|
||||
/// A port deliberately left unauthenticated, and the manifest's stated reason.
|
||||
@@ -187,6 +196,18 @@ pub fn build_port_map() -> PortMap {
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's runtime override wins over the manifest default, in
|
||||
// both directions: un-gate an app that fronts its own login, or force
|
||||
// the challenge back onto an `auth: open` port. Overrides only toggle
|
||||
// the challenge on gate-fronted ports — they never bind or release
|
||||
// anything, so a stale override cannot expose or strand a port.
|
||||
let overrides = crate::container::app_gate_config::all_gate_overrides();
|
||||
for gp in map.gated.values_mut() {
|
||||
if let Some(enabled) = overrides.get(&gp.app_id) {
|
||||
gp.auth_enabled = *enabled;
|
||||
}
|
||||
}
|
||||
|
||||
map.exempt.sort_by_key(|e| e.port);
|
||||
map
|
||||
}
|
||||
@@ -229,7 +250,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
// Explicit opt-in: the app is on loopback and the daemon
|
||||
// owns the external addresses. This is the ONLY way a
|
||||
// port gets bound by the gate, regardless of `bind`.
|
||||
PortAuth::Gated => {
|
||||
// `open` is the same takeover with the login challenge
|
||||
// defaulted off — the app fronts its own authentication
|
||||
// (rationale-required, see PortAuth::Open).
|
||||
PortAuth::Gated | PortAuth::Open => {
|
||||
map.gated.insert(
|
||||
port.host,
|
||||
GatedPort {
|
||||
@@ -239,6 +263,7 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
icon: icon.clone(),
|
||||
declared: true,
|
||||
session_passthrough: port.session_passthrough,
|
||||
auth_enabled: port.auth_policy() == PortAuth::Gated,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -289,6 +314,10 @@ fn classify_manifest(manifest: &AppManifest, map: &mut PortMap) {
|
||||
// An undeclared port never gets the node session —
|
||||
// passthrough is an explicit manifest opt-in only.
|
||||
session_passthrough: false,
|
||||
// Undeclared ports are challenged wherever the gate
|
||||
// can stand: reporting-and-protecting is the safe
|
||||
// default (operator override still applies below).
|
||||
auth_enabled: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,14 @@ impl AppGate {
|
||||
app: &GatedPort,
|
||||
client_ip: IpAddr,
|
||||
) -> Response<Body> {
|
||||
// The accept loop captured its GatedPort at bind time; per-request
|
||||
// policy (the operator's gate on/off toggle, session_passthrough)
|
||||
// must come from the live map or a Settings change would only apply
|
||||
// to ports (re)bound after the next sweep. Falls back to the bound
|
||||
// snapshot when the port momentarily leaves the map mid-refresh.
|
||||
let live = self.port_map.read().await.gated(app.port).cloned();
|
||||
let app = live.as_ref().unwrap_or(app);
|
||||
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
|
||||
@@ -169,6 +177,17 @@ impl AppGate {
|
||||
return proxy_to_app(req, app, true).await;
|
||||
}
|
||||
|
||||
// Gate challenge disabled for this app (manifest `auth: open`, or
|
||||
// the operator's Settings toggle): forward everything to the app's
|
||||
// own authentication. The Authorization header passes through
|
||||
// untouched — git clients speak basic-auth to Gitea, API clients
|
||||
// carry the app's own tokens. Gate cookies are still stripped in
|
||||
// proxy_to_app (an ungated app must never see the node session),
|
||||
// and the frame fixes / app-down page still apply.
|
||||
if !app.auth_enabled {
|
||||
return proxy_to_app(req, app, false).await;
|
||||
}
|
||||
|
||||
match self.authorize(req.headers(), &app.app_id).await {
|
||||
// The credential was a cookie (or none was needed): the
|
||||
// Authorization header, if any, belongs to the app. Forward it.
|
||||
@@ -446,7 +465,7 @@ async fn proxy_to_app(
|
||||
.to_string();
|
||||
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
|
||||
Ok(uri) => uri,
|
||||
Err(_) => return bad_gateway(),
|
||||
Err(_) => return app_down_page(app),
|
||||
};
|
||||
|
||||
let (mut parts, body) = req.into_parts();
|
||||
@@ -501,7 +520,7 @@ async fn proxy_to_app(
|
||||
let client = hyper::Client::new();
|
||||
let mut upstream_resp = match client.request(upstream_req).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => return bad_gateway(),
|
||||
Err(_) => return app_down_page(app),
|
||||
};
|
||||
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
|
||||
if let Some(client_upgrade) = client_upgrade {
|
||||
@@ -520,8 +539,60 @@ async fn proxy_to_app(
|
||||
|
||||
let client = hyper::Client::new();
|
||||
match client.request(Request::from_parts(parts, body)).await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => bad_gateway(),
|
||||
Ok(mut resp) => {
|
||||
neutralize_frame_blocking(resp.headers_mut());
|
||||
resp
|
||||
}
|
||||
Err(_) => app_down_page(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make gate-proxied app responses embeddable by the dashboard's My Apps
|
||||
/// iframe. Apps that were never designed for framing ship
|
||||
/// `X-Frame-Options: DENY` (Alby Hub) or a CSP `frame-ancestors` directive,
|
||||
/// and either one makes the embedded app session a dead grey pane — the
|
||||
/// historical workaround was a bespoke per-app nginx proxy (gitea), which is
|
||||
/// exactly the per-app patching the manifest platform exists to delete.
|
||||
///
|
||||
/// Framing protection exists to stop a FOREIGN origin from framing an authed
|
||||
/// page and clickjacking it. Behind the gate that threat model is already
|
||||
/// handled the way the gate's own pages handle it: every proxied request is
|
||||
/// authenticated by the gate first, and the gate's own responses declare
|
||||
/// `frame-ancestors 'self' http://*:* https://*:*` (see `page()`) because the
|
||||
/// dashboard is reached by LAN IP, mDNS name, and onion alike. Upstream
|
||||
/// X-Frame-Options is dropped entirely; only the `frame-ancestors` directive
|
||||
/// is removed from the app's CSP — the rest of the app's policy (script-src,
|
||||
/// connect-src, …) is the app's business and passes through untouched.
|
||||
fn neutralize_frame_blocking(headers: &mut hyper::HeaderMap) {
|
||||
headers.remove("x-frame-options");
|
||||
let Some(csp) = headers.get("content-security-policy") else {
|
||||
return;
|
||||
};
|
||||
let Ok(raw) = csp.to_str() else {
|
||||
return;
|
||||
};
|
||||
if !raw.to_ascii_lowercase().contains("frame-ancestors") {
|
||||
return;
|
||||
}
|
||||
let kept: Vec<&str> = raw
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|d| !d.to_ascii_lowercase().starts_with("frame-ancestors") && !d.is_empty())
|
||||
.collect();
|
||||
if kept.is_empty() {
|
||||
headers.remove("content-security-policy");
|
||||
return;
|
||||
}
|
||||
match header::HeaderValue::from_str(&kept.join("; ")) {
|
||||
Ok(v) => {
|
||||
headers.insert("content-security-policy", v);
|
||||
}
|
||||
Err(_) => {
|
||||
// Unrepresentable after filtering — fail open for framing but
|
||||
// closed for the policy: better to drop a mangled CSP than to
|
||||
// serve one we rewrote incorrectly.
|
||||
headers.remove("content-security-policy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,11 +654,32 @@ fn redirect_to_app() -> Response<Body> {
|
||||
.expect("static response builds")
|
||||
}
|
||||
|
||||
fn bad_gateway() -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.body(Body::from("app is not responding"))
|
||||
.expect("static response builds")
|
||||
/// Served when the app behind the gate does not answer on loopback.
|
||||
///
|
||||
/// A real page rather than the bare string `app is not responding`: the gate
|
||||
/// answers on the app's own port, so this text IS the app as far as the
|
||||
/// operator can tell, and the raw string read as the node itself being broken
|
||||
/// (reported against Gitea on a fleet node, 2026-08-10 — the actual fault was
|
||||
/// a ghost container crash-looping the app). Name the app, say the node is
|
||||
/// fine, and retry on our own: an app that is restarting comes back without
|
||||
/// the user knowing to reload. Status stays 502 so machine clients still see
|
||||
/// an upstream failure rather than a success with HTML in it.
|
||||
fn app_down_page(app: &GatedPort) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"{icon}
|
||||
<h1>{name} is not responding</h1>
|
||||
<p class="sub">The app is not answering right now — it may be stopped or still
|
||||
starting. This page retries automatically. If it does not recover, open the
|
||||
dashboard and check {name} under My Apps.</p>"#,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
);
|
||||
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
|
||||
// Header-based refresh, not <meta> or script: page()'s CSP allows no
|
||||
// script, and the header keeps the retry out of the document entirely.
|
||||
resp.headers_mut()
|
||||
.insert("Refresh", header::HeaderValue::from_static("5"));
|
||||
resp
|
||||
}
|
||||
|
||||
fn not_found() -> Response<Body> {
|
||||
@@ -658,6 +750,71 @@ fn icon_markup(app: &GatedPort) -> String {
|
||||
format!(r#"<div class="tile">{inner}</div>"#)
|
||||
}
|
||||
|
||||
/// The dashboard login's badge, reproduced square-for-square: the same 20
|
||||
/// white rects AnimatedLogo.vue draws, with the same 100ms stagger, inside
|
||||
/// the same gradient ring. Inline rather than an `<img>` because the shipped
|
||||
/// `favico-black-v2.svg` bakes its own ring into the artwork — wrapping it in
|
||||
/// the CSS ring drew a ring inside a ring, which is not what /login shows.
|
||||
/// (x, y, width, height) as they appear in AnimatedLogo.vue.
|
||||
const LOGO_RECTS: [(f32, f32, f32, f32); 20] = [
|
||||
(357.614, 318.0, 71.007, 70.936),
|
||||
(436.152, 318.0, 72.082, 70.936),
|
||||
(515.766, 318.0, 72.082, 70.936),
|
||||
(595.379, 318.0, 71.007, 70.936),
|
||||
(595.379, 396.46, 71.007, 72.011),
|
||||
(673.917, 396.46, 72.083, 72.011),
|
||||
(278.0, 475.994, 72.083, 72.012),
|
||||
(357.614, 475.994, 71.007, 72.012),
|
||||
(436.152, 475.994, 72.082, 72.012),
|
||||
(515.766, 475.994, 72.082, 72.012),
|
||||
(595.379, 475.994, 71.007, 72.012),
|
||||
(673.917, 475.994, 72.083, 72.012),
|
||||
(278.0, 555.529, 72.083, 70.936),
|
||||
(357.614, 555.529, 71.007, 70.936),
|
||||
(595.379, 555.529, 71.007, 70.936),
|
||||
(673.917, 555.529, 72.083, 70.936),
|
||||
(357.614, 633.989, 71.007, 72.011),
|
||||
(436.152, 633.989, 72.082, 72.011),
|
||||
(515.766, 633.989, 72.082, 72.011),
|
||||
(595.379, 633.989, 71.007, 72.011),
|
||||
];
|
||||
|
||||
fn logo_markup() -> String {
|
||||
let rects: String = LOGO_RECTS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (x, y, w, h))| {
|
||||
format!(
|
||||
r#"<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="white" class="sq" style="--d:{delay}ms"/>"#,
|
||||
delay = i * 100,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
r##"<div class="logo"><svg viewBox="0 0 1024 1024" role="img" aria-label="Archipelago" xmlns="http://www.w3.org/2000/svg"><rect width="1024" height="1024" fill="#030202"/>{rects}</svg></div>"##
|
||||
)
|
||||
}
|
||||
|
||||
/// The main login's in-button spinner, verbatim from Login.vue.
|
||||
const SPINNER_SVG: &str = r#"<svg class="spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle style="opacity:.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path style="opacity:.75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>"#;
|
||||
|
||||
/// Submit feedback: flip the pressed button into its loading face and stop a
|
||||
/// second press, exactly as /login does. This is the only script on the page,
|
||||
/// and the CSP admits it by hash — not `'unsafe-inline'` — so an injected
|
||||
/// `<script>` still cannot run. Everything works without it (the form is a
|
||||
/// plain POST); losing JS costs only the spinner.
|
||||
const SUBMIT_FEEDBACK_JS: &str = "document.addEventListener('submit',function(e){var b=e.target.querySelector('button');if(b){b.classList.add('loading');b.disabled=true;}});";
|
||||
|
||||
/// `'sha256-…'` CSP source expression for [`SUBMIT_FEEDBACK_JS`]. Computed
|
||||
/// from the constant itself so the two can never drift apart.
|
||||
fn submit_feedback_csp_hash() -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!(
|
||||
"'sha256-{}'",
|
||||
base64_encode(&Sha256::digest(SUBMIT_FEEDBACK_JS.as_bytes()))
|
||||
)
|
||||
}
|
||||
|
||||
/// Icons live with the web UI. Only files under the icon directory are read,
|
||||
/// and only known image extensions — the path comes from a manifest, which is
|
||||
/// signed, but treating it as untrusted costs nothing.
|
||||
@@ -709,7 +866,9 @@ const LOGIN_BACKGROUNDS: [&str; 4] = [
|
||||
/// join: the name arrives in a URL, and the gate answers before any
|
||||
/// authentication, so nothing here may be caller-controlled beyond this set.
|
||||
fn read_ui_asset(name: &str) -> Option<(Vec<u8>, &'static str)> {
|
||||
let allowed = LOGIN_BACKGROUNDS.contains(&name) || name == "favico-black-v2.svg";
|
||||
// Only the rotating backgrounds: the logo badge is inline SVG now, so no
|
||||
// image asset backs it.
|
||||
let allowed = LOGIN_BACKGROUNDS.contains(&name);
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
@@ -831,8 +990,15 @@ main {{ position:relative; z-index:1; width:min(92vw,28rem); }}
|
||||
width:5rem; height:5rem; border-radius:9999px; padding:3px;
|
||||
background:linear-gradient(135deg, rgba(255,255,255,.6) 0%, rgba(0,0,0,.8) 100%);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.5); }}
|
||||
.logo img {{ width:100%; height:100%; border-radius:9999px; display:block;
|
||||
background:#000; padding:.5rem; }}
|
||||
.logo::after {{ content:''; position:absolute; inset:3px; border-radius:9999px;
|
||||
background:#000; z-index:0; }}
|
||||
.logo svg {{ position:relative; z-index:1; width:100%; height:100%;
|
||||
border-radius:9999px; display:block; }}
|
||||
/* AnimatedLogo.vue's reveal, timing intact: each square fades in on its own
|
||||
100ms-step delay over a 3s loop. */
|
||||
.logo .sq {{ opacity:0; animation:logo-square-in 3s ease-out infinite;
|
||||
animation-delay:var(--d,0ms); animation-fill-mode:both; }}
|
||||
@keyframes logo-square-in {{ 0% {{ opacity:0; }} 15% {{ opacity:1; }} 100% {{ opacity:1; }} }}
|
||||
/* The app's own tile, in the My Apps shape: 18px-rounded square on dark
|
||||
glass with the same inner highlight and drop shadow. */
|
||||
.tile {{ width:60px; height:60px; border-radius:18px; margin:0 auto .75rem;
|
||||
@@ -853,22 +1019,42 @@ input {{ width:100%; padding:.75rem 1rem; margin-bottom:1rem; border-radius:.5re
|
||||
input::placeholder {{ color:rgba(255,255,255,.4); }}
|
||||
input:focus {{ outline:none; border-color:rgba(255,255,255,.4);
|
||||
box-shadow:0 0 0 1px rgba(255,255,255,.2); }}
|
||||
button {{ width:100%; min-height:44px; padding:.75rem 1.25rem; border:none;
|
||||
border-radius:.75rem; background:rgba(0,0,0,.6);
|
||||
/* .glass-button, longhand — the lift, the lightening and the rim glow on
|
||||
hover are what make the dashboard's buttons feel alive; the old flat
|
||||
darken-only hover here read as broken next to /login. */
|
||||
button {{ position:relative; display:inline-flex; align-items:center;
|
||||
justify-content:center; width:100%; min-height:44px; padding:.75rem 1.25rem;
|
||||
border:none; border-radius:.75rem; background:rgba(0,0,0,.6);
|
||||
backdrop-filter:blur(24px); -webkit-backdrop-filter:blur(24px);
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.22);
|
||||
color:rgba(255,255,255,.9); font-size:1rem; font-weight:500; cursor:pointer;
|
||||
transition:background-color .2s ease, transform .3s cubic-bezier(.4,0,.2,1); }}
|
||||
button:hover {{ background:rgba(0,0,0,.7); }}
|
||||
transition:transform .3s cubic-bezier(.4,0,.2,1), background-color .2s ease,
|
||||
box-shadow .3s ease; }}
|
||||
button::before {{ content:''; position:absolute; inset:0; border-radius:inherit;
|
||||
padding:2px; background:linear-gradient(135deg, rgba(0,0,0,.8), transparent);
|
||||
-webkit-mask:linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite:xor; mask-composite:exclude; pointer-events:none; }}
|
||||
button:hover {{ transform:translateY(-2px); background:rgba(0,0,0,.35);
|
||||
box-shadow:0 12px 32px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.25); }}
|
||||
button:hover::before {{ background:linear-gradient(135deg, rgba(255,255,255,.3), transparent); }}
|
||||
button:active {{ transform:translateY(1px); }}
|
||||
button:disabled {{ opacity:.5; cursor:not-allowed; transform:none; }}
|
||||
/* Two faces per button; the submit-feedback script flips .loading on. */
|
||||
button .busy {{ display:none; }}
|
||||
button.loading .idle {{ display:none; }}
|
||||
button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
|
||||
.spin {{ width:1.25rem; height:1.25rem; animation:spin 1s linear infinite; }}
|
||||
@keyframes spin {{ to {{ transform:rotate(360deg); }} }}
|
||||
.err {{ background:rgba(239,68,68,.2); border:1px solid rgba(239,68,68,.4);
|
||||
color:#fecaca; padding:.75rem; border-radius:.5rem; margin-bottom:1rem;
|
||||
font-size:.875rem; text-align:left; }}
|
||||
</style></head>
|
||||
<body>{backgrounds}<main><div class="card">{body}</div></main></body></html>"#,
|
||||
<body>{backgrounds}<main><div class="card">{body}</div></main>
|
||||
<script>{submit_feedback}</script></body></html>"#,
|
||||
title = esc(title),
|
||||
app_name = esc(&app.app_name),
|
||||
body = body,
|
||||
submit_feedback = SUBMIT_FEEDBACK_JS,
|
||||
backgrounds = background_layers(),
|
||||
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
|
||||
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
|
||||
@@ -888,10 +1074,16 @@ button:active {{ transform:translateY(1px); }}
|
||||
// login, on any port or scheme, which is exactly the dashboard.
|
||||
// Anything else — another site embedding it to harvest the node
|
||||
// password — is still refused.
|
||||
// script-src admits exactly one script, by hash: the submit-feedback
|
||||
// snippet above. Injected markup (an app name, an error string) still
|
||||
// cannot execute — its hash would not match.
|
||||
.header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
|
||||
form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
|
||||
format!(
|
||||
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
|
||||
script-src {hash}; form-action 'self'; frame-ancestors 'self' http://*:* https://*:*",
|
||||
hash = submit_feedback_csp_hash(),
|
||||
),
|
||||
)
|
||||
.body(Body::from(html))
|
||||
.expect("static response builds")
|
||||
@@ -902,15 +1094,17 @@ button:active {{ transform:translateY(1px); }}
|
||||
/// password by an unexplained page.
|
||||
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
|
||||
let body = format!(
|
||||
r#"<div class="logo"><img src="{prefix}asset/favico-black-v2.svg" alt="Archipelago"></div>
|
||||
r#"{logo}
|
||||
{icon}
|
||||
<h1>Sign in to open {name}</h1>
|
||||
<p class="sub">This app is protected by your node password.</p>
|
||||
{err}
|
||||
<form method="post" action="{prefix}login">
|
||||
<input type="password" name="password" placeholder="Node password" autocomplete="current-password" autofocus required>
|
||||
<button type="submit">Sign in</button>
|
||||
<button type="submit"><span class="idle">Sign in</span><span class="busy">{spinner}Signing in…</span></button>
|
||||
</form>"#,
|
||||
logo = logo_markup(),
|
||||
spinner = SPINNER_SVG,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
err = error
|
||||
@@ -931,8 +1125,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
|
||||
{err}
|
||||
<form method="post" action="{prefix}totp">
|
||||
<input type="text" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="000000" autofocus required>
|
||||
<button type="submit">Verify</button>
|
||||
<button type="submit"><span class="idle">Verify</span><span class="busy">{spinner}Verifying…</span></button>
|
||||
</form>"#,
|
||||
spinner = SPINNER_SVG,
|
||||
icon = icon_markup(app),
|
||||
name = esc(&app.app_name),
|
||||
err = error
|
||||
@@ -988,6 +1183,7 @@ mod tests {
|
||||
icon: None,
|
||||
declared: true,
|
||||
session_passthrough: false,
|
||||
auth_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,6 +1247,50 @@ mod tests {
|
||||
/// 2026-08-05). It must still be uncacheable, and still refuse to be
|
||||
/// framed by a foreign origin, which `frame-ancestors` expresses and
|
||||
/// `X-Frame-Options` cannot.
|
||||
/// Upstream frame-blocking must not survive the proxy: X-Frame-Options
|
||||
/// goes away entirely, CSP loses ONLY its frame-ancestors directive —
|
||||
/// the app's remaining policy must pass through byte-preserving in
|
||||
/// content (Alby Hub's DENY + strict CSP was the real-world case,
|
||||
/// archi-dev-box 2026-08-12).
|
||||
#[test]
|
||||
fn proxied_responses_lose_frame_blocking_but_keep_the_apps_csp() {
|
||||
let mut headers = hyper::HeaderMap::new();
|
||||
headers.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; frame-ancestors 'none'; img-src 'self' https://cdn.example"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
neutralize_frame_blocking(&mut headers);
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
let csp = headers["content-security-policy"].to_str().unwrap();
|
||||
assert!(!csp.contains("frame-ancestors"));
|
||||
assert!(csp.contains("default-src 'self'"));
|
||||
assert!(csp.contains("img-src 'self' https://cdn.example"));
|
||||
|
||||
// CSP that is ONLY a frame-ancestors directive disappears entirely.
|
||||
let mut only = hyper::HeaderMap::new();
|
||||
only.insert(
|
||||
"content-security-policy",
|
||||
"frame-ancestors 'self'".parse().unwrap(),
|
||||
);
|
||||
neutralize_frame_blocking(&mut only);
|
||||
assert!(!only.contains_key("content-security-policy"));
|
||||
|
||||
// No frame directives at all → CSP untouched.
|
||||
let mut plain = hyper::HeaderMap::new();
|
||||
plain.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'".parse().unwrap(),
|
||||
);
|
||||
neutralize_frame_blocking(&mut plain);
|
||||
assert_eq!(
|
||||
plain["content-security-policy"].to_str().unwrap(),
|
||||
"default-src 'self'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
@@ -1065,26 +1305,66 @@ mod tests {
|
||||
assert!(csp.contains("form-action 'self'"));
|
||||
}
|
||||
|
||||
/// A dead upstream must render as a page that names the app and retries,
|
||||
/// not the bare string "app is not responding" — that string standing
|
||||
/// alone on the app's own port read as the node being broken (Gitea on a
|
||||
/// fleet node, 2026-08-10). The 502 status must survive so machine
|
||||
/// clients still see an upstream failure.
|
||||
#[tokio::test]
|
||||
async fn a_dead_app_gets_a_named_retrying_page_not_a_bare_string() {
|
||||
let resp = app_down_page(&app());
|
||||
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(resp.headers()["Refresh"], "5");
|
||||
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Strfry Relay is not responding"));
|
||||
assert!(html.contains("<html"), "must be a page, not a bare string");
|
||||
}
|
||||
|
||||
/// The login page must render entirely from the gate's own origin: the
|
||||
/// CSP allows no external host, so a background or logo that 404s leaves
|
||||
/// a black page rather than the dashboard's art.
|
||||
/// CSP allows no external host, so a background that 404s leaves a black
|
||||
/// page rather than the dashboard's art. The badge itself is inline SVG —
|
||||
/// the same 20 squares as the dashboard login's AnimatedLogo — so it can
|
||||
/// never 404 at all.
|
||||
#[tokio::test]
|
||||
async fn login_page_sources_its_art_from_the_gate() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body).to_string();
|
||||
assert!(html.contains(&format!("{GATE_PREFIX}asset/favico-black-v2.svg")));
|
||||
assert_eq!(
|
||||
html.matches(r#"class="sq""#).count(),
|
||||
LOGO_RECTS.len(),
|
||||
"the badge must draw every AnimatedLogo square inline"
|
||||
);
|
||||
for name in LOGIN_BACKGROUNDS {
|
||||
assert!(
|
||||
html.contains(&format!("{GATE_PREFIX}asset/{name}")),
|
||||
"background {name} is not referenced"
|
||||
);
|
||||
}
|
||||
// Every referenced asset must be one the gate will actually serve.
|
||||
// The logo is the sidebar A mark (favico-black-v2.svg) since the
|
||||
// 2026-08-05 login-page rework — the old wordmark is off the
|
||||
// allowlist on purpose.
|
||||
assert!(read_ui_asset("favico-black-v2.svg").is_some() || cfg!(not(debug_assertions)));
|
||||
}
|
||||
|
||||
/// The only script the challenge pages may run is the submit-feedback
|
||||
/// snippet, admitted by hash. The page must carry exactly that script,
|
||||
/// and the CSP must name its hash — anything injected has a different
|
||||
/// hash and stays inert.
|
||||
#[tokio::test]
|
||||
async fn submit_feedback_script_is_present_and_hash_pinned() {
|
||||
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
|
||||
let csp = resp.headers()["Content-Security-Policy"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(csp.contains(&format!("script-src {}", submit_feedback_csp_hash())));
|
||||
assert!(!csp.contains("script-src 'unsafe-inline'"));
|
||||
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains(&format!("<script>{SUBMIT_FEEDBACK_JS}</script>")));
|
||||
// Both button faces render: idle label and the spinner face.
|
||||
assert!(html.contains(r#"<span class="idle">Sign in</span>"#));
|
||||
assert!(html.contains("Signing in…"));
|
||||
assert!(html.contains(r#"class="spin""#));
|
||||
}
|
||||
|
||||
/// The allowlist is the whole security boundary for asset serving: the
|
||||
|
||||
@@ -171,6 +171,30 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("tor-helper.sh already current"),
|
||||
Err(e) => warn!("tor-helper sync failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_welcome_banner_sync().await {
|
||||
Ok(true) => info!(
|
||||
"Console welcome banner synchronized (LAN address + .local name, not the WG tunnel IP)"
|
||||
),
|
||||
Ok(false) => debug!("Console welcome banner already current (or not an ISO node)"),
|
||||
Err(e) => warn!("Welcome banner sync failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_nginx_listener_repair().await {
|
||||
Ok(true) => info!("nginx HTTPS listeners retargeted to this host's current addresses"),
|
||||
Ok(false) => debug!("nginx listeners already match this host's addresses"),
|
||||
Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_ha_rpc_proxy_bind_repair().await {
|
||||
Ok(true) => info!(
|
||||
"HA bitcoind RPC forwarder rebound dynamically — survives network moves now"
|
||||
),
|
||||
Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"),
|
||||
Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_pull_never_image_repair().await {
|
||||
Ok(n) if n > 0 => info!(retagged = n, "Healed quadlet image refs orphaned by registry rename"),
|
||||
Ok(_) => debug!("All quadlet image refs resolve locally"),
|
||||
Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_tor_torrc_repair().await {
|
||||
Ok(true) => info!("Tor healed at boot (torrc rebuilt and/or daemon restarted)"),
|
||||
Ok(false) => debug!("Tor healthy and torrc in sync — no heal needed"),
|
||||
@@ -654,6 +678,352 @@ exit 2
|
||||
const TOR_HELPER_SH: &str = include_str!("../../../scripts/tor-helper.sh");
|
||||
const TOR_HELPER_PATH: &str = "/opt/archipelago/scripts/tor-helper.sh";
|
||||
|
||||
/// Heal socat forwarder units that were generated with the node's LAN IP
|
||||
/// baked into `bind=`.
|
||||
///
|
||||
/// `archy-ha-btc-rpc-proxy.service` (written on-node during the Pine/HA
|
||||
/// integration) bound socat to the box's DHCP address at generation time —
|
||||
/// pasta containers reach the host via its LAN address, so that was the
|
||||
/// address that worked. Move the box to a new network and the address no
|
||||
/// longer exists: `bind()` fails and the unit restart-loops forever
|
||||
/// (framework-pt after relocating, 2026-08-15: restart counter 2446, and
|
||||
/// Home Assistant's bitcoind sensor dead with it).
|
||||
///
|
||||
/// The rewrite computes the bind address at every service start instead, so
|
||||
/// `Restart=always` itself becomes the heal: plug the box into any network
|
||||
/// and the next restart binds to the new address.
|
||||
const HA_RPC_PROXY_UNIT_PATH: &str = "/etc/systemd/system/archy-ha-btc-rpc-proxy.service";
|
||||
|
||||
/// Parse `TCP-LISTEN:<port>,bind=<ipv4>` + trailing `TCP:<target>` out of a
|
||||
/// socat ExecStart line. Returns (listen_port, target).
|
||||
fn parse_socat_static_bind(exec_line: &str) -> Option<(String, String)> {
|
||||
let after_listen = exec_line.split("TCP-LISTEN:").nth(1)?;
|
||||
let port = after_listen.split(',').next()?.trim();
|
||||
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
// Only rewrite units pinned to a concrete address; a unit already using
|
||||
// a computed bind (or none) needs no heal.
|
||||
let bind = after_listen.split("bind=").nth(1)?.split(',').next()?.trim();
|
||||
if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") {
|
||||
return None;
|
||||
}
|
||||
let target = exec_line.rsplit(" TCP:").next()?.trim();
|
||||
if target.is_empty() || target == exec_line {
|
||||
return None;
|
||||
}
|
||||
Some((port.to_string(), target.to_string()))
|
||||
}
|
||||
|
||||
fn dynamic_bind_execstart(listen_port: &str, target: &str) -> String {
|
||||
// `$$` survives systemd's own expansion as a literal `$`, so the command
|
||||
// substitution runs in the shell at ExecStart time. If the box has no
|
||||
// default route yet, exit non-zero and let Restart=always retry.
|
||||
format!(
|
||||
"ExecStart=/bin/sh -c 'IP=$$(ip -4 route get 1.1.1.1 | sed -n \"s/.*src \\([0-9.]*\\).*/\\1/p\"); \
|
||||
[ -n \"$$IP\" ] || exit 1; \
|
||||
exec /usr/bin/socat TCP-LISTEN:{listen_port},bind=$$IP,fork,reuseaddr TCP:{target}'"
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_ha_rpc_proxy_bind_repair() -> Result<bool> {
|
||||
let unit = match tokio::fs::read_to_string(HA_RPC_PROXY_UNIT_PATH).await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Ok(false), // node never grew the forwarder
|
||||
};
|
||||
let Some(exec_line) = unit.lines().find(|l| l.trim_start().starts_with("ExecStart=")) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some((port, target)) = parse_socat_static_bind(exec_line) else {
|
||||
return Ok(false); // already dynamic (or not the shape we heal)
|
||||
};
|
||||
let healed = unit.replace(exec_line, &dynamic_bind_execstart(&port, &target));
|
||||
let staged = "/var/lib/archipelago/ha-rpc-proxy.staged";
|
||||
if let Some(dir) = Path::new(staged).parent() {
|
||||
tokio::fs::create_dir_all(dir).await.ok();
|
||||
}
|
||||
tokio::fs::write(staged, &healed)
|
||||
.await
|
||||
.context("stage ha-rpc-proxy unit")?;
|
||||
let script = format!(
|
||||
"set -eu\ninstall -m 0644 {staged} {dest}\nsystemctl daemon-reload\nsystemctl restart archy-ha-btc-rpc-proxy 2>/dev/null || true\nexit 0\n",
|
||||
staged = staged,
|
||||
dest = HA_RPC_PROXY_UNIT_PATH
|
||||
);
|
||||
host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("install ha-rpc-proxy unit")?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Re-point `--pull never` quadlets whose image ref no longer matches local
|
||||
/// storage.
|
||||
///
|
||||
/// The catalog signing pass rewrites image refs (bare-IP registry → domain),
|
||||
/// so a quadlet regenerated with the new ref points at an image the local
|
||||
/// store only holds under the old name. With `--pull never` the app can
|
||||
/// never start again on its own — Home Assistant looped 761 restarts on
|
||||
/// "image not known" (framework-pt, 2026-08-15) while an identical
|
||||
/// `name:tag` sat in storage under the bare-IP ref. If any local image
|
||||
/// shares the wanted `name:tag`, retag it; pulling is deliberately NOT
|
||||
/// attempted here (offline nodes, metered links — the doctor handles pulls).
|
||||
async fn run_pull_never_image_repair() -> Result<usize> {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string());
|
||||
let quadlet_dir = format!("{home}/.config/containers/systemd");
|
||||
let mut wanted: Vec<String> = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(&quadlet_dir).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(0),
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("container") {
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = tokio::fs::read_to_string(&path).await else {
|
||||
continue;
|
||||
};
|
||||
for line in text.lines() {
|
||||
if let Some(image) = line.trim().strip_prefix("Image=") {
|
||||
let image = image.trim();
|
||||
if !image.is_empty() {
|
||||
wanted.push(image.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if wanted.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let local = podman_stdout(&["images", "--format", "{{.Repository}}:{{.Tag}}"]).await;
|
||||
let local: Vec<&str> = local
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.contains("<none>"))
|
||||
.collect();
|
||||
let mut retagged = 0usize;
|
||||
for want in wanted {
|
||||
if local.iter().any(|l| *l == want) {
|
||||
continue;
|
||||
}
|
||||
// Same `name:tag`, any registry prefix, is the rename we heal.
|
||||
let Some(name_tag) = want.rsplit('/').next() else {
|
||||
continue;
|
||||
};
|
||||
if !name_tag.contains(':') {
|
||||
continue;
|
||||
}
|
||||
let suffix = format!("/{name_tag}");
|
||||
let Some(src) = local.iter().find(|l| l.ends_with(&suffix)) else {
|
||||
continue;
|
||||
};
|
||||
let status = tokio::process::Command::new("podman")
|
||||
.args(["tag", src, &want])
|
||||
.status()
|
||||
.await;
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
info!(from = %src, to = %want, "Retagged image for a --pull never quadlet");
|
||||
retagged += 1;
|
||||
}
|
||||
_ => warn!(from = %src, to = %want, "Image retag failed (non-fatal)"),
|
||||
}
|
||||
}
|
||||
Ok(retagged)
|
||||
}
|
||||
|
||||
async fn podman_stdout(args: &[&str]) -> String {
|
||||
match tokio::process::Command::new("podman").args(args).output().await {
|
||||
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep nginx's per-address HTTPS listeners in step with the addresses the
|
||||
/// host actually has, and get nginx running if a boot race killed it.
|
||||
///
|
||||
/// `scripts/setup-node-ca.sh` writes one `listen <addr>:443 ssl;` per LAN
|
||||
/// address at the moment it runs (per-address rather than wildcard on
|
||||
/// purpose: Tailscale holds :443 on the tailnet address). Its idempotency
|
||||
/// guard then never revisits them. Two ways that takes the WHOLE web UI
|
||||
/// down — nginx refuses to start if any listen address is missing, so this
|
||||
/// is not merely an HTTPS outage:
|
||||
/// 1. The node moves networks and the old address no longer exists.
|
||||
/// 2. Even in place, nginx starts before DHCP has assigned the address —
|
||||
/// and nginx.service ships no `Restart=`, so that single failure is
|
||||
/// permanent until a human intervenes.
|
||||
/// Both observed on archi-dev-box, 2026-08-15: nginx dead since boot with
|
||||
/// `bind() to 192.168.63.240:443 failed (99: Cannot assign requested
|
||||
/// address)`, and the dashboard simply unreachable.
|
||||
const NGINX_SITES: [&str; 2] = [
|
||||
"/etc/nginx/sites-available/archipelago-http",
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
];
|
||||
const NGINX_RESTART_DROPIN: &str = "/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
|
||||
|
||||
/// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) —
|
||||
/// the same exclusion `setup-node-ca.sh` applies, for the same reason.
|
||||
async fn host_lan_addrs() -> Vec<String> {
|
||||
let out = tokio::process::Command::new("ip")
|
||||
.args(["-o", "-4", "addr", "show", "scope", "global"])
|
||||
.output()
|
||||
.await;
|
||||
let Ok(out) = out else { return Vec::new() };
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| l.split_whitespace().nth(3))
|
||||
.filter_map(|cidr| cidr.split('/').next())
|
||||
.filter(|a| !is_cgnat(a))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_cgnat(addr: &str) -> bool {
|
||||
let mut parts = addr.split('.');
|
||||
let (Some(100), Some(second)) = (
|
||||
parts.next().and_then(|p| p.parse::<u8>().ok()),
|
||||
parts.next().and_then(|p| p.parse::<u8>().ok()),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
(64..=127).contains(&second)
|
||||
}
|
||||
|
||||
/// Rewrite the `listen <ip>:443 ssl;` set for one config's text. Returns the
|
||||
/// new text when it differs. Lines for absent addresses are dropped and one
|
||||
/// line per present address is kept, preserving the file's indentation.
|
||||
fn retarget_https_listeners(text: &str, present: &[String]) -> Option<String> {
|
||||
let listen_of = |l: &str| -> Option<String> {
|
||||
let t = l.trim();
|
||||
let rest = t.strip_prefix("listen ")?.strip_suffix(":443 ssl;")?;
|
||||
// Only per-address listeners; `listen 443 ssl ...` has no address.
|
||||
rest.split('.').count().eq(&4).then(|| rest.to_string())
|
||||
};
|
||||
if !text.lines().any(|l| listen_of(l).is_some()) {
|
||||
return None; // wildcard-only config; nothing address-pinned to heal
|
||||
}
|
||||
let stale: Vec<String> = text
|
||||
.lines()
|
||||
.filter_map(listen_of)
|
||||
.filter(|a| !present.contains(a))
|
||||
.collect();
|
||||
let existing: Vec<String> = text.lines().filter_map(listen_of).collect();
|
||||
let missing: Vec<&String> = present.iter().filter(|a| !existing.contains(a)).collect();
|
||||
if stale.is_empty() && missing.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let indent = text
|
||||
.lines()
|
||||
.find(|l| listen_of(l).is_some())
|
||||
.map(|l| l[..l.len() - l.trim_start().len()].to_string())
|
||||
.unwrap_or_else(|| " ".to_string());
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut wrote_block = false;
|
||||
for line in text.lines() {
|
||||
match listen_of(line) {
|
||||
Some(_) if !wrote_block => {
|
||||
wrote_block = true;
|
||||
for a in present {
|
||||
out.push(format!("{indent}listen {a}:443 ssl;"));
|
||||
}
|
||||
}
|
||||
Some(_) => {} // subsequent old listen lines are replaced by the block
|
||||
None => out.push(line.to_string()),
|
||||
}
|
||||
}
|
||||
Some(out.join("\n"))
|
||||
}
|
||||
|
||||
async fn run_nginx_listener_repair() -> Result<bool> {
|
||||
let present = host_lan_addrs().await;
|
||||
if present.is_empty() {
|
||||
return Ok(false); // no network yet; a later boot pass will do it
|
||||
}
|
||||
let mut changed = false;
|
||||
for site in NGINX_SITES {
|
||||
let Ok(text) = tokio::fs::read_to_string(site).await else {
|
||||
continue;
|
||||
};
|
||||
let Some(healed) = retarget_https_listeners(&text, &present) else {
|
||||
continue;
|
||||
};
|
||||
let staged = "/var/lib/archipelago/nginx-listeners.staged";
|
||||
if let Some(dir) = Path::new(staged).parent() {
|
||||
tokio::fs::create_dir_all(dir).await.ok();
|
||||
}
|
||||
tokio::fs::write(staged, &healed)
|
||||
.await
|
||||
.context("stage nginx listeners")?;
|
||||
// Install behind `nginx -t`, and roll back if the test fails — a bad
|
||||
// config here would take the dashboard down, which is the very
|
||||
// failure this repair exists to prevent.
|
||||
let script = format!(
|
||||
"set -eu\ncp {site} {site}.bak-listeners\ninstall -m 0644 {staged} {site}\n\
|
||||
if ! nginx -t 2>/dev/null; then cp {site}.bak-listeners {site}; exit 3; fi\nexit 0\n"
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script]).await?;
|
||||
match status.code() {
|
||||
Some(0) => changed = true,
|
||||
Some(3) => warn!(site, "nginx listener repair failed its config test — rolled back"),
|
||||
_ => warn!(site, "nginx listener repair helper failed"),
|
||||
}
|
||||
}
|
||||
// Whether or not the config changed: if nginx is down (the boot race, or
|
||||
// it died on an address that has since arrived), start it. And give it a
|
||||
// restart policy so the race stops being fatal in the first place.
|
||||
let script = format!(
|
||||
"set -eu\nmkdir -p $(dirname {dropin})\n\
|
||||
cat > {dropin} <<'EOF'\n[Service]\nRestart=on-failure\nRestartSec=5\n\
|
||||
[Unit]\nStartLimitIntervalSec=300\nStartLimitBurst=10\nEOF\n\
|
||||
systemctl daemon-reload\n\
|
||||
if ! systemctl is-active --quiet nginx; then systemctl reset-failed nginx 2>/dev/null || true; systemctl start nginx 2>/dev/null || true; \
|
||||
elif [ \"${{RELOAD:-1}}\" = 1 ]; then systemctl reload nginx 2>/dev/null || true; fi\nexit 0\n",
|
||||
dropin = NGINX_RESTART_DROPIN
|
||||
);
|
||||
host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("nginx restart policy + start")?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// The console welcome banner, embedded so the OTA can fix it on deployed
|
||||
/// nodes. `/etc/profile.d/archipelago.sh` is baked by the ISO installer and
|
||||
/// no OTA path touched it, so every node kept whatever its ISO generation
|
||||
/// shipped — including banners that print the node's own WireGuard address
|
||||
/// (10.44.0.1, present on EVERY node) as the "web ui", which is unreachable
|
||||
/// off-tunnel and actively misleading after a move to a new network
|
||||
/// (framework-pt, 2026-08-15). Canonical copy: scripts/welcome-banner.sh;
|
||||
/// the ISO builder inlines the same content for fresh installs.
|
||||
const WELCOME_BANNER_SH: &str = include_str!("../../../scripts/welcome-banner.sh");
|
||||
const WELCOME_BANNER_PATH: &str = "/etc/profile.d/archipelago.sh";
|
||||
|
||||
async fn run_welcome_banner_sync() -> Result<bool> {
|
||||
let current = tokio::fs::read_to_string(WELCOME_BANNER_PATH)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Only refresh a banner the installer put there: a dev machine running
|
||||
// the backend from a checkout has no business growing one in /etc.
|
||||
if current.is_empty() || current == WELCOME_BANNER_SH {
|
||||
return Ok(false);
|
||||
}
|
||||
let staged = "/var/lib/archipelago/welcome-banner.staged";
|
||||
if let Some(dir) = Path::new(staged).parent() {
|
||||
tokio::fs::create_dir_all(dir).await.ok();
|
||||
}
|
||||
tokio::fs::write(staged, WELCOME_BANNER_SH)
|
||||
.await
|
||||
.context("stage welcome banner")?;
|
||||
let script = format!(
|
||||
"set -eu\ninstall -m 0755 {staged} {dest}\nexit 0\n",
|
||||
staged = staged,
|
||||
dest = WELCOME_BANNER_PATH
|
||||
);
|
||||
host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
.context("install welcome banner")?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_tor_helper_sync() -> Result<bool> {
|
||||
let current = tokio::fs::read_to_string(TOR_HELPER_PATH)
|
||||
.await
|
||||
@@ -1427,6 +1797,74 @@ mod tests {
|
||||
heal_stale_web_search_block("location / { try_files $uri /index.html; }").is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The exact ExecStart framework-pt shipped with must parse, and the
|
||||
/// rewrite must preserve its listen port and forward target.
|
||||
#[test]
|
||||
fn static_socat_bind_is_parsed_and_rewritten_dynamically() {
|
||||
let line = "ExecStart=/usr/bin/socat TCP-LISTEN:18332,bind=192.168.1.249,fork,reuseaddr TCP:127.0.0.1:8332";
|
||||
let (port, target) = parse_socat_static_bind(line).expect("must parse");
|
||||
assert_eq!(port, "18332");
|
||||
assert_eq!(target, "127.0.0.1:8332");
|
||||
let dynamic = dynamic_bind_execstart(&port, &target);
|
||||
assert!(dynamic.contains("TCP-LISTEN:18332,bind=$$IP"));
|
||||
assert!(dynamic.contains("TCP:127.0.0.1:8332"));
|
||||
assert!(dynamic.contains("route get 1.1.1.1"));
|
||||
// The heal is idempotent: its own output no longer parses as a
|
||||
// static bind (bind=$$IP is not a concrete address).
|
||||
assert!(parse_socat_static_bind(&dynamic).is_none());
|
||||
}
|
||||
|
||||
/// The archi-dev-box config: one stale address (old network) beside the
|
||||
/// WireGuard one. The stale listener must go — nginx refuses to START
|
||||
/// while it names an address the host lacks — and the current LAN
|
||||
/// address must appear.
|
||||
#[test]
|
||||
fn stale_https_listeners_are_retargeted_to_present_addresses() {
|
||||
let cfg = "server {\n listen 80 default_server;\n listen 10.44.0.1:443 ssl;\n listen 192.168.63.240:443 ssl;\n ssl_certificate /x;\n}\n";
|
||||
let present = vec!["10.44.0.1".to_string(), "192.168.1.50".to_string()];
|
||||
let healed = retarget_https_listeners(cfg, &present).expect("must heal");
|
||||
assert!(healed.contains("listen 192.168.1.50:443 ssl;"));
|
||||
assert!(healed.contains("listen 10.44.0.1:443 ssl;"));
|
||||
assert!(!healed.contains("192.168.63.240"), "stale listener must be dropped");
|
||||
// Untouched lines survive, and the repair is idempotent.
|
||||
assert!(healed.contains("listen 80 default_server;"));
|
||||
assert!(healed.contains("ssl_certificate /x;"));
|
||||
assert!(retarget_https_listeners(&healed, &present).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_only_configs_and_cgnat_are_left_alone() {
|
||||
// No address-pinned listener → nothing to heal (the ISO's own config).
|
||||
assert!(retarget_https_listeners(
|
||||
"server {\n listen 443 ssl default_server;\n}\n",
|
||||
&["192.168.1.50".to_string()]
|
||||
)
|
||||
.is_none());
|
||||
// Tailscale CGNAT must never become an nginx listener: tailscaled
|
||||
// already holds :443 there, and binding it would fail nginx outright.
|
||||
assert!(is_cgnat("100.69.68.39"));
|
||||
assert!(is_cgnat("100.127.255.1"));
|
||||
assert!(!is_cgnat("100.128.0.1"));
|
||||
assert!(!is_cgnat("192.168.1.50"));
|
||||
assert!(!is_cgnat("10.44.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socat_units_that_need_no_heal_are_left_alone() {
|
||||
// Loopback bind is intentional (Tor bootstrap forwarder) — not ours.
|
||||
assert!(parse_socat_static_bind(
|
||||
"ExecStart=/usr/bin/socat TCP-LISTEN:18332,bind=127.0.0.1,reuseaddr,fork SOCKS4A:127.0.0.1:x.onion:8332,socksport=9050"
|
||||
)
|
||||
.is_none());
|
||||
// No bind at all.
|
||||
assert!(parse_socat_static_bind(
|
||||
"ExecStart=/usr/bin/socat TCP-LISTEN:18332,fork,reuseaddr TCP:127.0.0.1:8332"
|
||||
)
|
||||
.is_none());
|
||||
// Not a socat line.
|
||||
assert!(parse_socat_static_bind("ExecStart=/usr/bin/true").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair this node's own systemd restart policy.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Per-app operator override for the app gate's login requirement.
|
||||
//!
|
||||
//! The manifest declares each port's *default* policy (`auth: gated` = the
|
||||
//! gate challenges, the new `auth: open` = the gate fronts the port but does
|
||||
//! not challenge). This store holds the operator's runtime override — set
|
||||
//! from Settings → app details — so a node owner can un-gate an app that
|
||||
//! carries its own login (Gitea, BTCPay) or force the gate back onto an
|
||||
//! `open` app, without editing manifests or waiting for a catalog re-sign.
|
||||
//!
|
||||
//! Lives in the same merge-preserving per-app JSON files as the version
|
||||
//! preferences (`/var/lib/archipelago/app-configs/<app_id>.json`, key
|
||||
//! `"gateEnabled"`). Absent key = follow the manifest default.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
let base = std::env::var("ARCHIPELAGO_DATA_DIR")
|
||||
.unwrap_or_else(|_| "/var/lib/archipelago".to_string());
|
||||
PathBuf::from(base).join("app-configs")
|
||||
}
|
||||
|
||||
fn config_path(app_id: &str) -> PathBuf {
|
||||
config_dir().join(format!("{app_id}.json"))
|
||||
}
|
||||
|
||||
fn read_raw(app_id: &str) -> Map<String, Value> {
|
||||
match std::fs::read_to_string(config_path(app_id)) {
|
||||
Ok(s) => serde_json::from_str::<Value>(&s)
|
||||
.ok()
|
||||
.and_then(|v| v.as_object().cloned())
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's gate override for one app. `None` = no override recorded —
|
||||
/// the manifest default applies.
|
||||
pub fn gate_override(app_id: &str) -> Option<bool> {
|
||||
read_raw(app_id).get("gateEnabled").and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
/// Every recorded override, keyed by app id (the config file stem). Used by
|
||||
/// the gate's port-map build so one directory scan covers all apps.
|
||||
pub fn all_gate_overrides() -> HashMap<String, bool> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(entries) = std::fs::read_dir(config_dir()) else {
|
||||
return out;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(app_id) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(v) = gate_override(app_id) {
|
||||
out.insert(app_id.to_string(), v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Set (`Some`) or clear (`None`) the override, preserving every other key in
|
||||
/// the app's config file. Temp+rename so a crash mid-write can't truncate.
|
||||
pub fn write_gate_override(app_id: &str, enabled: Option<bool>) -> std::io::Result<()> {
|
||||
let path = config_path(app_id);
|
||||
let mut obj = read_raw(app_id);
|
||||
match enabled {
|
||||
Some(v) => {
|
||||
obj.insert("gateEnabled".to_string(), Value::Bool(v));
|
||||
}
|
||||
None => {
|
||||
obj.remove("gateEnabled");
|
||||
}
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let serialized = serde_json::to_string_pretty(&Value::Object(obj))
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serialized.as_bytes())?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_tmp_data_dir<T>(f: impl FnOnce() -> T) -> T {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
// Serialize env mutation across tests in this module.
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::set_var("ARCHIPELAGO_DATA_DIR", dir.path());
|
||||
let out = f();
|
||||
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_file_means_no_override() {
|
||||
with_tmp_data_dir(|| {
|
||||
assert_eq!(gate_override("gitea"), None);
|
||||
assert!(all_gate_overrides().is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_read_clear_roundtrip_preserves_other_keys() {
|
||||
with_tmp_data_dir(|| {
|
||||
// Seed an existing config with an unrelated key.
|
||||
std::fs::create_dir_all(config_dir()).unwrap();
|
||||
std::fs::write(config_path("gitea"), r#"{"autoUpdate": true}"#).unwrap();
|
||||
|
||||
write_gate_override("gitea", Some(false)).unwrap();
|
||||
assert_eq!(gate_override("gitea"), Some(false));
|
||||
assert_eq!(all_gate_overrides().get("gitea"), Some(&false));
|
||||
|
||||
// The unrelated key survives.
|
||||
let raw = std::fs::read_to_string(config_path("gitea")).unwrap();
|
||||
let v: Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true)));
|
||||
|
||||
write_gate_override("gitea", None).unwrap();
|
||||
assert_eq!(gate_override("gitea"), None);
|
||||
let raw = std::fs::read_to_string(config_path("gitea")).unwrap();
|
||||
let v: Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(v.get("autoUpdate"), Some(&Value::Bool(true)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ impl BootReconciler {
|
||||
let companion_handle = if self.companion_stage {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
let data_dir = orchestrator.data_dir().to_path_buf();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
@@ -128,34 +129,48 @@ impl BootReconciler {
|
||||
continue;
|
||||
};
|
||||
let failures = crate::container::companion::reconcile(&installed).await;
|
||||
// `reap_orphans` is deliberately NOT called here. It is
|
||||
// implemented and tested, and it must stay unwired until a
|
||||
// DURABLE record of "this app is installed" exists.
|
||||
// Reaper, RE-WIRED 2026-08-10 — driven by the DURABLE
|
||||
// installed-apps registry, never by runtime inference.
|
||||
//
|
||||
// Proven harmful on archi-dev-box 2026-08-08: it removed
|
||||
// archy-bitcoin-ui (36 minutes of no Bitcoin UI, until the
|
||||
// operator reinstalled the backend) and archy-lnd-ui, both
|
||||
// for apps that ARE installed. It was not a logic error —
|
||||
// it did exactly what it was told. The inputs lied: the
|
||||
// backends' containers were missing because of the
|
||||
// clean-exit vanishing bug, and both had already aged out
|
||||
// of running-containers.json, which only ever records what
|
||||
// is CURRENTLY RUNNING. So container-presence and
|
||||
// installation-evidence, the two independent signals the
|
||||
// reaper trusts, were false at the same time and for the
|
||||
// same underlying reason.
|
||||
// History: this call was unwired on 2026-08-08 after it
|
||||
// removed archy-bitcoin-ui and archy-lnd-ui for apps that
|
||||
// WERE installed. Not a logic error — the inputs lied:
|
||||
// `installed_app_ids` infers installation from runtime
|
||||
// state (containers present + running-containers.json),
|
||||
// and the clean-exit vanishing bug falsified both signals
|
||||
// at once. The unwire commit set the re-wire bar: a
|
||||
// durable record of "this app is installed".
|
||||
//
|
||||
// Reaping turns one lost app into two, which is strictly
|
||||
// worse than the orphan it cleans up. Leaving an orphan
|
||||
// costs a stale UI tile; reaping a live app's companion
|
||||
// costs the operator a working screen. Until "installed"
|
||||
// can be answered without inferring it from runtime state,
|
||||
// absence is not evidence of uninstallation.
|
||||
// That record now exists — installed-apps.json, written on
|
||||
// install, cleared on deliberate uninstall, backfilled at
|
||||
// boot from demonstrably-present containers, and immune to
|
||||
// container absence by construction (89b03c47 holds
|
||||
// entries while a container is gone). A vanished backend
|
||||
// no longer looks uninstalled, so the failure mode that
|
||||
// burned archi-dev-box cannot recur through this path.
|
||||
//
|
||||
// The provisioning half above is the actual fix for
|
||||
// "fedimint installs but does not work" and stands on its
|
||||
// own: a companion is never stood up for an app nobody
|
||||
// installed, so no NEW orphans are created.
|
||||
// `None` = the registry could not be read (missing or
|
||||
// corrupt) — which is "I could not look", NOT "nothing is
|
||||
// installed". The reaper stays idle in that case; the
|
||||
// runtime-derived `installed` set above is deliberately
|
||||
// NOT used as a fallback (it is exactly the input class
|
||||
// that caused the 2026-08-08 incident). ORPHAN_GRACE still
|
||||
// applies on top: a companion must be orphaned for the
|
||||
// full grace period before it is touched.
|
||||
if let Some(durable) =
|
||||
crate::crash_recovery::load_installed_apps_if_recorded(&data_dir).await
|
||||
{
|
||||
let durable: Vec<String> = durable.into_iter().collect();
|
||||
for (companion, err) in
|
||||
crate::container::companion::reap_orphans(&durable).await
|
||||
{
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reap failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
@@ -206,6 +221,12 @@ impl BootReconciler {
|
||||
}
|
||||
|
||||
async fn tick(&self) {
|
||||
// Sweep ghost containers first: a process tree podman has forgotten
|
||||
// still holds its app's ports and data locks, so reconcile would keep
|
||||
// restarting that app into the same wall (752 restarts on a fleet
|
||||
// node, 2026-08-10). Nothing else in the stack can see them —
|
||||
// every podman-level stop/rm misses a container podman lost.
|
||||
crate::container::ghost_reaper::reap_all().await;
|
||||
let report = self.orchestrator.reconcile_existing().await;
|
||||
Self::log_report(&report);
|
||||
}
|
||||
|
||||
@@ -682,8 +682,12 @@ fn due_after_grace(
|
||||
|
||||
/// Stop and remove any companion whose backend app is not installed.
|
||||
///
|
||||
/// ⚠️ NOT WIRED, ON PURPOSE. Do not call this from the reconciler until a
|
||||
/// DURABLE record of "this app is installed" exists to drive it.
|
||||
/// ⚠️ WIRED (2026-08-10) to exactly one caller — the boot reconciler's
|
||||
/// companion loop — and ONLY behind the durable installed-apps registry
|
||||
/// (`crash_recovery::load_installed_apps_if_recorded`). That satisfies the
|
||||
/// bar the 2026-08-08 unwire set: a DURABLE record of "this app is
|
||||
/// installed" drives it, never runtime inference. Do not add callers fed
|
||||
/// from runtime state; the history below is why.
|
||||
///
|
||||
/// It ran on archi-dev-box on 2026-08-08 and removed two companions whose
|
||||
/// backends were installed — archy-bitcoin-ui (36 minutes of no Bitcoin UI)
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Ghost-container reaper.
|
||||
//!
|
||||
//! A *ghost* is a container whose process tree (conmon → the app's init → the
|
||||
//! app) is still running while podman has no record of it — `podman ps -a`
|
||||
//! does not list it, so every podman-level stop/rm/recreate misses it. They
|
||||
//! are produced by a cleanup race: the exit-command runs `container cleanup
|
||||
//! --rm`, the record is deleted, but conmon and the payload survive.
|
||||
//!
|
||||
//! A ghost is not merely untidy — it still owns the things the app needs:
|
||||
//!
|
||||
//! * the published host port, so the replacement container fails to start with
|
||||
//! `rootlessport listen tcp 127.0.0.1:<port>: bind: address already in use`;
|
||||
//! * file locks inside the app's data dir, so a container that does start dies
|
||||
//! at boot (Gitea: `unable to lock level db … resource temporarily
|
||||
//! unavailable` → fatal).
|
||||
//!
|
||||
//! `Restart=always` then re-runs the app straight back into the same wall —
|
||||
//! observed at 752 restarts on a fleet node (2026-08-10) and again on the dev
|
||||
//! box (2026-08-16), where the app finally disappeared from My Apps because no
|
||||
//! container existed to list. Both were cleared by hand; this module is the
|
||||
//! automation, because no podman-level release logic can reap a container
|
||||
//! podman does not know about.
|
||||
//!
|
||||
//! Safety rule, and the reason this is id-based rather than name-based: a
|
||||
//! process is only ever a reap candidate when its container id is **absent**
|
||||
//! from `podman ps -a --no-trunc -q`. Killing by container *name* would hit
|
||||
//! the live managed container, which is the opposite of the fix.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// A container process tree podman has no record of.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Ghost {
|
||||
/// conmon's pid — killed last, so it cannot re-parent the payload.
|
||||
pub conmon_pid: i32,
|
||||
/// Full 64-hex container id from conmon's `-c` argument.
|
||||
pub container_id: String,
|
||||
/// Container name from conmon's `-n` argument, when present. This is what
|
||||
/// ties a ghost to an app id for the pre-start reap.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Read a process's argv from /proc, NUL-separated.
|
||||
fn proc_argv(pid: i32) -> Option<Vec<String>> {
|
||||
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
|
||||
Some(
|
||||
raw.split(|b| *b == 0)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| String::from_utf8_lossy(s).into_owned())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Every pid currently in /proc.
|
||||
fn all_pids() -> Vec<i32> {
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<i32>().ok()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Container ids podman currently knows about (running or stopped).
|
||||
async fn podman_known_ids() -> Option<HashSet<String>> {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--no-trunc", "-q"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
// A failed listing must NEVER be read as "podman knows nothing" —
|
||||
// that would make every running container look like a ghost and reap
|
||||
// the whole node. Absent knowledge = do nothing.
|
||||
warn!("ghost reaper: `podman ps` failed; skipping this pass");
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a conmon argv into (container_id, name), if it is a conmon at all.
|
||||
fn parse_conmon(argv: &[String]) -> Option<(String, Option<String>)> {
|
||||
let exe = argv.first()?;
|
||||
if !exe.ends_with("conmon") {
|
||||
return None;
|
||||
}
|
||||
let mut id = None;
|
||||
let mut name = None;
|
||||
let mut it = argv.iter().peekable();
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"-c" => {
|
||||
if let Some(v) = it.peek() {
|
||||
// Only a full 64-hex id counts; anything else is not a
|
||||
// container id and must not drive a kill decision.
|
||||
if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
id = Some((*v).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
"-n" => name = it.peek().map(|v| (*v).clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((id?, name))
|
||||
}
|
||||
|
||||
/// All ghost process trees on this host. Empty when podman cannot be listed
|
||||
/// (fail-closed: unknown state reaps nothing).
|
||||
pub async fn find_ghosts() -> Vec<Ghost> {
|
||||
let Some(known) = podman_known_ids().await else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ghosts = Vec::new();
|
||||
for pid in all_pids() {
|
||||
let Some(argv) = proc_argv(pid) else { continue };
|
||||
let Some((container_id, name)) = parse_conmon(&argv) else {
|
||||
continue;
|
||||
};
|
||||
if known.contains(&container_id) {
|
||||
continue;
|
||||
}
|
||||
ghosts.push(Ghost {
|
||||
conmon_pid: pid,
|
||||
container_id,
|
||||
name,
|
||||
});
|
||||
}
|
||||
ghosts
|
||||
}
|
||||
|
||||
fn signal(pid: i32, sig: i32) {
|
||||
// SAFETY: kill(2) with a pid we read from /proc; a dead pid returns ESRCH,
|
||||
// which we ignore. Signals are the only way to reach a process podman has
|
||||
// disowned.
|
||||
unsafe {
|
||||
libc::kill(pid, sig);
|
||||
}
|
||||
}
|
||||
|
||||
fn alive(pid: i32) -> bool {
|
||||
std::path::Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
/// Kill one ghost's process tree: the payload's process group first (so the
|
||||
/// app's own init can shut its children down), then conmon.
|
||||
///
|
||||
/// SIGTERM first with a short grace, then SIGKILL — a ghost has already
|
||||
/// out-lived its supervisor, and the Gitea case ignored SIGTERM outright.
|
||||
async fn kill_ghost(ghost: &Ghost) {
|
||||
// Children of conmon = the container's init (s6, tini, the app itself).
|
||||
let children: Vec<i32> = all_pids()
|
||||
.into_iter()
|
||||
.filter(|pid| {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
// ppid is field 4, after the comm field which may itself
|
||||
// contain spaces/parens — split on the last ')'.
|
||||
let tail = s.rsplit_once(')')?.1;
|
||||
tail.split_whitespace().nth(1)?.parse::<i32>().ok()
|
||||
})
|
||||
.is_some_and(|ppid| ppid == ghost.conmon_pid)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for pid in children.iter().copied() {
|
||||
signal(pid, libc::SIGTERM);
|
||||
}
|
||||
signal(ghost.conmon_pid, libc::SIGTERM);
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
for pid in children.iter().copied() {
|
||||
if alive(pid) {
|
||||
signal(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
if alive(ghost.conmon_pid) {
|
||||
signal(ghost.conmon_pid, libc::SIGKILL);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let survivors = children.iter().filter(|p| alive(**p)).count();
|
||||
if survivors > 0 || alive(ghost.conmon_pid) {
|
||||
warn!(
|
||||
container_id = %&ghost.container_id[..12],
|
||||
name = ?ghost.name,
|
||||
survivors,
|
||||
"ghost reaper: some processes survived SIGKILL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reap every ghost on the host. Returns how many trees were killed.
|
||||
///
|
||||
/// Call before a start/restart (so the replacement is not racing a dead
|
||||
/// twin for its port and locks) and from the periodic reconcile.
|
||||
pub async fn reap_all() -> usize {
|
||||
reap_matching(|_| true).await
|
||||
}
|
||||
|
||||
/// Reap only ghosts belonging to `app_id` — matched on the container name,
|
||||
/// which the orchestrator sets to the app id (companions carry it as a
|
||||
/// prefix, e.g. `archy-btcpay-db`).
|
||||
pub async fn reap_for_app(app_id: &str) -> usize {
|
||||
let app_id = app_id.to_string();
|
||||
reap_matching(move |g| {
|
||||
g.name.as_deref().is_some_and(|n| {
|
||||
n == app_id || n.starts_with(&format!("{app_id}-")) || n.ends_with(&format!("-{app_id}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
|
||||
let ghosts: Vec<Ghost> = find_ghosts().await.into_iter().filter(|g| pred(g)).collect();
|
||||
if ghosts.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
for ghost in &ghosts {
|
||||
warn!(
|
||||
container_id = %&ghost.container_id[..12],
|
||||
name = ?ghost.name,
|
||||
conmon_pid = ghost.conmon_pid,
|
||||
"ghost container found — podman has no record of it but its processes still \
|
||||
hold the app's ports and data locks; reaping"
|
||||
);
|
||||
kill_ghost(ghost).await;
|
||||
}
|
||||
info!(count = ghosts.len(), "ghost reaper: reaped ghost containers");
|
||||
ghosts.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn argv(parts: &[&str]) -> Vec<String> {
|
||||
parts.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
const ID: &str = "8ea2fc65603a6db8d48701e26da1a18f1651e5f8b0e2dd4ec931356f4fab0081";
|
||||
|
||||
#[test]
|
||||
fn parses_a_real_conmon_invocation() {
|
||||
let a = argv(&[
|
||||
"/usr/bin/conmon",
|
||||
"--api-version",
|
||||
"1",
|
||||
"-c",
|
||||
ID,
|
||||
"-u",
|
||||
ID,
|
||||
"-n",
|
||||
"gitea",
|
||||
"--full-attach",
|
||||
]);
|
||||
let (id, name) = parse_conmon(&a).expect("conmon parsed");
|
||||
assert_eq!(id, ID);
|
||||
assert_eq!(name.as_deref(), Some("gitea"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_processes_that_are_not_conmon() {
|
||||
assert!(parse_conmon(&argv(&["/usr/local/bin/gitea", "web"])).is_none());
|
||||
// A shell whose *arguments* mention conmon must never be parsed as one
|
||||
// — the grep-based detection used by hand did exactly this.
|
||||
assert!(parse_conmon(&argv(&["/bin/bash", "-c", "pgrep -af conmon"])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_or_missing_id_is_not_reapable() {
|
||||
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none());
|
||||
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_matching_covers_companions_but_not_unrelated_apps() {
|
||||
let g = |n: &str| Ghost {
|
||||
conmon_pid: 1,
|
||||
container_id: ID.to_string(),
|
||||
name: Some(n.to_string()),
|
||||
};
|
||||
let matches = |app: &str, name: &str| {
|
||||
let app = app.to_string();
|
||||
let gh = g(name);
|
||||
gh.name.as_deref().is_some_and(|n| {
|
||||
n == app
|
||||
|| n.starts_with(&format!("{app}-"))
|
||||
|| n.ends_with(&format!("-{app}"))
|
||||
})
|
||||
};
|
||||
assert!(matches("gitea", "gitea"));
|
||||
assert!(matches("btcpay-server", "btcpay-server"));
|
||||
assert!(matches("immich", "immich-postgres"));
|
||||
assert!(matches("btcpay", "archy-btcpay"));
|
||||
// Substring coincidences must not match.
|
||||
assert!(!matches("pay", "btcpay-server"));
|
||||
assert!(!matches("gitea", "gitea2"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_catalog;
|
||||
pub mod app_gate_config;
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
@@ -6,6 +7,7 @@ pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod filebrowser;
|
||||
pub mod ghost_reaper;
|
||||
pub mod hooks;
|
||||
pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
|
||||
@@ -1039,7 +1039,9 @@ async fn repair_manifest_host_ports_after_stability(
|
||||
container = %name,
|
||||
"host listener disappeared after startup; restarting container"
|
||||
);
|
||||
if uses_pasta_network(manifest) {
|
||||
if uses_pasta_network(manifest) && !quadlet::unit_exists(name).await {
|
||||
// Legacy (pre-quadlet) pasta app: no unit owns it, so a transient
|
||||
// scope keeps its networking's cgroup independent of the daemon.
|
||||
podman_user_scope(&["restart", name])
|
||||
.await
|
||||
.with_context(|| format!("podman restart {name}"))?;
|
||||
@@ -1085,9 +1087,16 @@ async fn start_container_scoped_if_pasta(
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
if uses_pasta_network(manifest) {
|
||||
// Rootless pasta/conmon inherit the cgroup of the process that starts
|
||||
// them. Starting through archipelago.service lets backend restarts kill
|
||||
// app networking; a transient user scope keeps app daemons independent.
|
||||
// Quadlet-managed pasta app: the unit owns the cgroup and the
|
||||
// container is rendered --rm — bare `podman start` would fight
|
||||
// systemd over it. Restart-through-the-unit starts a stopped one.
|
||||
if quadlet::unit_exists(name).await {
|
||||
return quadlet::restart_service(&format!("{name}.service")).await;
|
||||
}
|
||||
// Legacy pasta app: rootless pasta/conmon inherit the cgroup of the
|
||||
// process that starts them. Starting through archipelago.service lets
|
||||
// backend restarts kill app networking; a transient user scope keeps
|
||||
// app daemons independent.
|
||||
podman_user_scope(&["start", name]).await
|
||||
} else {
|
||||
runtime.start_container(name).await
|
||||
@@ -1100,6 +1109,9 @@ async fn restart_container_scoped_if_pasta(
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
if uses_pasta_network(manifest) {
|
||||
if quadlet::unit_exists(name).await {
|
||||
return quadlet::restart_service(&format!("{name}.service")).await;
|
||||
}
|
||||
podman_user_scope(&["restart", name]).await
|
||||
} else {
|
||||
let _ = runtime.stop_container(name).await;
|
||||
@@ -1503,6 +1515,10 @@ impl ProdContainerOrchestrator {
|
||||
self.data_dir = data_dir;
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &std::path::Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_lnd_paths(&mut self, paths: lnd::EnsurePaths) {
|
||||
self.lnd_paths = paths;
|
||||
@@ -2263,7 +2279,15 @@ impl ProdContainerOrchestrator {
|
||||
// after proving the container exists. Boot reconciliation must
|
||||
// not create every catalog app just because a Quadlet unit is
|
||||
// absent.
|
||||
if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
|
||||
//
|
||||
// Pasta apps included since 2026-08-10: the old exclusion
|
||||
// paired with the transient-scope machinery (daemon-started
|
||||
// pasta died with the daemon's cgroup). A quadlet unit gives
|
||||
// pasta the same independence with systemd supervision on top
|
||||
// — Restart=always + RestartSec=10, which also spaces restarts
|
||||
// past pasta's port teardown. The scoped start/restart helpers
|
||||
// now defer to the unit whenever one exists.
|
||||
if self.use_quadlet_backends {
|
||||
if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? {
|
||||
return Ok(action);
|
||||
}
|
||||
@@ -2535,10 +2559,7 @@ impl ProdContainerOrchestrator {
|
||||
// lost the container record after a crash/reboot. Sync the unit
|
||||
// bytes first (clears stale Notify=healthy/nc probes), then ask
|
||||
// user systemd to start the generated service.
|
||||
if self.use_quadlet_backends
|
||||
&& !uses_pasta_network(&resolved_manifest)
|
||||
&& self.quadlet_unit_exists(&name).await?
|
||||
{
|
||||
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
self.sync_quadlet_unit(lm, &name).await?;
|
||||
self.ensure_resolved_source_available(lm).await?;
|
||||
@@ -2721,11 +2742,13 @@ impl ProdContainerOrchestrator {
|
||||
self.prepare_for_start(&resolved_manifest).await?;
|
||||
self.ensure_container_network(&resolved_manifest).await?;
|
||||
|
||||
if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
|
||||
if self.use_quadlet_backends {
|
||||
// Phase 3.2 path: declarative .container unit + systemctl.
|
||||
// Containers parented under user.slice instead of
|
||||
// archipelago.service's cgroup → no FM3 cascade SIGKILL on
|
||||
// archipelago restart.
|
||||
// archipelago restart. Pasta apps included since 2026-08-10 —
|
||||
// the unit gives them the same cgroup independence the transient
|
||||
// scopes provided, plus Restart=always supervision.
|
||||
self.install_via_quadlet(&resolved_manifest, &name).await?;
|
||||
} else {
|
||||
self.remove_quadlet_unit_if_present(&name).await?;
|
||||
|
||||
@@ -661,6 +661,19 @@ fn parse_memory_mib(raw: &str) -> Option<u32> {
|
||||
num_part.trim().parse::<u32>().ok()?.checked_mul(mul)
|
||||
}
|
||||
|
||||
/// Does a quadlet `.container` unit exist for this container name?
|
||||
/// Errors count as "unknown" and return false — callers use this to decide
|
||||
/// whether systemd owns the container, and claiming ownership on an
|
||||
/// unreadable answer would route lifecycle ops around a live unit.
|
||||
pub async fn unit_exists(name: &str) -> bool {
|
||||
let Ok(dir) = unit_dir().await else {
|
||||
return false;
|
||||
};
|
||||
tokio::fs::try_exists(dir.join(format!("{name}.container")))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||
pub async fn unit_dir() -> Result<PathBuf> {
|
||||
let home = std::env::var_os("HOME")
|
||||
|
||||
@@ -120,6 +120,13 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
config
|
||||
.registries
|
||||
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
|
||||
// And the release server's own bare-IP twin (146.59.87.168:3000 — the
|
||||
// same host as source.archipelago-foundation.org): older defaults listed
|
||||
// both, so the registry UI showed one server twice. Bare-IP origins were
|
||||
// retired 2026-08-11; the named entry stays and covers the same pulls.
|
||||
config
|
||||
.registries
|
||||
.retain(|r| !r.url.contains("146.59.87.168"));
|
||||
let mut changed = config.registries.len() != before;
|
||||
|
||||
// Migrate: any default registry URL that isn't already in the
|
||||
|
||||
@@ -204,6 +204,19 @@ pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<S
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `load_installed_apps`, but keeps "no record" distinguishable from
|
||||
/// "empty record". The companion reaper must only ever run on `Some`:
|
||||
/// "I could not look" and "nothing is installed" both come back as an empty
|
||||
/// set from the lossy loader, yet they demand opposite behaviour — the
|
||||
/// distinction has to survive to the caller (see `reap_orphans`' contract).
|
||||
pub async fn load_installed_apps_if_recorded(
|
||||
data_dir: &Path,
|
||||
) -> Option<std::collections::HashSet<String>> {
|
||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||
let content = fs::read_to_string(&path).await.ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
||||
@@ -1193,6 +1206,31 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn if_recorded_distinguishes_no_record_from_empty_record() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// No file: the reaper must see "could not look", never "empty".
|
||||
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
|
||||
// Corrupt file: same — refuse to answer rather than guess.
|
||||
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "{ not json")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
|
||||
// A real (even empty) record answers.
|
||||
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "[]")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
load_installed_apps_if_recorded(tmp.path()).await,
|
||||
Some(std::collections::HashSet::new())
|
||||
);
|
||||
mark_installed(tmp.path(), "bitcoin-knots").await;
|
||||
assert!(load_installed_apps_if_recorded(tmp.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("bitcoin-knots"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_record_survives_and_forgets_on_uninstall() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -326,6 +326,26 @@ pub(crate) async fn notify_join(
|
||||
.await;
|
||||
match res {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
// A JSON-RPC-level rejection still arrives as HTTP 200
|
||||
// (the RPC layer returns errors in-band), so checking the
|
||||
// status alone logged "delivered" for calls the peer had
|
||||
// just rejected. Read the body: an in-band error is
|
||||
// terminal — the signed payload is deterministic, so
|
||||
// retrying identical bytes can never succeed.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let rpc_err = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").cloned())
|
||||
.filter(|e| !e.is_null());
|
||||
if let Some(err) = rpc_err {
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
transport = %transport,
|
||||
error = %err,
|
||||
"peer-joined notification rejected by peer — giving up (retrying identical payload cannot succeed)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
attempt,
|
||||
transport = %transport,
|
||||
|
||||
@@ -507,7 +507,13 @@ pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
#[allow(dead_code)]
|
||||
pub(super) async fn handle_identity_received(
|
||||
contact_id: u32,
|
||||
rssi: i16,
|
||||
// None = signal strength unknown at this layer (identity adverts arrive
|
||||
// through the transport-agnostic channel path, which carries no phy
|
||||
// stats). The periodic refresh_contacts pass fills in the real value for
|
||||
// transports that report one; hardcoding 0 here made every discovery
|
||||
// read as "0 dBm" — indistinguishable from a real (if implausible)
|
||||
// reading and from "no radio at all" (2026-08-16).
|
||||
rssi: Option<i16>,
|
||||
did: &str,
|
||||
ed_pubkey_hex: &str,
|
||||
x25519_pubkey_hex: &str,
|
||||
@@ -517,7 +523,7 @@ pub(super) async fn handle_identity_received(
|
||||
info!(
|
||||
contact_id,
|
||||
did = %did,
|
||||
rssi,
|
||||
rssi = ?rssi,
|
||||
"Archipelago peer discovered over mesh"
|
||||
);
|
||||
|
||||
@@ -592,7 +598,7 @@ pub(super) async fn handle_identity_received(
|
||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
x25519_pubkey: Some(x25519_bytes),
|
||||
rssi: Some(rssi),
|
||||
rssi,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
|
||||
@@ -421,7 +421,7 @@ async fn handle_channel_payload(
|
||||
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
|
||||
handle_identity_received(
|
||||
contact_id,
|
||||
0,
|
||||
None,
|
||||
&did,
|
||||
&ed_hex,
|
||||
&x_hex,
|
||||
|
||||
@@ -531,6 +531,18 @@ pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two /dev paths refer to the same serial device if their symlink-resolved
|
||||
/// targets match (e.g. `/dev/mesh-radio` vs the `/dev/ttyUSBn` it points at).
|
||||
/// Paths that fail to resolve fall back to a plain string comparison.
|
||||
async fn same_serial_device(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
let ra = fs::canonicalize(a).await.unwrap_or_else(|_| PathBuf::from(a));
|
||||
let rb = fs::canonicalize(b).await.unwrap_or_else(|_| PathBuf::from(b));
|
||||
ra == rb
|
||||
}
|
||||
|
||||
pub async fn load_ignored_radio_contacts(data_dir: &Path) -> Vec<String> {
|
||||
let path = data_dir.join(MESH_IGNORED_RADIO_FILE);
|
||||
if !path.exists() {
|
||||
@@ -2268,6 +2280,30 @@ impl MeshService {
|
||||
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
|
||||
// An operator-set RNode serial-port override (rnode-rf-settings.json)
|
||||
// outranks `device_path` when the Reticulum session opens the radio.
|
||||
// When a *different* device path is being configured (hot-swap, or
|
||||
// "Keep As Is" on a newly detected radio), a stale override pinned to
|
||||
// the old port would silently veto the choice the user just made —
|
||||
// clear it so the explicit device selection wins. Same-device aliases
|
||||
// (/dev/mesh-radio vs its ttyUSBn target) are left alone.
|
||||
if let Some(new_path) = config.device_path.as_deref() {
|
||||
let mut rf = rnode_settings::RNodeRfSettings::load(&self.data_dir).await;
|
||||
if let Some(port) = rf.port.clone() {
|
||||
if !same_serial_device(&port, new_path).await {
|
||||
info!(
|
||||
old_port = %port,
|
||||
new_path = %new_path,
|
||||
"Clearing stale RNode serial-port override — configured device path changed"
|
||||
);
|
||||
rf.port = None;
|
||||
if let Err(e) = rf.save(&self.data_dir).await {
|
||||
warn!("Failed to clear stale RNode port override: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let was_enabled = self.config.enabled;
|
||||
let needs_session_restart = session_config_changed(&self.config, &config);
|
||||
self.config = config.clone();
|
||||
|
||||
@@ -294,6 +294,13 @@ struct ReticulumPeer {
|
||||
/// In-memory only (a persisted value would be stale by definition) —
|
||||
/// `0` after a restart until the peer re-announces.
|
||||
last_advert_at: u64,
|
||||
/// Signal stats of the last announce/message heard from this peer.
|
||||
/// `Some` only for direct RNode (RF) receptions — the sidecar reports
|
||||
/// `null` for TCP interfaces and multi-hop relays, which is exactly the
|
||||
/// RF-vs-internet discriminator the UI needs (a TCP-fed mesh used to
|
||||
/// surface every peer as rssi=0 and look like working RF, 2026-08-16).
|
||||
last_rssi: Option<i16>,
|
||||
last_snr: Option<f32>,
|
||||
}
|
||||
|
||||
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
||||
@@ -619,6 +626,8 @@ impl ReticulumLink {
|
||||
// start conservative and let the first real event refresh it.
|
||||
reachable: false,
|
||||
last_advert_at: 0,
|
||||
last_rssi: None,
|
||||
last_snr: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -854,12 +863,13 @@ impl ReticulumLink {
|
||||
// which has no Reticulum analogue (always true, tracked
|
||||
// elsewhere via `take_rx_encrypted`), so leave it false here.
|
||||
pkc_capable: false,
|
||||
// RSSI/SNR/position are Meshtastic-only for now (see the
|
||||
// Meshtastic 1.8.0 backlog plan) — RNS doesn't expose
|
||||
// per-packet signal quality through LXMF, and there's no
|
||||
// Reticulum position-sharing convention wired up.
|
||||
rssi: None,
|
||||
snr: None,
|
||||
// Signal stats from the last direct RNode reception (the
|
||||
// sidecar reports them per announce/message; None over TCP
|
||||
// or multi-hop, which is the honest answer there). Position
|
||||
// stays Meshtastic-only — no Reticulum position-sharing
|
||||
// convention is wired up.
|
||||
rssi: p.last_rssi,
|
||||
snr: p.last_snr,
|
||||
lat: None,
|
||||
lon: None,
|
||||
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
|
||||
@@ -1057,6 +1067,16 @@ impl ReticulumLink {
|
||||
|
||||
let announced_name =
|
||||
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
||||
// Per-announce signal stats from the sidecar: real numbers on
|
||||
// a direct RNode reception, null over TCP or multi-hop. Only
|
||||
// overwrite the cached value when the sidecar reports one —
|
||||
// an announce relayed over TCP must not blank out the last
|
||||
// real RF reading.
|
||||
let rssi = ev
|
||||
.get("rssi")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|v| i16::try_from(v).ok());
|
||||
let snr = ev.get("snr").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -1072,6 +1092,12 @@ impl ReticulumLink {
|
||||
if arch_pubkey_hex.is_some() {
|
||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||
}
|
||||
if rssi.is_some() {
|
||||
p.last_rssi = rssi;
|
||||
}
|
||||
if snr.is_some() {
|
||||
p.last_snr = snr;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| ReticulumPeer {
|
||||
dest_hash: hash,
|
||||
@@ -1080,6 +1106,8 @@ impl ReticulumLink {
|
||||
arch_pubkey_hex,
|
||||
reachable: true,
|
||||
last_advert_at: now,
|
||||
last_rssi: rssi,
|
||||
last_snr: snr,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
@@ -1099,6 +1127,11 @@ impl ReticulumLink {
|
||||
// existing entry is proof of life too: mark it reachable so a
|
||||
// restart-restored (reachable=false) peer that DMs us doesn't
|
||||
// stay red-dotted until its next announce.
|
||||
let rssi = ev
|
||||
.get("rssi")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|v| i16::try_from(v).ok());
|
||||
let snr = ev.get("snr").and_then(Value::as_f64).map(|v| v as f32);
|
||||
match self.peers.entry(source_hash) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(ReticulumPeer {
|
||||
@@ -1107,11 +1140,20 @@ impl ReticulumLink {
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
last_advert_at: 0,
|
||||
last_rssi: rssi,
|
||||
last_snr: snr,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.get_mut().reachable = true;
|
||||
let p = e.get_mut();
|
||||
p.reachable = true;
|
||||
if rssi.is_some() {
|
||||
p.last_rssi = rssi;
|
||||
}
|
||||
if snr.is_some() {
|
||||
p.last_snr = snr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,6 +1250,10 @@ impl ReticulumLink {
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
last_advert_at: 0,
|
||||
// Resource transfers ride an established Link —
|
||||
// the sidecar reports no per-packet phy stats here.
|
||||
last_rssi: None,
|
||||
last_snr: None,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
|
||||
@@ -515,17 +515,33 @@ impl MeshcoreDevice {
|
||||
|
||||
// ─── Device detection ───────────────────────────────────────────────────
|
||||
|
||||
/// Candidate serial device paths to check on Linux.
|
||||
/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules).
|
||||
const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/mesh-radio",
|
||||
"/dev/ttyUSB0",
|
||||
"/dev/ttyUSB1",
|
||||
"/dev/ttyUSB2",
|
||||
"/dev/ttyACM0",
|
||||
"/dev/ttyACM1",
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
/// Enumerate candidate serial device paths on Linux.
|
||||
/// /dev/mesh-radio is a stable udev symlink (see 99-mesh-radio.rules) and is
|
||||
/// always listed first so it wins the alias dedup in `detect_serial_devices`.
|
||||
/// The rest is a live scan of /dev for ttyUSB*/ttyACM* nodes — the previous
|
||||
/// fixed ttyUSB0-2/ttyACM0-2 list made any radio that enumerated at index 3+
|
||||
/// (multi-adapter boxes, replug races) permanently invisible to detection.
|
||||
async fn serial_candidate_paths() -> Vec<String> {
|
||||
let mut candidates = vec!["/dev/mesh-radio".to_string()];
|
||||
let mut ttys: Vec<(u32, String)> = Vec::new();
|
||||
if let Ok(mut dir) = tokio::fs::read_dir("/dev").await {
|
||||
while let Ok(Some(entry)) = dir.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
for (group, prefix) in [(0u32, "ttyUSB"), (1 << 16, "ttyACM")] {
|
||||
if let Some(num) = name.strip_prefix(prefix) {
|
||||
if let Ok(n) = num.parse::<u32>() {
|
||||
// ttyUSB* before ttyACM*, each numerically ordered, so
|
||||
// probe order stays deterministic across boots.
|
||||
ttys.push((group | n, format!("/dev/{name}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ttys.sort();
|
||||
candidates.extend(ttys.into_iter().map(|(_, p)| p));
|
||||
candidates
|
||||
}
|
||||
|
||||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||||
|
||||
@@ -570,14 +586,15 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
|
||||
/// with — a continuous, UI-driven reset loop that only ran while that view
|
||||
/// was open (matches the reported "stops when I leave, resumes when I come
|
||||
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
|
||||
/// back"). `serial_candidate_paths` lists `/dev/mesh-radio` first, so it wins the
|
||||
/// dedup and is what's reported when both alias and target are present.
|
||||
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
|
||||
/// of the 2026-07-28 merge carried an equivalent implementation.)
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
let mut seen_real_paths = std::collections::HashSet::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
for path in serial_candidate_paths().await {
|
||||
let path = path.as_str();
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
|
||||
@@ -97,42 +97,53 @@ async fn get_wan_ip() -> Option<String> {
|
||||
}
|
||||
|
||||
/// Check if UPnP is available by attempting SSDP discovery.
|
||||
///
|
||||
/// The socket is a blocking `std::net::UdpSocket`, and on a network with no
|
||||
/// UPnP gateway — the normal case right after a node moves — the recv runs
|
||||
/// out its full read timeout. Inline on the runtime that parked a tokio
|
||||
/// worker for those 3s on every call, the same failure shape (smaller blast
|
||||
/// radius) as the OpenWrt SSH connect that stalled the API on framework-pt.
|
||||
/// Keep it on the blocking pool.
|
||||
async fn check_upnp_available() -> bool {
|
||||
use std::net::UdpSocket;
|
||||
tokio::task::spawn_blocking(|| {
|
||||
use std::net::UdpSocket;
|
||||
|
||||
let ssdp_request = "M-SEARCH * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:1900\r\n\
|
||||
MAN: \"ssdp:discover\"\r\n\
|
||||
MX: 2\r\n\
|
||||
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n";
|
||||
let ssdp_request = "M-SEARCH * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:1900\r\n\
|
||||
MAN: \"ssdp:discover\"\r\n\
|
||||
MX: 2\r\n\
|
||||
ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n";
|
||||
|
||||
let socket = match UdpSocket::bind("0.0.0.0:0") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let socket = match UdpSocket::bind("0.0.0.0:0") {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if socket
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if socket
|
||||
.send_to(ssdp_request.as_bytes(), "239.255.255.250:1900")
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, _)) => {
|
||||
let response = String::from_utf8_lossy(&buf[..len]);
|
||||
response.contains("InternetGatewayDevice") || response.contains("200 OK")
|
||||
if socket
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
|
||||
if socket
|
||||
.send_to(ssdp_request.as_bytes(), "239.255.255.250:1900")
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, _)) => {
|
||||
let response = String::from_utf8_lossy(&buf[..len]);
|
||||
response.contains("InternetGatewayDevice") || response.contains("200 OK")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Add a port forward (stored locally; actual UPnP mapping done on request).
|
||||
@@ -291,9 +302,24 @@ async fn check_tor_connectivity() -> bool {
|
||||
}
|
||||
|
||||
/// Check DNS resolution works.
|
||||
///
|
||||
/// `to_socket_addrs` is blocking glibc resolution with no app-level bound:
|
||||
/// against a dead or stale resolver — the moved-network case — it can block
|
||||
/// 5–40s (timeout × attempts × nameservers). This runs on every Server-tab
|
||||
/// load via `network.diagnostics`, so inline it parked a tokio worker each
|
||||
/// refresh. Off the runtime, and bounded so the tile reports "no DNS"
|
||||
/// instead of hanging.
|
||||
async fn check_dns() -> bool {
|
||||
use std::net::ToSocketAddrs;
|
||||
"cloudflare.com:443".to_socket_addrs().is_ok()
|
||||
let probe = tokio::task::spawn_blocking(|| {
|
||||
use std::net::ToSocketAddrs;
|
||||
"cloudflare.com:443".to_socket_addrs().is_ok()
|
||||
});
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(5), probe).await {
|
||||
Ok(Ok(ok)) => ok,
|
||||
// Timed out or the task failed: the blocking resolve may still be
|
||||
// running on the pool, but the caller is no longer waiting on it.
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Router Compatibility Abstraction ---
|
||||
|
||||
@@ -35,6 +35,59 @@ use tracing::warn;
|
||||
|
||||
const NOSTR_SECRET_FILE: &str = "nostr_secret";
|
||||
|
||||
/// Runtime discoverability override written by the `nostr.set-discovery` RPC.
|
||||
/// Lives here (not api/rpc) so the server's heartbeat can honour the same
|
||||
/// state the toggle writes.
|
||||
pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||
|
||||
/// How long a presence event stays valid. Published as a NIP-40 expiration
|
||||
/// tag AND enforced client-side in `discover` (relay NIP-40 support varies).
|
||||
/// Must be comfortably longer than the re-publish heartbeat (12h in
|
||||
/// server.rs) so a node that misses one heartbeat doesn't vanish: 48h
|
||||
/// tolerates three misses.
|
||||
pub const PRESENCE_TTL_SECS: u64 = 48 * 3600;
|
||||
|
||||
/// Read the runtime discovery override and the operator-chosen display name.
|
||||
/// Enabled `None` means the toggle has never been used on this node —
|
||||
/// callers fall back to the config flag.
|
||||
pub async fn discovery_overrides(data_dir: &Path) -> (Option<bool>, Option<String>) {
|
||||
let Ok(raw) = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)).await else {
|
||||
return (None, None);
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return (None, None);
|
||||
};
|
||||
let enabled = v.get("enabled").and_then(|e| e.as_bool());
|
||||
let name = v
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.and_then(clean_display_name);
|
||||
(enabled, name)
|
||||
}
|
||||
|
||||
/// Display names travel in a PUBLIC relay event and come back from untrusted
|
||||
/// peers — normalise both directions: single line, control chars stripped,
|
||||
/// hard length cap, empty collapses to None.
|
||||
pub fn clean_display_name(raw: &str) -> Option<String> {
|
||||
let cleaned: String = raw
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
(!cleaned.is_empty()).then_some(cleaned)
|
||||
}
|
||||
|
||||
/// This node's own published npub (bech32), if discovery keys exist.
|
||||
/// Load-only: never mints keys on a read.
|
||||
pub async fn own_npub(identity_dir: &Path) -> Result<Option<String>> {
|
||||
Ok(load_nostr_keys(identity_dir)
|
||||
.await?
|
||||
.map(|k| k.public_key().to_bech32().unwrap_or_default())
|
||||
.filter(|s| !s.is_empty()))
|
||||
}
|
||||
|
||||
/// Message types exchanged inside NIP-44 encrypted DMs (kind 4).
|
||||
///
|
||||
/// Note: NONE of these variants carry an onion address. The onion is only
|
||||
@@ -130,6 +183,7 @@ pub async fn publish_presence(
|
||||
identity_dir: &Path,
|
||||
did: &str,
|
||||
version: &str,
|
||||
name: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
@@ -145,14 +199,20 @@ pub async fn publish_presence(
|
||||
let nostr_npub = keys.public_key().to_bech32().unwrap_or_default();
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
|
||||
let content = serde_json::json!({
|
||||
let mut fields = serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"version": version,
|
||||
// No onion address — exchanged only via encrypted DM
|
||||
})
|
||||
.to_string();
|
||||
});
|
||||
// Operator-chosen display name (optional, already normalised). Public by
|
||||
// construction: it exists to label this node in other nodes' discovery
|
||||
// lists, so only ever include what clean_display_name lets through.
|
||||
if let Some(n) = name.and_then(clean_display_name) {
|
||||
fields["name"] = serde_json::Value::String(n);
|
||||
}
|
||||
let content = fields.to_string();
|
||||
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
@@ -164,8 +224,13 @@ pub async fn publish_presence(
|
||||
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
}
|
||||
|
||||
let builder =
|
||||
EventBuilder::new(Kind::Custom(30078), content).tag(Tag::identifier("archipelago-node"));
|
||||
// NIP-40 expiration: relays that honour it garbage-collect the event if
|
||||
// this node stops heartbeating (reinstall, decommission, long outage).
|
||||
// `discover` enforces the same window client-side for relays that don't.
|
||||
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
|
||||
let builder = EventBuilder::new(Kind::Custom(30078), content)
|
||||
.tag(Tag::identifier("archipelago-node"))
|
||||
.tag(Tag::expiration(expires));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
|
||||
@@ -176,6 +241,43 @@ pub async fn publish_presence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Overwrite this node's presence with an empty tombstone (NIP-33: same
|
||||
/// author + kind + d-tag replaces). Called when discovery is switched off
|
||||
/// and — critically — during factory-reset BEFORE the keys are wiped: once
|
||||
/// the secret is gone, nothing can ever replace the stale event.
|
||||
pub async fn publish_tombstone(
|
||||
identity_dir: &Path,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if relays.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(keys) = load_nostr_keys(identity_dir).await? else {
|
||||
return Ok(()); // never published — nothing to tombstone
|
||||
};
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
if tokio::time::timeout(Duration::from_secs(10), client.connect())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
||||
}
|
||||
// Tombstone also expires: after TTL the relay may drop it entirely,
|
||||
// which is the desired end state (nothing left to list).
|
||||
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
|
||||
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
|
||||
.tag(Tag::identifier("archipelago-node"))
|
||||
.tag(Tag::expiration(expires));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
tracing::info!("🔒 Published presence tombstone to {} relays", relays.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover other Archipelago nodes (presence-only — no onion addresses).
|
||||
/// Returns Nostr pubkeys and DIDs of discoverable nodes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -186,6 +288,9 @@ pub struct DiscoverableNode {
|
||||
pub nostr_npub: String,
|
||||
pub did: String,
|
||||
pub version: String,
|
||||
/// Operator-chosen display name from the presence event. Untrusted peer
|
||||
/// input — normalised through `clean_display_name` on the way in.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn discover_nodes(
|
||||
@@ -221,7 +326,17 @@ pub async fn discover_nodes(
|
||||
client.disconnect().await;
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS));
|
||||
for event in events {
|
||||
// Client-side staleness enforcement: pre-TTL events (and events from
|
||||
// relays that ignore NIP-40) would otherwise list dead installs
|
||||
// forever — every reinstall mints a new key, so the old author can
|
||||
// never replace its own event.
|
||||
if event.created_at < stale_cutoff {
|
||||
continue;
|
||||
}
|
||||
// A tombstone ("{}" content) parses but yields no pubkey — the
|
||||
// nostr_pubkey.is_empty() guard below already drops it.
|
||||
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
|
||||
let nostr_pubkey = content
|
||||
.get("nostr_pubkey")
|
||||
@@ -250,11 +365,16 @@ pub async fn discover_nodes(
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok())
|
||||
.unwrap_or_default();
|
||||
let name = content
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(clean_display_name);
|
||||
nodes.push(DiscoverableNode {
|
||||
nostr_pubkey,
|
||||
nostr_npub,
|
||||
did,
|
||||
version,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
|
||||
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
|
||||
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
|
||||
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
|
||||
const CASHU_ENTROPY_INFO: &[u8] = b"archipelago/cashu/bip39-entropy/v1";
|
||||
|
||||
// ─── MasterSeed ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -300,6 +301,30 @@ pub fn derive_lnd_entropy(seed: &MasterSeed) -> Result<[u8; 16]> {
|
||||
Ok(entropy)
|
||||
}
|
||||
|
||||
/// Derive the ecash (Cashu, NUT-13) wallet's own 24-word BIP-39 mnemonic.
|
||||
///
|
||||
/// The ecash wallet gets a **separate mnemonic** rather than being handed the
|
||||
/// node's own 24 words, and both halves of that matter:
|
||||
///
|
||||
/// - it is still covered by the node's recovery phrase, because it is derived
|
||||
/// from the master seed over a fixed domain-separated path — restore the
|
||||
/// node from its words and the same ecash wallet comes back, with nothing
|
||||
/// extra for the operator to write down;
|
||||
/// - but it is *portable*. NUT-13 is a standard, so these words restore the
|
||||
/// ecash in Minibits, Nutstash or `cdk-cli`. Showing the node seed here
|
||||
/// would have made "back up my ecash" and "hand over the key to the entire
|
||||
/// node" the same action.
|
||||
///
|
||||
/// One-way by construction: HKDF cannot be run backwards, so a leaked ecash
|
||||
/// mnemonic does not expose the master seed or any other derived key.
|
||||
pub fn derive_cashu_mnemonic(seed: &MasterSeed) -> Result<bip39::Mnemonic> {
|
||||
let mut entropy = hkdf_derive_32(seed.as_bytes(), CASHU_ENTROPY_INFO)?;
|
||||
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to derive the ecash mnemonic: {}", e));
|
||||
entropy.zeroize();
|
||||
mnemonic
|
||||
}
|
||||
|
||||
// ─── Encrypted Seed Storage ─────────────────────────────────────────────
|
||||
|
||||
/// Encrypt `plaintext` with Argon2(passphrase) + ChaCha20-Poly1305.
|
||||
@@ -657,6 +682,39 @@ mod tests {
|
||||
assert_eq!(e1.len(), 16);
|
||||
}
|
||||
|
||||
/// The ecash mnemonic must be reproducible from the node's words alone —
|
||||
/// that reproducibility is the entire backup story ("your 24 words already
|
||||
/// cover your ecash").
|
||||
#[test]
|
||||
fn cashu_mnemonic_is_reproducible_from_the_node_seed() {
|
||||
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let a = derive_cashu_mnemonic(&seed).unwrap();
|
||||
let b = derive_cashu_mnemonic(&seed).unwrap();
|
||||
assert_eq!(a.to_string(), b.to_string());
|
||||
assert_eq!(a.word_count(), 24);
|
||||
|
||||
// A different node seed must yield a different ecash wallet, or two
|
||||
// nodes would derive each other's coins.
|
||||
let (other_words, _) = MasterSeed::generate().unwrap();
|
||||
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
|
||||
assert_ne!(a.to_string(), derive_cashu_mnemonic(&other_seed).unwrap().to_string());
|
||||
}
|
||||
|
||||
/// It must NOT be the node's own phrase. Restoring ecash into a
|
||||
/// third-party wallet means handing these words over, and that must never
|
||||
/// be the same as handing over the node.
|
||||
#[test]
|
||||
fn cashu_mnemonic_is_not_the_node_mnemonic() {
|
||||
let (node_mnemonic, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let cashu = derive_cashu_mnemonic(&seed).unwrap();
|
||||
assert_ne!(cashu.to_string(), node_mnemonic.to_string());
|
||||
|
||||
// And knowing the ecash words must not re-derive the node seed: they
|
||||
// are a one-way HKDF descendant, so the seeds they expand to differ.
|
||||
let cashu_seed = MasterSeed::from_mnemonic(&cashu);
|
||||
assert_ne!(cashu_seed.as_bytes(), seed.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_produces_24_words() {
|
||||
let (mnemonic, _seed) = MasterSeed::generate().unwrap();
|
||||
@@ -1033,4 +1091,43 @@ mod tests {
|
||||
"release-root public key KAT"
|
||||
);
|
||||
}
|
||||
|
||||
/// The node's whole identity hangs off `Mnemonic::to_seed("")`, so this
|
||||
/// pins that derivation to the BIP-39 specification vectors rather than to
|
||||
/// whatever the `bip39` crate happens to do today.
|
||||
///
|
||||
/// It exists because the crate is not version-pinned any more: the exact
|
||||
/// `=2.1.0` pin was relaxed to `"2.1"` in 2026-08 so the `cashu` crate
|
||||
/// could resolve (the pin transitively froze `unicode-normalization` at a
|
||||
/// version with no common solution). A bump that changed derivation would
|
||||
/// silently re-key every node on the fleet and orphan every existing
|
||||
/// backup, which no amount of code review reliably catches — this does.
|
||||
#[test]
|
||||
fn seed_derivation_matches_the_bip39_specification_vectors() {
|
||||
let words = "abandon abandon abandon abandon abandon abandon abandon \
|
||||
abandon abandon abandon abandon about"
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mnemonic: bip39::Mnemonic = words.parse().expect("valid test mnemonic");
|
||||
|
||||
// Empty passphrase — exactly how MasterSeed::from_mnemonic derives.
|
||||
assert_eq!(
|
||||
hex::encode(mnemonic.to_seed("")),
|
||||
"5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1\
|
||||
9a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
|
||||
.replace(['\n', ' '], ""),
|
||||
"BIP-39 seed derivation changed — every node's keys would move"
|
||||
);
|
||||
|
||||
// With a passphrase, where NFKD normalisation actually participates;
|
||||
// this is the arm a `unicode-normalization` change could disturb.
|
||||
assert_eq!(
|
||||
hex::encode(mnemonic.to_seed("TREZOR")),
|
||||
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553\
|
||||
1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"
|
||||
.replace(['\n', ' '], ""),
|
||||
"BIP-39 passphrase normalisation changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +212,15 @@ impl Server {
|
||||
|
||||
// Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address).
|
||||
// Onion addresses are exchanged privately via NIP-44 encrypted DMs.
|
||||
if config.nostr_discovery_enabled && !config.nostr_relays.is_empty() {
|
||||
//
|
||||
// This is a heartbeat, not a one-shot: presence events carry a NIP-40
|
||||
// expiration of PRESENCE_TTL_SECS, so a node that stops re-publishing
|
||||
// ages out of discovery instead of lingering forever. First tick runs
|
||||
// immediately (preserving the old startup-publish behaviour); the
|
||||
// runtime toggle (nostr.set-discovery) is re-read every tick, so a
|
||||
// node switched on via the UI heartbeats too — not just ones with the
|
||||
// config flag baked in.
|
||||
{
|
||||
let identity_dir = config.data_dir.join("identity");
|
||||
let did =
|
||||
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
@@ -221,21 +229,36 @@ impl Server {
|
||||
// where handshake peers actually read (2026-07-22 unification).
|
||||
let data_dir_for_relays = config.data_dir.clone();
|
||||
let config_relays = config.nostr_relays.clone();
|
||||
let config_flag = config.nostr_discovery_enabled;
|
||||
let tor_proxy = config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
let relays =
|
||||
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
|
||||
const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3
|
||||
loop {
|
||||
let (enabled_override, display_name) =
|
||||
nostr_handshake::discovery_overrides(&data_dir_for_relays).await;
|
||||
let enabled = enabled_override.unwrap_or(config_flag);
|
||||
if enabled {
|
||||
let relays = crate::nostr_relays::merged_relay_list(
|
||||
&data_dir_for_relays,
|
||||
&config_relays,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&version,
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
|
||||
if !relays.is_empty() {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&version,
|
||||
display_name.as_deref(),
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(HEARTBEAT_SECS)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -530,6 +553,15 @@ impl Server {
|
||||
// heavy load) don't fire a burst of catch-up ticks back-to-back,
|
||||
// just resume the cadence from now.
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// Peers that never list us back would otherwise be re-notified
|
||||
// every 90s forever (e.g. they hold us at Observer and their
|
||||
// exported hints are Trusted-only, so `they_list_us` can never
|
||||
// become true). Back off per peer, doubling toward a daily
|
||||
// re-assert; reset the moment they do list us.
|
||||
let mut notify_backoff: std::collections::HashMap<
|
||||
String,
|
||||
(u32, tokio::time::Instant),
|
||||
> = std::collections::HashMap::new();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// Zero federated nodes is a clean no-op: nothing is read
|
||||
@@ -545,21 +577,27 @@ impl Server {
|
||||
}
|
||||
};
|
||||
let (snap, _) = state.get_snapshot().await;
|
||||
let local_did =
|
||||
match crate::identity::did_key_from_pubkey_hex(&snap.server_info.pubkey) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(id) => id,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Advertise the SAME key we sign with. server_info.pubkey
|
||||
// is only seeded at boot; onboarding/seed-restore rewrite
|
||||
// identity/node_key on disk without touching the snapshot,
|
||||
// and a peer-joined that advertises the stale boot key
|
||||
// while signing with the new seed-derived key is
|
||||
// deterministically rejected ("Invalid signature") by
|
||||
// every receiver, once per tick, forever (2026-08-16).
|
||||
let local_pubkey = node_identity.pubkey_hex();
|
||||
let local_did = match crate::identity::did_key_from_pubkey_hex(&local_pubkey) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Our own identity, for re-asserting membership to any peer
|
||||
// that doesn't list us back (asymmetry self-heal, below).
|
||||
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = snap.server_info.pubkey.clone();
|
||||
let local_name = snap.server_info.name.clone();
|
||||
let local_fips_npub = crate::identity::fips_npub(&identity_dir)
|
||||
.await
|
||||
@@ -595,24 +633,44 @@ impl Server {
|
||||
// re-add (the "peer missing everywhere" case).
|
||||
let they_list_us =
|
||||
state.federated_peers.iter().any(|h| h.did == local_did);
|
||||
if !they_list_us && !local_onion.is_empty() {
|
||||
crate::federation::notify_join(
|
||||
&node.onion,
|
||||
node.fips_npub.as_deref(),
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
// Re-assert at the level WE hold for
|
||||
// this peer; no invite token on heal.
|
||||
None,
|
||||
node.trust_level,
|
||||
|b| node_identity.sign(b),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
healed += 1;
|
||||
if they_list_us {
|
||||
notify_backoff.remove(&node.did);
|
||||
} else if !local_onion.is_empty() {
|
||||
let now = tokio::time::Instant::now();
|
||||
let due = notify_backoff
|
||||
.get(&node.did)
|
||||
.map(|(_, next)| *next <= now)
|
||||
.unwrap_or(true);
|
||||
if due {
|
||||
crate::federation::notify_join(
|
||||
&node.onion,
|
||||
node.fips_npub.as_deref(),
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
// Re-assert at the level WE hold for
|
||||
// this peer; no invite token on heal.
|
||||
None,
|
||||
node.trust_level,
|
||||
|b| node_identity.sign(b),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
healed += 1;
|
||||
let attempts = notify_backoff
|
||||
.get(&node.did)
|
||||
.map(|(a, _)| *a)
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
let delay =
|
||||
(90u64 << attempts.min(10)).min(86_400);
|
||||
notify_backoff.insert(
|
||||
node.did.clone(),
|
||||
(attempts, now + Duration::from_secs(delay)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+112
-64
@@ -84,11 +84,9 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
|
||||
/// The previous IP-based origin, kept as an automatic fallback so a node
|
||||
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
|
||||
/// once the fleet has moved.
|
||||
const LEGACY_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
// The previous IP-based origin (http://146.59.87.168:3000/…) was an automatic
|
||||
// DNS/TLS-broken fallback until 2026-08-11, when bare-IP origins were retired
|
||||
// from the update registry. `load_mirrors` strips it from saved lists by host.
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
@@ -131,20 +129,11 @@ fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Archipelago Foundation".to_string(),
|
||||
},
|
||||
// NOT a second server — the SAME host as the entry above, reached by
|
||||
// IP over plain HTTP instead of by name over TLS. It buys nothing if
|
||||
// the origin is down; what it recovers is a node whose **DNS is
|
||||
// broken or whose clock is wrong**, either of which fails TLS while
|
||||
// plain HTTP still works. Safe because the manifest carries an Ed25519
|
||||
// signature verified against the pinned release-root anchor, so
|
||||
// transport integrity is not what protects the update.
|
||||
//
|
||||
// Labelled explicitly so the UI cannot imply redundancy it doesn't
|
||||
// provide. Real redundancy needs a mirror on a different host.
|
||||
UpdateMirror {
|
||||
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Same server, no DNS/TLS".to_string(),
|
||||
},
|
||||
// The bare-IP plain-HTTP twin of the entry above was retired as a
|
||||
// default on 2026-08-11 (operator decision: no bare-IP origins in the
|
||||
// update registry). Its DNS/TLS-broken recovery value is accepted as
|
||||
// lost; the manifest signature was always what protected the update,
|
||||
// never the transport. `load_mirrors` strips it from saved lists.
|
||||
]
|
||||
}
|
||||
|
||||
@@ -182,8 +171,16 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
// ever served a stale manifest as the secondary mirror.
|
||||
// Exception to the usual "explicit removals stick" rule: the user never
|
||||
// chose to add these — they were defaults.
|
||||
// - 146.59.87.168: the release server's own bare-IP HTTP twin, retired
|
||||
// as a default 2026-08-11 — same host as the named origin, so it
|
||||
// provided no redundancy, only a plain-HTTP path the operator no
|
||||
// longer wants advertised in the update registry.
|
||||
let before = list.len();
|
||||
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
|
||||
list.retain(|m| {
|
||||
!m.url.contains("23.182.128.160")
|
||||
&& !m.url.contains("git.tx1138.com")
|
||||
&& !m.url.contains("146.59.87.168")
|
||||
});
|
||||
let mut changed = list.len() != before;
|
||||
|
||||
// Merge in any default URLs the saved config is missing.
|
||||
@@ -216,21 +213,14 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Archipelago Foundation".to_string();
|
||||
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
|
||||
// Rewritten on every load, so relabelling here reaches nodes that
|
||||
// already have the old "Direct (fallback)" text saved in their
|
||||
// update-mirrors.json — the merge below matches on URL, never on
|
||||
// label, so without this a renamed default would never propagate.
|
||||
mirror.label = "Same server, no DNS/TLS".to_string();
|
||||
}
|
||||
}
|
||||
// Named origin first, its same-host IP fallback second, anything the
|
||||
// operator added after that. Ordering matters: the list is tried in order,
|
||||
// so a stale entry sitting first costs a timeout on every check.
|
||||
// Named origin first, anything the operator added after that. Ordering
|
||||
// matters: the list is tried in order, so a stale entry sitting first
|
||||
// costs a timeout on every check.
|
||||
list.sort_by_key(|m| match m.url.as_str() {
|
||||
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
|
||||
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
|
||||
_ => 2,
|
||||
_ => 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1023,7 +1013,7 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||
})?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
@@ -1416,8 +1406,8 @@ async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest)
|
||||
.unwrap_or(0);
|
||||
if len != component.size_bytes {
|
||||
anyhow::bail!(
|
||||
"staged component {} is {} bytes but the manifest says {} — \
|
||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
||||
"Update staging is inconsistent: component {} is {} bytes but the manifest says {} — \
|
||||
re-download before applying (incomplete or concurrently-rewritten download)",
|
||||
component.name,
|
||||
len,
|
||||
component.size_bytes
|
||||
@@ -1529,11 +1519,11 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||
})?;
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
if !staging_dir.exists() {
|
||||
anyhow::bail!("No staged update found. Download first.");
|
||||
anyhow::bail!("Update not staged — download it first, then apply.");
|
||||
}
|
||||
|
||||
// Gate 1: the completion marker is written only after EVERY component
|
||||
@@ -1541,7 +1531,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
// or in-flight download — exactly what got installed on .198.
|
||||
if !has_staged_update(data_dir).await {
|
||||
anyhow::bail!(
|
||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
||||
"Update download was incomplete (no completion marker) — download the update again before applying"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1550,9 +1540,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.await?
|
||||
.available_update
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no update manifest in state to verify staged files against — re-download the update"
|
||||
)
|
||||
anyhow::anyhow!("Update manifest missing from state — re-download the update")
|
||||
})?;
|
||||
verify_staged_components(&staging_dir, &manifest).await?;
|
||||
|
||||
@@ -1588,41 +1576,83 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
info!("Current binary backed up");
|
||||
}
|
||||
|
||||
// Apply staged components
|
||||
let mut entries = fs::read_dir(&staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
// Apply staged components in a DETERMINISTIC order, binary LAST.
|
||||
// read_dir order is filesystem-arbitrary, and each component used to be
|
||||
// consumed destructively — so a mid-apply failure could leave staging
|
||||
// half-emptied and un-reappliable (Gate 2 re-verifies EVERY manifest
|
||||
// component against staging, so a missing one wedges every retry:
|
||||
// "doesn't apply, still says install, can never apply again"). Two
|
||||
// guards against that now: (a) nothing is removed from staging here —
|
||||
// the binary is copied, not moved (see its block) — so a failed apply
|
||||
// is always retryable from the same staged files; (b) the binary, the
|
||||
// one component whose swap changes what runs after restart, is applied
|
||||
// only after the frontend/runtime succeed, so a frontend failure never
|
||||
// leaves a new binary staged to run against an old frontend on the next
|
||||
// restart.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
{
|
||||
let mut entries = fs::read_dir(&staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
names.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|n| match n.as_str() {
|
||||
"archipelago" => 2, // binary last
|
||||
n if n.contains("runtime") && n.ends_with(".tar.gz") => 1,
|
||||
_ => 0, // frontend and everything else first
|
||||
});
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = entry.path();
|
||||
for name in &names {
|
||||
let name = name.as_str();
|
||||
let src = staging_dir.join(name);
|
||||
|
||||
match name.as_str() {
|
||||
match name {
|
||||
"archipelago" => {
|
||||
// Two namespace gotchas this block works around:
|
||||
// Three constraints this block works around:
|
||||
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||
// Use `mv`, which is atomic rename() and tolerates a
|
||||
// busy destination.
|
||||
// rename() over a busy destination is fine.
|
||||
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||
// sudo inherits the service's mount namespace. Route
|
||||
// the rename through systemd-run so it runs in a
|
||||
// transient unit with default protections.
|
||||
// through host_sudo (systemd-run transient unit with
|
||||
// default protections).
|
||||
// 3. The staged binary must SURVIVE this so a later
|
||||
// component's failure leaves the apply retryable. So we
|
||||
// COPY the staged file to a sibling temp in the target
|
||||
// dir, then atomic-rename the temp over the target —
|
||||
// the staging copy is never moved. (mv'ing the staged
|
||||
// file itself was the wedging bug: binary applied, then
|
||||
// frontend fails, staging now missing the binary, every
|
||||
// retry fails re-verification forever.)
|
||||
let staged = src.to_string_lossy().to_string();
|
||||
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
||||
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
let tmp = format!(
|
||||
"/usr/local/bin/.archipelago.new.{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let cp = host_sudo(&["cp", "-f", &staged, &tmp])
|
||||
.await
|
||||
.with_context(|| format!("Failed to copy staged binary for {}", name))?;
|
||||
if !cp.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!("copy of staged binary failed for {}", name);
|
||||
}
|
||||
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
|
||||
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||
if !status.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!(
|
||||
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||
name,
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
info!(name = %name, "Backend binary applied");
|
||||
info!(name = %name, "Backend binary applied (staging preserved)");
|
||||
}
|
||||
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
||||
// Tarball contents are the *inside* of web-ui/ (root entries
|
||||
@@ -2428,10 +2458,10 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
// The named origin leads, its IP fallback follows. A node with broken
|
||||
// DNS or a wrong clock (both break TLS) must still have a way to
|
||||
// update; the signature is what makes either source trustworthy.
|
||||
assert_eq!(list.len(), 2);
|
||||
// The named https origin is the ONLY default since 2026-08-11:
|
||||
// bare-IP origins were retired from the update registry (the IP
|
||||
// twin bought DNS/TLS-broken recovery, deliberately given up).
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(
|
||||
list[0]
|
||||
.url
|
||||
@@ -2439,11 +2469,14 @@ mod tests {
|
||||
"the named origin must be primary, got {}",
|
||||
list[0].url
|
||||
);
|
||||
assert!(list[1].url.contains("146.59.87.168"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
);
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("146.59.87.168")),
|
||||
"bare-IP origins were retired 2026-08-11 and must not be defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2471,7 +2504,11 @@ mod tests {
|
||||
"retired tx1138 mirror should be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("146.59.87.168")),
|
||||
"the bare-IP twin is retired too and must be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2741,9 +2778,20 @@ mod tests {
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("refusing to apply"),
|
||||
err.to_string().contains("re-download before applying"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
// Resilience: a refused apply must leave the update still available and
|
||||
// still staged, so the user can re-download and retry — never a wedge.
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
loaded.available_update.is_some(),
|
||||
"a refused apply must not clear the available update"
|
||||
);
|
||||
assert!(
|
||||
loaded.update_in_progress,
|
||||
"a refused apply must leave the staged-update flag set for retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||
//!
|
||||
//! Emits the cashuA (V3) token format:
|
||||
//! cashuA<base64url_encoded_json>
|
||||
//! Reads and writes both wire versions:
|
||||
//!
|
||||
//! Token JSON structure:
|
||||
//! {
|
||||
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
||||
//! "memo": "<optional>"
|
||||
//! }
|
||||
//! - **cashuA (V3)** — `cashuA<base64url_encoded_json>`, whose JSON is the
|
||||
//! structs below verbatim:
|
||||
//! ```text
|
||||
//! { "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>",
|
||||
//! "secret": "<str>", "C": "<hex>" }] }], "memo": "<optional>" }
|
||||
//! ```
|
||||
//! - **cashuB (V4)** — `cashuB<base64url_encoded_cbor>`, a CBOR map keyed by
|
||||
//! the spec's single letters (t/i/p/a/s/c/m/u/d/w) rather than the JSON
|
||||
//! names above, with the keyset id (`i`) and signature (`c`) as raw bytes.
|
||||
//! Those are hex-encoded into `Proof` on the way in so the rest of the
|
||||
//! wallet never has to know which version a token arrived in.
|
||||
//!
|
||||
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
|
||||
//! by default now:
|
||||
//! cashuB<base64url_encoded_cbor>
|
||||
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
|
||||
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
|
||||
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
|
||||
//! convention so the rest of the wallet doesn't need to know which version
|
||||
//! a token arrived in.
|
||||
//! `serialize_v4` is what we emit — most wallets default to cashuB now —
|
||||
//! with `serialize` (cashuA) kept for older receivers and as the fallback
|
||||
//! for the one token shape V4 cannot express (multi-mint).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::PublicKey;
|
||||
// Protocol types from the reference implementation (`cashu`, the crate CDK
|
||||
// itself is built on). Used for the parts of NUT-00/02 that move with the
|
||||
// spec — token parsing and keyset ids — while the structs below stay ours
|
||||
// because they are also the on-disk format (see docs/cashu-cdk-migration-plan.md).
|
||||
use cashu::nuts::nut00::{Proof as CdkProof, Token as CdkToken};
|
||||
use cashu::nuts::nut01::PublicKey as CdkPublicKey;
|
||||
use cashu::nuts::nut02::{
|
||||
Id as CdkId, KeySetInfo as CdkKeySetInfo, ShortKeysetId as CdkShortKeysetId,
|
||||
};
|
||||
use cashu::nuts::CurrencyUnit as CdkCurrencyUnit;
|
||||
use cashu::secret::Secret as CdkSecret;
|
||||
use cashu::{Amount as CdkAmount, MintUrl as CdkMintUrl};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Prefix for V3 (JSON) tokens.
|
||||
const CASHU_A_PREFIX: &str = "cashuA";
|
||||
@@ -141,6 +154,58 @@ impl CashuToken {
|
||||
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
||||
}
|
||||
|
||||
/// Encode as a cashuB (V4, CBOR) token string — the format most wallets
|
||||
/// default to today.
|
||||
///
|
||||
/// Built through the reference implementation rather than by hand. The V4
|
||||
/// envelope puts the keyset id and the signature on the wire as raw CBOR
|
||||
/// bytes under single-letter keys, and a token that is subtly wrong there
|
||||
/// is money the receiver cannot redeem — so upstream owns the encoding,
|
||||
/// the same way it owns keyset-id resolution.
|
||||
///
|
||||
/// V4 is single-mint by construction, so a multi-mint token — which only
|
||||
/// our internal plumbing ever builds — has no V4 form and is refused
|
||||
/// here; `send_token_at` falls back to cashuA for it.
|
||||
pub fn serialize_v4(&self) -> Result<String> {
|
||||
let entry = match self.token.as_slice() {
|
||||
[only] => only,
|
||||
[] => anyhow::bail!("Token has no entries"),
|
||||
many => anyhow::bail!(
|
||||
"cashuB carries one mint per token; this token spans {}",
|
||||
many.len()
|
||||
),
|
||||
};
|
||||
|
||||
let mint_url = CdkMintUrl::from_str(&entry.mint)
|
||||
.with_context(|| format!("Token has an unusable mint URL: {}", entry.mint))?;
|
||||
// `unit` is optional on our struct and on V3; V4 requires one. Every
|
||||
// proof this wallet holds is denominated in sats (the mint's SAT
|
||||
// keyset is selected explicitly at signing time), so that is the
|
||||
// right default rather than a guess.
|
||||
let unit = CdkCurrencyUnit::from_str(self.unit.as_deref().unwrap_or("sat"))
|
||||
.with_context(|| format!("Token has an unusable unit: {:?}", self.unit))?;
|
||||
|
||||
let proofs = entry
|
||||
.proofs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let keyset_id = CdkId::from_str(&p.id).with_context(|| {
|
||||
format!("Proof carries a keyset id cashuB cannot encode: {}", p.id)
|
||||
})?;
|
||||
let c = CdkPublicKey::from_hex(&p.c)
|
||||
.context("Proof carries an unparseable signature C")?;
|
||||
Ok(CdkProof::new(
|
||||
CdkAmount::from(p.amount),
|
||||
keyset_id,
|
||||
CdkSecret::new(p.secret.clone()),
|
||||
c,
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
Ok(CdkToken::new(mint_url, proofs, self.memo.clone(), unit).to_string())
|
||||
}
|
||||
|
||||
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||
@@ -215,12 +280,76 @@ impl CashuToken {
|
||||
if proof.c.is_empty() {
|
||||
anyhow::bail!("Proof has empty C");
|
||||
}
|
||||
validate_keyset_id(&proof.id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a token's (possibly short) keyset id against the mint's keyset
|
||||
/// list, using the reference implementation's NUT-02 rules.
|
||||
///
|
||||
/// A v1 id is complete at 8 bytes; a v2 id is 33 bytes and may legitimately
|
||||
/// travel in a token as a shorter prefix, which only the mint's keyset list
|
||||
/// can expand. Upstream `Id::from_short_keyset_id` implements exactly that,
|
||||
/// including the "8 bytes but `0x01`-versioned" case that a wallet written
|
||||
/// against the old format produces (framework-pt, 2026-08-17).
|
||||
///
|
||||
/// Returns the full hex id to send to the mint, or `None` when the id is
|
||||
/// already complete or cannot be resolved — the caller passes those through
|
||||
/// untouched so the mint's own error still reaches the operator.
|
||||
pub fn resolve_keyset_id(id_hex: &str, mint_keysets: &[CdkKeySetInfo]) -> Option<String> {
|
||||
let bytes = hex::decode(id_hex).ok()?;
|
||||
let short = CdkShortKeysetId::from_bytes(&bytes).ok()?;
|
||||
let full = CdkId::from_short_keyset_id(&short, mint_keysets).ok()?;
|
||||
let full_hex = hex::encode(full.to_bytes());
|
||||
(full_hex != id_hex).then_some(full_hex)
|
||||
}
|
||||
|
||||
/// NUT-02 keyset ID: hex for either 8 bytes (v1, the `00…` short form) or
|
||||
/// 33 bytes (v2, version-byte + hash).
|
||||
///
|
||||
/// Checked when a token is decoded rather than left to the mint. Forwarding
|
||||
/// an out-of-spec id produced a swap the mint rejected with a bare
|
||||
/// `422 Unprocessable Entity`, which reached the operator as "check server
|
||||
/// logs" with nothing actionable in the UI (framework-pt, 2026-08-17,
|
||||
/// mint.minibits.cash: `inputs[0].id: NUT02: ID length invalid`). A local
|
||||
/// check can say which keyset format the token uses and that this wallet
|
||||
/// cannot spend it, before any network call.
|
||||
fn validate_keyset_id(id: &str) -> Result<()> {
|
||||
let bytes = hex::decode(id).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Token uses a keyset id that is not hex ({id:?}) — this wallet supports \
|
||||
NUT-02 v1 (8-byte) and v2 (33-byte) hex keyset ids"
|
||||
)
|
||||
})?;
|
||||
match bytes.len() {
|
||||
// 8 bytes is v1's whole id, and also what a wallet that predates v2
|
||||
// leaves behind when it truncates one. Both are accepted here; the
|
||||
// truncated case is repaired against the mint's keyset list at swap
|
||||
// time (see MintClient::resolve_truncated_keyset_ids).
|
||||
8 | 33 => Ok(()),
|
||||
n => anyhow::bail!(
|
||||
"Token uses an unsupported keyset id format: {n}-byte id {id:?}. NUT-02 \
|
||||
defines 8-byte (v1) and 33-byte (v2) ids; the mint will reject a swap \
|
||||
carrying this one"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this the first 8 bytes of a NUT-02 **v2** keyset id rather than a
|
||||
/// complete v1 one?
|
||||
///
|
||||
/// A v1 id is 8 bytes beginning with the version byte `0x00`; a v2 id is 33
|
||||
/// bytes beginning with `0x01`. So an 8-byte id that starts with `0x01` is a
|
||||
/// v2 id some wallet cut to the old length — it cannot be spent as-is, but
|
||||
/// the full id can be recovered from the mint because the short form is a
|
||||
/// prefix of it.
|
||||
pub fn is_truncated_v2_keyset_id(id: &str) -> bool {
|
||||
id.len() == 16 && id.starts_with("01") && hex::decode(id).is_ok()
|
||||
}
|
||||
|
||||
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
|
||||
/// default) and falling back to other alphabets some implementations use.
|
||||
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
@@ -237,16 +366,57 @@ pub struct KeysetInfo {
|
||||
pub id: String,
|
||||
pub unit: String,
|
||||
pub active: bool,
|
||||
/// NUT-02 input fee, in parts-per-thousand of a proof. A mint charges
|
||||
/// this per *input* on a swap/melt; zero at fee-free mints, which is why
|
||||
/// ignoring it went unnoticed against Minibits.
|
||||
#[serde(default)]
|
||||
pub input_fee_ppk: u64,
|
||||
}
|
||||
|
||||
/// NUT-02 swap fee for a set of inputs: the summed per-proof parts-per-
|
||||
/// thousand, rounded **up** to whole units. Inputs whose keyset the mint
|
||||
/// didn't list contribute nothing — the mint is the authority, and guessing
|
||||
/// high would silently burn the sender's coins.
|
||||
pub fn swap_fee_for(proofs: &[Proof], keysets: &[KeysetInfo]) -> u64 {
|
||||
let ppk: u64 = proofs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
keysets
|
||||
.iter()
|
||||
.find(|k| k.id == p.id)
|
||||
.map(|k| k.input_fee_ppk)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum();
|
||||
ppk.div_ceil(1000)
|
||||
}
|
||||
|
||||
/// Mint keyset: maps denomination amounts to public keys.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MintKeyset {
|
||||
pub id: String,
|
||||
/// Currency unit this keyset signs for ("sat", "usd", "eur", "msat"…).
|
||||
///
|
||||
/// Defaulted rather than required: a mint that omits it is sat-only in
|
||||
/// practice, and refusing to parse would break wallets against mints that
|
||||
/// predate multi-unit support.
|
||||
#[serde(default = "default_unit")]
|
||||
pub unit: String,
|
||||
/// Whether the mint will still sign with this keyset.
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
/// Map of amount (as string) to hex-encoded public key.
|
||||
pub keys: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_unit() -> String {
|
||||
"sat".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl MintKeyset {
|
||||
/// Get the mint's public key for a given denomination amount.
|
||||
pub fn key_for_amount(&self, amount: u64) -> Result<PublicKey> {
|
||||
@@ -441,6 +611,107 @@ mod tests {
|
||||
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v4_token_we_emit_is_readable_by_our_own_v4_decoder() {
|
||||
// Cross-implementation check: upstream's encoder writes the CBOR,
|
||||
// our hand-written decoder reads it back. Agreement between two
|
||||
// independent implementations is the evidence that matters here —
|
||||
// a round trip through one codec would prove nothing about the wire.
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "https://testnut.cashu.space".to_string(),
|
||||
proofs: vec![
|
||||
Proof {
|
||||
amount: 8,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "abcdef1234567890".to_string(),
|
||||
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
|
||||
.to_string(),
|
||||
},
|
||||
Proof {
|
||||
amount: 2,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "fedcba0987654321".to_string(),
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
memo: Some("ten sats".to_string()),
|
||||
unit: Some("sat".to_string()),
|
||||
};
|
||||
|
||||
let encoded = token.serialize_v4().expect("V4 encoding must succeed");
|
||||
assert!(encoded.starts_with("cashuB"), "{encoded}");
|
||||
|
||||
let decoded = CashuToken::deserialize(&encoded).expect("our decoder must read it");
|
||||
assert_eq!(decoded.total_amount(), 10);
|
||||
assert_eq!(decoded.token[0].mint, "https://testnut.cashu.space");
|
||||
assert_eq!(decoded.memo, Some("ten sats".to_string()));
|
||||
|
||||
// Every proof survives byte-for-byte, including the hex convention we
|
||||
// impose on the raw-bytes CBOR fields.
|
||||
let mut got: Vec<_> = decoded
|
||||
.all_proofs()
|
||||
.iter()
|
||||
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
|
||||
.collect();
|
||||
got.sort();
|
||||
let mut want: Vec<_> = token
|
||||
.all_proofs()
|
||||
.iter()
|
||||
.map(|p| (p.amount, p.id.clone(), p.secret.clone(), p.c.clone()))
|
||||
.collect();
|
||||
want.sort();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_mint_token_has_no_v4_form_and_says_so() {
|
||||
// V4 is single-mint by construction. `send_token_at` relies on this
|
||||
// failing (rather than silently dropping an entry) to fall back to
|
||||
// cashuA — the proofs are already spent by the time it serializes.
|
||||
let one = |mint: &str| TokenEntry {
|
||||
mint: mint.to_string(),
|
||||
proofs: vec![Proof {
|
||||
amount: 1,
|
||||
id: "009a1f293253e41e".to_string(),
|
||||
secret: "s".to_string(),
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||
}],
|
||||
};
|
||||
let token = CashuToken {
|
||||
token: vec![one("https://mint-a.example"), one("https://mint-b.example")],
|
||||
memo: None,
|
||||
unit: Some("sat".to_string()),
|
||||
};
|
||||
|
||||
let err = token
|
||||
.serialize_v4()
|
||||
.expect_err("two mints cannot be one V4 token");
|
||||
assert!(err.to_string().contains("one mint per token"), "{err}");
|
||||
|
||||
// …and cashuA, the fallback, still carries it.
|
||||
assert!(token.serialize().unwrap().starts_with("cashuA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_keyset_id_is_refused_by_the_v4_encoder() {
|
||||
// The framework-pt case. A short v2 id is only resolvable against the
|
||||
// mint's keyset list, so it must never be baked into a token we emit.
|
||||
let token = CashuToken::new(
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
vec![Proof {
|
||||
amount: 1,
|
||||
id: "01fc0ec0e59cd6fa".to_string(),
|
||||
secret: "s".to_string(),
|
||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
|
||||
}],
|
||||
);
|
||||
let err = token.serialize_v4().expect_err("short id must not encode");
|
||||
assert!(err.to_string().contains("keyset id"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amount_to_denominations() {
|
||||
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
||||
@@ -473,4 +744,66 @@ mod tests {
|
||||
};
|
||||
assert!(proof.c_as_pubkey().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyset_ids_of_both_nut02_versions_are_accepted() {
|
||||
// v1: 8 bytes / 16 hex chars.
|
||||
assert!(validate_keyset_id("009a1f293253e41e").is_ok());
|
||||
// v2: 33 bytes / 66 hex chars (version byte + 32-byte hash).
|
||||
let v2 = format!("01{}", "ab".repeat(32));
|
||||
assert!(validate_keyset_id(&v2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_v2_keyset_id_is_recognised_as_repairable() {
|
||||
// The real case: a Minibits token carried the first 8 bytes of the
|
||||
// mint's 33-byte v2 keyset id (2026-08-17).
|
||||
let short = "01fc0ec0e59cd6fa";
|
||||
let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
||||
assert!(is_truncated_v2_keyset_id(short));
|
||||
assert!(full.starts_with(short), "short form must prefix the full id");
|
||||
// It must survive token validation so the swap path can repair it,
|
||||
// rather than being rejected as malformed.
|
||||
assert!(validate_keyset_id(short).is_ok());
|
||||
|
||||
// A genuine v1 id (version byte 00) is not "truncated".
|
||||
assert!(!is_truncated_v2_keyset_id("009a1f293253e41e"));
|
||||
// Neither is a complete v2 id.
|
||||
assert!(!is_truncated_v2_keyset_id(full));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_out_of_spec_keyset_id_is_rejected_locally_with_its_length() {
|
||||
// 9 bytes — what a legacy base64 keyset id decodes to, and neither
|
||||
// NUT-02 length. The mint answers this with a bare 422, so the
|
||||
// message has to come from here.
|
||||
let err = validate_keyset_id("00112233445566778899")
|
||||
.expect_err("9-byte keyset id must be rejected");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("10-byte") || msg.contains("unsupported keyset id"), "{msg}");
|
||||
|
||||
// Non-hex ids (the original base64 keyset format) are named as such
|
||||
// rather than reported as a length problem.
|
||||
let err = validate_keyset_id("I2yN+iRYfkzT").expect_err("base64 id must be rejected");
|
||||
assert!(err.to_string().contains("not hex"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_carrying_an_unsupported_keyset_id_fails_to_decode() {
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry {
|
||||
mint: "https://mint.example.com".to_string(),
|
||||
proofs: vec![Proof {
|
||||
amount: 1,
|
||||
id: "I2yN+iRYfkzT".to_string(),
|
||||
secret: "s".to_string(),
|
||||
c: "02".to_string(),
|
||||
}],
|
||||
}],
|
||||
memo: None,
|
||||
unit: None,
|
||||
};
|
||||
// The whole point: this must fail here, not at the mint.
|
||||
assert!(token.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use super::cashu::{amount_to_denominations, CashuToken, Proof};
|
||||
use super::mint_client::MintClient;
|
||||
use super::nut13::RecoverySource;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
@@ -14,6 +15,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
const WALLET_FILE: &str = "wallet/ecash.json";
|
||||
const MINTS_FILE: &str = "wallet/accepted_mints.json";
|
||||
const NETWORK_FILE: &str = "wallet/network.json";
|
||||
|
||||
/// Transaction type for history tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -213,56 +215,192 @@ impl WalletState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which ecash network this node's wallet is operating on.
|
||||
///
|
||||
/// Cashu itself has no notion of a testnet — a "test" wallet is simply one
|
||||
/// pointed at a mint that issues valueless coins (the public `testnut` mint).
|
||||
/// Modelling it as a network setting rather than "just add a mint" matters
|
||||
/// because the two must never share a purse: test proofs and real proofs in
|
||||
/// one file would be spendable interchangeably, and a balance would be a lie.
|
||||
///
|
||||
/// So each network gets its own wallet and its own accepted-mints list.
|
||||
/// **Mainnet keeps the original filenames**, so an existing node's funds file
|
||||
/// is untouched by this feature and by switching back and forth.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EcashNetwork {
|
||||
#[default]
|
||||
Mainnet,
|
||||
Testnet,
|
||||
}
|
||||
|
||||
impl EcashNetwork {
|
||||
fn wallet_file(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mainnet => WALLET_FILE,
|
||||
Self::Testnet => "wallet/ecash.testnet.json",
|
||||
}
|
||||
}
|
||||
|
||||
fn mints_file(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mainnet => MINTS_FILE,
|
||||
Self::Testnet => "wallet/accepted_mints.testnet.json",
|
||||
}
|
||||
}
|
||||
|
||||
/// The mint a fresh wallet on this network starts out trusting.
|
||||
pub fn default_mint(&self) -> String {
|
||||
match self {
|
||||
Self::Mainnet => default_mint_url(),
|
||||
// Public test mint: issues coins with no monetary value, and hands
|
||||
// them out freely, so every route (mint/melt/send/receive/swap)
|
||||
// can be exercised end to end without risking real sats.
|
||||
Self::Testnet => "https://testnut.cashu.space".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_test(&self) -> bool {
|
||||
matches!(self, Self::Testnet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the node's ecash network. Absent file = mainnet, so nodes that never
|
||||
/// touch this setting behave exactly as before.
|
||||
pub async fn load_network(data_dir: &Path) -> EcashNetwork {
|
||||
let path = data_dir.join(NETWORK_FILE);
|
||||
let Ok(content) = fs::read_to_string(&path).await else {
|
||||
return EcashNetwork::Mainnet;
|
||||
};
|
||||
serde_json::from_str::<NetworkConfig>(&content)
|
||||
.map(|c| c.network)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Switch the node's ecash network. The other network's wallet is left on
|
||||
/// disk untouched, so switching is reversible and loses nothing.
|
||||
pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let content = serde_json::to_string_pretty(&NetworkConfig { network })
|
||||
.context("Failed to serialize ecash network")?;
|
||||
fs::write(data_dir.join(NETWORK_FILE), content)
|
||||
.await
|
||||
.context("Failed to write ecash network")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
struct NetworkConfig {
|
||||
#[serde(default)]
|
||||
network: EcashNetwork,
|
||||
}
|
||||
|
||||
/// Load wallet state from disk.
|
||||
pub async fn load_wallet(data_dir: &Path) -> Result<WalletState> {
|
||||
let path = data_dir.join(WALLET_FILE);
|
||||
let network = load_network(data_dir).await;
|
||||
let path = data_dir.join(network.wallet_file());
|
||||
if !path.exists() {
|
||||
return Ok(WalletState {
|
||||
mint_url: default_mint_url(),
|
||||
mint_url: network.default_mint(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read wallet file")?;
|
||||
let mut wallet: WalletState = serde_json::from_str(&content).unwrap_or_default();
|
||||
|
||||
// An empty file is a legitimate "nothing here yet" (a create that never
|
||||
// got its first write); anything else that fails to parse is a damaged
|
||||
// purse and must NOT be read as an empty one.
|
||||
//
|
||||
// This used to be `unwrap_or_default()`, which turned a truncated file
|
||||
// into a zero balance — and because the very next operation saves the
|
||||
// wallet back, that empty state was then written over the only copy of
|
||||
// the proofs. Failing here keeps the damaged file intact so the coins
|
||||
// can still be recovered from it (or from a backup) by hand.
|
||||
let mut wallet: WalletState = if content.trim().is_empty() {
|
||||
WalletState::default()
|
||||
} else {
|
||||
serde_json::from_str(&content).with_context(|| {
|
||||
format!(
|
||||
"Ecash wallet file {} is damaged and was NOT overwritten — your coins are \
|
||||
still in it. Restore it from a backup, or move it aside to start empty.",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
// Set default mint URL if empty
|
||||
if wallet.mint_url.is_empty() {
|
||||
wallet.mint_url = default_mint_url();
|
||||
wallet.mint_url = network.default_mint();
|
||||
}
|
||||
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
/// Write `content` to `path` without ever leaving a half-written file there.
|
||||
///
|
||||
/// Writes a sibling temp file, flushes it to the platter, then renames over
|
||||
/// the target — rename is atomic within a filesystem, so a crash or power cut
|
||||
/// leaves either the old file or the new one, never a truncated one. The
|
||||
/// previous plain write truncated the real file first, which is precisely how
|
||||
/// a wallet ends up unparseable.
|
||||
async fn write_file_atomically(path: &Path, content: &str) -> Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create {}", tmp.display()))?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
f.write_all(content.as_bytes())
|
||||
.await
|
||||
.context("Failed to write wallet temp file")?;
|
||||
f.sync_all().await.context("Failed to flush wallet file")?;
|
||||
drop(f);
|
||||
fs::rename(&tmp, path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to replace {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save wallet state to disk.
|
||||
pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let path = data_dir.join(WALLET_FILE);
|
||||
let path = data_dir.join(load_network(data_dir).await.wallet_file());
|
||||
let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write wallet file")?;
|
||||
write_file_atomically(&path, &content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load accepted mints list.
|
||||
pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
|
||||
let path = data_dir.join(MINTS_FILE);
|
||||
let network = load_network(data_dir).await;
|
||||
let path = data_dir.join(network.mints_file());
|
||||
if !path.exists() {
|
||||
return Ok(AcceptedMints {
|
||||
mints: vec![default_mint_url()],
|
||||
mints: vec![network.default_mint()],
|
||||
});
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.context("Failed to read accepted mints")?;
|
||||
let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints {
|
||||
mints: vec![default_mint_url()],
|
||||
});
|
||||
// A damaged mint list must not silently become "trust only the default"
|
||||
// — that would reject perfectly good tokens from mints the operator
|
||||
// added. Empty file is still a legitimate fresh state.
|
||||
let mints: AcceptedMints = if content.trim().is_empty() {
|
||||
AcceptedMints {
|
||||
mints: vec![network.default_mint()],
|
||||
}
|
||||
} else {
|
||||
serde_json::from_str(&content).with_context(|| {
|
||||
format!("Accepted-mints file {} is damaged", path.display())
|
||||
})?
|
||||
};
|
||||
Ok(mints)
|
||||
}
|
||||
|
||||
@@ -272,22 +410,33 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let path = data_dir.join(MINTS_FILE);
|
||||
let path = data_dir.join(load_network(data_dir).await.mints_file());
|
||||
let content =
|
||||
serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write accepted mints")?;
|
||||
write_file_atomically(&path, &content).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a mint client whose proofs are **restorable from the wallet phrase**.
|
||||
///
|
||||
/// Every output such a client creates has its secret derived via NUT-13
|
||||
/// (`wallet/nut13.rs`) rather than drawn from randomness, so the coins can be
|
||||
/// re-derived and re-claimed if `wallet/ecash.json` is ever lost. That is the
|
||||
/// only difference from `MintClient::new`, and it is the reason this wallet
|
||||
/// has a backup story at all — so every mint/swap path in this module goes
|
||||
/// through here. On a node with no phrase yet the source is absent and the
|
||||
/// behaviour is exactly as it was before: valid proofs, no backup.
|
||||
async fn mint_client(data_dir: &Path, mint_url: &str) -> Result<MintClient> {
|
||||
Ok(MintClient::new(mint_url)?.with_recovery(RecoverySource::load(data_dir).await))
|
||||
}
|
||||
|
||||
/// Request a mint quote — returns a Lightning invoice to pay.
|
||||
pub async fn mint_quote(
|
||||
data_dir: &Path,
|
||||
amount_sats: u64,
|
||||
) -> Result<super::mint_client::MintQuote> {
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
let client = MintClient::new(&wallet.mint_url)?;
|
||||
let client = mint_client(data_dir, &wallet.mint_url).await?;
|
||||
client.mint_quote(amount_sats).await
|
||||
}
|
||||
|
||||
@@ -295,7 +444,7 @@ pub async fn mint_quote(
|
||||
pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> Result<u64> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = wallet.mint_url.clone();
|
||||
let client = MintClient::new(&mint_url)?;
|
||||
let client = mint_client(data_dir, &mint_url).await?;
|
||||
|
||||
let result = client.mint_tokens(quote_id, amount_sats).await?;
|
||||
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
|
||||
@@ -317,7 +466,7 @@ pub async fn mint_tokens(data_dir: &Path, quote_id: &str, amount_sats: u64) -> R
|
||||
/// Request a melt quote — how much to pay a Lightning invoice with ecash.
|
||||
pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_client::MeltQuote> {
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
let client = MintClient::new(&wallet.mint_url)?;
|
||||
let client = mint_client(data_dir, &wallet.mint_url).await?;
|
||||
client.melt_quote(bolt11).await
|
||||
}
|
||||
|
||||
@@ -325,7 +474,7 @@ pub async fn melt_quote(data_dir: &Path, bolt11: &str) -> Result<super::mint_cli
|
||||
pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Result<u64> {
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let mint_url = wallet.mint_url.clone();
|
||||
let client = MintClient::new(&mint_url)?;
|
||||
let client = mint_client(data_dir, &mint_url).await?;
|
||||
|
||||
// Get the melt quote to know the amount needed
|
||||
let quote = client.melt_quote(bolt11).await?;
|
||||
@@ -448,8 +597,8 @@ pub async fn swap_between_mints(
|
||||
);
|
||||
}
|
||||
|
||||
let from = MintClient::new(from_mint)?;
|
||||
let to = MintClient::new(to_mint)?;
|
||||
let from = mint_client(data_dir, from_mint).await?;
|
||||
let to = mint_client(data_dir, to_mint).await?;
|
||||
|
||||
// 1. Mint quote on the target → invoice to pay.
|
||||
let mint_quote = to
|
||||
@@ -587,13 +736,13 @@ async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a cashuA token string to send to a peer, drawing from the home mint.
|
||||
/// Create an ecash token string to send to a peer, drawing from the home mint.
|
||||
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
|
||||
let mint_url = load_wallet(data_dir).await?.mint_url;
|
||||
send_token_at(data_dir, &mint_url, amount_sats).await
|
||||
}
|
||||
|
||||
/// Create a cashuA token denominated in a specific mint's tokens.
|
||||
/// Create an ecash token denominated in a specific mint's tokens.
|
||||
///
|
||||
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
|
||||
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
|
||||
@@ -620,7 +769,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
|
||||
// If there's overpayment, swap to get exact change
|
||||
let send_proofs = if overpayment > 0 {
|
||||
let client = MintClient::new(&mint_url)?;
|
||||
let client = mint_client(data_dir, &mint_url).await?;
|
||||
let send_denoms = amount_to_denominations(amount_sats);
|
||||
let change_denoms = amount_to_denominations(overpayment);
|
||||
|
||||
@@ -669,9 +818,20 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
selected_proofs
|
||||
};
|
||||
|
||||
// Serialize as cashuA token
|
||||
// Emit cashuB (V4) — what Minibits, Nutstash and cdk-cli read by default.
|
||||
// cashuA stays the fallback rather than the default: it is still valid and
|
||||
// every wallet accepts it, so a token this wallet cannot express in V4 is
|
||||
// worth sending in V3 rather than failing the send outright. The warning
|
||||
// exists so that never happens silently — at this point in `send_token_at`
|
||||
// the proofs are already marked spent.
|
||||
let token = CashuToken::new(&mint_url, send_proofs);
|
||||
let token_str = token.serialize()?;
|
||||
let token_str = match token.serialize_v4() {
|
||||
Ok(v4) => v4,
|
||||
Err(e) => {
|
||||
warn!("Falling back to a cashuA token — cashuB encoding failed: {e:#}");
|
||||
token.serialize()?
|
||||
}
|
||||
};
|
||||
|
||||
wallet.record_tx(
|
||||
TransactionType::Send,
|
||||
@@ -763,7 +923,7 @@ fn plan_payment(
|
||||
PaymentPlan::Insufficient
|
||||
}
|
||||
|
||||
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
|
||||
/// Build an ecash token to pay a seeder `amount_sats`, denominated in one of the
|
||||
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
|
||||
/// we don't already hold the right mint. Returns the token string ready to send.
|
||||
///
|
||||
@@ -883,7 +1043,7 @@ pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
|
||||
let pending = load_pending_swaps(data_dir).await?;
|
||||
let mut reclaimed = 0u64;
|
||||
for swap in pending {
|
||||
let to = match MintClient::new(&swap.to_mint) {
|
||||
let to = match mint_client(data_dir, &swap.to_mint).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -1016,7 +1176,7 @@ fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
|
||||
/// Receive a Cashu token from a peer — swaps proofs at the mint for fresh ones.
|
||||
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
// Handle legacy format for backwards compatibility
|
||||
if token_str.starts_with("cashuSend_") {
|
||||
@@ -1049,7 +1209,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
||||
|
||||
// Swap proofs at each mint
|
||||
for entry in &token.token {
|
||||
let client = MintClient::new(&entry.mint)?;
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
match client.receive_token(&token).await {
|
||||
Ok(new_proofs) => {
|
||||
let amount: u64 = new_proofs.iter().map(|p| p.amount).sum();
|
||||
@@ -1165,7 +1325,7 @@ pub async fn verify_and_receive_payment(
|
||||
return Ok(received);
|
||||
}
|
||||
|
||||
// Parse and validate cashuA token
|
||||
// Parse and validate the token (cashuA or cashuB)
|
||||
let token = CashuToken::deserialize(token_str)?;
|
||||
let total = token.total_amount();
|
||||
|
||||
@@ -1190,7 +1350,7 @@ pub async fn verify_and_receive_payment(
|
||||
let mut received_total = 0u64;
|
||||
|
||||
for entry in &token.token {
|
||||
let client = MintClient::new(&entry.mint)?;
|
||||
let client = mint_client(data_dir, &entry.mint).await?;
|
||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(entry_total);
|
||||
|
||||
@@ -1226,6 +1386,232 @@ pub async fn verify_and_receive_payment(
|
||||
Ok(received_total)
|
||||
}
|
||||
|
||||
// ── Restore from the NUT-13 phrase ─────────────────────────────────────────
|
||||
|
||||
/// How many counters to probe per `/v1/restore` call.
|
||||
const RESTORE_BATCH: u32 = 100;
|
||||
/// How many consecutive empty batches end a keyset's scan.
|
||||
///
|
||||
/// Counters are consumed in order but gaps happen: a reservation is persisted
|
||||
/// before the mint call, so any failed mint or swap burns its counters. Three
|
||||
/// empty batches is 300 unused counters in a row — far beyond any realistic
|
||||
/// run of failures, while still terminating quickly on a fresh wallet.
|
||||
const RESTORE_GAP_BATCHES: u32 = 3;
|
||||
|
||||
/// What a restore found.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct RestoreOutcome {
|
||||
/// Sats recovered and added to the wallet.
|
||||
pub recovered_sats: u64,
|
||||
/// Proofs added.
|
||||
pub recovered_proofs: usize,
|
||||
/// Proofs the mint had signed but which are already spent — the wallet's
|
||||
/// history, not its balance. Reported because "found nothing" and "found
|
||||
/// only coins you already spent" mean very different things to someone
|
||||
/// staring at an empty balance.
|
||||
pub already_spent: usize,
|
||||
/// Keysets scanned at the mint.
|
||||
pub keysets_scanned: usize,
|
||||
}
|
||||
|
||||
/// Rebuild this wallet's proofs from its NUT-13 phrase by asking a mint which
|
||||
/// of the re-derived secrets it has signed.
|
||||
///
|
||||
/// This is the half of the backup that cannot be done offline. The phrase
|
||||
/// re-derives every secret the wallet ever used, but a secret alone is not
|
||||
/// money — the mint's signature over it is. `/v1/restore` returns those
|
||||
/// signatures, and unblinding them reconstitutes the proofs.
|
||||
///
|
||||
/// Additive and idempotent by design: proofs already in the wallet are skipped
|
||||
/// by secret, and anything the mint reports as spent is counted but not added.
|
||||
/// So a restore can be run against a *working* wallet without duplicating
|
||||
/// coins or resurrecting spent ones, which matters because the most likely
|
||||
/// time to press this button is when something already looks wrong.
|
||||
pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<RestoreOutcome> {
|
||||
let recovery = RecoverySource::load(data_dir).await.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"This wallet has no backup phrase yet, so there is nothing to restore from. \
|
||||
Set one up in Settings → Ecash backup phrase."
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = MintClient::new(mint_url)?;
|
||||
// Every keyset, not just the active one: coins signed by a retired keyset
|
||||
// are still spendable, and skipping it would leave them behind.
|
||||
let keysets: Vec<_> = client
|
||||
.get_keysets()
|
||||
.await
|
||||
.context("Could not list the mint's keysets")?
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut wallet = load_wallet(data_dir).await?;
|
||||
let known_secrets: std::collections::HashSet<String> = wallet
|
||||
.proofs
|
||||
.iter()
|
||||
.map(|p| p.proof.secret.clone())
|
||||
.collect();
|
||||
|
||||
let mut outcome = RestoreOutcome::default();
|
||||
let mut found: Vec<Proof> = Vec::new();
|
||||
|
||||
for keyset in &keysets {
|
||||
// The mint's public keys for this keyset — needed to unblind.
|
||||
let keys = match client.get_keyset(&keyset.id).await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
warn!("Skipping keyset {} during restore: {e:#}", keyset.id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !keys.unit.eq_ignore_ascii_case("sat") {
|
||||
continue;
|
||||
}
|
||||
outcome.keysets_scanned += 1;
|
||||
|
||||
let mut counter = 0u32;
|
||||
let mut empty_batches = 0u32;
|
||||
let mut highest_seen: Option<u32> = None;
|
||||
|
||||
while empty_batches < RESTORE_GAP_BATCHES {
|
||||
// Re-derive this batch's outputs. The amount is deliberately 0:
|
||||
// the mint matches a restore on the blinded message `B_` alone and
|
||||
// returns the true amount in its signature — we do not know what
|
||||
// denomination each counter was used for, and guessing would be
|
||||
// wrong for most of them.
|
||||
let mut derived = Vec::with_capacity(RESTORE_BATCH as usize);
|
||||
let mut outputs = Vec::with_capacity(RESTORE_BATCH as usize);
|
||||
for i in 0..RESTORE_BATCH {
|
||||
let n = counter + i;
|
||||
let (secret, r) = match recovery.derive_at(&keyset.id, n) {
|
||||
Ok(pair) => pair,
|
||||
// A keyset id NUT-13 cannot address — nothing was ever
|
||||
// derived for it, so there is nothing to find.
|
||||
Err(e) => {
|
||||
debug!("Cannot derive for keyset {}: {e:#}", keyset.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let blinded = super::bdhke::blind_message(&secret, &r)?;
|
||||
outputs.push(super::cashu::BlindedMessageRequest {
|
||||
amount: 0,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
derived.push((n, secret, r, hex::encode(blinded.b_prime.serialize())));
|
||||
}
|
||||
if outputs.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let restored = client.restore(&outputs).await?;
|
||||
if restored.is_empty() {
|
||||
empty_batches += 1;
|
||||
counter += RESTORE_BATCH;
|
||||
continue;
|
||||
}
|
||||
empty_batches = 0;
|
||||
|
||||
for (b_prime, sig) in restored {
|
||||
let Some((n, secret, r, _)) = derived.iter().find(|(_, _, _, b)| *b == b_prime)
|
||||
else {
|
||||
warn!("Mint restored an output we did not send — ignoring");
|
||||
continue;
|
||||
};
|
||||
let mint_key = match keys.key_for_amount(sig.amount) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
warn!("Restored a {} sat output with no matching key: {e:#}", sig.amount);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let c_prime = sig.c_prime_as_pubkey()?;
|
||||
let c = super::bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||
|
||||
highest_seen = Some(highest_seen.map_or(*n, |h: u32| h.max(*n)));
|
||||
let secret = String::from_utf8_lossy(secret).to_string();
|
||||
if known_secrets.contains(&secret) {
|
||||
continue; // already in the wallet
|
||||
}
|
||||
found.push(Proof {
|
||||
amount: sig.amount,
|
||||
id: keyset.id.clone(),
|
||||
secret,
|
||||
c: hex::encode(c.serialize()),
|
||||
});
|
||||
}
|
||||
counter += RESTORE_BATCH;
|
||||
}
|
||||
|
||||
// Never hand out a counter this keyset has already used. The scan may
|
||||
// have found coins beyond where the counter file thought we were —
|
||||
// reusing those would mint proofs that collide with existing ones.
|
||||
if let Some(highest) = highest_seen {
|
||||
if let Err(e) =
|
||||
super::nut13::advance_counter_to(data_dir, &keyset.id, highest + 1).await
|
||||
{
|
||||
warn!("Could not advance the NUT-13 counter after restore: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found.is_empty() {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
// Only unspent proofs are money. The mint signed every one of these at
|
||||
// some point, including the ones already spent — adding those would
|
||||
// inflate the balance with coins that fail on first use.
|
||||
let states = client
|
||||
.check_state(&found)
|
||||
.await
|
||||
.context("Could not check which restored coins are still unspent")?;
|
||||
// NUT-07 answers in request order. Insist on that rather than assuming it:
|
||||
// a mismatched length would pair a proof with someone else's verdict and
|
||||
// credit spent coins as spendable.
|
||||
if states.len() != found.len() {
|
||||
anyhow::bail!(
|
||||
"Mint returned {} proof states for {} restored coins — refusing to \
|
||||
decide which are spendable",
|
||||
states.len(),
|
||||
found.len()
|
||||
);
|
||||
}
|
||||
|
||||
let mut keep = Vec::new();
|
||||
for (proof, state) in found.iter().zip(states.iter()) {
|
||||
if state.state.eq_ignore_ascii_case("UNSPENT") {
|
||||
keep.push(proof.clone());
|
||||
} else {
|
||||
outcome.already_spent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
outcome.recovered_sats = keep.iter().map(|p| p.amount).sum();
|
||||
outcome.recovered_proofs = keep.len();
|
||||
|
||||
if !keep.is_empty() {
|
||||
wallet.add_proofs(mint_url, keep);
|
||||
wallet.record_tx(
|
||||
TransactionType::Receive,
|
||||
outcome.recovered_sats,
|
||||
&format!(
|
||||
"Restored {} sats from the backup phrase",
|
||||
outcome.recovered_sats
|
||||
),
|
||||
mint_url,
|
||||
"",
|
||||
);
|
||||
save_wallet(data_dir, &wallet).await?;
|
||||
info!(
|
||||
"Restored {} sats ({} proofs) from the ecash backup phrase",
|
||||
outcome.recovered_sats, outcome.recovered_proofs
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Check the wallet balance.
|
||||
pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||
let wallet = load_wallet(data_dir).await?;
|
||||
@@ -1885,4 +2271,166 @@ mod tests {
|
||||
other => panic!("expected swap into liquid target, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ecash_network_defaults_to_mainnet_and_leaves_files_alone() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
assert_eq!(load_network(dir).await, EcashNetwork::Mainnet);
|
||||
// A node that never touches this setting has no new file.
|
||||
assert!(!dir.join(NETWORK_FILE).exists());
|
||||
assert_eq!(
|
||||
load_wallet(dir).await.unwrap().mint_url,
|
||||
default_mint_url(),
|
||||
"mainnet must keep the original default mint"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn switching_to_testnet_uses_a_separate_purse_and_test_mint() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Put real coins in the mainnet wallet.
|
||||
let mut real = load_wallet(dir).await.unwrap();
|
||||
real.proofs.push(StoredProof {
|
||||
proof: Proof {
|
||||
amount: 1000,
|
||||
id: "009a1f293253e41e".into(),
|
||||
secret: "real".into(),
|
||||
c: "02".into(),
|
||||
},
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
reserved: false,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
save_wallet(dir, &real).await.unwrap();
|
||||
assert_eq!(load_wallet(dir).await.unwrap().balance(), 1000);
|
||||
|
||||
// Switching to testnet must show an EMPTY purse pointed at the test
|
||||
// mint — never the real coins.
|
||||
save_network(dir, EcashNetwork::Testnet).await.unwrap();
|
||||
let test_wallet = load_wallet(dir).await.unwrap();
|
||||
assert_eq!(test_wallet.balance(), 0, "test wallet must not see real coins");
|
||||
assert!(test_wallet.mint_url.contains("testnut"));
|
||||
assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut"));
|
||||
|
||||
// Test coins are written to their own file...
|
||||
let mut t = test_wallet;
|
||||
t.proofs.push(StoredProof {
|
||||
proof: Proof {
|
||||
amount: 7,
|
||||
id: "009a1f293253e41e".into(),
|
||||
secret: "test".into(),
|
||||
c: "02".into(),
|
||||
},
|
||||
mint_url: EcashNetwork::Testnet.default_mint(),
|
||||
spent: false,
|
||||
reserved: false,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
save_wallet(dir, &t).await.unwrap();
|
||||
assert!(dir.join("wallet/ecash.testnet.json").exists());
|
||||
|
||||
// ...and switching back finds the real balance exactly as it was.
|
||||
save_network(dir, EcashNetwork::Mainnet).await.unwrap();
|
||||
let back = load_wallet(dir).await.unwrap();
|
||||
assert_eq!(back.balance(), 1000, "real funds must survive a round trip");
|
||||
assert_eq!(back.proofs.len(), 1);
|
||||
assert_eq!(back.proofs[0].proof.secret, "real");
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("wallet")).unwrap();
|
||||
|
||||
// A truncated file — what a crash mid-write used to leave behind.
|
||||
let damaged = r#"{"proofs":[{"amount":1000,"id":"009a1f293253e41e","secr"#;
|
||||
let path = dir.join("wallet/ecash.json");
|
||||
std::fs::write(&path, damaged).unwrap();
|
||||
|
||||
// It must NOT read as an empty wallet: that is what caused the real
|
||||
// balance to be overwritten with nothing on the next save.
|
||||
let err = load_wallet(dir).await.expect_err("damaged wallet must error");
|
||||
assert!(
|
||||
err.to_string().contains("damaged"),
|
||||
"error should name the problem: {err}"
|
||||
);
|
||||
|
||||
// And the bytes must still be there for recovery.
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_wallet_file_is_treated_as_a_fresh_wallet() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("wallet")).unwrap();
|
||||
std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap();
|
||||
// A create that never got its first write is not damage.
|
||||
let w = load_wallet(dir).await.expect("empty file is a fresh wallet");
|
||||
assert_eq!(w.balance(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_leaves_no_temp_file_and_round_trips() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
let mut w = load_wallet(dir).await.unwrap();
|
||||
w.proofs.push(StoredProof {
|
||||
proof: Proof {
|
||||
amount: 21,
|
||||
id: "009a1f293253e41e".into(),
|
||||
secret: "s".into(),
|
||||
c: "02".into(),
|
||||
},
|
||||
mint_url: default_mint_url(),
|
||||
spent: false,
|
||||
reserved: false,
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
});
|
||||
save_wallet(dir, &w).await.unwrap();
|
||||
|
||||
assert_eq!(load_wallet(dir).await.unwrap().balance(), 21);
|
||||
// The atomic write must not litter, or the next reader could find it.
|
||||
assert!(!dir.join("wallet/ecash.json.tmp").exists());
|
||||
}
|
||||
|
||||
/// The exact shape a pre-update node has on disk, parsed by the current
|
||||
/// code. Guards the on-disk contract: an update must never strand funds.
|
||||
#[tokio::test]
|
||||
async fn a_pre_update_wallet_file_still_loads_with_its_balance() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("wallet")).unwrap();
|
||||
// Verbatim shape from a live node (proof fields flattened, capital C,
|
||||
// spent/reserved flags, RFC-3339 created_at, lowercase tx type).
|
||||
let legacy = r#"{
|
||||
"proofs": [
|
||||
{"amount": 2, "id": "00107937db0cc865", "secret": "9b4bdb0e", "C": "030391",
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin", "spent": true,
|
||||
"reserved": false, "created_at": "2026-07-23T19:49:42.468773802+00:00"},
|
||||
{"amount": 512, "id": "00107937db0cc865", "secret": "aa11", "C": "0322",
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin", "spent": false,
|
||||
"reserved": false, "created_at": "2026-07-23T19:49:42.468773802+00:00"}
|
||||
],
|
||||
"transactions": [
|
||||
{"id": "8f14e45f", "tx_type": "receive", "amount_sats": 512,
|
||||
"timestamp": "2026-07-23T19:49:42+00:00", "description": "",
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin", "peer": ""}
|
||||
],
|
||||
"mint_url": "https://mint.minibits.cash/Bitcoin"
|
||||
}"#;
|
||||
std::fs::write(dir.join("wallet/ecash.json"), legacy).unwrap();
|
||||
|
||||
let w = load_wallet(dir).await.expect("pre-update wallet must load");
|
||||
assert_eq!(w.balance(), 512, "spendable balance must survive an update");
|
||||
assert_eq!(w.proofs.len(), 2, "spent proofs are retained too");
|
||||
assert_eq!(w.transactions.len(), 1);
|
||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
|
||||
use super::bdhke;
|
||||
use super::cashu::{
|
||||
amount_to_denominations, BlindSignature, BlindedMessageRequest, CashuToken, MintKeyset, Proof,
|
||||
amount_to_denominations, is_truncated_v2_keyset_id, BlindSignature, BlindedMessageRequest,
|
||||
CashuToken, KeysetInfo, MintKeyset, Proof,
|
||||
};
|
||||
use super::nut13::RecoverySource;
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Default timeout for mint API calls.
|
||||
const MINT_TIMEOUT_SECS: u64 = 10;
|
||||
@@ -129,10 +132,19 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
|
||||
pub struct MintClient {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
/// NUT-13 output source. When set, every proof this client creates has a
|
||||
/// secret derived from the wallet's phrase and is therefore restorable;
|
||||
/// when absent, secrets are random and live only in `wallet/ecash.json`.
|
||||
recovery: Option<RecoverySource>,
|
||||
}
|
||||
|
||||
impl MintClient {
|
||||
/// Create a new mint client for the given mint URL.
|
||||
///
|
||||
/// Proofs minted through a client built this way are **not** recoverable
|
||||
/// from the wallet phrase. Prefer `ecash::mint_client`, which attaches the
|
||||
/// NUT-13 source; this stays for callers with no data directory (probes,
|
||||
/// keyset lookups, tests).
|
||||
pub fn new(mint_url: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
|
||||
@@ -142,6 +154,7 @@ impl MintClient {
|
||||
Ok(Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
recovery: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,13 +163,67 @@ impl MintClient {
|
||||
Self {
|
||||
url: mint_url.trim_end_matches('/').to_string(),
|
||||
client,
|
||||
recovery: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive this client's blinded outputs from the wallet's NUT-13 phrase,
|
||||
/// so the proofs it creates can be restored from those words.
|
||||
pub fn with_recovery(mut self, recovery: Option<RecoverySource>) -> Self {
|
||||
self.recovery = recovery;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Build the blinded messages for a batch of output amounts, together with
|
||||
/// the `(secret, blinding factor, amount)` needed to unblind the mint's
|
||||
/// signatures afterwards.
|
||||
///
|
||||
/// Prefers NUT-13 derivation so the resulting proofs are restorable. Falls
|
||||
/// back to random secrets when this wallet has no phrase yet, or when the
|
||||
/// keyset id is one NUT-13 cannot address — a random secret still mints a
|
||||
/// perfectly valid, spendable proof, so refusing here would break the
|
||||
/// wallet to protect a backup that does not exist.
|
||||
async fn blinded_outputs(
|
||||
&self,
|
||||
keyset_id: &str,
|
||||
amounts: &[u64],
|
||||
) -> Result<(Vec<BlindedMessageRequest>, Vec<(Vec<u8>, secp256k1::SecretKey, u64)>)> {
|
||||
let derived = match &self.recovery {
|
||||
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
|
||||
Ok(pairs) => Some(pairs),
|
||||
Err(e) => {
|
||||
warn!("Minting unrecoverable proofs — NUT-13 derivation failed: {e:#}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut blinded_messages = Vec::with_capacity(amounts.len());
|
||||
let mut blinding_data = Vec::with_capacity(amounts.len());
|
||||
|
||||
for (i, &amount) in amounts.iter().enumerate() {
|
||||
let (secret, r) = match &derived {
|
||||
Some(pairs) => pairs[i].clone(),
|
||||
None => (bdhke::generate_secret(), bdhke::random_blinding_factor()),
|
||||
};
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount,
|
||||
id: keyset_id.to_string(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, amount));
|
||||
}
|
||||
|
||||
Ok((blinded_messages, blinding_data))
|
||||
}
|
||||
|
||||
// ── Keyset discovery (NUT-01, NUT-02) ──
|
||||
|
||||
/// Fetch the active keyset from the mint.
|
||||
@@ -184,16 +251,78 @@ impl MintClient {
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// List the mint's keysets (NUT-02 `GET /v1/keysets`) — ids and status
|
||||
/// only, no public keys. Unlike `/v1/keys` this includes *inactive*
|
||||
/// keysets, which a received token may well reference: coins from a
|
||||
/// retired keyset stay spendable.
|
||||
pub async fn get_keysets(&self) -> Result<Vec<KeysetInfo>> {
|
||||
let url = format!("{}/v1/keysets", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch mint keysets")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keysets request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?;
|
||||
let keysets: Vec<KeysetInfo> = serde_json::from_value(
|
||||
body.get("keysets")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse keyset list")?;
|
||||
Ok(keysets)
|
||||
}
|
||||
|
||||
/// Fetch one keyset's public keys by id (NUT-01 `GET /v1/keys/{id}`).
|
||||
///
|
||||
/// `/v1/keys` returns only what the mint will still *sign* with, but a
|
||||
/// restore has to unblind signatures made by keysets that have since been
|
||||
/// retired — those coins are still spendable, and skipping their keysets
|
||||
/// would quietly leave money behind.
|
||||
pub async fn get_keyset(&self, keyset_id: &str) -> Result<MintKeyset> {
|
||||
let url = format!("{}/v1/keys/{}", self.url, keyset_id);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch a mint keyset")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keyset request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keyset")?;
|
||||
let keysets: Vec<MintKeyset> = serde_json::from_value(
|
||||
body.get("keysets")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse keyset")?;
|
||||
keysets
|
||||
.into_iter()
|
||||
.find(|k| k.id == keyset_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Mint did not return keyset {keyset_id}"))
|
||||
}
|
||||
|
||||
/// Get the active keyset for the "sat" unit.
|
||||
pub async fn get_active_sat_keyset(&self) -> Result<MintKeyset> {
|
||||
let keysets = self.get_keys().await?;
|
||||
// Must be a *sat* keyset, not merely the first one with keys. A
|
||||
// multi-unit mint answers /v1/keys with usd/eur/msat keysets too, and
|
||||
// whichever came first would then sign sat-denominated requests —
|
||||
// the mint rejects that with `11013 Unit unsupported` (seen against
|
||||
// testnut.cashu.space, 2026-08-17). Sat-only mints omit the field
|
||||
// entirely and default to "sat", so this stays correct for them.
|
||||
keysets
|
||||
.into_iter()
|
||||
.find(|k| {
|
||||
// Find active sat keyset — check keys map is non-empty
|
||||
!k.keys.is_empty()
|
||||
.filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat"))
|
||||
// Prefer a keyset the mint will still sign with.
|
||||
.max_by_key(|k| k.active)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No active sat keyset found at mint {}", self.url)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No active keyset found at mint {}", self.url))
|
||||
}
|
||||
|
||||
// ── Mint quotes (NUT-04) ──
|
||||
@@ -243,21 +372,8 @@ impl MintClient {
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
let denominations = amount_to_denominations(amount);
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new(); // (secret, blinding_factor, amount)
|
||||
|
||||
for &denom in &denominations {
|
||||
let secret = bdhke::generate_secret();
|
||||
let r = bdhke::random_blinding_factor();
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount: denom,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, denom));
|
||||
}
|
||||
let (blinded_messages, blinding_data) =
|
||||
self.blinded_outputs(&keyset.id, &denominations).await?;
|
||||
|
||||
let url = format!("{}/v1/mint/bolt11", self.url);
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -372,21 +488,37 @@ impl MintClient {
|
||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||
let keyset = self.get_active_sat_keyset().await?;
|
||||
|
||||
let mut blinded_messages = Vec::new();
|
||||
let mut blinding_data = Vec::new();
|
||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||
// outright unless outputs == inputs - fee (`11005 Transaction inputs
|
||||
// should equal outputs less fee`). Applied here rather than at each
|
||||
// call site so send, receive and cross-mint swaps are all covered.
|
||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
||||
let fee = match self.get_keysets().await {
|
||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
||||
Err(e) => {
|
||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
||||
0
|
||||
}
|
||||
};
|
||||
let spendable = inputs_total.saturating_sub(fee);
|
||||
let requested: u64 = target_amounts.iter().sum();
|
||||
let owned_targets: Vec<u64>;
|
||||
let target_amounts: &[u64] = if requested > spendable {
|
||||
if spendable == 0 {
|
||||
anyhow::bail!(
|
||||
"The mint's fee ({fee} sat) consumes this whole amount — nothing would be left"
|
||||
);
|
||||
}
|
||||
debug!("Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee");
|
||||
owned_targets = amount_to_denominations(spendable);
|
||||
&owned_targets
|
||||
} else {
|
||||
target_amounts
|
||||
};
|
||||
|
||||
for &amount in target_amounts {
|
||||
let secret = bdhke::generate_secret();
|
||||
let r = bdhke::random_blinding_factor();
|
||||
let blinded = bdhke::blind_message(&secret, &r)?;
|
||||
|
||||
blinded_messages.push(BlindedMessageRequest {
|
||||
amount,
|
||||
id: keyset.id.clone(),
|
||||
b_prime: hex::encode(blinded.b_prime.serialize()),
|
||||
});
|
||||
blinding_data.push((secret, r, amount));
|
||||
}
|
||||
let (blinded_messages, blinding_data) =
|
||||
self.blinded_outputs(&keyset.id, target_amounts).await?;
|
||||
|
||||
let url = format!("{}/v1/swap", self.url);
|
||||
let res = self
|
||||
@@ -481,8 +613,151 @@ impl MintClient {
|
||||
Ok(states)
|
||||
}
|
||||
|
||||
// ── Restore (NUT-09) ──
|
||||
|
||||
/// Ask the mint which of a batch of blinded messages it has signed before,
|
||||
/// and hand back its signatures for those.
|
||||
///
|
||||
/// This is the half of the backup story the mint owns. A NUT-13 phrase can
|
||||
/// re-derive every secret this wallet ever used, but not the mint's
|
||||
/// signature over them — without that a re-derived secret is not yet money.
|
||||
/// `/v1/restore` closes the gap: send the blinded messages again, get back
|
||||
/// the signatures the mint already issued, unblind, and the proofs exist
|
||||
/// again.
|
||||
///
|
||||
/// The response echoes the subset of `outputs` it recognised alongside the
|
||||
/// matching `signatures`, so the caller matches on `B_` rather than
|
||||
/// assuming positions line up — mints are free to return fewer, and
|
||||
/// assuming otherwise would pair a signature with the wrong secret and
|
||||
/// silently produce unspendable proofs.
|
||||
pub async fn restore(
|
||||
&self,
|
||||
outputs: &[BlindedMessageRequest],
|
||||
) -> Result<Vec<(String, BlindSignature)>> {
|
||||
if outputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let url = format!("{}/v1/restore", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "outputs": outputs }))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to ask the mint to restore outputs")?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(mint_error("Restore", status, &body));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = res
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse the mint's restore response")?;
|
||||
|
||||
let echoed: Vec<BlindedMessageRequest> = serde_json::from_value(
|
||||
body.get("outputs")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse restored outputs")?;
|
||||
let signatures: Vec<BlindSignature> = serde_json::from_value(
|
||||
body.get("signatures")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!([])),
|
||||
)
|
||||
.context("Failed to parse restored signatures")?;
|
||||
|
||||
if echoed.len() != signatures.len() {
|
||||
anyhow::bail!(
|
||||
"Mint restored {} outputs but {} signatures — refusing to pair them",
|
||||
echoed.len(),
|
||||
signatures.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(echoed
|
||||
.into_iter()
|
||||
.map(|o| o.b_prime)
|
||||
.zip(signatures)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Receive a CashuToken by swapping its proofs for fresh ones.
|
||||
/// This prevents double-spend and ensures only we can spend the new proofs.
|
||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||
///
|
||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||
/// wallets written against the original 8-byte format truncate it when
|
||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
||||
/// bytes, and rejects the swap — reported as
|
||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||
/// a Minibits-issued token, 2026-08-17).
|
||||
///
|
||||
/// The id only names which keyset signed the proof, so restoring the full
|
||||
/// id the mint advertises is exactly what the sender meant. It is also
|
||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
||||
/// verification at the mint and no coins move. Anything already valid, or
|
||||
/// with no unambiguous match, is passed through untouched so the mint's
|
||||
/// own error is what the operator sees.
|
||||
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||
if !needs_repair {
|
||||
return proofs.to_vec();
|
||||
}
|
||||
|
||||
// The mint's own keyset list, in the reference implementation's shape
|
||||
// so its NUT-02 resolver can consume it directly.
|
||||
let known = match self.get_cdk_keysets().await {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
||||
return proofs.to_vec();
|
||||
}
|
||||
};
|
||||
|
||||
proofs
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut p| {
|
||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
||||
p.id = full;
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The mint's keysets as upstream `KeySetInfo`, for NUT-02 id resolution.
|
||||
async fn get_cdk_keysets(&self) -> Result<Vec<cashu::nuts::nut02::KeySetInfo>> {
|
||||
let url = format!("{}/v1/keysets", self.url);
|
||||
let res = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch mint keysets")?;
|
||||
if !res.status().is_success() {
|
||||
anyhow::bail!("Mint keysets request failed: {}", res.status());
|
||||
}
|
||||
let body: serde_json::Value = res.json().await.context("Failed to parse mint keysets")?;
|
||||
// Deserialize per-entry and keep what parses: a mint may advertise a
|
||||
// keyset in a unit or format this build doesn't model, and one such
|
||||
// entry must not block resolving the id we actually need.
|
||||
let list = body
|
||||
.get("keysets")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(list
|
||||
.into_iter()
|
||||
.filter_map(|v| serde_json::from_value::<cashu::nuts::nut02::KeySetInfo>(v).ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn receive_token(&self, token: &CashuToken) -> Result<Vec<Proof>> {
|
||||
let mut all_new_proofs = Vec::new();
|
||||
|
||||
@@ -498,7 +773,8 @@ impl MintClient {
|
||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||
let target_amounts = amount_to_denominations(total);
|
||||
|
||||
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
||||
let result = self.swap(&proofs, &target_amounts).await?;
|
||||
all_new_proofs.extend(result.new_proofs);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ pub mod cashu;
|
||||
pub mod ecash;
|
||||
pub mod fedimint_client;
|
||||
pub mod mint_client;
|
||||
pub mod nut13;
|
||||
pub mod profits;
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
//! NUT-13 deterministic secrets — what makes the ecash wallet restorable.
|
||||
//!
|
||||
//! Until this module existed, every Cashu proof this node held was backed by a
|
||||
//! secret drawn from `OsRng` and written to exactly one file. Losing
|
||||
//! `wallet/ecash.json` lost the coins outright: there was no phrase to write
|
||||
//! down, and no amount of talking to the mint could reconstruct them. Ecash is
|
||||
//! a bearer instrument, so "one file, no backup" was the sharpest edge in the
|
||||
//! wallet.
|
||||
//!
|
||||
//! [NUT-13] fixes that by deriving each proof's secret and blinding factor
|
||||
//! from `(wallet seed, keyset id, counter)` instead of from randomness. The
|
||||
//! wallet is then a *phrase*, and the coins can be re-derived and re-claimed
|
||||
//! from the mint — here, or in any other NUT-13 wallet.
|
||||
//!
|
||||
//! Three pieces live here:
|
||||
//!
|
||||
//! - **The wallet seed** (`wallet/cashu_seed.json`) — a 24-word BIP-39
|
||||
//! mnemonic derived from the node's master seed, so the node's own recovery
|
||||
//! phrase already covers the ecash. See [`crate::seed::derive_cashu_mnemonic`]
|
||||
//! for why it is a *separate* phrase rather than the node's own.
|
||||
//! - **The counters** (`wallet/cashu_counters.json`) — the next unused counter
|
||||
//! per keyset. Recovery metadata, not funds: losing it costs a restore scan,
|
||||
//! never coins.
|
||||
//! - **The derivation itself** — delegated to the reference implementation, so
|
||||
//! the secrets a third-party wallet re-derives from these words are the same
|
||||
//! ones we did.
|
||||
//!
|
||||
//! ## Why the seed sits on disk in the clear
|
||||
//!
|
||||
//! The node's master seed is encrypted at rest and needs the operator's
|
||||
//! password to open, which no background mint/swap can ask for. This file is
|
||||
//! not encrypted, and that is deliberate: it lives in the same directory as
|
||||
//! `wallet/ecash.json`, which already holds spendable bearer secrets in
|
||||
//! plaintext. A NUT-13 seed regenerates exactly those same secrets, so it is
|
||||
//! the same sensitivity class as the file beside it — encrypting one and not
|
||||
//! the other would buy nothing. It is written 0600, matching
|
||||
//! `identity/nostr_secret`, which is derived and persisted the same way.
|
||||
//!
|
||||
//! [NUT-13]: https://github.com/cashubtc/nuts/blob/main/13.md
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin::secp256k1::SecretKey;
|
||||
use cashu::nuts::nut01::SecretKey as CdkSecretKey;
|
||||
use cashu::nuts::nut02::Id as CdkId;
|
||||
use cashu::secret::Secret as CdkSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// The wallet's BIP-39 phrase. One file for both networks: NUT-13 derivation
|
||||
/// is keyed by keyset id, and a testnet mint's keysets never collide with a
|
||||
/// real mint's, so the two purses cannot derive each other's secrets.
|
||||
const SEED_FILE: &str = "wallet/cashu_seed.json";
|
||||
/// Next-unused counter per keyset.
|
||||
const COUNTER_FILE: &str = "wallet/cashu_counters.json";
|
||||
|
||||
/// Serialises counter reservation within this process. Reservation is a
|
||||
/// read-modify-write of one small file, and two concurrent mints handing out
|
||||
/// the same counter would mean two proofs with the same secret — the mint
|
||||
/// signs both and only one is ever spendable.
|
||||
static COUNTER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// On-disk shape of `wallet/cashu_seed.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct StoredSeed {
|
||||
/// The 24-word BIP-39 phrase.
|
||||
mnemonic: String,
|
||||
/// How this wallet got its phrase — see [`SeedSource`].
|
||||
#[serde(default)]
|
||||
source: SeedSource,
|
||||
/// When it was first written, for the operator's benefit.
|
||||
#[serde(default)]
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
/// Where an ecash wallet's phrase came from, which decides what restoring the
|
||||
/// *node* gets you back.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum SeedSource {
|
||||
/// Derived from the node's master seed. The node's 24 words restore this
|
||||
/// ecash wallet too — nothing extra to write down.
|
||||
#[default]
|
||||
NodeSeed,
|
||||
/// Generated independently of the node seed. Still a perfectly good
|
||||
/// NUT-13 wallet, but restoring the node from its recovery phrase will
|
||||
/// *not* bring it back — only these words will.
|
||||
Independent,
|
||||
}
|
||||
|
||||
/// A loaded ecash wallet seed, ready to derive secrets from.
|
||||
#[derive(Clone)]
|
||||
pub struct EcashSeed {
|
||||
/// BIP-39 seed bytes — the NUT-13 input.
|
||||
seed: [u8; 64],
|
||||
mnemonic: bip39::Mnemonic,
|
||||
source: SeedSource,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EcashSeed {
|
||||
/// Never let the phrase or the seed bytes reach a log line.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EcashSeed")
|
||||
.field("source", &self.source)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl EcashSeed {
|
||||
fn from_mnemonic(mnemonic: bip39::Mnemonic, source: SeedSource) -> Self {
|
||||
Self {
|
||||
seed: mnemonic.to_seed(""),
|
||||
mnemonic,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 24 words, for the backup screen. Everything else about this type
|
||||
/// keeps them out of reach.
|
||||
pub fn words(&self) -> Vec<String> {
|
||||
self.mnemonic.words().map(|w| w.to_string()).collect()
|
||||
}
|
||||
|
||||
pub fn source(&self) -> SeedSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
/// Derive the NUT-13 secret and blinding factor for one output.
|
||||
///
|
||||
/// Delegated to the reference implementation rather than reimplemented:
|
||||
/// NUT-13 uses BIP-32 for v1 keyset ids and an HMAC-SHA256 KDF for v2, and
|
||||
/// getting either subtly wrong yields a wallet whose words restore
|
||||
/// *nothing* — a failure that only shows up on the day it matters.
|
||||
pub fn derive_output(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
|
||||
let id = CdkId::from_str(keyset_id)
|
||||
.with_context(|| format!("Keyset id {keyset_id} is not one NUT-13 can derive for"))?;
|
||||
|
||||
let secret = CdkSecret::from_seed(&self.seed, id, counter)
|
||||
.context("NUT-13 secret derivation failed")?;
|
||||
let blinding = CdkSecretKey::from_seed(&self.seed, id, counter)
|
||||
.context("NUT-13 blinding-factor derivation failed")?;
|
||||
let blinding = SecretKey::from_slice(&blinding.to_secret_bytes())
|
||||
.context("NUT-13 produced a blinding factor secp256k1 rejects")?;
|
||||
|
||||
Ok((secret.to_bytes(), blinding))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EcashSeed {
|
||||
fn drop(&mut self) {
|
||||
use zeroize::Zeroize;
|
||||
self.seed.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(SEED_FILE)
|
||||
}
|
||||
|
||||
/// Is this wallet backed by a phrase yet?
|
||||
pub fn seed_exists(data_dir: &Path) -> bool {
|
||||
seed_path(data_dir).exists()
|
||||
}
|
||||
|
||||
/// Load the wallet seed, or `None` if this node has never established one.
|
||||
///
|
||||
/// A *damaged* seed file is an error, not a `None`: silently treating it as
|
||||
/// "no seed" would send the wallet back to unrecoverable random secrets while
|
||||
/// telling the operator their backup was fine.
|
||||
pub async fn load_seed(data_dir: &Path) -> Result<Option<EcashSeed>> {
|
||||
let path = seed_path(data_dir);
|
||||
let Ok(content) = fs::read_to_string(&path).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let stored: StoredSeed = serde_json::from_str(&content)
|
||||
.with_context(|| format!("The ecash seed file is damaged: {}", path.display()))?;
|
||||
let mnemonic: bip39::Mnemonic = stored
|
||||
.mnemonic
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("The stored ecash phrase is not valid BIP-39: {e}"))?;
|
||||
Ok(Some(EcashSeed::from_mnemonic(mnemonic, stored.source)))
|
||||
}
|
||||
|
||||
/// Establish the wallet seed from the node's master seed, writing it if this
|
||||
/// node does not have one yet.
|
||||
///
|
||||
/// Idempotent, and deliberately **never overwrites**: an existing phrase is
|
||||
/// the only thing that can re-derive the proofs already minted under it, so a
|
||||
/// re-derivation that disagreed (a different master seed after a restore from
|
||||
/// different words, say) must not be allowed to replace it. The existing seed
|
||||
/// is returned instead, and the mismatch is logged.
|
||||
pub async fn establish_from_master(
|
||||
data_dir: &Path,
|
||||
master: &crate::seed::MasterSeed,
|
||||
) -> Result<EcashSeed> {
|
||||
let derived = crate::seed::derive_cashu_mnemonic(master)?;
|
||||
|
||||
if let Some(existing) = load_seed(data_dir).await? {
|
||||
if existing.mnemonic != derived {
|
||||
warn!(
|
||||
"The ecash wallet's phrase does not match the one this node's master seed \
|
||||
derives — keeping the existing phrase, because it is what the current \
|
||||
proofs were minted under. Back it up from Settings; the node's own \
|
||||
recovery phrase does not cover this wallet."
|
||||
);
|
||||
}
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
write_seed(data_dir, &derived, SeedSource::NodeSeed).await?;
|
||||
debug!("Established the ecash wallet seed from the node master seed");
|
||||
Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed))
|
||||
}
|
||||
|
||||
/// Write the seed file at 0600, creating the wallet directory if needed.
|
||||
async fn write_seed(
|
||||
data_dir: &Path,
|
||||
mnemonic: &bip39::Mnemonic,
|
||||
source: SeedSource,
|
||||
) -> Result<()> {
|
||||
let path = seed_path(data_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create the wallet directory")?;
|
||||
}
|
||||
let stored = StoredSeed {
|
||||
mnemonic: mnemonic.to_string(),
|
||||
source,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let content =
|
||||
serde_json::to_string_pretty(&stored).context("Failed to serialize the ecash seed")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to write the ecash seed")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.context("Failed to restrict permissions on the ecash seed")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Counters ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// On-disk shape of `wallet/cashu_counters.json`.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct StoredCounters {
|
||||
/// keyset id → next unused counter.
|
||||
#[serde(default)]
|
||||
counters: BTreeMap<String, u32>,
|
||||
}
|
||||
|
||||
/// Reserve `count` consecutive counters for `keyset_id` and return the first.
|
||||
///
|
||||
/// Written to disk **before** the outputs are used, and never rolled back on
|
||||
/// failure. A gap in the sequence costs a restore scan a few extra probes; a
|
||||
/// *reused* counter costs a coin, because two proofs with the same secret can
|
||||
/// only ever be spent once. So the asymmetry is resolved in favour of gaps.
|
||||
pub async fn reserve_counters(data_dir: &Path, keyset_id: &str, count: usize) -> Result<u32> {
|
||||
let _guard = COUNTER_LOCK.lock().await;
|
||||
let path = data_dir.join(COUNTER_FILE);
|
||||
|
||||
let mut state: StoredCounters = match fs::read_to_string(&path).await {
|
||||
Ok(content) if !content.trim().is_empty() => serde_json::from_str(&content)
|
||||
.with_context(|| format!("The ecash counter file is damaged: {}", path.display()))?,
|
||||
_ => StoredCounters::default(),
|
||||
};
|
||||
|
||||
let start = *state.counters.get(keyset_id).unwrap_or(&0);
|
||||
let next = start
|
||||
.checked_add(u32::try_from(count).context("Absurd output count")?)
|
||||
.context("NUT-13 counter space exhausted for this keyset")?;
|
||||
state.counters.insert(keyset_id.to_string(), next);
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create the wallet directory")?;
|
||||
}
|
||||
let content =
|
||||
serde_json::to_string_pretty(&state).context("Failed to serialize ecash counters")?;
|
||||
fs::write(&path, content)
|
||||
.await
|
||||
.context("Failed to persist ecash counters")?;
|
||||
|
||||
Ok(start)
|
||||
}
|
||||
|
||||
/// Read the next-unused counter for a keyset without reserving anything.
|
||||
pub async fn counter_for(data_dir: &Path, keyset_id: &str) -> u32 {
|
||||
let path = data_dir.join(COUNTER_FILE);
|
||||
let Ok(content) = fs::read_to_string(&path).await else {
|
||||
return 0;
|
||||
};
|
||||
serde_json::from_str::<StoredCounters>(&content)
|
||||
.ok()
|
||||
.and_then(|s| s.counters.get(keyset_id).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Move a keyset's counter forward to at least `next`, so a restore that found
|
||||
/// coins beyond the recorded point cannot hand the same counters out again.
|
||||
pub async fn advance_counter_to(data_dir: &Path, keyset_id: &str, next: u32) -> Result<()> {
|
||||
let current = counter_for(data_dir, keyset_id).await;
|
||||
if next > current {
|
||||
reserve_counters(data_dir, keyset_id, (next - current) as usize).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── The source handed to the mint client ───────────────────────────────────
|
||||
|
||||
/// Supplies NUT-13 outputs to [`crate::wallet::mint_client::MintClient`].
|
||||
///
|
||||
/// Holds the data directory as well as the seed because reserving a counter is
|
||||
/// a disk write that has to happen before the outputs are handed out.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecoverySource {
|
||||
seed: EcashSeed,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RecoverySource {
|
||||
/// Build a recovery source for this node, or `None` when the wallet has no
|
||||
/// seed yet. Callers fall back to random secrets in that case, which is
|
||||
/// exactly the pre-NUT-13 behaviour — correct, just not restorable.
|
||||
pub async fn load(data_dir: &Path) -> Option<Self> {
|
||||
match load_seed(data_dir).await {
|
||||
Ok(Some(seed)) => Some(Self {
|
||||
seed,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
}),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("Ecash wallet seed unusable, minting unrecoverable proofs: {e:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve and derive `count` outputs for `keyset_id`.
|
||||
pub async fn next_outputs(
|
||||
&self,
|
||||
keyset_id: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<(Vec<u8>, SecretKey)>> {
|
||||
// Fail the derivation *before* burning counters if this keyset id is
|
||||
// one NUT-13 cannot address.
|
||||
let start = reserve_counters(&self.data_dir, keyset_id, count).await?;
|
||||
(0..count)
|
||||
.map(|i| self.seed.derive_output(keyset_id, start + i as u32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive one output at an explicit counter, without reserving — the
|
||||
/// restore scan's probe, which must be able to re-derive the past.
|
||||
pub fn derive_at(&self, keyset_id: &str, counter: u32) -> Result<(Vec<u8>, SecretKey)> {
|
||||
self.seed.derive_output(keyset_id, counter)
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::seed::MasterSeed;
|
||||
|
||||
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
|
||||
/// A real NUT-02 v1 keyset id (the one in the NUT test vectors).
|
||||
const V1_KEYSET: &str = "009a1f293253e41e";
|
||||
/// A NUT-02 v2 keyset id — 33 bytes, version byte 0x01. The two versions
|
||||
/// take different derivation paths in the spec, so both need covering.
|
||||
const V2_KEYSET: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
||||
|
||||
fn seed() -> EcashSeed {
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
let mnemonic = crate::seed::derive_cashu_mnemonic(&master).unwrap();
|
||||
EcashSeed::from_mnemonic(mnemonic, SeedSource::NodeSeed)
|
||||
}
|
||||
|
||||
/// The whole promise of NUT-13: the same phrase and counter must give back
|
||||
/// the same secret, or a restore finds nothing.
|
||||
#[test]
|
||||
fn the_same_phrase_and_counter_rederive_the_same_output() {
|
||||
let a = seed();
|
||||
let b = seed();
|
||||
for keyset in [V1_KEYSET, V2_KEYSET] {
|
||||
let (s1, r1) = a.derive_output(keyset, 7).unwrap();
|
||||
let (s2, r2) = b.derive_output(keyset, 7).unwrap();
|
||||
assert_eq!(s1, s2, "secret must be reproducible ({keyset})");
|
||||
assert_eq!(
|
||||
r1.secret_bytes(),
|
||||
r2.secret_bytes(),
|
||||
"blinding factor must be reproducible ({keyset})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Different counters — and different keysets — must not collide, or two
|
||||
/// proofs would share a secret and only one could ever be spent.
|
||||
#[test]
|
||||
fn different_counters_and_keysets_give_different_outputs() {
|
||||
let s = seed();
|
||||
let (a, _) = s.derive_output(V1_KEYSET, 0).unwrap();
|
||||
let (b, _) = s.derive_output(V1_KEYSET, 1).unwrap();
|
||||
let (c, _) = s.derive_output(V2_KEYSET, 0).unwrap();
|
||||
assert_ne!(a, b, "counter must separate secrets");
|
||||
assert_ne!(a, c, "keyset must separate secrets");
|
||||
}
|
||||
|
||||
/// The secret must look like the one the rest of the wallet expects: a
|
||||
/// 32-byte value, hex-encoded, carried as ASCII bytes — the same shape
|
||||
/// `bdhke::generate_secret` produces.
|
||||
#[test]
|
||||
fn a_derived_secret_has_the_shape_the_wallet_already_uses() {
|
||||
let (secret, _) = seed().derive_output(V1_KEYSET, 0).unwrap();
|
||||
assert_eq!(secret.len(), 64, "32 bytes, hex-encoded");
|
||||
let text = String::from_utf8(secret).expect("secret must be ASCII hex");
|
||||
assert!(hex::decode(&text).is_ok(), "{text}");
|
||||
}
|
||||
|
||||
/// A truncated v2 id cannot address a keyset, and must fail loudly rather
|
||||
/// than deriving from a prefix that means nothing.
|
||||
#[test]
|
||||
fn an_unaddressable_keyset_id_is_refused() {
|
||||
let err = seed()
|
||||
.derive_output("01fc0ec0e59cd6fa", 0)
|
||||
.expect_err("short v2 id must not derive");
|
||||
assert!(err.to_string().contains("NUT-13"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn counters_are_reserved_in_order_and_never_reused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
|
||||
assert_eq!(reserve_counters(d, V1_KEYSET, 3).await.unwrap(), 0);
|
||||
assert_eq!(reserve_counters(d, V1_KEYSET, 2).await.unwrap(), 3);
|
||||
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
|
||||
|
||||
// A second keyset counts independently.
|
||||
assert_eq!(reserve_counters(d, V2_KEYSET, 1).await.unwrap(), 0);
|
||||
assert_eq!(counter_for(d, V1_KEYSET).await, 5);
|
||||
}
|
||||
|
||||
/// Reservation must survive a process restart — the file is the state.
|
||||
#[tokio::test]
|
||||
async fn reserved_counters_persist_across_reloads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
reserve_counters(d, V1_KEYSET, 4).await.unwrap();
|
||||
// Nothing cached in memory: read it back cold.
|
||||
assert_eq!(counter_for(d, V1_KEYSET).await, 4);
|
||||
assert_eq!(reserve_counters(d, V1_KEYSET, 1).await.unwrap(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn establishing_the_seed_is_idempotent_and_never_overwrites() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
|
||||
assert!(!seed_exists(d));
|
||||
let first = establish_from_master(d, &master).await.unwrap();
|
||||
assert!(seed_exists(d));
|
||||
assert_eq!(first.source(), SeedSource::NodeSeed);
|
||||
|
||||
let second = establish_from_master(d, &master).await.unwrap();
|
||||
assert_eq!(first.words(), second.words());
|
||||
|
||||
// A *different* master seed must not replace the phrase the existing
|
||||
// proofs were minted under.
|
||||
let (other_words, _) = MasterSeed::generate().unwrap();
|
||||
let (_, other_master) =
|
||||
MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
|
||||
let third = establish_from_master(d, &other_master).await.unwrap();
|
||||
assert_eq!(
|
||||
first.words(),
|
||||
third.words(),
|
||||
"an established ecash phrase must never be silently replaced"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_seed_file_is_owner_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
establish_from_master(d, &master).await.unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(seed_path(d)).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only");
|
||||
}
|
||||
}
|
||||
|
||||
/// A damaged seed file must not read back as "this wallet has no backup" —
|
||||
/// that would quietly return the wallet to unrecoverable random secrets.
|
||||
#[tokio::test]
|
||||
async fn a_damaged_seed_file_is_an_error_not_an_absence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
fs::create_dir_all(d.join("wallet")).await.unwrap();
|
||||
fs::write(seed_path(d), "{ truncated").await.unwrap();
|
||||
|
||||
assert!(load_seed(d).await.is_err());
|
||||
assert!(
|
||||
RecoverySource::load(d).await.is_none(),
|
||||
"an unusable seed must not be presented as a working one"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_recovery_source_hands_out_consecutive_outputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let d = dir.path();
|
||||
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
|
||||
establish_from_master(d, &master).await.unwrap();
|
||||
|
||||
let source = RecoverySource::load(d).await.expect("seed was established");
|
||||
let first = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
||||
let second = source.next_outputs(V1_KEYSET, 2).await.unwrap();
|
||||
|
||||
assert_eq!(first.len(), 2);
|
||||
// Counters advanced, so no secret repeats across the two batches.
|
||||
let secrets: std::collections::HashSet<_> = first
|
||||
.iter()
|
||||
.chain(second.iter())
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
assert_eq!(secrets.len(), 4, "counters must not be handed out twice");
|
||||
|
||||
// And the batch is exactly what re-deriving counters 0..4 gives.
|
||||
for (i, (secret, _)) in first.iter().chain(second.iter()).enumerate() {
|
||||
let (expected, _) = source.derive_at(V1_KEYSET, i as u32).unwrap();
|
||||
assert_eq!(secret, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,6 +561,21 @@ pub enum PortAuth {
|
||||
/// manifest to say so means the loopback pin and the daemon takeover
|
||||
/// ship together, atomically, and a stale manifest fails safe.
|
||||
Gated,
|
||||
/// Like `gated` — loopback-pinned app, daemon owns the external
|
||||
/// addresses — but the gate does NOT require the dashboard login.
|
||||
///
|
||||
/// For apps that carry a complete login of their own and are broken by
|
||||
/// an upstream challenge: Gitea (git clients speak basic-auth, not
|
||||
/// cookies), BTCPay (checkout pages must be reachable by anonymous
|
||||
/// payers). The gate still fronts the port — frame-header neutralising,
|
||||
/// the app-down retry page, the Tor upstream — it just lets every
|
||||
/// request through to the app's own authentication. Requires
|
||||
/// `auth_rationale`, exactly like `none`: an unchallenged surface nobody
|
||||
/// can explain is one nobody reviewed. The operator can flip any
|
||||
/// gate-fronted app between `gated` and `open` behaviour at runtime
|
||||
/// (Settings → app → App gate; stored node-side, manifest sets the
|
||||
/// default).
|
||||
Open,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1154,6 +1169,20 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
"ports[{i}].auth_rationale cannot be empty"
|
||||
)));
|
||||
}
|
||||
// `open` serves the app without the gate's login challenge, so it
|
||||
// carries the same burden of proof as `none`.
|
||||
(PortAuth::Open, None) => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}] sets auth: open but no auth_rationale — a port served \
|
||||
without the gate's login must state why (typically: the app \
|
||||
enforces its own authentication)"
|
||||
)));
|
||||
}
|
||||
(PortAuth::Open, Some(rationale)) if rationale.trim().is_empty() => {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].auth_rationale cannot be empty"
|
||||
)));
|
||||
}
|
||||
// A rationale on a gated port means the author wrote an
|
||||
// exemption and did not get one. Silently keeping the port
|
||||
// protected would be safe but misleading, so say so.
|
||||
@@ -1717,6 +1746,12 @@ app:
|
||||
}
|
||||
}
|
||||
exempt.sort();
|
||||
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
|
||||
// loopback-only JSON API whose own generated http password
|
||||
// authenticates every request (added with the phoenixd onboarding,
|
||||
// which did not update this count — exactly the drift this test
|
||||
// exists to catch).
|
||||
//
|
||||
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
|
||||
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
|
||||
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
|
||||
@@ -1727,11 +1762,50 @@ app:
|
||||
// stage timed out that cycle, so the count here lagged at 17.
|
||||
assert_eq!(
|
||||
exempt.len(),
|
||||
25,
|
||||
26,
|
||||
"unauthenticated port set changed — review before updating this count: {exempt:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
|
||||
/// so they are the second unauthenticated-by-the-gate surface and get the
|
||||
/// same review guard as `auth: none`. Each one must be an app that
|
||||
/// enforces a real login of its own.
|
||||
#[test]
|
||||
fn gate_open_ports_are_all_accounted_for() {
|
||||
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
|
||||
let Ok(entries) = std::fs::read_dir(&apps) else {
|
||||
return;
|
||||
};
|
||||
let mut open: Vec<(String, u16)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let manifest = entry.path().join("manifest.yml");
|
||||
if !manifest.is_file() {
|
||||
continue;
|
||||
}
|
||||
let yaml = std::fs::read_to_string(&manifest).expect("manifest readable");
|
||||
let parsed = AppManifest::parse(&yaml).expect("manifest valid");
|
||||
for port in &parsed.app.ports {
|
||||
if port.auth_policy() == PortAuth::Open {
|
||||
open.push((parsed.app.id.clone(), port.host));
|
||||
}
|
||||
}
|
||||
}
|
||||
open.sort();
|
||||
// Gitea 3001 (git clients speak basic-auth, not browser cookies) and
|
||||
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
|
||||
// by anonymous payers). Both enforce their own account login, and an
|
||||
// operator can re-gate either from Settings → Access control.
|
||||
assert_eq!(
|
||||
open,
|
||||
vec![
|
||||
("btcpay-server".to_string(), 23000u16),
|
||||
("gitea".to_string(), 3001u16)
|
||||
],
|
||||
"gate-open port set changed — every entry must be an app with its own login"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
|
||||
// Two different questions, and conflating them caused both gate
|
||||
|
||||
@@ -13,10 +13,30 @@ pub struct Router {
|
||||
}
|
||||
|
||||
impl Router {
|
||||
/// Bounded TCP connect. The OS default connect timeout against an
|
||||
/// unreachable RFC1918 address is ~2 minutes; a router that stayed
|
||||
/// behind when its node moved networks turned every status poll into a
|
||||
/// worker-thread hostage for that long, stalling unrelated RPCs
|
||||
/// (framework-pt, 2026-08-15 — even TOTP codes expired in flight).
|
||||
/// Read/write timeouts bound the session the same way once connected.
|
||||
fn bounded_tcp(host: &str, port: u16) -> Result<TcpStream> {
|
||||
use std::net::ToSocketAddrs;
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let resolved = addr
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("resolve {}", addr))?
|
||||
.next()
|
||||
.with_context(|| format!("no address for {}", addr))?;
|
||||
let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5))
|
||||
.with_context(|| format!("TCP connect to {}", addr))?;
|
||||
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
Ok(tcp)
|
||||
}
|
||||
|
||||
/// Connect to an OpenWrt router via SSH using a private key.
|
||||
pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
|
||||
let tcp = Self::bounded_tcp(host, port)?;
|
||||
|
||||
let mut session = Session::new().context("create SSH session")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
@@ -34,8 +54,7 @@ impl Router {
|
||||
|
||||
/// Connect using a password (fallback for routers not yet provisioned with a key).
|
||||
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
|
||||
let tcp = Self::bounded_tcp(host, port)?;
|
||||
|
||||
let mut session = Session::new().context("create SSH session")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
commit=7b82dfc779f94e068ed4d7b2ada39d6fd8f77dce
|
||||
built_at=2026-08-09T19:43:11Z
|
||||
commit=a4be1b4b7d8e50d16ed602d5f9b3f905a048f9a9
|
||||
built_at=2026-08-14T17:41:17Z
|
||||
base_path=/aiui/
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
.header-overlay-panel[data-v-cce6627c]{background:#000000e0}.picker-enter-active[data-v-cce6627c]{transition:all .2s cubic-bezier(.22,1,.36,1)}.picker-leave-active[data-v-cce6627c]{transition:all .15s ease-in}.picker-enter-from[data-v-cce6627c],.picker-leave-to[data-v-cce6627c]{opacity:0;transform:translateY(-8px)}.context-menu-enter-active[data-v-13d6c372]{transition:all .15s cubic-bezier(.22,1,.36,1)}.context-menu-leave-active[data-v-13d6c372]{transition:all .1s ease-in}.context-menu-enter-from[data-v-13d6c372],.context-menu-leave-to[data-v-13d6c372]{opacity:0;transform:scale(.95)}.settings-modal-enter-active[data-v-c97db749]{transition:opacity .2s ease-out}.settings-modal-enter-active .glass-card[data-v-c97db749]{transition:all .25s cubic-bezier(.22,1,.36,1)}.settings-modal-leave-active[data-v-c97db749]{transition:opacity .15s ease-in}.settings-modal-enter-from[data-v-c97db749],.settings-modal-leave-to[data-v-c97db749]{opacity:0}
|
||||
.header-overlay-panel[data-v-49f33a03]{background:#000000e0}.picker-enter-active[data-v-49f33a03]{transition:all .2s cubic-bezier(.22,1,.36,1)}.picker-leave-active[data-v-49f33a03]{transition:all .15s ease-in}.picker-enter-from[data-v-49f33a03],.picker-leave-to[data-v-49f33a03]{opacity:0;transform:translateY(-8px)}.context-menu-enter-active[data-v-13d6c372]{transition:all .15s cubic-bezier(.22,1,.36,1)}.context-menu-leave-active[data-v-13d6c372]{transition:all .1s ease-in}.context-menu-enter-from[data-v-13d6c372],.context-menu-leave-to[data-v-13d6c372]{opacity:0;transform:scale(.95)}.settings-modal-enter-active[data-v-c97db749]{transition:opacity .2s ease-out}.settings-modal-enter-active .glass-card[data-v-c97db749]{transition:all .25s cubic-bezier(.22,1,.36,1)}.settings-modal-leave-active[data-v-c97db749]{transition:opacity .15s ease-in}.settings-modal-enter-from[data-v-c97db749],.settings-modal-leave-to[data-v-c97db749]{opacity:0}
|
||||
+40
-40
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
import{a as S,D as V,c as r,e as s,E as y,G as g,t as c,F as p,H as h,i as b,g as j,I as B,r as u,k as T,J as U,b as l,n as k}from"./index-8cIrvc8q.js";import{useNostr as E}from"./useNostr-XONW-p_l.js";const F={class:"min-h-screen bg-[#0a0a0a] text-white"},H={class:"sticky top-0 z-10 glass border-b border-white/5"},L={class:"max-w-3xl mx-auto px-4 py-3 flex items-center gap-3"},R={class:"flex-1 min-w-0"},z={class:"text-sm font-semibold text-white/90 truncate"},G={class:"text-xs text-white/40"},P={key:0,class:"flex items-center justify-center h-64"},J={key:1,class:"max-w-3xl mx-auto px-4 py-12 text-center"},Y={class:"text-white/40 text-sm"},q={key:2,class:"max-w-3xl mx-auto px-4 py-6 space-y-4"},K={class:"flex items-center gap-2 mb-2"},O=["textContent"],Z=S({__name:"ConversationViewerPage",setup(Q){const C=B(),{connect:N,fetchNote:A}=E(),v=u(!0),i=u(null),f=u("Shared Conversation"),x=u(null),d=u(null),w=u([]),I=T(()=>d.value?new Date(d.value*1e3).toLocaleDateString("en-US",{year:"numeric",month:"long",day:"numeric"}):"");function D(n){const t=[],o=n.split(`
|
||||
import{a as S,D as V,c as r,e as s,E as y,G as g,t as c,F as p,H as h,i as b,g as j,I as B,r as u,k as T,J as U,b as l,n as k}from"./index-DNCGxUDM.js";import{useNostr as E}from"./useNostr-CNDx2L4S.js";const F={class:"min-h-screen bg-[#0a0a0a] text-white"},H={class:"sticky top-0 z-10 glass border-b border-white/5"},L={class:"max-w-3xl mx-auto px-4 py-3 flex items-center gap-3"},R={class:"flex-1 min-w-0"},z={class:"text-sm font-semibold text-white/90 truncate"},G={class:"text-xs text-white/40"},P={key:0,class:"flex items-center justify-center h-64"},J={key:1,class:"max-w-3xl mx-auto px-4 py-12 text-center"},Y={class:"text-white/40 text-sm"},q={key:2,class:"max-w-3xl mx-auto px-4 py-6 space-y-4"},K={class:"flex items-center gap-2 mb-2"},O=["textContent"],Z=S({__name:"ConversationViewerPage",setup(Q){const C=B(),{connect:N,fetchNote:A}=E(),v=u(!0),i=u(null),f=u("Shared Conversation"),x=u(null),d=u(null),w=u([]),I=T(()=>d.value?new Date(d.value*1e3).toLocaleDateString("en-US",{year:"numeric",month:"long",day:"numeric"}):"");function D(n){const t=[],o=n.split(`
|
||||
`);let e="",a=[];for(const m of o){const _=m.match(/^##?\s*(?:Human|User|You)/),M=m.match(/^##?\s*(?:Assistant|AI|Claude)/);_||M?(e&&a.length>0&&t.push({role:e,content:a.join(`
|
||||
`).trim()}),e=_?"user":"assistant",a=[]):a.push(m)}return e&&a.length>0&&t.push({role:e,content:a.join(`
|
||||
`).trim()}),t.length===0&&n.trim()&&t.push({role:"assistant",content:n.trim()}),t}return V(async()=>{try{const n=C.params.nostrAddr;if(!n){i.value="No Nostr address provided.";return}await N();let t=null;try{const e=atob(n).split(":");e.length>=2&&(t={dTag:e[0],pubkey:e[1]})}catch{}if(t){const o=await A(t.dTag);if(o){const e=o.tags.find(a=>a[0]==="title");e&&(f.value=e[1]),x.value=o.authorName??null,d.value=o.created_at,w.value=D(o.content)}else i.value="Conversation not found on relays."}else i.value="Invalid Nostr address format."}catch(n){i.value=n instanceof Error?n.message:"Failed to load conversation."}finally{v.value=!1}}),(n,t)=>{const o=U("router-link");return l(),r("div",F,[s("header",H,[s("div",L,[y(o,{to:"/",class:"text-white/40 hover:text-white/70 transition-colors"},{default:g(()=>[...t[0]||(t[0]=[s("svg",{class:"w-5 h-5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},[s("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M10 19l-7-7m0 0l7-7m-7 7h18"})],-1)])]),_:1}),s("div",R,[s("h1",z,c(f.value),1),s("p",G,[x.value?(l(),r(p,{key:0},[h("by "+c(x.value),1)],64)):b("",!0),d.value?(l(),r(p,{key:1},[h(" · "+c(I.value),1)],64)):b("",!0)])]),t[1]||(t[1]=s("span",{class:"text-xs px-2 py-1 rounded-full bg-white/5 text-white/40"},"Read-only",-1))])]),v.value?(l(),r("div",P,[...t[2]||(t[2]=[s("div",{class:"w-6 h-6 rounded-full border-2 border-accent/30 border-t-accent animate-spin"},null,-1)])])):i.value?(l(),r("div",J,[s("p",Y,c(i.value),1),y(o,{to:"/",class:"mt-4 inline-block text-accent text-sm hover:underline"},{default:g(()=>[...t[3]||(t[3]=[h(" Go to AIUI ",-1)])]),_:1})])):(l(),r("main",q,[(l(!0),r(p,null,j(w.value,(e,a)=>(l(),r("div",{key:a,class:k(["rounded-xl p-4",e.role==="user"?"bg-white/[0.03] border border-white/5 ml-8":"mr-8"])},[s("div",K,[s("span",{class:k(["text-xs font-bold uppercase tracking-wider",e.role==="user"?"text-accent/70":"text-white/30"])},c(e.role==="user"?"Human":"Assistant"),3)]),s("div",{class:"text-sm text-white/80 leading-relaxed whitespace-pre-wrap break-words",textContent:c(e.content)},null,8,O)],2))),128))])),t[4]||(t[4]=s("footer",{class:"max-w-3xl mx-auto px-4 py-8 text-center"},[s("p",{class:"text-xs text-white/20"}," Shared via AIUI · Powered by Nostr ")],-1))])}}});export{Z as default};
|
||||
@@ -1 +1 @@
|
||||
import{_ as m}from"./FilmDetail.vue_vue_type_script_setup_true_lang-BhKlPG3Y.js";import"./index-8cIrvc8q.js";export{m as default};
|
||||
import{_ as m}from"./FilmDetail.vue_vue_type_script_setup_true_lang-Cgf4f-Ng.js";import"./index-DNCGxUDM.js";export{m as default};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{_ as o}from"./FilmGrid.vue_vue_type_script_setup_true_lang-Dj0SEfcW.js";import"./index-8cIrvc8q.js";import"./useContentImages-7wLVntsF.js";export{o as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{_ as o}from"./FilmGrid.vue_vue_type_script_setup_true_lang-CkIQ4bRp.js";import"./index-DNCGxUDM.js";import"./useContentImages-DdjyABL9.js";export{o as default};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{a as F,b as l,c as r,e as o,n as d,u as a,t as c,f as L,w as S,v as j,F as v,g as m,h as U,i as u,j as E,r as y,k as w,l as z,m as B,p as D}from"./index-8cIrvc8q.js";import{u as G}from"./useContentImages-7wLVntsF.js";const N={class:"h-full flex flex-col"},V={class:"flex items-center justify-between gap-2"},I={class:"flex items-center gap-2 shrink-0"},M={class:"flex flex-wrap gap-1.5"},R=["onClick"],T={class:"flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16"},q={class:"grid grid-cols-2 sm:grid-cols-3 gap-4"},P=["aria-label","onClick"],A={class:"poster-card flex-1 min-h-0"},H={key:0,class:"absolute inset-0 animate-shimmer"},J=["src","alt","onError"],K=["src","alt"],O={key:3,class:"absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none"},Q={class:"absolute bottom-0 left-0 right-0 p-2"},W={class:"text-xs font-semibold text-white/90 leading-tight truncate"},X={class:"flex items-center gap-1 mt-0.5"},Y={key:0,class:"text-xs text-accent font-bold"},Z={key:1,class:"text-xs text-white/40"},ee={class:"absolute top-1.5 right-1.5 flex gap-0.5"},te={key:0,class:"flex items-center justify-center py-12"},ae=F({__name:"FilmGrid",props:{films:{},title:{default:"Recommended Films"}},emits:["selectFilm"],setup(_){const b=_,{isDark:n}=E(),p=y(""),h=y(null),{coverSrc:x,fallbackSrc:k,onError:C,isLoading:f}=G({items:D(b,"films"),id:t=>t.id,existingUrl:t=>t.posterUrl||t.backdropUrl,fetch:t=>B(t.title,t.year).then(s=>s.posterUrl),fallback:t=>z(t.title,t.year)}),$=w(()=>{const t=new Map;for(const s of b.films)for(const e of s.genres)t.set(e,(t.get(e)??0)+1);return[...t.entries()].sort((s,e)=>e[1]-s[1]).slice(0,8).map(([s])=>s)}),g=w(()=>{let t=b.films;if(p.value){const s=p.value.toLowerCase();t=t.filter(e=>e.title.toLowerCase().includes(s)||e.director.toLowerCase().includes(s)||e.cast.some(i=>i.toLowerCase().includes(s)))}return h.value&&(t=t.filter(s=>s.genres.includes(h.value))),t});return(t,s)=>(l(),r("div",N,[o("div",{class:"p-4 space-y-3",style:U(a(n)?"border-bottom: 1px solid rgba(255, 255, 255, 0.08)":"border-bottom: 1px solid rgba(0, 0, 0, 0.06)")},[o("div",V,[o("h3",{class:d(["text-sm font-bold",a(n)?"text-white/90":"text-gray-900"])},c(_.title),3),o("div",I,[o("span",{class:d(["text-xs font-mono",a(n)?"text-white/30":"text-gray-400"])},c(g.value.length)+" films ",3),L(t.$slots,"header-actions")])]),S(o("input",{"onUpdate:modelValue":s[0]||(s[0]=e=>p.value=e),type:"text",placeholder:"Search films...",class:d(["w-full px-3 py-2 rounded-lg text-base outline-none transition-colors",a(n)?"bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10":"bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5"])},null,2),[[j,p.value]]),o("div",M,[(l(!0),r(v,null,m($.value,e=>(l(),r("button",{key:e,class:d(["text-xs px-2 py-1 rounded-md transition-all duration-150",h.value===e?"nav-tab-active":a(n)?"text-white/40 hover:text-white/70 hover:bg-white/5":"text-gray-500 hover:text-gray-800 hover:bg-black/5"]),onClick:i=>h.value=h.value===e?null:e},c(e),11,R))),128))])],4),o("div",T,[o("div",q,[(l(!0),r(v,null,m(g.value,e=>(l(),r("button",{key:e.id,class:"group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105","aria-label":`${e.title} (${e.year})`,onClick:i=>t.$emit("selectFilm",e)},[o("div",A,[o("div",{class:d(["aspect-[2/3] relative w-full overflow-hidden rounded-[10px]",a(x)(e)?"":a(n)?"bg-white/[0.06]":"bg-black/[0.04]"])},[a(f)(e)?(l(),r("div",H)):u("",!0),a(x)(e)?(l(),r("img",{key:1,src:a(x)(e),alt:`${e.title} (${e.year}) directed by ${e.director}`,class:"w-full h-full object-cover transition-transform duration-300 group-hover:scale-110",loading:"lazy",onError:i=>a(C)(e)},null,40,J)):a(f)(e)?u("",!0):(l(),r("img",{key:2,src:a(k)(e),alt:e.title,class:"w-full h-full object-cover"},null,8,K)),a(x)(e)?(l(),r("div",O)):u("",!0),o("div",Q,[o("p",W,c(e.title),1),o("div",X,[e.rating>0?(l(),r("span",Y,"★ "+c(e.rating),1)):u("",!0),e.year>0?(l(),r("span",Z,c(e.year),1)):u("",!0)])]),o("div",ee,[(l(!0),r(v,null,m(e.sources.slice(0,2),i=>(l(),r("span",{key:i.type,class:"text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"},c(i.type),1))),128))])],2)])],8,P))),128))]),g.value.length===0?(l(),r("div",te,[o("p",{class:d(["text-sm",a(n)?"text-white/30":"text-gray-400"])}," No films match your search ",2)])):u("",!0)])]))}});export{ae as _};
|
||||
import{a as F,b as l,c as r,e as o,n as d,u as a,t as c,f as L,w as S,v as j,F as v,g as m,h as U,i as u,j as E,r as y,k as w,l as z,m as B,p as D}from"./index-DNCGxUDM.js";import{u as G}from"./useContentImages-DdjyABL9.js";const N={class:"h-full flex flex-col"},V={class:"flex items-center justify-between gap-2"},I={class:"flex items-center gap-2 shrink-0"},M={class:"flex flex-wrap gap-1.5"},R=["onClick"],T={class:"flex-1 overflow-y-auto custom-scrollbar px-4 pt-4 pb-16"},q={class:"grid grid-cols-2 sm:grid-cols-3 gap-4"},P=["aria-label","onClick"],A={class:"poster-card flex-1 min-h-0"},H={key:0,class:"absolute inset-0 animate-shimmer"},J=["src","alt","onError"],K=["src","alt"],O={key:3,class:"absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none"},Q={class:"absolute bottom-0 left-0 right-0 p-2"},W={class:"text-xs font-semibold text-white/90 leading-tight truncate"},X={class:"flex items-center gap-1 mt-0.5"},Y={key:0,class:"text-xs text-accent font-bold"},Z={key:1,class:"text-xs text-white/40"},ee={class:"absolute top-1.5 right-1.5 flex gap-0.5"},te={key:0,class:"flex items-center justify-center py-12"},ae=F({__name:"FilmGrid",props:{films:{},title:{default:"Recommended Films"}},emits:["selectFilm"],setup(_){const b=_,{isDark:n}=E(),p=y(""),h=y(null),{coverSrc:x,fallbackSrc:k,onError:C,isLoading:f}=G({items:D(b,"films"),id:t=>t.id,existingUrl:t=>t.posterUrl||t.backdropUrl,fetch:t=>B(t.title,t.year).then(s=>s.posterUrl),fallback:t=>z(t.title,t.year)}),$=w(()=>{const t=new Map;for(const s of b.films)for(const e of s.genres)t.set(e,(t.get(e)??0)+1);return[...t.entries()].sort((s,e)=>e[1]-s[1]).slice(0,8).map(([s])=>s)}),g=w(()=>{let t=b.films;if(p.value){const s=p.value.toLowerCase();t=t.filter(e=>e.title.toLowerCase().includes(s)||e.director.toLowerCase().includes(s)||e.cast.some(i=>i.toLowerCase().includes(s)))}return h.value&&(t=t.filter(s=>s.genres.includes(h.value))),t});return(t,s)=>(l(),r("div",N,[o("div",{class:"p-4 space-y-3",style:U(a(n)?"border-bottom: 1px solid rgba(255, 255, 255, 0.08)":"border-bottom: 1px solid rgba(0, 0, 0, 0.06)")},[o("div",V,[o("h3",{class:d(["text-sm font-bold",a(n)?"text-white/90":"text-gray-900"])},c(_.title),3),o("div",I,[o("span",{class:d(["text-xs font-mono",a(n)?"text-white/30":"text-gray-400"])},c(g.value.length)+" films ",3),L(t.$slots,"header-actions")])]),S(o("input",{"onUpdate:modelValue":s[0]||(s[0]=e=>p.value=e),type:"text",placeholder:"Search films...",class:d(["w-full px-3 py-2 rounded-lg text-base outline-none transition-colors",a(n)?"bg-white/5 text-white/80 placeholder:text-white/25 focus:bg-white/10":"bg-black/3 text-gray-800 placeholder:text-gray-400 focus:bg-black/5"])},null,2),[[j,p.value]]),o("div",M,[(l(!0),r(v,null,m($.value,e=>(l(),r("button",{key:e,class:d(["text-xs px-2 py-1 rounded-md transition-all duration-150",h.value===e?"nav-tab-active":a(n)?"text-white/40 hover:text-white/70 hover:bg-white/5":"text-gray-500 hover:text-gray-800 hover:bg-black/5"]),onClick:i=>h.value=h.value===e?null:e},c(e),11,R))),128))])],4),o("div",T,[o("div",q,[(l(!0),r(v,null,m(g.value,e=>(l(),r("button",{key:e.id,class:"group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105","aria-label":`${e.title} (${e.year})`,onClick:i=>t.$emit("selectFilm",e)},[o("div",A,[o("div",{class:d(["aspect-[2/3] relative w-full overflow-hidden rounded-[10px]",a(x)(e)?"":a(n)?"bg-white/[0.06]":"bg-black/[0.04]"])},[a(f)(e)?(l(),r("div",H)):u("",!0),a(x)(e)?(l(),r("img",{key:1,src:a(x)(e),alt:`${e.title} (${e.year}) directed by ${e.director}`,class:"w-full h-full object-cover transition-transform duration-300 group-hover:scale-110",loading:"lazy",onError:i=>a(C)(e)},null,40,J)):a(f)(e)?u("",!0):(l(),r("img",{key:2,src:a(k)(e),alt:e.title,class:"w-full h-full object-cover"},null,8,K)),a(x)(e)?(l(),r("div",O)):u("",!0),o("div",Q,[o("p",W,c(e.title),1),o("div",X,[e.rating>0?(l(),r("span",Y,"★ "+c(e.rating),1)):u("",!0),e.year>0?(l(),r("span",Z,c(e.year),1)):u("",!0)])]),o("div",ee,[(l(!0),r(v,null,m(e.sources.slice(0,2),i=>(l(),r("span",{key:i.type,class:"text-xs px-1 py-0.5 rounded bg-black/60 text-white/70 backdrop-blur-sm"},c(i.type),1))),128))])],2)])],8,P))),128))]),g.value.length===0?(l(),r("div",te,[o("p",{class:d(["text-sm",a(n)?"text-white/30":"text-gray-400"])}," No films match your search ",2)])):u("",!0)])]))}});export{ae as _};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user