Compare commits
81
Commits
@@ -18,7 +18,7 @@ on:
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
- '.gitea/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -19,3 +19,7 @@ local.properties
|
||||
# updates then install over the top without an uninstall. Debug keys are not
|
||||
# secret (well-known password "android"); never commit a real release keystore.
|
||||
!/app/debug.keystore
|
||||
|
||||
# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64)
|
||||
/rust/archy-fips-core/target
|
||||
/app/src/main/jniLibs
|
||||
|
||||
@@ -11,12 +11,17 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 19
|
||||
versionName = "0.4.15"
|
||||
versionCode = 20
|
||||
versionName = "0.5.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only,
|
||||
// matching real handsets. FipsNative.available gates every call, so
|
||||
// the app still runs as a plain companion elsewhere (e.g. x86 emu).
|
||||
ndk { abiFilters += "arm64-v8a" }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -80,6 +85,44 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk
|
||||
// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug`
|
||||
// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk,
|
||||
// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir).
|
||||
// ---------------------------------------------------------------------------
|
||||
val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core")
|
||||
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs")
|
||||
|
||||
tasks.register<Exec>("buildRustArm64") {
|
||||
workingDir = rustCrateDir.asFile
|
||||
inputs.dir(rustCrateDir.dir("src"))
|
||||
inputs.file(rustCrateDir.file("Cargo.toml"))
|
||||
outputs.dir(jniLibsDir)
|
||||
// cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have.
|
||||
val home = System.getProperty("user.home")
|
||||
environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}")
|
||||
if (System.getenv("ANDROID_NDK_HOME") == null) {
|
||||
val sdkNdk = file("$home/Library/Android/sdk/ndk")
|
||||
.listFiles()?.maxByOrNull { it.name }
|
||||
if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath)
|
||||
}
|
||||
commandLine(
|
||||
"cargo", "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"--platform", "26",
|
||||
"-o", jniLibsDir.asFile.absolutePath,
|
||||
"build", "--release",
|
||||
)
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in listOf(
|
||||
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
)
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||
implementation(composeBom)
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".ArchipelagoApp"
|
||||
@@ -39,6 +43,21 @@
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
configured entirely by scanning the node's pairing QR. -->
|
||||
<service
|
||||
android:name=".fips.ArchyVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="mesh-vpn-tunnel" />
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -19,25 +19,35 @@ data class ServerEntry(
|
||||
val port: String = "",
|
||||
val password: String = "",
|
||||
val name: String = "",
|
||||
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
||||
val meshIp: String = "",
|
||||
) {
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
/** 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
|
||||
|
||||
fun toUrl(): String {
|
||||
val scheme = if (useHttps) "https" else "http"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
return "$scheme://${urlHost(address)}$portSuffix"
|
||||
}
|
||||
|
||||
fun toWsUrl(): String {
|
||||
val scheme = if (useHttps) "wss" else "ws"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
return "$scheme://${urlHost(address)}$portSuffix"
|
||||
}
|
||||
|
||||
// name is the trailing field so entries saved before naming existed
|
||||
// (4 fields) still deserialize, with name defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
|
||||
/** Mesh-address UI URL, or null when the node never advertised one. */
|
||||
fun toMeshUrl(): String? =
|
||||
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
||||
|
||||
// name/meshIp are trailing fields so entries saved before they existed
|
||||
// (4/5 fields) still deserialize, defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
|
||||
|
||||
companion object {
|
||||
fun deserialize(raw: String): ServerEntry? {
|
||||
@@ -49,6 +59,7 @@ data class ServerEntry(
|
||||
port = parts.getOrElse(2) { "" },
|
||||
password = parts.getOrElse(3) { "" },
|
||||
name = parts.getOrElse(4) { "" },
|
||||
meshIp = parts.getOrElse(5) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +72,10 @@ class ServerPreferences(private val context: Context) {
|
||||
private val activePortKey = stringPreferencesKey("active_port")
|
||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||
private val activeNameKey = stringPreferencesKey("active_name")
|
||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||
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
|
||||
@@ -72,6 +85,7 @@ class ServerPreferences(private val context: Context) {
|
||||
port = prefs[activePortKey] ?: "",
|
||||
password = prefs[activePasswordKey] ?: "",
|
||||
name = prefs[activeNameKey] ?: "",
|
||||
meshIp = prefs[activeMeshIpKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +98,11 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[gestureHintSeenKey] ?: false
|
||||
}
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[activeAddressKey] = server.address
|
||||
@@ -91,6 +110,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[activePortKey] = server.port
|
||||
prefs[activePasswordKey] = server.password
|
||||
prefs[activeNameKey] = server.name
|
||||
prefs[activeMeshIpKey] = server.meshIp
|
||||
}
|
||||
addSavedServer(server)
|
||||
}
|
||||
@@ -102,6 +122,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs.remove(activePortKey)
|
||||
prefs.remove(activePasswordKey)
|
||||
prefs.remove(activeNameKey)
|
||||
prefs.remove(activeMeshIpKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +160,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[activePortKey] = updated.port
|
||||
prefs[activePasswordKey] = updated.password
|
||||
prefs[activeNameKey] = updated.name
|
||||
prefs[activeMeshIpKey] = updated.meshIp
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +184,7 @@ class ServerPreferences(private val context: Context) {
|
||||
merged = server.copy(
|
||||
password = server.password.ifBlank { existing.password },
|
||||
name = server.name.ifBlank { existing.name },
|
||||
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
||||
)
|
||||
}
|
||||
val filtered = current.filterNot { raw ->
|
||||
@@ -197,4 +220,10 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[introSeenKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markGestureHintSeen() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[gestureHintSeenKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import com.archipelago.app.fips.AnchorPeer
|
||||
import com.archipelago.app.fips.FipsPairInfo
|
||||
|
||||
/**
|
||||
* Result of parsing a pairing QR / deep link.
|
||||
@@ -10,7 +12,11 @@ import android.net.Uri
|
||||
* user to update the app rather than call the code invalid.
|
||||
*/
|
||||
sealed class PairResult {
|
||||
data class Success(val server: ServerEntry) : PairResult()
|
||||
data class Success(
|
||||
val server: ServerEntry,
|
||||
/** Mesh info when the node advertises FIPS (fnpub present). */
|
||||
val fips: FipsPairInfo? = null,
|
||||
) : PairResult()
|
||||
object UnsupportedVersion : PairResult()
|
||||
object Invalid : PairResult()
|
||||
}
|
||||
@@ -19,13 +25,18 @@ sealed class PairResult {
|
||||
* Parser for the companion pairing QR / OS deep link. Contract:
|
||||
* docs/companion-pairing-qr.md (repo root).
|
||||
*
|
||||
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
|
||||
* archipelago://pair?v=1&url=<origin>&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
|
||||
*
|
||||
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
|
||||
* for the public demo); trailing slashes are normalized away.
|
||||
* - `pw` is only ever present for the public demo.
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1 —
|
||||
* `name` is already honored if present).
|
||||
* - `tok` is a device token minted by the node; it goes through the password
|
||||
* field on purpose — the whole password auto-login path (WebSocket auth +
|
||||
* WebView form injection) then works unchanged, and the backend accepts
|
||||
* device tokens wherever it accepts the password. `pw` (demo only) wins if
|
||||
* both are ever present.
|
||||
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
|
||||
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1).
|
||||
*/
|
||||
object ServerQrParser {
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
@@ -54,14 +65,51 @@ object ServerQrParser {
|
||||
val host = server.host
|
||||
if (host.isNullOrBlank()) return PairResult.Invalid
|
||||
|
||||
val fips = parseFips(uri)
|
||||
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
|
||||
?: uri.getQueryParameter("tok")
|
||||
?: ""
|
||||
|
||||
return PairResult.Success(
|
||||
ServerEntry(
|
||||
server = ServerEntry(
|
||||
address = host,
|
||||
useHttps = scheme == "https",
|
||||
port = if (server.port != -1) server.port.toString() else "",
|
||||
password = uri.getQueryParameter("pw") ?: "",
|
||||
password = credential,
|
||||
name = uri.getQueryParameter("name") ?: "",
|
||||
)
|
||||
meshIp = fips?.ula ?: "",
|
||||
),
|
||||
fips = fips,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||
val host = uri.getQueryParameter("fhost")?.trim()
|
||||
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||
return FipsPairInfo(
|
||||
npub = npub,
|
||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||
host = host,
|
||||
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
|
||||
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
|
||||
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
|
||||
)
|
||||
}
|
||||
|
||||
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
|
||||
private fun parseAnchors(raw: String?): List<AnchorPeer> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return raw.split(",").mapNotNull { item ->
|
||||
val at = item.indexOf('@')
|
||||
val slash = item.lastIndexOf('/')
|
||||
if (at <= 0 || slash <= at) return@mapNotNull null
|
||||
val npub = item.substring(0, at).trim()
|
||||
val addr = item.substring(at + 1, slash).trim()
|
||||
val transport = item.substring(slash + 1).trim().lowercase()
|
||||
if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null
|
||||
if (transport != "udp" && transport != "tcp") return@mapNotNull null
|
||||
AnchorPeer(npub = npub, addr = addr, transport = transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.archipelago.app.MainActivity
|
||||
import com.archipelago.app.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* VpnService hosting the embedded FIPS mesh node.
|
||||
*
|
||||
* Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so
|
||||
* normal phone traffic is untouched — this is mesh reachability, not a
|
||||
* default-route VPN. The established fd is detached and handed to the Rust
|
||||
* node (Node::start_with_tun_fd); the node owns it until stop.
|
||||
*/
|
||||
class ArchyVpnService : VpnService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
shutdown()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
scope.launch { startMesh() }
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private suspend fun startMesh() {
|
||||
if (!FipsNative.available) {
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
val prefs = FipsPreferences(this)
|
||||
val identity = FipsManager.ensureIdentity(prefs)
|
||||
val peersJson = prefs.peersJson()
|
||||
if (identity == null || peersJson == "[]") {
|
||||
Log.w(TAG, "mesh not configured — stopping")
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
|
||||
// Re-establishing while running would strand the old fd; restart clean.
|
||||
if (FipsNative.isRunning()) FipsNative.stop()
|
||||
|
||||
val pfd = try {
|
||||
Builder()
|
||||
.setSession("Archipelago Mesh")
|
||||
.setMtu(1280)
|
||||
.addAddress(identity.address, 128)
|
||||
.addRoute("fd00::", 8)
|
||||
.establish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "VPN establish failed", e)
|
||||
null
|
||||
}
|
||||
if (pfd == null) {
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
|
||||
val fd = pfd.detachFd()
|
||||
val result = FipsNative.start(identity.secret, peersJson, fd)
|
||||
Log.i(TAG, "mesh start: $result")
|
||||
if (result.contains("\"error\"")) shutdown()
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
FipsNative.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
FipsNative.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
// User pulled VPN permission from system settings.
|
||||
shutdown()
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Mesh connection",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply { description = "Keeps the node reachable from anywhere" }
|
||||
)
|
||||
}
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Connected to your Archipelago")
|
||||
.setContentText("Secure mesh link active")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "com.archipelago.app.fips.STOP"
|
||||
private const val CHANNEL_ID = "archy_mesh"
|
||||
private const val NOTIFICATION_ID = 4841
|
||||
private const val TAG = "ArchyVpnService"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Glue between pairing and the mesh: persists the node peer from a scanned
|
||||
* QR and asks the UI to bring the tunnel up. There is deliberately no
|
||||
* settings surface — scanning a node's QR is the entire configuration.
|
||||
*
|
||||
* The one unavoidable interaction is Android's VPN consent dialog
|
||||
* (VpnService.prepare), which only an Activity can launch; [consentNeeded]
|
||||
* signals AppNavHost to run it, once, on first pairing.
|
||||
*/
|
||||
object FipsManager {
|
||||
|
||||
/** Set when a pairing registered mesh info and the VPN needs starting. */
|
||||
private val _consentNeeded = MutableStateFlow(false)
|
||||
val consentNeeded: StateFlow<Boolean> = _consentNeeded
|
||||
|
||||
fun consentHandled() {
|
||||
_consentNeeded.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist mesh info from a pairing scan and request tunnel start.
|
||||
* 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)
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
|
||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||
suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? {
|
||||
prefs.identity()?.let { return it }
|
||||
val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null
|
||||
prefs.saveIdentity(generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service if this device is paired and the user has already
|
||||
* consented to the VPN (prepare() == null). Called on app start so the
|
||||
* tunnel comes back without any interaction; first-time consent goes
|
||||
* 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)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
val intent = Intent(context, ArchyVpnService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val intent = Intent(context, ArchyVpnService::class.java)
|
||||
.setAction(ArchyVpnService.ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core,
|
||||
* built into libarchy_fips_core.so by the buildRustArm64 gradle task).
|
||||
*
|
||||
* All calls return JSON strings; failures come back as {"error": "…"} rather
|
||||
* than exceptions. [available] is false on ABIs the .so isn't built for
|
||||
* (anything but arm64) — every caller must gate on it so the app still runs
|
||||
* as a plain companion there.
|
||||
*/
|
||||
object FipsNative {
|
||||
val available: Boolean = try {
|
||||
System.loadLibrary("archy_fips_core")
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
external fun generateIdentity(): String
|
||||
external fun deriveIdentity(secret: String): String
|
||||
external fun start(secret: String, peersJson: String, tunFd: Int): String
|
||||
external fun stop()
|
||||
external fun isRunning(): Boolean
|
||||
external fun statusJson(): String
|
||||
|
||||
data class Identity(val secret: String, val npub: String, val address: String)
|
||||
|
||||
/** Parse an identity JSON reply; null on {"error": …} or malformed. */
|
||||
fun parseIdentity(json: String): Identity? = try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.has("error")) null
|
||||
else Identity(
|
||||
secret = obj.getString("secret"),
|
||||
npub = obj.getString("npub"),
|
||||
address = obj.getString("address"),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
/**
|
||||
* Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp —
|
||||
* docs/companion-pairing-qr.md). The phone's embedded FIPS node dials
|
||||
* host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers
|
||||
* without registration, so possession of these params is all pairing takes.
|
||||
*/
|
||||
data class FipsPairInfo(
|
||||
val npub: String,
|
||||
/** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */
|
||||
val ula: String,
|
||||
val host: String,
|
||||
val udpPort: Int,
|
||||
val tcpPort: Int,
|
||||
/**
|
||||
* Public rendezvous anchors (the node's seed-anchor list). The phone
|
||||
* peers with these too, so it can route to the node via the mesh when
|
||||
* the node's LAN endpoint isn't directly dialable.
|
||||
*/
|
||||
val anchors: List<AnchorPeer> = emptyList(),
|
||||
)
|
||||
|
||||
/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */
|
||||
data class AnchorPeer(
|
||||
val npub: String,
|
||||
val addr: String,
|
||||
val transport: String,
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
|
||||
|
||||
/**
|
||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||
* storage model as ServerPreferences (the server password lives there the
|
||||
* same way); the mesh secret only grants mesh membership, not node login.
|
||||
*/
|
||||
class FipsPreferences(private val context: Context) {
|
||||
|
||||
private val secretKey = stringPreferencesKey("fips_secret")
|
||||
private val npubKey = stringPreferencesKey("fips_npub")
|
||||
private val addressKey = stringPreferencesKey("fips_address")
|
||||
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
|
||||
private val peersKey = stringPreferencesKey("fips_node_peers")
|
||||
|
||||
suspend fun identity(): FipsNative.Identity? {
|
||||
val prefs = context.fipsDataStore.data.first()
|
||||
val secret = prefs[secretKey] ?: return null
|
||||
return FipsNative.Identity(
|
||||
secret = secret,
|
||||
npub = prefs[npubKey] ?: "",
|
||||
address = prefs[addressKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveIdentity(identity: FipsNative.Identity) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
prefs[secretKey] = identity.secret
|
||||
prefs[npubKey] = identity.npub
|
||||
prefs[addressKey] = identity.address
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun peersJson(): String {
|
||||
val prefs = context.fipsDataStore.data.first()
|
||||
return prefs[peersKey] ?: "[]"
|
||||
}
|
||||
|
||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
||||
|
||||
/**
|
||||
* Add or update the node peer plus its rendezvous anchors (each matched
|
||||
* by npub, so re-pairing updates addresses instead of duplicating).
|
||||
* Stored directly in the fips PeerConfig JSON shape the Rust side
|
||||
* deserializes. The node's direct addresses get the best priorities;
|
||||
* anchors trail so the mesh prefers the direct path when it works.
|
||||
*/
|
||||
suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) {
|
||||
val incoming = mutableListOf<JSONObject>()
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", info.npub)
|
||||
put("alias", alias.ifBlank { "Archipelago" })
|
||||
val addresses = JSONArray()
|
||||
if (info.udpPort > 0) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${info.host}:${info.udpPort}")
|
||||
put("priority", 10)
|
||||
})
|
||||
}
|
||||
if (info.tcpPort > 0) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "tcp")
|
||||
put("addr", "${info.host}:${info.tcpPort}")
|
||||
put("priority", 20)
|
||||
})
|
||||
}
|
||||
put("addresses", addresses)
|
||||
}
|
||||
for (anchor in info.anchors) {
|
||||
if (anchor.npub == info.npub) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", anchor.npub)
|
||||
put("alias", "Mesh anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", anchor.transport)
|
||||
put("addr", anchor.addr)
|
||||
put("priority", 30)
|
||||
}))
|
||||
}
|
||||
}
|
||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
val merged = JSONArray()
|
||||
for (i in 0 until current.length()) {
|
||||
val existing = current.optJSONObject(i) ?: continue
|
||||
if (existing.optString("npub") !in incomingNpubs) merged.put(existing)
|
||||
}
|
||||
incoming.forEach { merged.put(it) }
|
||||
prefs[peersKey] = merged.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
|
||||
@Composable
|
||||
fun GamepadLayout(
|
||||
onKey: (String) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onThreeFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val surface = Neo.surface()
|
||||
@@ -54,9 +54,9 @@ fun GamepadLayout(
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
|
||||
if (a.size < 2) t = 0L
|
||||
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
|
||||
if (a.size < 3) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
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.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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* First-launch teaching overlay for the three-finger hold gesture. Three
|
||||
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
|
||||
* short caption explains what the gesture opens. Dismissed by tapping
|
||||
* anywhere (or automatically after a few seconds) — shown once, ever.
|
||||
*/
|
||||
@Composable
|
||||
fun GestureHintOverlay(onDismiss: () -> Unit) {
|
||||
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
|
||||
LaunchedEffect(Unit) {
|
||||
delay(6500)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "gesture-hint")
|
||||
// Fingertips press down together…
|
||||
val press by transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.86f,
|
||||
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
|
||||
label = "press",
|
||||
)
|
||||
// …while a ring ripples outward on each press cycle.
|
||||
val ripple by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
|
||||
label = "ripple",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.72f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Hand: three fingertip dots in a natural arc + ripple ring.
|
||||
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(150.dp)
|
||||
.scale(0.4f + ripple * 0.6f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
|
||||
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
|
||||
FingerDot(x = 44.dp, y = 8.dp, scale = press)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 48.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.12f))
|
||||
.clickable(onClick = onDismiss)
|
||||
.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_got_it),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
|
||||
Box(
|
||||
Modifier
|
||||
.offset(x = x, y = y)
|
||||
.size(26.dp)
|
||||
.scale(scale)
|
||||
.background(Color.White.copy(alpha = 0.92f), CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ fun NESController(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.twoFingerHold(onMenu)
|
||||
.threeFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Two-finger hold gesture modifier */
|
||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
|
||||
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
var t = 0L; var fired = false
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||
if (a.size < 2) t = 0L
|
||||
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||
if (a.size < 3) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ fun NESPortraitController(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.twoFingerHold(onMenu)
|
||||
.threeFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -87,7 +87,7 @@ fun NESPortraitController(
|
||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||
onClick = { onMouseClick(it) },
|
||||
onScroll = { dy -> onMouseScroll(dy) },
|
||||
onTwoFingerHold = onMenu,
|
||||
onThreeFingerHold = onMenu,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
|
||||
@@ -56,7 +56,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
@@ -82,7 +81,7 @@ import java.util.concurrent.Executors
|
||||
fun QrScannerOverlay(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (ServerEntry) -> Unit,
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -131,7 +130,7 @@ fun QrScannerOverlay(
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result.server)
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
@@ -218,13 +217,20 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||
@Composable
|
||||
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
||||
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).
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -283,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
||||
)
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ fun Trackpad(
|
||||
onMove: (dx: Int, dy: Int) -> Unit,
|
||||
onClick: (button: Int) -> Unit,
|
||||
onScroll: (dy: Int) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onThreeFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fingers by remember { mutableIntStateOf(0) }
|
||||
@@ -53,7 +53,7 @@ fun Trackpad(
|
||||
val t0 = System.currentTimeMillis()
|
||||
var maxPtrs = 1
|
||||
var holdFired = false
|
||||
var twoStart = 0L
|
||||
var threeStart = 0L
|
||||
var scrollAcc = 0f
|
||||
fingers = 1
|
||||
|
||||
@@ -64,19 +64,24 @@ fun Trackpad(
|
||||
fingers = active.size
|
||||
|
||||
when {
|
||||
active.size >= 2 -> {
|
||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
||||
// Three fingers = hold for menu; two = scroll. Kept
|
||||
// on separate counts so a long two-finger scroll can
|
||||
// never fire the menu mid-gesture.
|
||||
active.size >= 3 -> {
|
||||
if (threeStart == 0L) threeStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
|
||||
holdFired = true
|
||||
onTwoFingerHold()
|
||||
onThreeFingerHold()
|
||||
}
|
||||
if (!holdFired) {
|
||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||
scrollAcc += dy
|
||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||
scrollAcc = 0f
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
active.size == 2 -> {
|
||||
threeStart = 0L
|
||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||
scrollAcc += dy
|
||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||
scrollAcc = 0f
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
@@ -99,7 +104,11 @@ fun Trackpad(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (fingers >= 2) "hold for menu" else "",
|
||||
text = when {
|
||||
fingers >= 3 -> "hold for menu"
|
||||
fingers == 2 -> "scroll"
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = muted.copy(alpha = 0.4f),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
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.platform.LocalContext
|
||||
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
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* progress, "not recognised" errors) back in via [status] and closes the
|
||||
* modal through the JS bridge once it accepts a code.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletQrScannerModal(
|
||||
visible: Boolean,
|
||||
status: Pair<String, Boolean>?, // message from the web page + isError
|
||||
onDecoded: (String) -> Unit,
|
||||
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 }
|
||||
|
||||
// Local error from a failed image upload; a fresh web status replaces it.
|
||||
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||
val noQrMessage = stringResource(R.string.no_qr_in_image)
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val decoded = decodeQrFromUri(context, uri)
|
||||
if (decoded != null) {
|
||||
uploadError = null
|
||||
onDecoded(decoded)
|
||||
} else {
|
||||
uploadError = noQrMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
var sample = 1
|
||||
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
while (maxDim / (sample * 2) >= 1600) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
||||
?: return null
|
||||
val pixels = IntArray(bmp.width * bmp.height)
|
||||
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
||||
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
||||
val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||
} catch (_: NotFoundException) {
|
||||
// Light-on-dark QRs (dark-themed wallets) decode inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.archipelago.app.ui.navigation
|
||||
|
||||
import android.net.VpnService
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -16,6 +19,7 @@ import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
@@ -46,6 +50,34 @@ fun AppNavHost(
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
|
||||
// Mesh tunnel: Android's VPN consent dialog is the single unavoidable
|
||||
// interaction — it can only be launched from an Activity, so pairing
|
||||
// paths raise FipsManager.consentNeeded and it is handled here, once.
|
||||
val consentNeeded by FipsManager.consentNeeded.collectAsState()
|
||||
val vpnConsentLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
FipsManager.consentHandled()
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
FipsManager.startService(context)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(consentNeeded) {
|
||||
if (!consentNeeded) return@LaunchedEffect
|
||||
val consentIntent = VpnService.prepare(context)
|
||||
if (consentIntent == null) {
|
||||
FipsManager.consentHandled()
|
||||
FipsManager.startService(context)
|
||||
} else {
|
||||
vpnConsentLauncher.launch(consentIntent)
|
||||
}
|
||||
}
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
@@ -58,6 +90,7 @@ fun AppNavHost(
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
@@ -125,6 +158,7 @@ fun AppNavHost(
|
||||
WebViewScreen(
|
||||
serverUrl = server.toUrl(),
|
||||
serverPassword = server.password,
|
||||
meshFallbackUrl = server.toMeshUrl(),
|
||||
onDisconnect = {
|
||||
scope.launch {
|
||||
prefs.clearActiveServer()
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.network.ConnectionState
|
||||
import com.archipelago.app.network.InputWebSocket
|
||||
import com.archipelago.app.ui.components.NESController
|
||||
@@ -171,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
onThreeFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
@@ -256,10 +257,11 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
QrScannerOverlay(
|
||||
visible = showQrScanner,
|
||||
onDismiss = { showQrScanner = false },
|
||||
onServerScanned = { server ->
|
||||
onServerScanned = { scan ->
|
||||
showQrScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(server)
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,8 +71,10 @@ 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.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -190,12 +192,14 @@ fun ServerConnectScreen(
|
||||
}
|
||||
|
||||
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
|
||||
// (payload carried a password — the demo flow) or land on the password
|
||||
// prompt with everything else filled in (real nodes never embed one).
|
||||
fun onQrScanned(scanned: ServerEntry) {
|
||||
// (payload carried a credential — demo password or a real node's device
|
||||
// token) or land on the password prompt with everything else filled in.
|
||||
// Mesh info (when present) is registered so the FIPS tunnel comes up too.
|
||||
fun onQrScanned(scan: PairResult.Success) {
|
||||
showScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scanned)
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||
prefill(merged)
|
||||
if (merged.password.isNotBlank()) {
|
||||
connect(merged)
|
||||
|
||||
@@ -53,11 +53,14 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -68,13 +71,21 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import android.webkit.ValueCallback
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
@@ -146,9 +157,14 @@ fun WebViewScreen(
|
||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||
// demo flow from docs/companion-pairing-qr.md.
|
||||
serverPassword: String = "",
|
||||
// Node's FIPS mesh URL (http://[fd…]). When the primary address fails on
|
||||
// a main-frame load — typically the phone left the LAN — retry there
|
||||
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||
meshFallbackUrl: String? = null,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadProgress by remember { mutableIntStateOf(0) }
|
||||
var triedMeshFallback by remember { mutableStateOf(false) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
@@ -171,6 +187,39 @@ fun WebViewScreen(
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
|
||||
// bridge; status lines stream back from the page while it's up.
|
||||
var walletScannerVisible by remember { mutableStateOf(false) }
|
||||
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
|
||||
|
||||
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||
// it while DataStore is still loading).
|
||||
val prefs = remember { ServerPreferences(webViewContext) }
|
||||
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||
// ~2 minutes after the kiosk first finishes loading, once the user has
|
||||
// settled in.
|
||||
var gestureHintReady by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isLoading }.first { !it }
|
||||
delay(120_000)
|
||||
gestureHintReady = true
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// <input type="file"> support — without a chooser implementation the
|
||||
// WebView silently ignores file inputs (broke the wallet's upload path).
|
||||
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
|
||||
val fileChooserLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
pendingFileChooser?.onReceiveValue(
|
||||
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
||||
)
|
||||
pendingFileChooser = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
@@ -296,6 +345,35 @@ fun WebViewScreen(
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
// Wallet QR bridge. The web scan modal calls:
|
||||
// window.ArchipelagoQr.open() — show the native scanner
|
||||
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
||||
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||
// Decodes flow back through window.__archyQrResult(text);
|
||||
// a user cancel calls window.__archyQrCancelled().
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun open() {
|
||||
webViewRef.post {
|
||||
walletScannerStatus = null
|
||||
walletScannerVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun setStatus(message: String, isError: Boolean) {
|
||||
webViewRef.post { walletScannerStatus = message to isError }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun close() {
|
||||
webViewRef.post { walletScannerVisible = false }
|
||||
}
|
||||
},
|
||||
"ArchipelagoQr",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
@@ -336,8 +414,13 @@ fun WebViewScreen(
|
||||
)
|
||||
|
||||
// Auto-login with the stored password (QR pairing /
|
||||
// saved server) — only on our own server's pages.
|
||||
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
|
||||
// saved server) — only on our own server's pages
|
||||
// (LAN origin or the mesh address).
|
||||
val onOwnServer = url != null && (
|
||||
url.startsWith(serverUrl) ||
|
||||
(meshFallbackUrl != null && url.startsWith(meshFallbackUrl))
|
||||
)
|
||||
if (serverPassword.isNotBlank() && onOwnServer) {
|
||||
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
|
||||
}
|
||||
}
|
||||
@@ -348,6 +431,15 @@ fun WebViewScreen(
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
if (request?.isForMainFrame == true) {
|
||||
val mesh = meshFallbackUrl
|
||||
if (mesh != null && !triedMeshFallback &&
|
||||
request.url?.toString()?.startsWith(mesh) != true
|
||||
) {
|
||||
triedMeshFallback = true
|
||||
isLoading = true
|
||||
view?.loadUrl(mesh)
|
||||
return
|
||||
}
|
||||
hasError = true
|
||||
isLoading = false
|
||||
}
|
||||
@@ -380,6 +472,8 @@ fun WebViewScreen(
|
||||
val url = request?.url?.toString() ?: return false
|
||||
// Keep kiosk navigation (same origin incl. port) in place
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Mesh address is the same server too
|
||||
if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) return false
|
||||
// Same node (other port) → in-app; external → browser
|
||||
routeOutbound(url)
|
||||
return true
|
||||
@@ -391,6 +485,27 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
view: WebView?,
|
||||
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean {
|
||||
pendingFileChooser?.onReceiveValue(null)
|
||||
pendingFileChooser = filePathCallback
|
||||
val intent = fileChooserParams?.createIntent()
|
||||
if (intent == null) {
|
||||
pendingFileChooser = null
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
fileChooserLauncher.launch(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
pendingFileChooser = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet QR scanner: grant the page camera access.
|
||||
// Only video capture is granted — anything else the
|
||||
// page asks for is denied as before.
|
||||
@@ -446,22 +561,24 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Two-finger hold (500ms) → navigate to remote input
|
||||
var twoFingerStart = 0L
|
||||
var twoFingerFired = false
|
||||
// Three-finger hold (500ms) → navigate to remote input.
|
||||
// Three fingers, not two: two-finger scroll/pinch on the
|
||||
// page collided with the old two-finger hold.
|
||||
var threeFingerStart = 0L
|
||||
var threeFingerFired = false
|
||||
setOnTouchListener { _, event ->
|
||||
val pointerCount = event.pointerCount
|
||||
when (event.actionMasked) {
|
||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
if (pointerCount >= 2) {
|
||||
twoFingerStart = System.currentTimeMillis()
|
||||
twoFingerFired = false
|
||||
if (pointerCount >= 3) {
|
||||
threeFingerStart = System.currentTimeMillis()
|
||||
threeFingerFired = false
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
||||
twoFingerFired = true
|
||||
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||
threeFingerFired = true
|
||||
onRemoteInput()
|
||||
}
|
||||
}
|
||||
@@ -469,8 +586,8 @@ fun WebViewScreen(
|
||||
android.view.MotionEvent.ACTION_UP,
|
||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
if (event.pointerCount <= 2) {
|
||||
twoFingerStart = 0L
|
||||
if (event.pointerCount <= 3) {
|
||||
threeFingerStart = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,6 +623,38 @@ fun WebViewScreen(
|
||||
onClose = { inAppUrl = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
|
||||
WalletQrScannerModal(
|
||||
visible = walletScannerVisible,
|
||||
status = walletScannerStatus,
|
||||
onDecoded = { text ->
|
||||
webView?.evaluateJavascript(
|
||||
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
|
||||
null,
|
||||
)
|
||||
},
|
||||
onDismiss = {
|
||||
walletScannerVisible = false
|
||||
webView?.evaluateJavascript(
|
||||
"window.__archyQrCancelled && window.__archyQrCancelled()",
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// First-launch teaching overlay for the three-finger hold — armed
|
||||
// ~2 minutes after login so it never fights the splash/first look.
|
||||
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
||||
!isLoading && inAppUrl == null
|
||||
) {
|
||||
GestureHintOverlay(
|
||||
onDismiss = {
|
||||
gestureHintDismissed = true
|
||||
scope.launch { prefs.markGestureHintSeen() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,11 @@
|
||||
<string name="edit_server_title">Edit Server</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="gesture_hint_title">Hold with three fingers</string>
|
||||
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
|
||||
<string name="gesture_hint_got_it">Got it</string>
|
||||
<string name="scan_to_send">Scan to send</string>
|
||||
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||
<string name="upload_qr_image">Upload image</string>
|
||||
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||
</resources>
|
||||
|
||||
Generated
+1689
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
# Embedded FIPS mesh node for the Archipelago companion app.
|
||||
#
|
||||
# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task
|
||||
# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so.
|
||||
# Also builds on the host so `cargo test` covers the non-JNI logic.
|
||||
#
|
||||
# `fips` is pinned to the fips-native fork rev that this integration was
|
||||
# developed against — the fork carries Android support upstream lacks
|
||||
# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths).
|
||||
# Override with a local checkout when hacking on fips itself:
|
||||
# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"'
|
||||
[package]
|
||||
name = "archy-fips-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "archy_fips_core"
|
||||
# rlib for host tests; cdylib for the Android shared library.
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
||||
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
|
||||
anyhow = "1.0"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys).
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
|
||||
tracing = "0.1"
|
||||
|
||||
# The JNI surface only exists on Android; host builds skip it and drive the
|
||||
# mesh module directly (tests).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`.
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
paranoid-android = "0.2"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,126 @@
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
|
||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::sys::{jboolean, jint, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use crate::mesh;
|
||||
|
||||
static LOG_INIT: Once = Once::new();
|
||||
|
||||
fn init_logging() {
|
||||
LOG_INIT.call_once(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new("info"))
|
||||
.with(paranoid_android::layer("archy-fips"))
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
fn jstr(env: &mut JNIEnv, s: &JString) -> String {
|
||||
env.get_string(s).map(|s| s.into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn out(env: &JNIEnv, s: String) -> jstring {
|
||||
env.new_string(s)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn err_json(e: impl std::fmt::Display) -> String {
|
||||
serde_json::json!({ "error": e.to_string() }).to_string()
|
||||
}
|
||||
|
||||
fn identity_json(info: &mesh::IdentityInfo) -> String {
|
||||
serde_json::json!({
|
||||
"secret": info.secret_hex,
|
||||
"npub": info.npub,
|
||||
"address": info.address,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun generateIdentity(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let json = match mesh::generate_identity() {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun deriveIdentity(secret: String): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let json = match mesh::derive_identity(&secret) {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
|
||||
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
peers_json: JString,
|
||||
tun_fd: jint,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let peers = jstr(&mut env, &peers_json);
|
||||
let json = match mesh::start(&secret, &peers, tun_fd) {
|
||||
Ok((npub, address)) => {
|
||||
serde_json::json!({ "npub": npub, "address": address }).to_string()
|
||||
}
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun stop()`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) {
|
||||
mesh::stop();
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun isRunning(): Boolean`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jboolean {
|
||||
mesh::is_running() as jboolean
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun statusJson(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
out(&env, mesh::status_json())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Embedded FIPS mesh node for the Archipelago companion app.
|
||||
//!
|
||||
//! The phone runs a real, leaf-only FIPS node in-process: Android's
|
||||
//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic
|
||||
//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`].
|
||||
//! Peering is outbound-only — the pairing QR carries the node's npub and
|
||||
//! transport endpoints, and FIPS nodes accept inbound peers without prior
|
||||
//! registration, so no server-side enrollment step exists.
|
||||
//!
|
||||
//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and
|
||||
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
|
||||
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
||||
|
||||
pub mod mesh;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod jni_glue;
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Mesh lifecycle: identity, config assembly, and the node task.
|
||||
//!
|
||||
//! Host-buildable (no JNI) so the config/identity logic is unit-testable;
|
||||
//! only [`start`] needs a real TUN fd and therefore only runs on-device.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use fips::config::{PeerConfig, TransportInstances, UdpConfig};
|
||||
use fips::{Config, Identity, Node};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// How long `start` waits for the node to come up (TUN attach + transports).
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long `stop` waits for the node task to drain.
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
struct MeshHandle {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
shutdown: Arc<Notify>,
|
||||
running: Arc<AtomicBool>,
|
||||
npub: String,
|
||||
address: String,
|
||||
}
|
||||
|
||||
static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityInfo {
|
||||
pub secret_hex: String,
|
||||
pub npub: String,
|
||||
/// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress.
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh mesh identity from the OS CSPRNG.
|
||||
pub fn generate_identity() -> Result<IdentityInfo> {
|
||||
// ~1 in 2^128 chance a candidate is off the curve; loop regardless.
|
||||
loop {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::getrandom(&mut bytes).context("OS RNG")?;
|
||||
if let Ok(id) = Identity::from_secret_bytes(&bytes) {
|
||||
return Ok(IdentityInfo {
|
||||
secret_hex: hex::encode(bytes),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive npub + ULA from a stored secret.
|
||||
pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
|
||||
let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?;
|
||||
Ok(IdentityInfo {
|
||||
secret_hex: secret.to_string(),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the phone-side node config: leaf-only (never routes third-party
|
||||
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
|
||||
/// TUN enabled but attached to the VpnService fd rather than created.
|
||||
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
|
||||
let mut cfg = Config::default();
|
||||
cfg.node.identity.nsec = Some(secret.to_string());
|
||||
cfg.node.identity.persistent = false;
|
||||
cfg.node.leaf_only = true;
|
||||
cfg.tun.enabled = true;
|
||||
cfg.tun.mtu = Some(1280);
|
||||
cfg.dns.enabled = false;
|
||||
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
|
||||
cfg.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
bind_addr: Some("0.0.0.0:0".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
||||
cfg.peers = peers;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`:
|
||||
/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]`
|
||||
pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
|
||||
serde_json::from_str(peers_json).context("peers JSON")
|
||||
}
|
||||
|
||||
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
|
||||
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
||||
/// Any previously running node is stopped first.
|
||||
pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
|
||||
stop();
|
||||
|
||||
let peers = parse_peers(peers_json)?;
|
||||
let config = build_config(secret, peers);
|
||||
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
|
||||
let npub = node.npub();
|
||||
let address = node.identity().address().to_ipv6().to_string();
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.thread_name("archy-fips")
|
||||
.build()
|
||||
.context("tokio runtime")?;
|
||||
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let running = Arc::new(AtomicBool::new(false));
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>();
|
||||
|
||||
let task = {
|
||||
let shutdown = shutdown.clone();
|
||||
let running = running.clone();
|
||||
runtime.spawn(async move {
|
||||
match node.start_with_tun_fd(tun_fd).await {
|
||||
Ok(()) => {
|
||||
running.store(true, Ordering::SeqCst);
|
||||
let _ = started_tx.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = started_tx.send(Err(anyhow!("node start: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("mesh rx loop error: {e}");
|
||||
}
|
||||
}
|
||||
_ = shutdown.notified() => {}
|
||||
}
|
||||
if let Err(e) = node.stop().await {
|
||||
tracing::warn!("mesh stop: {e}");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
})
|
||||
};
|
||||
|
||||
let started = runtime
|
||||
.block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await })
|
||||
.map_err(|_| anyhow!("node start timed out"))?
|
||||
.map_err(|_| anyhow!("node task died during start"))?;
|
||||
if let Err(e) = started {
|
||||
runtime.shutdown_background();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
*MESH.lock().unwrap() = Some(MeshHandle {
|
||||
runtime,
|
||||
task: Some(task),
|
||||
shutdown,
|
||||
running,
|
||||
npub: npub.clone(),
|
||||
address: address.clone(),
|
||||
});
|
||||
Ok((npub, address))
|
||||
}
|
||||
|
||||
/// Stop the mesh node if running. Idempotent.
|
||||
pub fn stop() {
|
||||
let Some(mut handle) = MESH.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
handle.shutdown.notify_waiters();
|
||||
if let Some(task) = handle.task.take() {
|
||||
let _ = handle
|
||||
.runtime
|
||||
.block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await });
|
||||
}
|
||||
handle.runtime.shutdown_background();
|
||||
}
|
||||
|
||||
pub fn is_running() -> bool {
|
||||
MESH.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|h| h.running.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `{running, npub, address}` for the Kotlin status surface.
|
||||
pub fn status_json() -> String {
|
||||
let guard = MESH.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(h) => serde_json::json!({
|
||||
"running": h.running.load(Ordering::SeqCst),
|
||||
"npub": h.npub,
|
||||
"address": h.address,
|
||||
})
|
||||
.to_string(),
|
||||
None => serde_json::json!({ "running": false }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_roundtrip() {
|
||||
let a = generate_identity().unwrap();
|
||||
let b = derive_identity(&a.secret_hex).unwrap();
|
||||
assert_eq!(a.npub, b.npub);
|
||||
assert_eq!(a.address, b.address);
|
||||
// ULA in fd::/8
|
||||
assert!(a.address.starts_with("fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peers_json_parses_into_peer_config() {
|
||||
let peers = parse_peers(
|
||||
r#"[{
|
||||
"npub": "npub1abc",
|
||||
"alias": "My Archipelago",
|
||||
"addresses": [
|
||||
{"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10},
|
||||
{"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20}
|
||||
]
|
||||
}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].addresses.len(), 2);
|
||||
assert!(peers[0].is_auto_connect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_is_leaf_only_with_tun() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![]);
|
||||
assert!(cfg.node.leaf_only);
|
||||
assert!(cfg.tun.enabled);
|
||||
assert_eq!(cfg.tun.mtu(), 1280);
|
||||
assert!(!cfg.dns.enabled);
|
||||
assert!(!cfg.transports.udp.is_empty());
|
||||
assert!(!cfg.transports.tcp.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.112-alpha (2026-07-22)
|
||||
|
||||
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
|
||||
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
|
||||
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
|
||||
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
|
||||
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
|
||||
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
|
||||
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
|
||||
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
|
||||
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
|
||||
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
|
||||
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
|
||||
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
|
||||
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
|
||||
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
|
||||
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
|
||||
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
|
||||
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
|
||||
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
|
||||
|
||||
## v1.7.111-alpha (2026-07-22)
|
||||
|
||||
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
|
||||
- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.)
|
||||
- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.
|
||||
- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.
|
||||
- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.
|
||||
- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.
|
||||
- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.
|
||||
- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.
|
||||
- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.
|
||||
- On the phone home screen, the wallet card moved up to sit right under My Apps.
|
||||
- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably.
|
||||
|
||||
## v1.7.110-alpha (2026-07-21)
|
||||
|
||||
- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.
|
||||
|
||||
@@ -351,12 +351,12 @@
|
||||
{
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2024.1.0",
|
||||
"version": "2026.7.3",
|
||||
"description": "Open source home automation platform. Control and monitor your smart home devices.",
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -373,8 +373,8 @@
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.1.1",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.",
|
||||
"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.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
|
||||
@@ -35,6 +35,9 @@ app:
|
||||
fi;
|
||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -43,9 +46,9 @@ 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 -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -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
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -35,6 +35,9 @@ app:
|
||||
fi;
|
||||
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||
RPC_CONF="/tmp/rpc.conf";
|
||||
umask 077;
|
||||
{ echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF";
|
||||
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||
@@ -43,9 +46,9 @@ 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 -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -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 -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -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
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
|
||||
@@ -9,13 +9,22 @@ app:
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
|
||||
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
|
||||
# container is actually running (Knots, Core, or any future distro archy
|
||||
# ships), so the gateway is never pinned to one node's container name.
|
||||
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
|
||||
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
derived_env:
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||
|
||||
@@ -21,6 +21,11 @@ app:
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
- key: FM_API_URL
|
||||
template: ws://{{HOST_MDNS}}:8174
|
||||
# Resolves to whichever bitcoin container is running (Knots/Core/future
|
||||
# distro) instead of a hardcoded name — the guardian works on any node
|
||||
# regardless of which Bitcoin software it runs.
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -62,7 +67,8 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
|
||||
# hardcoded name — do not re-add it here.
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
app:
|
||||
id: pine-openwakeword
|
||||
name: Pine Wake Word (openWakeWord)
|
||||
version: "2.1.0"
|
||||
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
|
||||
category: home
|
||||
|
||||
# Hyphen name matches the runtime references (stack member table / startup
|
||||
# order) so the orchestrator adopts a matching running container instead of
|
||||
# recreating it.
|
||||
container_name: pine-openwakeword
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-openwakeword:2.1.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-openwakeword]
|
||||
# The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock
|
||||
# "ok nabu" model; /custom is where a trained custom model (yo_archy)
|
||||
# drops in later — the engine picks up new .tflite files on restart.
|
||||
custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"]
|
||||
|
||||
dependencies:
|
||||
- storage: 512Mi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server
|
||||
# on an unprivileged port needs no added capabilities.
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||
# host.containers.internal:10400 (the Wyoming integration endpoint).
|
||||
- host: 10400
|
||||
container: 10400
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine-openwakeword
|
||||
target: /custom
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:10400
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
metadata:
|
||||
author: Rhasspy / Home Assistant
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming-openwakeword
|
||||
repo: https://github.com/rhasspy/wyoming-openwakeword
|
||||
license: MIT
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- wake-word
|
||||
- wyoming
|
||||
@@ -1,7 +1,11 @@
|
||||
app:
|
||||
id: pine-whisper
|
||||
name: Pine Whisper (STT)
|
||||
version: "3.4.1"
|
||||
# App revision 3.4.2 = upstream wyoming-whisper 3.4.1 image + tuned args
|
||||
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
|
||||
# pick up the args change; the pre-release form "3.4.1-1" would compare
|
||||
# LOWER than 3.4.1 under semver and never roll out.
|
||||
version: "3.4.2"
|
||||
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
|
||||
category: home
|
||||
|
||||
@@ -18,7 +22,11 @@ app:
|
||||
network_aliases: [pine-whisper]
|
||||
# The image entrypoint already binds tcp://0.0.0.0:10300; these args only
|
||||
# pick the model + language (mirrors the pine ha-stack.yml compose command).
|
||||
custom_args: ["--model", "base-int8", "--language", "en"]
|
||||
# --beam-size 1: the image default is 5 on x86 (1 on ARM). Benchmarked on
|
||||
# framework-pt (i5-1135G7, base-int8): beam 1 transcribes the same text
|
||||
# ~45% faster — the standard low-latency setting for short voice commands
|
||||
# (HA's own whisper add-on defaults to 1).
|
||||
custom_args: ["--model", "base-int8", "--language", "en", "--beam-size", "1"]
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
+65
-6
@@ -1,8 +1,8 @@
|
||||
app:
|
||||
id: pine
|
||||
name: Pine
|
||||
version: "1.1.1"
|
||||
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.
|
||||
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.
|
||||
category: home
|
||||
|
||||
# The user-facing launcher (app_id + container both "pine", matching the
|
||||
@@ -30,6 +30,7 @@ app:
|
||||
dependencies:
|
||||
- app_id: pine-whisper
|
||||
- app_id: pine-piper
|
||||
- app_id: pine-openwakeword
|
||||
- storage: 128Mi
|
||||
|
||||
resources:
|
||||
@@ -92,6 +93,15 @@ app:
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Live node facts for the status card — proxied to the node's
|
||||
# public status tier so the (https) page can fetch same-origin.
|
||||
location = /node-status {
|
||||
proxy_pass http://host.containers.internal:80/api/pine/status;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 10s;
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
- path: /var/lib/archipelago/pine/index.html
|
||||
@@ -156,9 +166,16 @@ app:
|
||||
<div class="card status">
|
||||
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
|
||||
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
|
||||
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
|
||||
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card status">
|
||||
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
|
||||
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
|
||||
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card insecure" id="insecure">
|
||||
This page isn’t running over HTTPS, so the browser blocks Bluetooth.
|
||||
Open it via its <b>https://…:10380</b> address (accept the self-signed
|
||||
@@ -174,10 +191,19 @@ app:
|
||||
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
|
||||
</div>
|
||||
|
||||
<p class="ha">After WiFi joins, add the speaker to Home Assistant:
|
||||
<b>Settings → Devices & services → Add Wyoming Protocol</b>, host =
|
||||
the speaker’s IP, port <b>10700</b>, then pick an Assist pipeline using
|
||||
Whisper + Piper. Wake word: <b>“Hey Jarvis.”</b></p>
|
||||
<p class="ha">After WiFi joins, one manual step remains — pair the
|
||||
speaker in Home Assistant: <b>Settings → Devices & services →
|
||||
Add Wyoming Protocol</b>, host = the speaker’s IP, port
|
||||
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
|
||||
are wired up automatically when Pine installs. Wake word:
|
||||
<b>“Hey Jarvis.”</b> Ask node things like <i>“what’s the block
|
||||
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
|
||||
synced?”</i> or <i>“what’s my lightning balance?”</i> — and when a
|
||||
Claude API key is set on the node, anything else gets answered by
|
||||
Claude. New mesh messages are announced on the speaker too.</p>
|
||||
<p class="ha">Troubleshooting: if it hears you (LED reacts) but answers
|
||||
are silent, unplug and replug the speaker — an interrupted answer can
|
||||
wedge its audio output until it reboots.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -299,6 +325,39 @@ app:
|
||||
try { device?.gatt?.disconnect(); } catch {}
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
// Live node status card — public tier of /api/pine/status via the
|
||||
// same-origin /node-status proxy. Best-effort: failures just show
|
||||
// "unavailable" and retry on the next tick.
|
||||
const nsNode = document.getElementById("ns-node");
|
||||
const nsBtc = document.getElementById("ns-btc");
|
||||
const nsPeers = document.getElementById("ns-peers");
|
||||
async function refreshNodeStatus() {
|
||||
try {
|
||||
const r = await fetch("/node-status", { cache: "no-store" });
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const s = await r.json();
|
||||
const up = Math.floor((s.uptime_seconds || 0) / 3600);
|
||||
nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`;
|
||||
if (s.bitcoin && s.bitcoin.height != null) {
|
||||
const pct = s.bitcoin.sync_percent;
|
||||
nsBtc.textContent = `block ${s.bitcoin.height}` +
|
||||
(pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : "");
|
||||
} else {
|
||||
nsBtc.textContent = "not running";
|
||||
}
|
||||
const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?";
|
||||
const meshPeers = s.mesh ? s.mesh.peers : 0;
|
||||
nsPeers.textContent = `${btcPeers} bitcoin` +
|
||||
(s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : "");
|
||||
} catch {
|
||||
nsNode.textContent = "node status unavailable";
|
||||
nsBtc.textContent = "—";
|
||||
nsPeers.textContent = "—";
|
||||
}
|
||||
}
|
||||
refreshNodeStatus();
|
||||
setInterval(refreshNodeStatus, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.109-alpha"
|
||||
version = "1.7.111-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.110-alpha"
|
||||
version = "1.7.111-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -188,7 +188,7 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
// Not a paid item — no invoice to issue.
|
||||
return Ok(build_response(
|
||||
@@ -198,6 +198,13 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
};
|
||||
if !content_server::method_accepted(&item.access, "lightning") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
|
||||
));
|
||||
}
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
@@ -315,7 +322,18 @@ impl ApiHandler {
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&i.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -580,6 +580,26 @@ impl ApiHandler {
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// Pine node status — public tier (version/uptime/height/sync/peer
|
||||
// counts) is unauthenticated like /bitcoin-status; Lightning
|
||||
// balances + latest mesh message additionally require the bearer
|
||||
// token the pine/HA seeder minted (or a valid session).
|
||||
(Method::GET, "/api/pine/status") => {
|
||||
let bearer = headers
|
||||
.get(hyper::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
let authorized = self.rpc_handler.pine_status_token_ok(bearer).await
|
||||
|| self.is_authenticated(&headers).await;
|
||||
let body = self.rpc_handler.pine_status_json(authorized).await;
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
|
||||
@@ -9,6 +9,23 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
// Companion device-token login: minted via auth.createDeviceToken and
|
||||
// carried by the pairing QR. Verified here so it shares the login rate
|
||||
// limiter with password attempts.
|
||||
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
|
||||
return match crate::device_tokens::verify(&self.config.data_dir, token).await {
|
||||
Some(device) => {
|
||||
tracing::info!("[onboarding] device-token login ({device})");
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("[onboarding] device-token login failed");
|
||||
Err(anyhow::anyhow!("Invalid device token"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -32,6 +49,15 @@ impl RpcHandler {
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
// The companion app sends its device token through the password
|
||||
// field (it reuses the whole password auto-login path, including
|
||||
// the WebView form). Accept a valid token here so that path works.
|
||||
if let Some(device) =
|
||||
crate::device_tokens::verify(&self.config.data_dir, password).await
|
||||
{
|
||||
tracing::info!("[onboarding] device-token login via password field ({device})");
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
tracing::warn!("[onboarding] login failed — wrong password");
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
@@ -73,6 +99,48 @@ impl RpcHandler {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Mint a device token for the companion pairing QR. Session-gated by the
|
||||
/// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI
|
||||
/// can mint one. The plaintext token is returned exactly once.
|
||||
pub(super) async fn handle_auth_create_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("companion")
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
|
||||
}
|
||||
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
||||
Ok(serde_json::json!({ "name": name, "token": token }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_list_device_tokens(&self) -> Result<serde_json::Value> {
|
||||
let tokens = crate::device_tokens::list(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!(tokens
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({ "name": t.name, "created": t.created }))
|
||||
.collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_revoke_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?;
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("[onboarding] logout");
|
||||
Ok(serde_json::Value::Null)
|
||||
|
||||
@@ -179,7 +179,24 @@ impl RpcHandler {
|
||||
if price == 0 {
|
||||
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
||||
}
|
||||
AccessControl::Paid { price_sats: price }
|
||||
// Optional list of payment methods the sharer accepts.
|
||||
// Absent/empty = all methods (backward compatible).
|
||||
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
|
||||
let accepted: Vec<String> = params
|
||||
.get("accepted_methods")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m.as_str())
|
||||
.filter(|m| KNOWN_METHODS.contains(m))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted,
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
||||
};
|
||||
@@ -412,6 +429,55 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// NEVER pay twice for content we already own (2026-07-22: a file
|
||||
// shared twice produced two catalog ids for the same bytes and the
|
||||
// buyer paid both). Guard BEFORE any ecash is minted, matching both
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/')
|
||||
== f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) = crate::content_owned::read_owned(
|
||||
&self.config.data_dir,
|
||||
&o.onion,
|
||||
&o.content_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||
// verify_payment_token accepts either, so a node whose balance lives in
|
||||
@@ -590,6 +656,54 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!(
|
||||
"paid download: filed into {}",
|
||||
target.display()
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ impl RpcHandler {
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
"auth.createDeviceToken" => self.handle_auth_create_device_token(params).await,
|
||||
"auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await,
|
||||
"auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await,
|
||||
|
||||
// Seed management (BIP-39 mnemonic)
|
||||
"seed.generate" => self.handle_seed_generate().await,
|
||||
@@ -257,6 +260,7 @@ impl RpcHandler {
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
@@ -383,6 +387,7 @@ impl RpcHandler {
|
||||
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
@@ -486,6 +491,7 @@ impl RpcHandler {
|
||||
|
||||
// FIPS mesh transport
|
||||
"fips.status" => self.handle_fips_status().await,
|
||||
"fips.pair-info" => self.handle_fips_pair_info().await,
|
||||
"fips.check-update" => self.handle_fips_check_update().await,
|
||||
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
|
||||
@@ -118,6 +118,31 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
|
||||
/// with sufficient balance. Returns the notes token for the recipient
|
||||
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
|
||||
/// split from Cashu 2026-07-22). The heavy lifting already existed in
|
||||
/// `fedimint_client::spend_from_any`; it was simply never exposed.
|
||||
pub(super) async fn handle_wallet_fedimint_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
|
||||
let (token, federation_id) =
|
||||
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"token": token,
|
||||
"federation_id": federation_id,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
|
||||
@@ -16,6 +16,53 @@ impl RpcHandler {
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Everything the companion app needs to join this node's mesh, embedded
|
||||
/// in the pairing QR by the web UI: the daemon's npub (identity to dial),
|
||||
/// the fips0 ULA (where the UI is reachable once the phone is meshed),
|
||||
/// and the transport ports on this host. The QR builder supplies the
|
||||
/// host/IP itself — it knows which origin the browser reached the node on.
|
||||
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| {
|
||||
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
|
||||
})?;
|
||||
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
|
||||
// The node's seed anchors ride along so the phone can rendezvous
|
||||
// through the same public mesh points when the node's LAN endpoint
|
||||
// isn't directly dialable (phone away from home, node behind NAT).
|
||||
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Pairing must always carry a public rendezvous point: without one the
|
||||
// phone is IP-bound to the LAN host it scanned and goes dark the
|
||||
// moment it leaves that network. This is a pairing hint only — the
|
||||
// node's own anchor file is not modified, so an operator's removal of
|
||||
// the default anchors still sticks for the node itself.
|
||||
if !anchor_list
|
||||
.iter()
|
||||
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
|
||||
{
|
||||
anchor_list.push(fips::anchors::archy_anchor());
|
||||
}
|
||||
let anchors = anchor_list
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"npub": a.npub,
|
||||
"addr": a.address,
|
||||
"transport": a.transport,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"npub": npub,
|
||||
"ula": ula,
|
||||
"udp_port": fips::PUBLISHED_UDP_PORT,
|
||||
"tcp_port": fips::DEFAULT_TCP_PORT,
|
||||
"anchors": anchors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
|
||||
let check = fips::update::check().await?;
|
||||
Ok(serde_json::to_value(check)?)
|
||||
|
||||
@@ -86,7 +86,7 @@ impl RpcHandler {
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||
.unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.config.nostr_relays.clone();
|
||||
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_presence(
|
||||
@@ -106,6 +106,17 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// The relay set every handshake operation uses: the user-managed relay
|
||||
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
|
||||
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
|
||||
/// hardcoded config relays (one of which is defunct) and ignored user
|
||||
/// relay edits entirely — so a sender publishing where the receiver
|
||||
/// never read was a routine, silent way for peer requests to vanish.
|
||||
pub(super) async fn handshake_relays(&self) -> Vec<String> {
|
||||
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Discover discoverable nodes via Nostr presence events.
|
||||
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
@@ -113,9 +124,10 @@ impl RpcHandler {
|
||||
// to query relays as long as the user is actively browsing — they're
|
||||
// an anonymous observer of presence events, not publishing anything.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -161,7 +173,7 @@ impl RpcHandler {
|
||||
our_version,
|
||||
our_name,
|
||||
message,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -191,6 +203,40 @@ impl RpcHandler {
|
||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||
///
|
||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
|
||||
/// ONLY when a user opened Federation and pressed the Poll button — a
|
||||
/// peer request sat on the relay until the target's operator happened to
|
||||
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
|
||||
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
|
||||
/// and nudges the websocket revision when anything new lands so open UIs
|
||||
/// refresh immediately.
|
||||
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
|
||||
match self.handle_handshake_poll().await {
|
||||
Ok(res) => {
|
||||
let new = res
|
||||
.get("new_requests")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
let applied = res
|
||||
.get("applied_invites")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if new > 0 || applied > 0 {
|
||||
tracing::info!(
|
||||
new_requests = new,
|
||||
applied_invites = applied,
|
||||
"handshake poll: inbound peer activity"
|
||||
);
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||
// touch the relays. The poll endpoint is a hard no-op until they
|
||||
@@ -207,9 +253,10 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,21 @@ use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
/// LND rejects RPCs with "server is still in the process of starting" for a
|
||||
/// short window after wallet unlock (p2p/graph subsystems still loading).
|
||||
/// Transient by design — match it so callers retry and, if it persists,
|
||||
/// surface a calm notice instead of a scary failure. The exact phrase below
|
||||
/// is what the frontend keys its softer (non-red) styling on.
|
||||
fn lnd_still_starting(msg: &str) -> bool {
|
||||
let m = msg.to_ascii_lowercase();
|
||||
m.contains("in the process of starting") || m.contains("server is still starting")
|
||||
}
|
||||
|
||||
/// User-facing text for the still-starting state. Deliberately calm: this is
|
||||
/// a "wait a moment", not an error.
|
||||
const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \
|
||||
usually takes a minute or two after the node comes online. Please try again shortly.";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
@@ -251,25 +266,45 @@ impl RpcHandler {
|
||||
"perm": false,
|
||||
"timeout": "30"
|
||||
});
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Right after wallet unlock, LND's RPC answers while its p2p
|
||||
// server is still spinning up, and every connect attempt gets
|
||||
// "server is still in the process of starting". That's a
|
||||
// transient state, not a failure — it clears in seconds — so
|
||||
// retry quietly for ~30s before surfacing a calm, non-scary
|
||||
// notice (the frontend styles LND_STARTING_MSG as info, not red).
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
|
||||
if !connect_resp.status().is_success() {
|
||||
if connect_resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND returns an error if we already have this peer — that is fine
|
||||
if !msg.contains("already connected") {
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
if lnd_still_starting(msg) && attempt < 5 {
|
||||
attempt += 1;
|
||||
info!(attempt, "LND still starting — retrying peer connect in 6s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
continue;
|
||||
}
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +340,9 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,172 @@ pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time wallet push (user req 2026-07-22): the UI must reflect an
|
||||
/// incoming on-chain transaction the moment the node sees it, not on the
|
||||
/// next poll. Streams LND's `/v1/transactions/subscribe` — it fires on
|
||||
/// 0-conf mempool arrival AND again on each confirmation — and nudges the
|
||||
/// shared data-model revision on every event; /ws/db pushes that to every
|
||||
/// connected client and the frontend refetches wallet balance/transactions.
|
||||
/// Reconnects forever with capped backoff: LND restarting, wallet locked, or
|
||||
/// LND not installed yet all just mean "try again shortly".
|
||||
pub(crate) fn spawn_lnd_tx_watcher(state_manager: std::sync::Arc<crate::state::StateManager>) {
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
match stream_lnd_transactions(&state_manager).await {
|
||||
// Stream ended cleanly (LND shutdown) — resume fast.
|
||||
Ok(()) => delay = std::time::Duration::from_secs(5),
|
||||
Err(e) => {
|
||||
tracing::debug!("lnd tx watcher: {e:#} — retrying in {delay:?}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(std::time::Duration::from_secs(120));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> {
|
||||
let macaroon_hex = hex::encode(read_lnd_admin_macaroon().await?);
|
||||
// Dedicated client: the shared lnd_client() carries a 15s total timeout,
|
||||
// which would kill this deliberately long-lived stream.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
let mut resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/subscribe"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("subscribe request failed")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"transactions/subscribe returned {}",
|
||||
resp.status()
|
||||
);
|
||||
tracing::info!("lnd tx watcher: streaming wallet transaction events");
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any streamed event = wallet activity. Revision bump is the push
|
||||
// contract (same pattern as the mesh-peer bridge in server.rs) — the
|
||||
// clients refetch, so we don't need to parse the event body.
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
tracing::debug!(
|
||||
bytes = chunk.len(),
|
||||
"lnd tx watcher: wallet tx event — nudged ws clients"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
|
||||
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||
/// with channels that need a peer) persists. A restart reliably clears it
|
||||
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
};
|
||||
let macaroon_hex = hex::encode(bytes);
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(resp) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
};
|
||||
let synced = info
|
||||
.get("synced_to_chain")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let channels = info
|
||||
.get("num_active_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_inactive_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
bad_minutes += 1;
|
||||
if bad_minutes < 15 {
|
||||
continue;
|
||||
}
|
||||
if last_restart
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
synced_to_chain = synced,
|
||||
num_peers = peers,
|
||||
channels,
|
||||
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "lnd"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!("LND watchdog restart complete");
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
"LND watchdog restart failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||
}
|
||||
last_restart = Some(tokio::time::Instant::now());
|
||||
bad_minutes = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
|
||||
@@ -62,6 +62,14 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,11 @@ impl RpcHandler {
|
||||
config.lora_radio_params = Some(parsed);
|
||||
}
|
||||
}
|
||||
// Hot-swap "keep as is": false = never write config to the radio
|
||||
// (region/channel/PHY/advert name) — use it exactly as flashed.
|
||||
if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) {
|
||||
config.manage_radio = manage;
|
||||
}
|
||||
// Firmware pin: probe only the named firmware on the port ("auto"/""
|
||||
// clears the pin and restores strict-probe auto-detect).
|
||||
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
|
||||
|
||||
@@ -52,6 +52,9 @@ impl RpcHandler {
|
||||
.map(|k| k.to_string().to_lowercase())
|
||||
.into(),
|
||||
);
|
||||
// Hot-swap "keep as is" state: false = archipelago never writes
|
||||
// config to the radio (see MeshConfig::manage_radio).
|
||||
obj.insert("manage_radio".into(), config.manage_radio.into());
|
||||
// USB identity per detected port so the setup modal can show the
|
||||
// actual board (product string on native-USB boards, vid:pid as
|
||||
// the fallback for bridge chips).
|
||||
@@ -78,6 +81,35 @@ impl RpcHandler {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// mesh.probe-device — Identify the firmware on a detected serial port and
|
||||
/// read its current configuration WITHOUT provisioning it. Powers the
|
||||
/// hot-swap "device detected" modal's current-details view. The path must
|
||||
/// be one of the currently detected candidate ports (no arbitrary device
|
||||
/// paths), and probing the live session's own port is refused.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_probe_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
let service = self.mesh_service.read().await;
|
||||
let probe = match service.as_ref() {
|
||||
Some(svc) => svc.probe_device(&path).await?,
|
||||
// No mesh service yet (radio never enabled) — probe directly.
|
||||
None => mesh::listener::probe_device(&path).await?,
|
||||
};
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
|
||||
@@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Mempool requires",
|
||||
"Container",
|
||||
"Image",
|
||||
// Wallet-actionable errors: masking "Insufficient balance: need 80
|
||||
// sats, have 0 sats" behind "Operation failed. Check server logs."
|
||||
// sent the operator to journalctl for a message that was written for
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
// send, 2026-07-23).
|
||||
"Payment failed",
|
||||
"Invalid payment request",
|
||||
"Missing 'payment_request'",
|
||||
"Your Lightning node is still finishing",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
@@ -151,6 +165,25 @@ mod sanitize_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_payment_errors_pass_through() {
|
||||
// LND's payment-failure reasons are written for the payer — masking
|
||||
// "invoice expired" as "Check server logs" left a user retrying a
|
||||
// dead invoice (framework-pt, 2026-07-23).
|
||||
for msg in [
|
||||
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
|
||||
"Payment failed: unable to find a path to destination",
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)",
|
||||
"Missing 'payment_request' parameter",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_stay_generic() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -15,7 +15,7 @@ mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
pub(in crate::api) mod lnd;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod middleware;
|
||||
@@ -26,7 +26,9 @@ mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
mod router;
|
||||
mod security;
|
||||
@@ -531,9 +533,31 @@ impl RpcHandler {
|
||||
self.login_rate_limiter.record_failure(client_ip).await;
|
||||
}
|
||||
|
||||
// On successful login, check if 2FA is required
|
||||
// On successful login, check if 2FA is required. Device-token logins
|
||||
// (companion pairing QR) skip the TOTP challenge like remember-me does:
|
||||
// the token was minted from an already-authenticated session, and there
|
||||
// is no password with which to decrypt the TOTP secret anyway.
|
||||
if method == "auth.login" && rpc_resp.error.is_none() {
|
||||
let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str());
|
||||
let is_token_login = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
|| match password {
|
||||
// Companion device tokens also arrive through the password
|
||||
// field (see handle_auth_login) — those logins get a full
|
||||
// session too; there's no password to decrypt TOTP with.
|
||||
Some(pw) => crate::device_tokens::verify(&self.config.data_dir, pw)
|
||||
.await
|
||||
.is_some(),
|
||||
None => false,
|
||||
};
|
||||
let totp_enabled = !is_token_login
|
||||
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
if totp_enabled {
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
|
||||
@@ -497,7 +497,12 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"netbird-server".into(),
|
||||
],
|
||||
// Pine voice-assistant stack: launcher + the two Wyoming engines.
|
||||
"pine" => vec!["pine".into(), "pine-whisper".into(), "pine-piper".into()],
|
||||
"pine" => vec![
|
||||
"pine".into(),
|
||||
"pine-whisper".into(),
|
||||
"pine-piper".into(),
|
||||
"pine-openwakeword".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
|
||||
@@ -206,19 +206,24 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !deps.has_bitcoin {
|
||||
missing.push("Bitcoin Knots");
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push("ElectrumX");
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
missing.join(" and ")
|
||||
))
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"Mempool requires Bitcoin Knots to be running. \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
// ElectrumX is deliberately NOT a hard gate for mempool: mempool-api
|
||||
// reconnects to electrum on its own (verified live — it retries
|
||||
// ECONNREFUSED in a loop and comes good when electrum starts
|
||||
// listening), and an ElectrumX mid-resync can be legitimately down
|
||||
// for DAYS. The hard gate here blocked repeated mempool installs on
|
||||
// .116 (2026-07-22) while electrumx was compacting. Block/fee views
|
||||
// work from bitcoind immediately; address lookups light up when
|
||||
// ElectrumX finishes.
|
||||
"mempool" | "mempool-web" if !deps.has_electrumx => {
|
||||
info!(
|
||||
"Mempool installing while ElectrumX is not running — \
|
||||
mempool-api reconnects automatically once ElectrumX is up"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
"fedimint" if !deps.has_bitcoin => {
|
||||
info!("Fedimint installing without local Bitcoin node — configure remote Bitcoin RPC in Fedimint guardian setup");
|
||||
@@ -298,9 +303,10 @@ fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep>
|
||||
if !deps.has_bitcoin {
|
||||
missing.push(BITCOIN);
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push(ELECTRUM);
|
||||
}
|
||||
// ElectrumX intentionally not required — see check_install_deps:
|
||||
// mempool-api reconnects when electrum comes up, and a resyncing
|
||||
// ElectrumX can be down for days (the 3-minute bounded wait here
|
||||
// would still fail the install for no benefit).
|
||||
}
|
||||
// fedimint deliberately absent: check_install_deps allows it without
|
||||
// a local Bitcoin node (remote RPC configured in guardian setup).
|
||||
@@ -598,6 +604,7 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
| "pine"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -629,7 +636,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -1110,7 +1117,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
|
||||
async fn mempool_waits_on_bitcoin_only() {
|
||||
// ElectrumX is deliberately NOT gated (2026-07-22): a resyncing
|
||||
// ElectrumX can be down for days and mempool-api reconnects on
|
||||
// its own, so the wait only covers Bitcoin — even when electrumx
|
||||
// is installed and stopped.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
@@ -1118,8 +1129,8 @@ mod tests {
|
||||
"mempool",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
// Bitcoin comes up on probe 2, electrumx on probe 3.
|
||||
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
|
||||
// Bitcoin comes up on probe 2; electrumx never does.
|
||||
async move { Ok(probe(n >= 1, false, &["bitcoin-knots", "electrumx"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
@@ -1130,22 +1141,19 @@ mod tests {
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(
|
||||
labels.as_slice(),
|
||||
&[
|
||||
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
|
||||
"Waiting for ElectrumX to start…".to_string(),
|
||||
]
|
||||
&["Waiting for Bitcoin to start…".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
|
||||
// Bitcoin is installed (waiting could help) but ElectrumX is not
|
||||
// installed at all — waiting can never satisfy the gate, so fail
|
||||
// fast with the canonical message.
|
||||
async fn mempool_fails_fast_when_bitcoin_is_not_installed() {
|
||||
// Bitcoin isn't installed at all — waiting can never satisfy the
|
||||
// gate, so fail fast with the canonical message. (A missing
|
||||
// ElectrumX no longer blocks anything.)
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"mempool",
|
||||
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||
|| async { Ok(probe(false, false, &["electrumx"])) },
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
|
||||
@@ -4,6 +4,7 @@ mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
//! Home Assistant <-> Pine voice-stack provisioning.
|
||||
//!
|
||||
//! The Pine app ships the two Wyoming engines (pine-whisper :10300,
|
||||
//! pine-piper :10200) as plain containers. Home Assistant only uses them once
|
||||
//! a `wyoming` config entry exists for each, and once an Assist pipeline
|
||||
//! points at the resulting `stt.faster_whisper` / `tts.piper` entities. Out of
|
||||
//! the box that meant clicking through HA's integration UI twice and building
|
||||
//! a pipeline by hand — so seed all of it directly into HA's `.storage` when
|
||||
//! both apps are present.
|
||||
//! The Pine app ships the Wyoming engines (pine-whisper :10300, pine-piper
|
||||
//! :10200, pine-openwakeword :10400) as plain containers. Home Assistant only
|
||||
//! uses them once a `wyoming` config entry exists for each, and once an
|
||||
//! Assist pipeline points at the resulting entities. Out of the box that
|
||||
//! meant clicking through HA's integration UI and building a pipeline by
|
||||
//! hand — so seed all of it directly into HA's `.storage` when both apps are
|
||||
//! present:
|
||||
//!
|
||||
//! - a `wyoming` config entry per engine,
|
||||
//! - an Assist pipeline wired to whisper + piper,
|
||||
//! - "ask Archy" voice intents backed by REST sensors on the node's
|
||||
//! `/api/pine/status` endpoint (block height, peers, sync, balances),
|
||||
//! - a Claude conversation agent (HA's `anthropic` integration) using the
|
||||
//! node's shared `secrets/claude-api-key`, set as the pipeline's brain with
|
||||
//! `prefer_local_intents: true` so node questions stay local/free,
|
||||
//! - an automation announcing new mesh messages on any Assist satellite.
|
||||
//!
|
||||
//! Everything here is best-effort (warn, never fail an install): HA migrates
|
||||
//! the minimal store shapes we write to its current schema on boot, and skips
|
||||
@@ -15,17 +24,30 @@
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage";
|
||||
/// Timestamp in the exact format HA writes to `.storage`
|
||||
/// (`2026-07-22T08:29:35.123456+00:00` — micros, `+00:00` offset), safe for
|
||||
/// Python's `datetime.fromisoformat`.
|
||||
fn ha_now() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, false)
|
||||
}
|
||||
|
||||
/// The two Wyoming engines the Pine stack publishes on the host.
|
||||
const HA_CONFIG_DIR: &str = "/var/lib/archipelago/home-assistant";
|
||||
const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage";
|
||||
const NODE_SECRETS_DIR: &str = "/var/lib/archipelago/secrets";
|
||||
|
||||
/// The Wyoming engines the Pine stack publishes on the host.
|
||||
/// HA runs under pasta, so the host is reachable as host.containers.internal.
|
||||
const PINE_ENGINES: [(&str, &str, u16); 2] = [
|
||||
const PINE_ENGINES: [(&str, &str, u16); 3] = [
|
||||
("faster-whisper", "host.containers.internal", 10300),
|
||||
("piper", "host.containers.internal", 10200),
|
||||
("openwakeword", "host.containers.internal", 10400),
|
||||
];
|
||||
|
||||
/// Seed Home Assistant with the Pine voice defaults: a `wyoming` config entry
|
||||
/// per engine and an Assist pipeline wired to them. Called after the Pine
|
||||
/// Entity id the Anthropic conversation subentry produces (device name
|
||||
/// "Claude conversation" -> slug). Deterministic on a fresh registry.
|
||||
const CLAUDE_CONVERSATION_ENTITY: &str = "conversation.claude_conversation";
|
||||
|
||||
/// Seed Home Assistant with the Pine voice defaults. Called after the Pine
|
||||
/// stack installs and after Home Assistant installs (each side no-ops when
|
||||
/// the other is missing). HA picks the new entries up on its next start; the
|
||||
/// caller restarts the container when it is already running.
|
||||
@@ -39,34 +61,209 @@ pub(super) async fn seed_home_assistant_pine_defaults() -> bool {
|
||||
}
|
||||
|
||||
let entries_changed = seed_wyoming_config_entries(storage).await;
|
||||
let pipeline_changed = seed_assist_pipeline(storage).await;
|
||||
let claude = seed_claude_conversation(storage).await;
|
||||
let pipeline_changed = seed_assist_pipeline(
|
||||
storage,
|
||||
claude.available.then_some(CLAUDE_CONVERSATION_ENTITY),
|
||||
)
|
||||
.await;
|
||||
let intents_changed = seed_voice_intents().await;
|
||||
entries_changed || pipeline_changed || intents_changed
|
||||
let automation_changed = seed_mesh_announce_automation().await;
|
||||
entries_changed || claude.changed || pipeline_changed || intents_changed || automation_changed
|
||||
}
|
||||
|
||||
/// Marker guarding the seeded block in configuration.yaml (idempotence).
|
||||
const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice intents + REST sensors (configuration.yaml + custom_sentences)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Seed "ask Archy" node-info voice intents: custom sentences + intent_script
|
||||
/// + a REST sensor for the block height (via the mempool backend when that
|
||||
/// app is installed). Skips cleanly when the user already defines
|
||||
/// intent_script themselves.
|
||||
/// Markers bounding the seeded block in configuration.yaml. The begin marker
|
||||
/// doubles as the legacy marker (early seeds had no end marker and always sat
|
||||
/// at EOF, so legacy upgrade = replace from begin marker to end of file).
|
||||
const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---";
|
||||
const VOICE_END_MARKER: &str = "# --- archipelago pine voice (end) ---";
|
||||
|
||||
/// Read the pine status token (minting it on first use, 0600). The same
|
||||
/// token gates the sensitive tier of `/api/pine/status` (see
|
||||
/// `api::rpc::pine_status`), so seeding it into HA's REST sensor config is
|
||||
/// what lets HA — and only HA — read balances and mesh text.
|
||||
async fn ensure_status_token() -> Option<String> {
|
||||
let dir = std::path::Path::new(NODE_SECRETS_DIR);
|
||||
let path = dir.join(crate::api::rpc::pine_status::PINE_STATUS_TOKEN_FILE);
|
||||
if let Ok(existing) = tokio::fs::read_to_string(&path).await {
|
||||
let existing = existing.trim().to_string();
|
||||
if !existing.is_empty() {
|
||||
return Some(existing);
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::create_dir_all(dir).await {
|
||||
warn!("pine/HA seed: cannot create {}: {}", NODE_SECRETS_DIR, e);
|
||||
return None;
|
||||
}
|
||||
let raw: [u8; 32] = rand::random();
|
||||
let token = hex::encode(raw);
|
||||
if let Err(e) = tokio::fs::write(&path, &token).await {
|
||||
warn!("pine/HA seed: writing status token failed: {e}");
|
||||
return None;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
info!("pine/HA seed: minted pine status token");
|
||||
Some(token)
|
||||
}
|
||||
|
||||
/// The seeded configuration.yaml block: REST sensors polling the node's
|
||||
/// status endpoint (through nginx on :80) + intent_script answers.
|
||||
fn voice_config_block(token: &str) -> String {
|
||||
format!(
|
||||
r#"{VOICE_MARKER}
|
||||
rest:
|
||||
- resource: http://host.containers.internal/api/pine/status
|
||||
headers:
|
||||
Authorization: "Bearer {token}"
|
||||
# 15s: the mesh-message sensor only carries the LATEST message id, so two
|
||||
# messages inside one poll window coalesce and only the newest announces
|
||||
# (observed on framework-pt 2026-07-22). Halving the window halves both
|
||||
# the announce lag and the coalescing odds; the endpoint is local + cheap.
|
||||
scan_interval: 15
|
||||
sensor:
|
||||
- name: "Archy Block Height"
|
||||
unique_id: archy_block_height
|
||||
value_template: "{{{{ value_json.bitcoin.height }}}}"
|
||||
- name: "Archy Bitcoin Sync"
|
||||
unique_id: archy_bitcoin_sync
|
||||
unit_of_measurement: "%"
|
||||
value_template: "{{{{ value_json.bitcoin.sync_percent }}}}"
|
||||
availability: "{{{{ value_json.bitcoin.sync_percent is not none }}}}"
|
||||
- name: "Archy Bitcoin Peers"
|
||||
unique_id: archy_bitcoin_peers
|
||||
value_template: "{{{{ value_json.bitcoin.peers }}}}"
|
||||
- name: "Archy Mesh Peers"
|
||||
unique_id: archy_mesh_peers
|
||||
value_template: "{{{{ value_json.mesh.peers }}}}"
|
||||
- name: "Archy Lightning Balance"
|
||||
unique_id: archy_lightning_balance
|
||||
unit_of_measurement: "sats"
|
||||
value_template: "{{{{ (value_json.lightning or {{}}).get('channel_balance_sats') }}}}"
|
||||
availability: "{{{{ (value_json.lightning or {{}}).get('channel_balance_sats') is not none }}}}"
|
||||
- name: "Archy Onchain Balance"
|
||||
unique_id: archy_onchain_balance
|
||||
unit_of_measurement: "sats"
|
||||
value_template: "{{{{ (value_json.lightning or {{}}).get('balance_sats') }}}}"
|
||||
availability: "{{{{ (value_json.lightning or {{}}).get('balance_sats') is not none }}}}"
|
||||
- name: "Archy Mesh Message"
|
||||
unique_id: archy_mesh_message
|
||||
value_template: "{{{{ (value_json.mesh_message or {{}}).get('id') }}}}"
|
||||
json_attributes_path: "$.mesh_message"
|
||||
json_attributes:
|
||||
- from
|
||||
- text
|
||||
- timestamp
|
||||
intent_script:
|
||||
ArchyBlockHeight:
|
||||
description: >-
|
||||
Get the current Bitcoin block height of this node. Use for any question
|
||||
about the block height, chain tip, or number of blocks.
|
||||
speech:
|
||||
text: >-
|
||||
{{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable', 'None'] %}}
|
||||
The block height is {{{{ states('sensor.archy_block_height') }}}}.
|
||||
{{% else %}}
|
||||
I can't read the block height right now.
|
||||
{{% endif %}}
|
||||
ArchyPeers:
|
||||
description: >-
|
||||
Get how many peers this node is connected to (bitcoin peers and mesh
|
||||
radio peers). Use for any question about peer or connection counts.
|
||||
speech:
|
||||
text: >-
|
||||
{{% set btc = states('sensor.archy_bitcoin_peers') %}}
|
||||
{{% set mesh = states('sensor.archy_mesh_peers') %}}
|
||||
{{% if btc not in ['unknown', 'unavailable', 'None'] %}}
|
||||
The node has {{{{ btc }}}} bitcoin peers{{% if mesh not in ['unknown', 'unavailable', 'None'] and mesh | int(0) > 0 %}} and {{{{ mesh }}}} mesh peers{{% endif %}}.
|
||||
{{% else %}}
|
||||
I can't read the peer count right now.
|
||||
{{% endif %}}
|
||||
ArchySyncStatus:
|
||||
description: >-
|
||||
Get this node's Bitcoin sync status / progress percentage. Use for any
|
||||
question about whether the node or bitcoin is synced, syncing, or up to
|
||||
date.
|
||||
speech:
|
||||
text: >-
|
||||
{{% set pct = states('sensor.archy_bitcoin_sync') %}}
|
||||
{{% if pct in ['unknown', 'unavailable', 'None'] %}}
|
||||
I can't read the sync status right now.
|
||||
{{% elif pct | float(0) >= 99.99 %}}
|
||||
The node is fully synced at block {{{{ states('sensor.archy_block_height') }}}}.
|
||||
{{% else %}}
|
||||
Bitcoin is {{{{ pct }}}} percent synced.
|
||||
{{% endif %}}
|
||||
ArchyLightningBalance:
|
||||
description: >-
|
||||
Get the Lightning wallet balance of this node in sats. Use for any
|
||||
question about the lightning balance, wallet balance, or how many sats
|
||||
are available.
|
||||
speech:
|
||||
text: >-
|
||||
{{% set ln = states('sensor.archy_lightning_balance') %}}
|
||||
{{% if ln not in ['unknown', 'unavailable', 'None'] %}}
|
||||
Your Lightning balance is {{{{ ln }}}} sats.
|
||||
{{% else %}}
|
||||
I can't read the Lightning balance right now. Is Lightning set up on this node?
|
||||
{{% endif %}}
|
||||
{VOICE_END_MARKER}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
const VOICE_SENTENCES: &str = r#"language: "en"
|
||||
intents:
|
||||
ArchyBlockHeight:
|
||||
data:
|
||||
- sentences:
|
||||
- "what's the [current] block height"
|
||||
- "what is the [current] block height"
|
||||
- "[current] block height"
|
||||
- "how many blocks [are there]"
|
||||
ArchyPeers:
|
||||
data:
|
||||
- sentences:
|
||||
- "how many peers [do I have]"
|
||||
- "how many peers (is|are) [the node] connected to"
|
||||
- "[node] peer count"
|
||||
ArchySyncStatus:
|
||||
data:
|
||||
- sentences:
|
||||
- "is (the node|bitcoin) [fully] synced"
|
||||
- "[bitcoin] sync (status|progress|percent|percentage)"
|
||||
- "how synced is (the node|bitcoin)"
|
||||
ArchyLightningBalance:
|
||||
data:
|
||||
- sentences:
|
||||
- "what's my lightning balance"
|
||||
- "what is my lightning balance"
|
||||
- "lightning balance"
|
||||
- "how many sats (do I have|are in my wallet)"
|
||||
"#;
|
||||
|
||||
/// Seed "ask Archy" node-info voice intents: custom sentences + a bounded
|
||||
/// intent_script/rest block in configuration.yaml. Upgrades earlier seeded
|
||||
/// blocks in place (including the pre-endpoint legacy block that ended at
|
||||
/// EOF); skips cleanly when the user defines intent_script/rest themselves
|
||||
/// outside our markers.
|
||||
async fn seed_voice_intents() -> bool {
|
||||
let config_dir = std::path::Path::new("/var/lib/archipelago/home-assistant");
|
||||
let config_dir = std::path::Path::new(HA_CONFIG_DIR);
|
||||
let config_yaml = config_dir.join("configuration.yaml");
|
||||
|
||||
let existing = tokio::fs::read_to_string(&config_yaml)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if existing.contains(VOICE_MARKER) {
|
||||
let Some(token) = ensure_status_token().await else {
|
||||
return false;
|
||||
}
|
||||
if existing.contains("intent_script:") || existing.contains("\nrest:") {
|
||||
warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let desired_block = voice_config_block(&token);
|
||||
|
||||
// Sentences file (its own dir, no key-collision risk).
|
||||
// Sentences file (its own dir, no key-collision risk) — keep in sync.
|
||||
let sentences_dir = config_dir.join("custom_sentences/en");
|
||||
if let Err(e) = tokio::fs::create_dir_all(&sentences_dir).await {
|
||||
warn!(
|
||||
@@ -76,55 +273,52 @@ async fn seed_voice_intents() -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let sentences = r#"language: "en"
|
||||
intents:
|
||||
ArchyBlockHeight:
|
||||
data:
|
||||
- sentences:
|
||||
- "what's the [current] block height"
|
||||
- "what is the [current] block height"
|
||||
- "[current] block height"
|
||||
- "how many blocks [are there]"
|
||||
"#;
|
||||
if let Err(e) = tokio::fs::write(sentences_dir.join("archy.yaml"), sentences).await {
|
||||
warn!("pine/HA seed: writing custom sentences failed: {e}");
|
||||
return false;
|
||||
}
|
||||
|
||||
let mempool_installed = tokio::fs::metadata("/var/lib/archipelago/mempool")
|
||||
let sentences_path = sentences_dir.join("archy.yaml");
|
||||
let sentences_changed = tokio::fs::read_to_string(&sentences_path)
|
||||
.await
|
||||
.is_ok()
|
||||
|| tokio::fs::metadata("/var/lib/archipelago/data/mempool")
|
||||
.await
|
||||
.is_ok();
|
||||
let mut block = format!("\n{VOICE_MARKER}\n");
|
||||
if mempool_installed {
|
||||
block.push_str(
|
||||
r#"rest:
|
||||
- resource: http://host.containers.internal:8999/api/blocks/tip/height
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "Archy Block Height"
|
||||
unique_id: archy_block_height
|
||||
value_template: "{{ value }}"
|
||||
"#,
|
||||
);
|
||||
.map(|cur| cur != VOICE_SENTENCES)
|
||||
.unwrap_or(true);
|
||||
if sentences_changed {
|
||||
if let Err(e) = tokio::fs::write(&sentences_path, VOICE_SENTENCES).await {
|
||||
warn!("pine/HA seed: writing custom sentences failed: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
block.push_str(
|
||||
r#"intent_script:
|
||||
ArchyBlockHeight:
|
||||
speech:
|
||||
text: >-
|
||||
{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable'] %}
|
||||
The block height is {{ states('sensor.archy_block_height') }}.
|
||||
{% else %}
|
||||
I can't read the block height right now.
|
||||
{% endif %}
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut merged = existing;
|
||||
merged.push_str(&block);
|
||||
let existing = tokio::fs::read_to_string(&config_yaml)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let merged = if let Some(begin) = existing.find(VOICE_MARKER) {
|
||||
if let Some(end) = existing.find(VOICE_END_MARKER) {
|
||||
// Bounded block — replace when stale.
|
||||
let after = end + VOICE_END_MARKER.len();
|
||||
// Consume the trailing newline of the old block, if present.
|
||||
let after = after + existing[after..].starts_with('\n') as usize;
|
||||
let current = &existing[begin..after];
|
||||
if current == desired_block {
|
||||
return sentences_changed;
|
||||
}
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&existing[..begin],
|
||||
desired_block,
|
||||
&existing[after..]
|
||||
)
|
||||
} else {
|
||||
// Legacy block (no end marker) — it was always appended at EOF,
|
||||
// possibly hand-edited on the node (e.g. the interim bitcoind
|
||||
// socat sensor). Replace marker..EOF wholesale.
|
||||
format!("{}{}", &existing[..begin], desired_block)
|
||||
}
|
||||
} else {
|
||||
if existing.contains("intent_script:") || existing.contains("\nrest:") {
|
||||
warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents");
|
||||
return sentences_changed;
|
||||
}
|
||||
format!("{existing}\n{desired_block}")
|
||||
};
|
||||
|
||||
let tmp = config_yaml.with_extension("yaml.tmp-seed");
|
||||
if let Err(e) = tokio::fs::write(&tmp, &merged).await {
|
||||
warn!("pine/HA seed: writing configuration.yaml failed: {e}");
|
||||
@@ -134,17 +328,219 @@ intents:
|
||||
warn!("pine/HA seed: replacing configuration.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
info!(
|
||||
"pine/HA seed: voice intents seeded (block height{})",
|
||||
if mempool_installed {
|
||||
" + mempool REST sensor"
|
||||
} else {
|
||||
", sensor pending mempool install"
|
||||
}
|
||||
);
|
||||
info!("pine/HA seed: voice intents + node-status sensors seeded");
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mesh-message announcements (automations.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MESH_ANNOUNCE_AUTOMATION_ID: &str = "archy_mesh_announce";
|
||||
|
||||
/// Announce new mesh messages on every Assist satellite. Only seeded into an
|
||||
/// empty/missing automations.yaml — merging into user-authored YAML risks
|
||||
/// mangling it, and anyone with existing automations can paste this one from
|
||||
/// the docs. Trigger conditions skip HA-restart transitions (unknown -> id)
|
||||
/// but deliberately allow `None`/`unavailable` -> id: `None` is the sensor's
|
||||
/// state before the first-ever received message (and again whenever the mesh
|
||||
/// message store restarts empty), and `unavailable` follows any endpoint
|
||||
/// blip — blocking those swallowed the first DM a fresh node ever received
|
||||
/// (framework-pt, 2026-07-22). HA restarts still can't re-announce an old
|
||||
/// message: rest sensors don't restore state, so the post-restart transition
|
||||
/// is always from `unknown`.
|
||||
const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce
|
||||
alias: Announce mesh messages on Pine
|
||||
description: Speak new mesh messages on the Pine speaker (seeded by Archipelago)
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: sensor.archy_mesh_message
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.from_state is not none
|
||||
and trigger.from_state.state != 'unknown'
|
||||
and trigger.to_state.state not in ['unknown', 'unavailable', 'None']
|
||||
and trigger.from_state.state != trigger.to_state.state }}
|
||||
actions:
|
||||
- variables:
|
||||
satellites: "{{ states.assist_satellite | map(attribute='entity_id') | list }}"
|
||||
- condition: template
|
||||
value_template: "{{ satellites | count > 0 }}"
|
||||
- action: assist_satellite.announce
|
||||
target:
|
||||
entity_id: "{{ satellites }}"
|
||||
data:
|
||||
message: >-
|
||||
New mesh message from {{ state_attr('sensor.archy_mesh_message', 'from') or 'unknown' }}:
|
||||
{{ state_attr('sensor.archy_mesh_message', 'text') or '' }}
|
||||
mode: queued
|
||||
max: 5
|
||||
"#;
|
||||
|
||||
async fn seed_mesh_announce_automation() -> bool {
|
||||
let path = std::path::Path::new(HA_CONFIG_DIR).join("automations.yaml");
|
||||
let existing = tokio::fs::read_to_string(&path).await.unwrap_or_default();
|
||||
if existing.contains(MESH_ANNOUNCE_AUTOMATION_ID) {
|
||||
// v1 of the seeded condition also blocked `None`/`unavailable` -> id
|
||||
// transitions, swallowing the first-ever received message. Upgrade by
|
||||
// replacing exactly that clause; anything else in the file (including
|
||||
// user-added automations) is left untouched.
|
||||
const V1_CLAUSE: &str =
|
||||
"trigger.from_state.state not in ['unknown', 'unavailable', 'None']";
|
||||
const V2_CLAUSE: &str = "trigger.from_state.state != 'unknown'";
|
||||
if existing.contains(V1_CLAUSE) {
|
||||
let upgraded = existing.replace(V1_CLAUSE, V2_CLAUSE);
|
||||
if let Err(e) = tokio::fs::write(&path, upgraded).await {
|
||||
warn!("pine/HA seed: upgrading automations.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
info!("pine/HA seed: mesh announce automation upgraded (first message announces)");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let trimmed = existing.trim();
|
||||
if !(trimmed.is_empty() || trimmed == "[]") {
|
||||
warn!("pine/HA seed: automations.yaml has user content — skipping mesh announce seed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&path, MESH_ANNOUNCE_AUTOMATION).await {
|
||||
warn!("pine/HA seed: writing automations.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
info!("pine/HA seed: mesh-message announce automation seeded");
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude conversation agent (anthropic config entry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ClaudeSeed {
|
||||
/// An anthropic entry exists (seeded now or already there) — the
|
||||
/// pipeline may point at the Claude conversation entity.
|
||||
available: bool,
|
||||
/// This call wrote the store.
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Seed HA's `anthropic` integration from the node's shared Claude API key
|
||||
/// (`secrets/claude-api-key`, the same key the mesh `!ai` assistant uses).
|
||||
/// Skips when the key is absent or an anthropic entry already exists.
|
||||
async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
|
||||
let none = ClaudeSeed {
|
||||
available: false,
|
||||
changed: false,
|
||||
};
|
||||
let key = match tokio::fs::read_to_string(
|
||||
std::path::Path::new(NODE_SECRETS_DIR).join("claude-api-key"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||
_ => {
|
||||
info!("pine/HA seed: no claude-api-key on node — skipping Claude conversation agent");
|
||||
return none;
|
||||
}
|
||||
};
|
||||
|
||||
let path = storage.join("core.config_entries");
|
||||
let mut store = if tokio::fs::metadata(&path).await.is_ok() {
|
||||
match read_store(&path).await {
|
||||
Some(v) => v,
|
||||
None => return none,
|
||||
}
|
||||
} else {
|
||||
json!({
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.config_entries",
|
||||
"data": { "entries": [] }
|
||||
})
|
||||
};
|
||||
|
||||
let Some(entries) = store
|
||||
.get_mut("data")
|
||||
.and_then(|d| d.get_mut("entries"))
|
||||
.and_then(|e| e.as_array_mut())
|
||||
else {
|
||||
warn!("pine/HA seed: core.config_entries has unexpected shape, leaving untouched");
|
||||
return none;
|
||||
};
|
||||
|
||||
if entries
|
||||
.iter()
|
||||
.any(|e| e.get("domain").and_then(Value::as_str) == Some("anthropic"))
|
||||
{
|
||||
return ClaudeSeed {
|
||||
available: true,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let id = |raw: [u8; 16]| hex::encode(raw);
|
||||
// Shape mirrors what HA 2026.7's anthropic config flow creates (entry
|
||||
// version 2.4 with conversation + ai_task subentries). Bookkeeping
|
||||
// fields (created_at/modified_at/discovery_keys) must be written here:
|
||||
// the store already carries HA's current schema minor_version, so HA
|
||||
// never migrates appended entries — a missing created_at is a
|
||||
// KeyError that crash-loops HA at boot.
|
||||
entries.push(json!({
|
||||
"entry_id": id(rand::random()),
|
||||
"version": 2,
|
||||
"minor_version": 4,
|
||||
"domain": "anthropic",
|
||||
"title": "Claude",
|
||||
"data": { "api_key": key },
|
||||
"options": {},
|
||||
"pref_disable_new_entities": false,
|
||||
"pref_disable_polling": false,
|
||||
"source": "user",
|
||||
"unique_id": null,
|
||||
"disabled_by": null,
|
||||
"created_at": ha_now(),
|
||||
"modified_at": ha_now(),
|
||||
"discovery_keys": {},
|
||||
"subentries": [
|
||||
{
|
||||
"subentry_id": id(rand::random()),
|
||||
"subentry_type": "conversation",
|
||||
"title": "Claude conversation",
|
||||
"unique_id": null,
|
||||
"data": {
|
||||
"recommended": true,
|
||||
"llm_hass_api": ["assist"],
|
||||
// Steers fuzzy phrasings onto the local Archy* intent
|
||||
// tools (cheap + exact) instead of free-form answers,
|
||||
// and keeps replies speaker-length.
|
||||
"prompt": "You are Archy, the voice of this Archipelago Bitcoin node, speaking through a smart speaker. Answers are spoken aloud: keep them to one or two short sentences, no markdown, no lists. When the user asks about the node — block height, sync status, peers, balances — call the matching Archy tool rather than answering from memory, even if the phrasing is loose. Only answer directly when no tool fits."
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_id": id(rand::random()),
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "Claude AI Task",
|
||||
"unique_id": null,
|
||||
"data": { "recommended": true }
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
if !write_store(&path, &store).await {
|
||||
return none;
|
||||
}
|
||||
info!("pine/HA seed: added Claude conversation agent (anthropic config entry)");
|
||||
ClaudeSeed {
|
||||
available: true,
|
||||
changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wyoming config entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ensure `core.config_entries` has a `wyoming` entry per Pine engine.
|
||||
async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||
let path = storage.join("core.config_entries");
|
||||
@@ -202,7 +598,11 @@ async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||
"pref_disable_polling": false,
|
||||
"source": "user",
|
||||
"unique_id": null,
|
||||
"disabled_by": null
|
||||
"disabled_by": null,
|
||||
"created_at": ha_now(),
|
||||
"modified_at": ha_now(),
|
||||
"discovery_keys": {},
|
||||
"subentries": []
|
||||
}));
|
||||
info!("pine/HA seed: added wyoming config entry {title} ({host}:{port})");
|
||||
added = true;
|
||||
@@ -214,11 +614,17 @@ async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||
added
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assist pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ensure an Assist pipeline uses the Pine engines. Creates the store when
|
||||
/// missing; when it exists, only repairs the pre-2024.6 conversation-agent id
|
||||
/// (`homeassistant` -> `conversation.home_assistant`), which modern HA no
|
||||
/// longer resolves (pipelines error with `intent-not-supported` otherwise).
|
||||
async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
/// missing; when it exists, repairs the pre-2024.6 conversation-agent id
|
||||
/// (`homeassistant` -> `conversation.home_assistant`) and — when a Claude
|
||||
/// agent is available — upgrades pipelines still on the default local agent
|
||||
/// to Claude with `prefer_local_intents: true` (node intents stay local/free,
|
||||
/// everything else goes to Claude).
|
||||
async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&str>) -> bool {
|
||||
let path = storage.join("assist_pipeline.pipelines");
|
||||
|
||||
if let Some(mut store) = read_store(&path).await {
|
||||
@@ -229,15 +635,25 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut repaired = false;
|
||||
let mut changed = false;
|
||||
for item in items.iter_mut() {
|
||||
if item.get("conversation_engine").and_then(Value::as_str) == Some("homeassistant") {
|
||||
item["conversation_engine"] = json!("conversation.home_assistant");
|
||||
repaired = true;
|
||||
info!("pine/HA seed: repaired legacy conversation agent id in Assist pipeline");
|
||||
changed = true;
|
||||
}
|
||||
if let Some(engine) = claude_entity {
|
||||
if item.get("conversation_engine").and_then(Value::as_str)
|
||||
== Some("conversation.home_assistant")
|
||||
{
|
||||
item["conversation_engine"] = json!(engine);
|
||||
item["prefer_local_intents"] = json!(true);
|
||||
info!("pine/HA seed: pipeline upgraded to Claude (local intents preferred)");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if repaired {
|
||||
info!("pine/HA seed: repaired legacy conversation agent id in Assist pipelines");
|
||||
if changed {
|
||||
return write_store(&path, &store).await;
|
||||
}
|
||||
return false;
|
||||
@@ -257,11 +673,12 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
"key": "assist_pipeline.pipelines",
|
||||
"data": {
|
||||
"items": [{
|
||||
"conversation_engine": "conversation.home_assistant",
|
||||
"conversation_engine": claude_entity.unwrap_or("conversation.home_assistant"),
|
||||
"conversation_language": "en",
|
||||
"id": id,
|
||||
"language": "en",
|
||||
"name": "Pine (local)",
|
||||
"prefer_local_intents": claude_entity.is_some(),
|
||||
"stt_engine": "stt.faster_whisper",
|
||||
"stt_language": "en",
|
||||
"tts_engine": "tts.piper",
|
||||
@@ -280,6 +697,193 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wyoming satellite keeper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
|
||||
///
|
||||
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
|
||||
/// renumbering — or the whole node moving to a different network — strands
|
||||
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
|
||||
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
|
||||
/// never re-resolves on its own. This keeper probes each satellite entry and,
|
||||
/// when one stops answering, sweeps the node's local /24s for the same
|
||||
/// Wyoming port and rewrites the entry to the address that answers.
|
||||
pub(crate) async fn wyoming_satellite_keeper() {
|
||||
loop {
|
||||
if home_assistant_installed().await {
|
||||
heal_wyoming_satellites().await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(ms),
|
||||
tokio::net::TcpStream::connect((host, port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
|
||||
/// excluded — satellites live on real LANs.
|
||||
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
|
||||
let Ok(out) = tokio::process::Command::new("ip")
|
||||
.args(["-4", "-o", "addr", "show", "scope", "global"])
|
||||
.output()
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let cidr = l.split_whitespace().nth(3)?;
|
||||
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
|
||||
let o = ip.octets();
|
||||
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
|
||||
if o[0] == 100 && (64..128).contains(&o[1]) {
|
||||
return None;
|
||||
}
|
||||
Some(ip)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Sweep the /24 of every local interface for something answering `port`.
|
||||
/// First responder wins; the node's own addresses are skipped.
|
||||
async fn find_satellite(port: u16) -> Option<String> {
|
||||
let self_ips = local_ipv4_addresses().await;
|
||||
for base in self_ips.iter().map(|ip| ip.octets()) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for i in 1..255u8 {
|
||||
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
|
||||
if self_ips.contains(&ip) {
|
||||
continue;
|
||||
}
|
||||
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
if let Ok(Some(ip)) = res {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
|
||||
/// port now answers. Stops HA before editing the store (HA flushes its own
|
||||
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
|
||||
/// again after.
|
||||
async fn heal_wyoming_satellites() {
|
||||
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
|
||||
let Some(store) = read_store(&path).await else {
|
||||
return;
|
||||
};
|
||||
let Some(entries) = store
|
||||
.get("data")
|
||||
.and_then(|d| d.get("entries"))
|
||||
.and_then(|e| e.as_array())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// (host, port, new_host) for every stranded satellite we can re-resolve.
|
||||
let mut moves: Vec<(String, u16, String)> = Vec::new();
|
||||
for e in entries {
|
||||
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||
continue;
|
||||
}
|
||||
let Some(host) = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Engines use host.containers.internal — only IP-pinned entries drift.
|
||||
if host.parse::<std::net::Ipv4Addr>().is_err() {
|
||||
continue;
|
||||
}
|
||||
let port = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as u16;
|
||||
if port == 0 || tcp_alive(host, port, 1500).await {
|
||||
continue;
|
||||
}
|
||||
let Some(new_host) = find_satellite(port).await else {
|
||||
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
|
||||
continue;
|
||||
};
|
||||
if new_host != host {
|
||||
moves.push((host.to_string(), port, new_host));
|
||||
}
|
||||
}
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop HA, re-read + rewrite the store, start HA.
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "homeassistant"])
|
||||
.output()
|
||||
.await;
|
||||
if let Some(mut store) = read_store(&path).await {
|
||||
let mut changed = false;
|
||||
if let Some(entries) = store
|
||||
.get_mut("data")
|
||||
.and_then(|d| d.get_mut("entries"))
|
||||
.and_then(|e| e.as_array_mut())
|
||||
{
|
||||
for e in entries.iter_mut() {
|
||||
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||
continue;
|
||||
}
|
||||
let host = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let port = e
|
||||
.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0) as u16;
|
||||
if let Some((_, _, new_host)) =
|
||||
moves.iter().find(|(h, p, _)| *h == host && *p == port)
|
||||
{
|
||||
if let Some(data) = e.get_mut("data") {
|
||||
data["host"] = json!(new_host);
|
||||
}
|
||||
e["modified_at"] = json!(ha_now());
|
||||
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
write_store(&path, &store).await;
|
||||
}
|
||||
}
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "homeassistant"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presence probes + HA restart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// True when the Pine Wyoming engines are installed on this node (their shared
|
||||
/// data dir is created by the stack install).
|
||||
pub(super) async fn pine_engines_installed() -> bool {
|
||||
@@ -291,9 +895,7 @@ pub(super) async fn pine_engines_installed() -> bool {
|
||||
/// True when Home Assistant has a config dir on this node (installed at some
|
||||
/// point; .storage may not exist until first boot, which seeding handles).
|
||||
pub(super) async fn home_assistant_installed() -> bool {
|
||||
tokio::fs::metadata("/var/lib/archipelago/home-assistant")
|
||||
.await
|
||||
.is_ok()
|
||||
tokio::fs::metadata(HA_CONFIG_DIR).await.is_ok()
|
||||
}
|
||||
|
||||
/// Restart the HA container so it loads the seeded storage. Best-effort: when
|
||||
@@ -368,3 +970,44 @@ async fn write_store(path: &std::path::Path, store: &Value) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voice_block_is_bounded_and_carries_token() {
|
||||
let block = voice_config_block("deadbeef");
|
||||
assert!(block.starts_with(VOICE_MARKER));
|
||||
assert!(block.trim_end().ends_with(VOICE_END_MARKER));
|
||||
assert!(block.contains("Bearer deadbeef"));
|
||||
// Every intent the sentences file declares has a script answer.
|
||||
for intent in [
|
||||
"ArchyBlockHeight",
|
||||
"ArchyPeers",
|
||||
"ArchySyncStatus",
|
||||
"ArchyLightningBalance",
|
||||
] {
|
||||
assert!(block.contains(intent), "missing intent {intent}");
|
||||
assert!(
|
||||
VOICE_SENTENCES.contains(intent),
|
||||
"missing sentences {intent}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_block_replacement_drops_old_content() {
|
||||
// Simulates the pre-endpoint node state: legacy marker + hand-edited
|
||||
// socat/bitcoind sensor block at EOF.
|
||||
let existing = format!(
|
||||
"default_config:\n\n{VOICE_MARKER}\nrest:\n - resource: http://host.containers.internal:18332/\n username: archipelago\n"
|
||||
);
|
||||
let begin = existing.find(VOICE_MARKER).unwrap();
|
||||
let desired = voice_config_block("tok");
|
||||
let merged = format!("{}{}", &existing[..begin], desired);
|
||||
assert!(!merged.contains("18332"));
|
||||
assert!(merged.contains("Bearer tok"));
|
||||
assert!(merged.starts_with("default_config:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ use tracing::info;
|
||||
|
||||
use super::install::{install_log, patch_indeedhub_nostr_provider};
|
||||
|
||||
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
// 45s, not 10s: under heavy disk load (e.g. an ElectrumX resync/compaction
|
||||
// hammering the same SSD) a plain `podman ps` was observed taking >10s on
|
||||
// .116 (2026-07-22), failing a mempool install at the probe step for no real
|
||||
// reason. Podman being slow is not podman being dead.
|
||||
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const PODMAN_STACK_LOG_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PODMAN_STACK_PULL_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const PODMAN_STACK_REMOVE_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
@@ -750,7 +754,7 @@ fn pine_stack_app_ids() -> &'static [&'static str] {
|
||||
// then the user-facing launcher ("pine", the nginx that serves the setup /
|
||||
// status page + is the Open target). Mirrors the pine startup_order in
|
||||
// dependencies.rs + the stack member table in app_ops.rs.
|
||||
&["pine-whisper", "pine-piper", "pine"]
|
||||
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"]
|
||||
}
|
||||
|
||||
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||
@@ -1938,8 +1942,12 @@ impl RpcHandler {
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) =
|
||||
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"pine",
|
||||
"pine",
|
||||
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Self::seed_pine_ha_defaults().await;
|
||||
return Ok(adopted);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Node-status JSON for the Pine voice stack (`GET /api/pine/status`).
|
||||
//!
|
||||
//! Two tiers in one endpoint:
|
||||
//! - **Public** (no credentials): version, uptime, bitcoin height / sync /
|
||||
//! peer count, mesh peer count. Feeds the Pine launcher page's live status
|
||||
//! card — nothing here a LAN visitor couldn't already infer from the
|
||||
//! existing unauthenticated `/bitcoin-status`.
|
||||
//! - **Token** (`Authorization: Bearer <pine-status-token>`): adds Lightning
|
||||
//! balances and the most recent received mesh text message. Feeds the
|
||||
//! Home Assistant REST sensors seeded by `package::pine_ha` — the seeder
|
||||
//! mints the token into `data_dir/secrets/pine-status-token` (0600) and
|
||||
//! embeds it in HA's configuration, so only HA (and the node owner) can
|
||||
//! read balances or message text.
|
||||
//!
|
||||
//! Replaces the interim per-node socat forwarder + bitcoind-RPC-credentials-
|
||||
//! in-configuration.yaml stopgap used before this endpoint existed.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::mesh::types::MessageDirection;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// File under `data_dir/secrets/` holding the bearer token that unlocks the
|
||||
/// sensitive tier. Written by the pine/HA seeder, read per-request here.
|
||||
pub(crate) const PINE_STATUS_TOKEN_FILE: &str = "pine-status-token";
|
||||
|
||||
/// Constant-time-ish equality — avoids early-exit timing on the token compare.
|
||||
fn token_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes()
|
||||
.zip(b.bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// True when `presented` matches the on-disk pine status token. Missing or
|
||||
/// empty token file means the sensitive tier is locked (seeder not run).
|
||||
pub(crate) async fn pine_status_token_ok(&self, presented: &str) -> bool {
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("secrets")
|
||||
.join(PINE_STATUS_TOKEN_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(tok) => {
|
||||
let tok = tok.trim();
|
||||
!tok.is_empty() && token_eq(tok, presented.trim())
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the status document. `authorized` selects the token tier.
|
||||
/// Every sub-source is best-effort: a dead bitcoind/LND/mesh never turns
|
||||
/// the endpoint into an error — the field just reports what it can.
|
||||
pub(crate) async fn pine_status_json(&self, authorized: bool) -> Value {
|
||||
let bs = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let bitcoin = {
|
||||
let info = bs.blockchain_info.as_ref();
|
||||
let height = info.and_then(|i| i.get("blocks")).and_then(Value::as_u64);
|
||||
let progress = info
|
||||
.and_then(|i| i.get("verificationprogress"))
|
||||
.and_then(Value::as_f64);
|
||||
let ibd = info
|
||||
.and_then(|i| i.get("initialblockdownload"))
|
||||
.and_then(Value::as_bool);
|
||||
let peers = bs
|
||||
.network_info
|
||||
.as_ref()
|
||||
.and_then(|n| n.get("connections"))
|
||||
.and_then(Value::as_u64);
|
||||
json!({
|
||||
"ok": bs.ok,
|
||||
"height": height,
|
||||
"sync_percent": progress.map(|p| (p * 10000.0).round() / 100.0),
|
||||
"ibd": ibd,
|
||||
"peers": peers,
|
||||
})
|
||||
};
|
||||
|
||||
let (mesh, mesh_message) = {
|
||||
let guard = self.mesh_service.read().await;
|
||||
match guard.as_ref() {
|
||||
Some(svc) => {
|
||||
let status = svc.status().await;
|
||||
let latest = if authorized {
|
||||
svc.messages(None)
|
||||
.await
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| {
|
||||
m.direction == MessageDirection::Received
|
||||
&& m.message_type == "text"
|
||||
})
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"from": m.peer_name.clone()
|
||||
.unwrap_or_else(|| format!("contact {}", m.peer_contact_id)),
|
||||
"text": m.plaintext,
|
||||
"timestamp": m.timestamp,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
json!({ "enabled": status.enabled, "peers": status.peer_count }),
|
||||
latest,
|
||||
)
|
||||
}
|
||||
None => (json!({ "enabled": false, "peers": 0 }), None),
|
||||
}
|
||||
};
|
||||
|
||||
let lightning = if authorized {
|
||||
match self.handle_lnd_getinfo().await {
|
||||
Ok(info) => json!({
|
||||
"balance_sats": info.get("balance_sats"),
|
||||
"channel_balance_sats": info.get("channel_balance_sats"),
|
||||
"active_channels": info.get("num_active_channels"),
|
||||
"synced_to_chain": info.get("synced_to_chain"),
|
||||
}),
|
||||
Err(_) => Value::Null,
|
||||
}
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": crate::crash_recovery::uptime_seconds(),
|
||||
"bitcoin": bitcoin,
|
||||
"mesh": mesh,
|
||||
"lightning": lightning,
|
||||
// Always an object: HA's REST sensor reads attributes via
|
||||
// json_attributes_path "$.mesh_message", and a null there makes
|
||||
// HA log a "JSON result was not a dictionary" warning every scan.
|
||||
"mesh_message": mesh_message.unwrap_or_else(|| json!({})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::token_eq;
|
||||
|
||||
#[test]
|
||||
fn token_eq_matches_only_exact() {
|
||||
assert!(token_eq("abc123", "abc123"));
|
||||
assert!(!token_eq("abc123", "abc124"));
|
||||
assert!(!token_eq("abc123", "abc12"));
|
||||
assert!(!token_eq("", "x"));
|
||||
assert!(token_eq("", ""));
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,17 @@ impl RpcHandler {
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "archipelago".to_string());
|
||||
// LAN IPv4 rides along for the companion pairing QR: when the
|
||||
// operator's browser reaches this node over Tailscale/VPN or
|
||||
// localhost, that origin is useless to a phone on the LAN — the QR
|
||||
// must advertise an address the phone can actually dial
|
||||
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
|
||||
// companion could never connect).
|
||||
let lan_ip = crate::host_ip::primary_host_ipv4().await;
|
||||
Ok(serde_json::json!({
|
||||
"hostname": hostname,
|
||||
"mdns_hostname": format!("{hostname}.local"),
|
||||
"lan_ip": lan_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
// The legacy umbrella id maps to the split stack (the orchestrator's
|
||||
// umbrella alias handles this too; listing it here keeps the RPC
|
||||
// layer's fan-out explicit).
|
||||
|
||||
@@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
|
||||
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
|
||||
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
|
||||
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
|
||||
// The router daemon handles routing + the ELD re-modeset nudge; this heal
|
||||
// installs the packages, group membership, script and unit on deployed nodes.
|
||||
const AUDIO_ROUTER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
|
||||
const AUDIO_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
|
||||
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
|
||||
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
|
||||
|
||||
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
|
||||
// splice-from-configs + self-heal pattern as the audio router.
|
||||
const GAMEPAD_KEYS: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
|
||||
const GAMEPAD_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
|
||||
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
|
||||
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
|
||||
|
||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||
// write the identical file at build time (image-recipe/_archived/
|
||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||
@@ -80,6 +102,13 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the Pine node-status proxy.
|
||||
/// `/api/pine/status` serves the Pine launcher page's live status card and
|
||||
/// the seeded Home Assistant REST sensors (the sensitive tier is gated by a
|
||||
/// bearer token at the backend, so nginx just forwards). Kept in sync with
|
||||
/// the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PINE_STATUS_BLOCK: &str = "\n # Pine node status — live node facts for the Pine launcher page and the\n # seeded Home Assistant sensors. Sensitive fields are token-gated at the\n # backend; nginx only forwards.\n location /api/pine/status {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Authorization $http_authorization;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 10s;\n proxy_read_timeout 15s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
@@ -787,6 +816,109 @@ pub async fn ensure_kiosk_hardened() {
|
||||
}
|
||||
}
|
||||
|
||||
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
|
||||
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
|
||||
/// under the lingering user manager — no logind seat, so no udev ACLs on
|
||||
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
|
||||
/// nudge) installed and current. No-op on nodes without the kiosk — audio
|
||||
/// only matters where media plays on an attached display.
|
||||
pub async fn ensure_audio_stack() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // no kiosk → no display audio to route
|
||||
}
|
||||
|
||||
// Package install runs via systemd-run (host_sudo), outside the service
|
||||
// sandbox — /usr and the dpkg database are read-only in our namespace.
|
||||
if fs::metadata("/usr/bin/pactl").await.is_err() {
|
||||
info!("audio: PipeWire stack missing — installing packages");
|
||||
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
|
||||
match host_sudo(&[
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"--no-install-recommends",
|
||||
"pipewire",
|
||||
"pipewire-pulse",
|
||||
"pipewire-alsa",
|
||||
"wireplumber",
|
||||
"alsa-utils",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
|
||||
Ok(s) => {
|
||||
warn!("audio: package install exited with {} — will retry next start", s);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("audio: package install failed: {:#} — will retry next start", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
|
||||
|
||||
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("audio: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
// First install on this node — bring it up now and on every boot.
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
|
||||
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
|
||||
} else if script_changed || unit_changed {
|
||||
// Content update: restart only if it's running — never re-enable a
|
||||
// unit an operator deliberately disabled.
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
|
||||
info!("audio: router updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
|
||||
/// daemon (controller works inside every app iframe on the TV) installed and
|
||||
/// current. Same gating as audio: no kiosk → no display input to bridge.
|
||||
pub async fn ensure_gamepad_keys() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
|
||||
info!("gamepad: bridge installed and enabled (TV controller input)");
|
||||
} else if script_changed || unit_changed {
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
|
||||
info!("gamepad: bridge updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@@ -854,6 +986,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
&& !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
|
||||
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
|
||||
let missing_pine_status = has_lnd_anchor && !content.contains("location /api/pine/status");
|
||||
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
@@ -862,6 +995,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !missing_pine_status
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
{
|
||||
@@ -907,6 +1041,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
if missing_pine_status {
|
||||
// Same anchoring as the LND proxy: prepend to every server block that
|
||||
// proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PINE_STATUS_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/pine/status patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
|
||||
@@ -108,17 +108,33 @@ impl BootReconciler {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
Some(tokio::spawn(async move {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
let installed = orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
||||
{
|
||||
let failures =
|
||||
crate::container::companion::reconcile(&installed).await;
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
time::sleep(interval).await;
|
||||
// A failed repair can involve registry pulls and full
|
||||
// image builds; retrying every 30s hammered unreachable
|
||||
// registries ~174×/image/day on an offline node
|
||||
// (archy-x250-dev log sweep, 2026-07-22). Back off
|
||||
// exponentially while rounds keep failing — 30s doubling
|
||||
// to a 1h cap — and reset the moment a round is clean.
|
||||
failure_rounds = if failures.is_empty() {
|
||||
0
|
||||
} else {
|
||||
failure_rounds.saturating_add(1)
|
||||
};
|
||||
let backoff = interval
|
||||
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
|
||||
.min(Duration::from_secs(3600));
|
||||
time::sleep(backoff).await;
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
|
||||
@@ -59,6 +59,7 @@ impl DockerPackageScanner {
|
||||
"netbird-dashboard",
|
||||
"pine-whisper",
|
||||
"pine-piper",
|
||||
"pine-openwakeword",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
|
||||
@@ -3094,8 +3094,9 @@ impl ProdContainerOrchestrator {
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
// The known Bitcoin node containers, preferred in order. Any archy
|
||||
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
@@ -3105,12 +3106,23 @@ impl ProdContainerOrchestrator {
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||
// Prefer a known name in priority order…
|
||||
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
||||
return hit.to_string();
|
||||
}
|
||||
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
||||
// future Bitcoin distribution archy ships works without editing this
|
||||
// list (user req 2026-07-22). Excludes companions/sidecars like
|
||||
// `bitcoin-ui` and `archy-*`.
|
||||
if let Some(other) = running.iter().find(|n| {
|
||||
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
||||
&& !n.ends_with("-ui")
|
||||
&& !n.starts_with("archy-")
|
||||
}) {
|
||||
return other.to_string();
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3247,10 +3259,10 @@ impl ProdContainerOrchestrator {
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
|
||||
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
|
||||
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
|
||||
}
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
|
||||
// derived-env and broke Core nodes.
|
||||
|
||||
let provider = FileSecretsProvider {
|
||||
root: self.secrets_dir.clone(),
|
||||
|
||||
@@ -284,7 +284,7 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "PodmanArgs=--cpus={cpus}");
|
||||
}
|
||||
if let Some(h) = &self.health {
|
||||
let _ = writeln!(s, "HealthCmd={}", h.cmd);
|
||||
let _ = writeln!(s, "HealthCmd={}", h.cmd.replace('%', "%%"));
|
||||
let _ = writeln!(s, "HealthInterval={}", h.interval);
|
||||
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
|
||||
let _ = writeln!(s, "HealthRetries={}", h.retries);
|
||||
@@ -298,7 +298,7 @@ impl QuadletUnit {
|
||||
// images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper
|
||||
// into `bitcoind sh -lc ...` and crash-looped). Emitting
|
||||
// Entrypoint= makes the unit independent of the image's entrypoint.
|
||||
let _ = writeln!(s, "Entrypoint={first}");
|
||||
let _ = writeln!(s, "Entrypoint={}", first.replace('%', "%%"));
|
||||
let mut parts: Vec<String> = rest.to_vec();
|
||||
parts.extend(self.command.iter().cloned());
|
||||
if !parts.is_empty() {
|
||||
@@ -335,11 +335,16 @@ impl QuadletUnit {
|
||||
/// the minimum quoting needed so quadlet's parser sees one element per
|
||||
/// item: anything containing whitespace, quotes, or shell metacharacters
|
||||
/// gets wrapped in double quotes with embedded `"` and `\` escaped.
|
||||
///
|
||||
/// `%` is escaped to `%%`: quadlet copies Exec= content into the generated
|
||||
/// service's ExecStart, where systemd expands `%` specifiers at load time —
|
||||
/// a bare `%s` in a manifest script silently became `/bin/bash` (the user's
|
||||
/// shell) and corrupted bitcoind's generated rpc.conf.
|
||||
fn shell_join(parts: &[String]) -> String {
|
||||
parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.replace(['\r', '\n'], " ");
|
||||
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
|
||||
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
|
||||
let escaped = p
|
||||
.replace('\\', "\\\\")
|
||||
@@ -355,7 +360,8 @@ fn shell_join(parts: &[String]) -> String {
|
||||
}
|
||||
|
||||
fn quote_environment(env: &str) -> String {
|
||||
let env = env.replace(['\r', '\n'], " ");
|
||||
// `%` → `%%` for the same systemd-specifier reason as shell_join.
|
||||
let env = env.replace(['\r', '\n'], " ").replace('%', "%%");
|
||||
if env.is_empty()
|
||||
|| env
|
||||
.chars()
|
||||
@@ -1122,6 +1128,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_is_escaped_for_systemd_specifiers() {
|
||||
// Regression: a bare `%s` in an Exec= line is a systemd specifier
|
||||
// (user's shell = /bin/bash) once quadlet copies it into the
|
||||
// generated ExecStart — bitcoind's rpc.conf came out as
|
||||
// `rpcuser=/bin/bash` and every RPC consumer got 401s.
|
||||
assert_eq!(
|
||||
shell_join(&["printf 'rpcuser=%s' \"$U\"".to_string()]),
|
||||
"\"printf 'rpcuser=%%s' \\\"$$U\\\"\""
|
||||
);
|
||||
assert_eq!(shell_join(&["--fmt=%h:%p".to_string()]), "--fmt=%%h:%%p");
|
||||
assert_eq!(quote_environment("FMT=%m"), "FMT=%%m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_policy_emits_correct_systemd_string() {
|
||||
assert_eq!(RestartPolicy::Always.as_systemd(), "always");
|
||||
|
||||
@@ -51,9 +51,25 @@ pub enum AccessControl {
|
||||
PeersOnly,
|
||||
Paid {
|
||||
price_sats: u64,
|
||||
/// Payment methods the sharer accepts: "lightning", "onchain",
|
||||
/// "ecash", "fedimint". Empty = everything — which is also what
|
||||
/// catalogs written before this field deserialize to.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
accepted: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Does the sharer accept this payment method for the item? Empty list =
|
||||
/// all methods (pre-field catalogs and "no preference").
|
||||
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
|
||||
match access {
|
||||
AccessControl::Paid { accepted, .. } => {
|
||||
accepted.is_empty() || accepted.iter().any(|m| m == method)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ContentCatalog {
|
||||
pub items: Vec<ContentItem>,
|
||||
@@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
|
||||
}
|
||||
|
||||
/// Add a content item to the catalog.
|
||||
///
|
||||
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
||||
/// every call, so id-only dedup let the same file be shared twice as two
|
||||
/// separately-priced entries — and a buyer paid twice for one file
|
||||
/// (2026-07-22). Same filename → update the existing entry in place and
|
||||
/// keep its id, so existing buyers' owned records stay valid.
|
||||
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
|
||||
let mut catalog = load_catalog(data_dir).await?;
|
||||
if catalog.items.iter().any(|i| i.id == item.id) {
|
||||
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
|
||||
}
|
||||
catalog.items.push(item);
|
||||
let norm = |f: &str| f.trim_start_matches('/').to_string();
|
||||
if let Some(existing) = catalog
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|i| norm(&i.filename) == norm(&item.filename))
|
||||
{
|
||||
let keep_id = existing.id.clone();
|
||||
*existing = item;
|
||||
existing.id = keep_id;
|
||||
} else {
|
||||
catalog.items.push(item);
|
||||
}
|
||||
save_catalog(data_dir, &catalog).await?;
|
||||
Ok(catalog)
|
||||
}
|
||||
@@ -252,20 +285,26 @@ pub async fn serve_content(
|
||||
|
||||
// Check access control
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats } => {
|
||||
AccessControl::Paid { price_sats, .. } => {
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||
|
||||
for (i, record) in containers.iter().enumerate() {
|
||||
// Skip containers that are already up — `podman start` on a running
|
||||
// container produces the noisy benign conmon "Failed to create
|
||||
// container" + cgroup Permission-denied journal pair (fleet log
|
||||
// sweep 2026-07-22).
|
||||
if container_state(&record.name).await.as_deref() == Some("running") {
|
||||
report.recovered += 1;
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"Recovering container: {} (image: {})",
|
||||
record.name, record.image
|
||||
@@ -768,6 +776,7 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
|
||||
| "netbird"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
| "pine"
|
||||
)
|
||||
}
|
||||
@@ -836,9 +845,10 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
|
||||
aliases: &[
|
||||
("pine-whisper", "pine-whisper"),
|
||||
("pine-piper", "pine-piper"),
|
||||
("pine-openwakeword", "pine-openwakeword"),
|
||||
("pine", "pine"),
|
||||
],
|
||||
containers: &["pine-whisper", "pine-piper", "pine"],
|
||||
containers: &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
// The launcher only depends on the two engines; whisper is the
|
||||
// heaviest/first member, so treat it as the stack anchor (its
|
||||
// presence means the stack was really installed, not orphan debris).
|
||||
@@ -934,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
async fn start_existing_container(container: &str) -> bool {
|
||||
info!("Recovering stack container: {}", container);
|
||||
let timeout = match container {
|
||||
"immich_server" | "netbird-server" => Duration::from_secs(120),
|
||||
_ => Duration::from_secs(90),
|
||||
};
|
||||
if container_state(container).await.as_deref() == Some("initialized") {
|
||||
cleanup_container_runtime_state(container).await;
|
||||
match container_state(container).await.as_deref() {
|
||||
// Already up — `podman start` on a running container makes conmon
|
||||
// try to join the live cgroup and fail with a scary "Failed to
|
||||
// create container: exit status 1" + cgroup.procs Permission denied
|
||||
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
|
||||
// these per day per node, all benign. Skip instead.
|
||||
Some("running") => return true,
|
||||
Some("initialized") => cleanup_container_runtime_state(container).await,
|
||||
_ => {}
|
||||
}
|
||||
info!("Recovering stack container: {}", container);
|
||||
match podman_output(&["start", container], timeout).await {
|
||||
Ok(output) if output.status.success() => {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Companion device tokens — long-lived bearer credentials minted from an
|
||||
//! authenticated session, so the pairing QR can log a phone in without
|
||||
//! carrying the admin password (which the browser never has anyway).
|
||||
//!
|
||||
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
|
||||
//! data dir); the plaintext is returned exactly once at mint time and rides
|
||||
//! the QR as the `tok` param. Verification goes through `auth.login`'s
|
||||
//! `token` param and is covered by the same login rate limiter as passwords.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const TOKENS_FILE: &str = "device-tokens.json";
|
||||
|
||||
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
|
||||
/// so this only limits the number of *distinct* device names.
|
||||
const MAX_TOKENS: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceToken {
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the plaintext token.
|
||||
pub hash: String,
|
||||
/// Unix seconds at mint time.
|
||||
pub created: u64,
|
||||
}
|
||||
|
||||
fn tokens_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(TOKENS_FILE)
|
||||
}
|
||||
|
||||
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
match fs::read(tokens_path(data_dir)).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(tokens)?;
|
||||
fs::write(tokens_path(data_dir), bytes)
|
||||
.await
|
||||
.context("write device-tokens.json")
|
||||
}
|
||||
|
||||
fn hash_hex(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// Mint a new token for `name`. An existing token with the same name is
|
||||
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
||||
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
||||
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
||||
let token_bytes: [u8; 32] = rand::random();
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let mut tokens = load(data_dir).await;
|
||||
tokens.retain(|t| t.name != name);
|
||||
if tokens.len() >= MAX_TOKENS {
|
||||
tokens.remove(0);
|
||||
}
|
||||
tokens.push(DeviceToken {
|
||||
name: name.to_string(),
|
||||
hash: hash_hex(&token),
|
||||
created: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
});
|
||||
save(data_dir, &tokens).await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verify a candidate token. Returns the device name it was minted for.
|
||||
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
|
||||
let candidate_hash = hash_hex(candidate);
|
||||
load(data_dir)
|
||||
.await
|
||||
.iter()
|
||||
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
|
||||
.map(|t| t.name.clone())
|
||||
}
|
||||
|
||||
/// List stored tokens (hashes only — plaintexts are unrecoverable).
|
||||
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
load(data_dir).await
|
||||
}
|
||||
|
||||
/// Remove the token minted for `name`. Returns whether one existed.
|
||||
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
|
||||
let mut tokens = load(data_dir).await;
|
||||
let before = tokens.len();
|
||||
tokens.retain(|t| t.name != name);
|
||||
let removed = tokens.len() != before;
|
||||
if removed {
|
||||
save(data_dir, &tokens).await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_verify_replace_remove() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token = create(dir.path(), "phone").await.unwrap();
|
||||
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
|
||||
assert!(verify(dir.path(), "not-a-token").await.is_none());
|
||||
|
||||
// Re-minting the same name invalidates the old token.
|
||||
let token2 = create(dir.path(), "phone").await.unwrap();
|
||||
assert!(verify(dir.path(), &token).await.is_none());
|
||||
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
|
||||
assert_eq!(list(dir.path()).await.len(), 1);
|
||||
|
||||
assert!(remove(dir.path(), "phone").await.unwrap());
|
||||
assert!(verify(dir.path(), &token2).await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,31 @@ pub fn archy_anchor() -> SeedAnchor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Public FIPS-network anchors from join.fips.network (adverts published as
|
||||
/// Nostr kind-37195 `fips-overlay-v1` events, refreshed hourly). Of the eight
|
||||
/// live test anchors (2026-07-22), these two are the dual-transport ones —
|
||||
/// they answer on TCP as well as UDP, and TCP is what traverses restrictive
|
||||
/// networks. The remaining six are UDP-only; add them per-node via
|
||||
/// `fips.add-seed-anchor` if more rendezvous diversity is wanted.
|
||||
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
|
||||
vec![
|
||||
SeedAnchor {
|
||||
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u"
|
||||
.to_string(),
|
||||
address: "23.182.128.74:443".to_string(),
|
||||
transport: "tcp".to_string(),
|
||||
label: "FIPS network anchor (join.fips.network)".to_string(),
|
||||
},
|
||||
SeedAnchor {
|
||||
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
|
||||
.to_string(),
|
||||
address: "217.77.8.91:443".to_string(),
|
||||
transport: "tcp".to_string(),
|
||||
label: "FIPS network anchor (join.fips.network)".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The default anchor set carried implicitly by `load()` on nodes that have
|
||||
/// never edited their anchor list, so every node dials them without operator
|
||||
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
|
||||
@@ -88,7 +113,9 @@ pub fn archy_anchor() -> SeedAnchor {
|
||||
/// network can reach wins. The Archipelago-operated anchor is listed first
|
||||
/// because it is reachable from the widest set of networks.
|
||||
pub fn default_public_anchors() -> Vec<SeedAnchor> {
|
||||
vec![archy_anchor(), default_public_anchor()]
|
||||
let mut anchors = vec![archy_anchor(), default_public_anchor()];
|
||||
anchors.extend(fips_network_anchors());
|
||||
anchors
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
@@ -331,10 +358,10 @@ mod tests {
|
||||
// Removing every default leaves an empty authoritative list that must
|
||||
// not be re-seeded on next load.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
|
||||
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut list = Vec::new();
|
||||
for anchor in default_public_anchors() {
|
||||
list = remove(dir.path(), &anchor.npub).await.unwrap();
|
||||
}
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "defaults must stay removed once edited");
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT,
|
||||
};
|
||||
|
||||
/// Header prepended to the generated YAML. serde doesn't emit comments, so
|
||||
@@ -137,7 +137,7 @@ impl Default for FipsConfig {
|
||||
},
|
||||
transports: TransportsSection {
|
||||
udp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
|
||||
bind_addr: format!("0.0.0.0:{PUBLISHED_UDP_PORT}"),
|
||||
},
|
||||
tcp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
|
||||
@@ -293,7 +293,7 @@ mod tests {
|
||||
fn test_rendered_yaml_matches_upstream_schema() {
|
||||
let yaml = render_config_yaml();
|
||||
assert!(yaml.contains("persistent: true"));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", PUBLISHED_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
|
||||
assert!(yaml.contains("udp:"));
|
||||
assert!(yaml.contains("tcp:"));
|
||||
@@ -329,7 +329,7 @@ dns:
|
||||
bind_addr: 127.0.0.1
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: 0.0.0.0:8668
|
||||
bind_addr: 0.0.0.0:2121
|
||||
tcp:
|
||||
bind_addr: 0.0.0.0:8443
|
||||
peers: []
|
||||
|
||||
@@ -119,6 +119,15 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
|
||||
/// Default UDP port the daemon listens on.
|
||||
pub const DEFAULT_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// UDP port archipelago actually publishes/binds for the daemon. The
|
||||
/// container publishes `2121:2121/udp` (see `package/config.rs`) and every
|
||||
/// peer roster in the fleet dials `<host>:2121`, but the rendered daemon
|
||||
/// config used to bind upstream's 8668 — leaving inbound UDP dead on
|
||||
/// bridged-network installs and everything silently riding TCP 8443. The
|
||||
/// bind now uses this port so the published mapping, the fleet rosters,
|
||||
/// and the pairing QR all agree.
|
||||
pub const PUBLISHED_UDP_PORT: u16 = 2121;
|
||||
|
||||
/// Default TCP port the daemon listens on. Used as a fallback when a
|
||||
/// peer can't be reached over UDP — common on networks that block UDP
|
||||
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
|
||||
|
||||
@@ -45,6 +45,7 @@ mod content_server;
|
||||
mod crash_recovery;
|
||||
mod credentials;
|
||||
mod data_model;
|
||||
mod device_tokens;
|
||||
mod disk_monitor;
|
||||
mod electrs_status;
|
||||
mod federation;
|
||||
@@ -385,6 +386,19 @@ async fn main() -> Result<()> {
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
tokio::spawn(bootstrap::ensure_audio_stack());
|
||||
|
||||
// TV input: gamepad→keyboard bridge so controllers work inside every app
|
||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||
|
||||
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
|
||||
// DHCP renumbering strands them — HA never re-resolves on its own.
|
||||
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
|
||||
@@ -15,13 +15,15 @@ mod frames;
|
||||
mod node_cmd;
|
||||
mod session;
|
||||
|
||||
pub(crate) use session::{probe_device, DeviceProbe};
|
||||
|
||||
use super::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// How often to broadcast our identity advertisement (seconds).
|
||||
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
|
||||
@@ -52,6 +54,22 @@ const RX_STALL_TIMEOUT: Duration = Duration::from_secs(1800);
|
||||
/// Maximum stored messages (circular buffer).
|
||||
const MAX_MESSAGES: usize = 100;
|
||||
|
||||
/// On-disk message-history file under `data_dir/` (written by
|
||||
/// `spawn_message_persister`, restored by `load_persisted_messages`).
|
||||
const MESSAGES_FILE: &str = "mesh-messages.json";
|
||||
|
||||
/// Serialized form of the persisted history. `send_seqs` rides along because
|
||||
/// the per-target outbound sequence counters must survive restarts too:
|
||||
/// receivers dedup on (sender_pubkey, sender_seq), so a node that reboots and
|
||||
/// starts counting from 1 again has its first messages silently dropped by
|
||||
/// every peer that already saw those sequence numbers.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedMessages {
|
||||
messages: Vec<MeshMessage>,
|
||||
#[serde(default)]
|
||||
send_seqs: HashMap<u32, u64>,
|
||||
}
|
||||
|
||||
/// Check if two ISO8601 timestamps are within N seconds of each other.
|
||||
fn within_seconds_iso(ts1: &str, ts2: &str, secs: i64) -> bool {
|
||||
use chrono::DateTime;
|
||||
@@ -402,6 +420,90 @@ impl MeshState {
|
||||
let count = self.peers.read().await.len();
|
||||
self.status.write().await.peer_count = count;
|
||||
}
|
||||
|
||||
/// Restore the persisted message history + outbound sequence counters.
|
||||
/// Called once at service startup, before the listener spawns. Missing
|
||||
/// file (fresh node) is normal; a corrupt file is logged and skipped
|
||||
/// rather than blocking mesh startup.
|
||||
pub async fn load_persisted_messages(&self) {
|
||||
let path = self.data_dir.join(MESSAGES_FILE);
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
warn!("mesh: reading {} failed: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let persisted: PersistedMessages = match serde_json::from_slice(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("mesh: parsing {} failed (skipping restore): {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = persisted.messages.len();
|
||||
let max_id = persisted.messages.iter().map(|m| m.id).max().unwrap_or(0);
|
||||
*self.messages.write().await = persisted.messages.into();
|
||||
if !persisted.send_seqs.is_empty() {
|
||||
*self.next_send_seq.write().await = persisted.send_seqs;
|
||||
}
|
||||
{
|
||||
let mut id = self.next_message_id.write().await;
|
||||
if *id <= max_id {
|
||||
*id = max_id + 1;
|
||||
}
|
||||
}
|
||||
info!("mesh: restored {count} persisted messages (next id {})", max_id + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the message history on a low-rate timer instead of hooking every
|
||||
/// mutation path (store, delivered/transport/encrypted stamps, edits, deletes,
|
||||
/// read-receipt prunes — and whatever gets added next). Serializing ≤100
|
||||
/// messages every few seconds is trivial; the write is skipped when nothing
|
||||
/// changed, and at most the last few seconds of history are lost on a hard
|
||||
/// kill — versus the entire history, which is what a restart cost before this
|
||||
/// existed. Atomic write-then-rename, 0600 (DM plaintext lives in this file).
|
||||
pub fn spawn_message_persister(state: Arc<MeshState>) {
|
||||
tokio::spawn(async move {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = state.data_dir.join(MESSAGES_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut last_written: Option<Vec<u8>> = None;
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let snapshot = PersistedMessages {
|
||||
messages: state.messages.read().await.iter().cloned().collect(),
|
||||
send_seqs: state.next_send_seq.read().await.clone(),
|
||||
};
|
||||
let json = match serde_json::to_vec_pretty(&snapshot) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
warn!("mesh: serializing message history failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if last_written.as_deref() == Some(json.as_slice()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&tmp, &json).await {
|
||||
warn!("mesh: writing {} failed: {e}", tmp.display());
|
||||
continue;
|
||||
}
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
if let Err(e) = tokio::fs::set_permissions(&tmp, perms).await {
|
||||
warn!("mesh: chmod {} failed: {e}", tmp.display());
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
|
||||
warn!("mesh: renaming {} -> {} failed: {e}", tmp.display(), path.display());
|
||||
continue;
|
||||
}
|
||||
last_written = Some(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn the background mesh listener task.
|
||||
@@ -425,6 +527,7 @@ pub fn spawn_mesh_listener(
|
||||
channel_name: Option<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||
manage_radio: bool,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
@@ -461,6 +564,7 @@ pub fn spawn_mesh_listener(
|
||||
channel_name.as_deref(),
|
||||
device_kind,
|
||||
reticulum_tcp.clone(),
|
||||
manage_radio,
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
@@ -500,11 +604,17 @@ pub fn spawn_mesh_listener(
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to disconnected
|
||||
// Update status to disconnected. device_type/firmware_version are
|
||||
// reset too — they were previously left holding the LAST radio's
|
||||
// identity, so after a hot-swap the UI showed the old firmware
|
||||
// ("meshcore, disconnected") while a different stick sat in the
|
||||
// port. Unknown-until-next-successful-connect is the honest state.
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = false;
|
||||
status.device_path = None;
|
||||
status.device_type = super::types::DeviceType::Unknown;
|
||||
status.firmware_version = None;
|
||||
}
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
|
||||
|
||||
|
||||
@@ -340,6 +340,102 @@ async fn auto_detect_and_open(
|
||||
)
|
||||
}
|
||||
|
||||
/// What the hot-swap probe learned about a just-plugged radio, without
|
||||
/// provisioning anything. Serialized straight into the `mesh.probe-device`
|
||||
/// RPC response for the device setup modal.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DeviceProbe {
|
||||
pub path: String,
|
||||
/// "reticulum" | "meshcore" | "meshtastic"
|
||||
pub kind: String,
|
||||
pub firmware_version: Option<String>,
|
||||
pub node_id: Option<u32>,
|
||||
pub advert_name: Option<String>,
|
||||
/// Meshtastic-only: current radio config, read via `want_config`.
|
||||
pub region: Option<String>,
|
||||
pub modem_preset: Option<String>,
|
||||
pub primary_channel: Option<String>,
|
||||
pub secondary_channel: Option<String>,
|
||||
pub max_contacts: Option<u16>,
|
||||
}
|
||||
|
||||
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
|
||||
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
|
||||
/// for the same RNode-wedging reason) and read what's currently configured on
|
||||
/// it. The port is released when this returns, so the listener's reconnect
|
||||
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
|
||||
/// DETECT probe — no daemon spawn just to identify a stick.
|
||||
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||
// The listener's reconnect loop may hold this port for ~10s of every
|
||||
// backoff cycle, and Linux happily double-opens a tty — two concurrent
|
||||
// handshakes corrupt each other into silence (observed on framework-pt:
|
||||
// every firmware "failed" while the listener was mid-cycle). Retry across
|
||||
// the listener's idle gaps instead of failing on first collision.
|
||||
let mut last_err = None;
|
||||
for attempt in 0..3u32 {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(Duration::from_secs(7)).await;
|
||||
}
|
||||
match probe_device_once(path).await {
|
||||
Ok(p) => return Ok(p),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("probe failed")))
|
||||
}
|
||||
|
||||
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
||||
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
kind: "reticulum".to_string(),
|
||||
firmware_version: Some("RNode (KISS)".to_string()),
|
||||
node_id: None,
|
||||
advert_name: None,
|
||||
region: None,
|
||||
modem_preset: None,
|
||||
primary_channel: None,
|
||||
secondary_channel: None,
|
||||
max_contacts: None,
|
||||
});
|
||||
}
|
||||
if let Ok(mut dev) = MeshcoreDevice::open(path).await {
|
||||
if let Ok(info) = dev.initialize().await {
|
||||
let queried = dev.query_device_info().await;
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
kind: "meshcore".to_string(),
|
||||
firmware_version: queried.as_ref().map(|(v, _)| v.clone()),
|
||||
node_id: Some(info.node_id),
|
||||
advert_name: dev.advert_name(),
|
||||
region: None,
|
||||
modem_preset: None,
|
||||
primary_channel: None,
|
||||
secondary_channel: None,
|
||||
max_contacts: queried.map(|(_, m)| m).or(Some(info.max_contacts)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Ok(mut dev) = MeshtasticDevice::open(path).await {
|
||||
if let Ok(info) = dev.initialize().await {
|
||||
let cfg = dev.probed_config();
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
kind: "meshtastic".to_string(),
|
||||
firmware_version: Some(info.firmware_version.clone()),
|
||||
node_id: Some(info.node_id),
|
||||
advert_name: dev.advert_name(),
|
||||
region: cfg.region,
|
||||
modem_preset: cfg.modem_preset,
|
||||
primary_channel: cfg.primary_channel,
|
||||
secondary_channel: cfg.secondary_channel,
|
||||
max_contacts: Some(info.max_contacts),
|
||||
});
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no supported mesh radio firmware answered on {path}")
|
||||
}
|
||||
|
||||
async fn open_preferred_path(
|
||||
path: &str,
|
||||
data_dir: &Path,
|
||||
@@ -859,6 +955,7 @@ pub(super) async fn run_mesh_session(
|
||||
channel_name: Option<&str>,
|
||||
device_kind: Option<DeviceType>,
|
||||
reticulum_tcp: Option<ReticulumTcpConfig>,
|
||||
manage_radio: bool,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> Result<()> {
|
||||
@@ -923,8 +1020,15 @@ pub(super) async fn run_mesh_session(
|
||||
// re-handshake the freshly-rebooted radio (and then set its name on the
|
||||
// reconnect, where the region already matches and no reboot occurs).
|
||||
use std::sync::atomic::Ordering;
|
||||
if !manage_radio {
|
||||
// Keep-as-is mode (hot-swap "keep as is" decision): the operator told
|
||||
// us to use whatever is flashed/configured on this radio. Skip every
|
||||
// config write below — region, channel, PHY params, advert name — and
|
||||
// run with the device's own settings.
|
||||
info!("manage_radio=false — leaving the radio's own configuration untouched");
|
||||
}
|
||||
let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
if manage_radio && region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match device.ensure_lora_region(lora_region).await {
|
||||
Ok(true) => {
|
||||
REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -943,7 +1047,7 @@ pub(super) async fn run_mesh_session(
|
||||
Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||
Err(e) => warn!("Failed to provision LoRa region: {}", e),
|
||||
}
|
||||
} else if lora_region.is_some() {
|
||||
} else if manage_radio && lora_region.is_some() {
|
||||
warn!(
|
||||
region = lora_region.unwrap_or(""),
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
@@ -958,7 +1062,7 @@ pub(super) async fn run_mesh_session(
|
||||
// the radio). Without a matching channel two same-region radios still can't
|
||||
// decode each other's traffic. Same retry-cap + restart-on-change pattern.
|
||||
let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
if manage_radio && channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match device.ensure_channel(channel_name).await {
|
||||
Ok(true) => {
|
||||
CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -974,7 +1078,7 @@ pub(super) async fn run_mesh_session(
|
||||
Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||
Err(e) => warn!("Failed to provision mesh channel: {}", e),
|
||||
}
|
||||
} else if channel_name.is_some() {
|
||||
} else if manage_radio && channel_name.is_some() {
|
||||
warn!(
|
||||
channel = channel_name.unwrap_or(""),
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
@@ -991,7 +1095,9 @@ pub(super) async fn run_mesh_session(
|
||||
// versions), so we send the set-command once per configured value and never
|
||||
// reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so
|
||||
// a successful write restarts the session like the region path.
|
||||
if let (Some(params), MeshRadioDevice::Meshcore(dev)) = (lora_radio_params, &mut device) {
|
||||
if let (true, Some(params), MeshRadioDevice::Meshcore(dev)) =
|
||||
(manage_radio, lora_radio_params, &mut device)
|
||||
{
|
||||
let marker_path = data_dir.join("meshcore-radio-params.json");
|
||||
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
|
||||
.await
|
||||
@@ -1040,23 +1146,27 @@ pub(super) async fn run_mesh_session(
|
||||
}
|
||||
|
||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||
// falling back to the DID fragment if no name is configured.
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||
name.chars().take(20).collect::<String>()
|
||||
} else {
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
format!("Archy-{}", short_did)
|
||||
};
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
} else {
|
||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||
// its own radio echo from the peer list. Without this, the status
|
||||
// still carries whatever pre-set name the firmware reported and the
|
||||
// self-filter never matches.
|
||||
let mut status = state.status.write().await;
|
||||
status.self_advert_name = Some(advert_name.clone());
|
||||
// falling back to the DID fragment if no name is configured. Skipped in
|
||||
// keep-as-is mode — the radio keeps the name it came with (already
|
||||
// reflected in status from the connect handshake).
|
||||
if manage_radio {
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||
name.chars().take(20).collect::<String>()
|
||||
} else {
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
format!("Archy-{}", short_did)
|
||||
};
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
} else {
|
||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||
// its own radio echo from the peer list. Without this, the status
|
||||
// still carries whatever pre-set name the firmware reported and the
|
||||
// self-filter never matches.
|
||||
let mut status = state.status.write().await;
|
||||
status.self_advert_name = Some(advert_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast our advertisement so other nodes can discover us
|
||||
|
||||
@@ -167,7 +167,40 @@ pub struct MeshtasticDevice {
|
||||
pending_reinit: bool,
|
||||
}
|
||||
|
||||
/// Read-only snapshot of the radio config learned during `initialize`'s
|
||||
/// `want_config` stream — surfaced by the hot-swap probe so the setup modal
|
||||
/// can show what's currently on a just-plugged radio.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MeshtasticProbedConfig {
|
||||
pub region: Option<String>,
|
||||
pub modem_preset: Option<String>,
|
||||
pub primary_channel: Option<String>,
|
||||
pub secondary_channel: Option<String>,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
/// See [`MeshtasticProbedConfig`]. Everything here was learned via reads
|
||||
/// (`want_config`) — safe for a probe that must not modify the device.
|
||||
pub fn probed_config(&self) -> MeshtasticProbedConfig {
|
||||
MeshtasticProbedConfig {
|
||||
region: self
|
||||
.current_region
|
||||
.and_then(region_code_to_name)
|
||||
.map(str::to_string),
|
||||
modem_preset: self
|
||||
.current_modem_preset
|
||||
.and_then(modem_preset_name)
|
||||
.map(str::to_string),
|
||||
primary_channel: self
|
||||
.current_primary_channel
|
||||
.as_ref()
|
||||
.map(|(name, _)| if name.is_empty() { "(default public)".to_string() } else { name.clone() }),
|
||||
secondary_channel: self
|
||||
.current_secondary_channel
|
||||
.as_ref()
|
||||
.map(|(name, _)| name.clone()),
|
||||
}
|
||||
}
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
@@ -1321,6 +1354,53 @@ fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
|
||||
/// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g.
|
||||
/// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive.
|
||||
/// Returns `None` for an unrecognised name so we never write a bogus region.
|
||||
/// Inverse of `region_name_to_code` — for showing a probed radio's current
|
||||
/// region to the user (hot-swap setup modal).
|
||||
pub(crate) fn region_code_to_name(code: u32) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
0 => "UNSET",
|
||||
1 => "US",
|
||||
2 => "EU_433",
|
||||
3 => "EU_868",
|
||||
4 => "CN",
|
||||
5 => "JP",
|
||||
6 => "ANZ",
|
||||
7 => "KR",
|
||||
8 => "TW",
|
||||
9 => "RU",
|
||||
10 => "IN",
|
||||
11 => "NZ_865",
|
||||
12 => "TH",
|
||||
13 => "LORA_24",
|
||||
14 => "UA_433",
|
||||
15 => "UA_868",
|
||||
16 => "MY_433",
|
||||
17 => "MY_919",
|
||||
18 => "SG_923",
|
||||
19 => "PH_433",
|
||||
20 => "PH_868",
|
||||
21 => "PH_915",
|
||||
22 => "ANZ_433",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Meshtastic `Config.LoRaConfig.ModemPreset` enum names, for display.
|
||||
pub(crate) fn modem_preset_name(code: u32) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
0 => "LONG_FAST",
|
||||
1 => "LONG_SLOW",
|
||||
2 => "VERY_LONG_SLOW",
|
||||
3 => "MEDIUM_SLOW",
|
||||
4 => "MEDIUM_FAST",
|
||||
5 => "SHORT_SLOW",
|
||||
6 => "SHORT_FAST",
|
||||
7 => "LONG_MODERATE",
|
||||
8 => "SHORT_TURBO",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn region_name_to_code(name: &str) -> Option<u32> {
|
||||
Some(match name.trim().to_uppercase().as_str() {
|
||||
"UNSET" => 0,
|
||||
|
||||
@@ -420,6 +420,12 @@ pub struct MeshConfig {
|
||||
/// serial-only/auto-detect behavior unchanged.
|
||||
#[serde(default)]
|
||||
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
|
||||
/// Whether archipelago manages the radio's configuration (region, shared
|
||||
/// channel, PHY params, advert name) on connect. `true` (default) is
|
||||
/// today's behavior. `false` = the hot-swap modal's "keep as is": use the
|
||||
/// radio exactly as flashed — no config writes at all.
|
||||
#[serde(default = "default_true")]
|
||||
pub manage_radio: bool,
|
||||
}
|
||||
|
||||
/// True when `name` is a LoRa region the Meshtastic driver can program
|
||||
@@ -458,6 +464,7 @@ impl Default for MeshConfig {
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
device_kind: None,
|
||||
reticulum_tcp: None,
|
||||
manage_radio: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,6 +698,13 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the message history + outbound send-sequence counters, then
|
||||
// keep them persisted. Until 2026-07-22 both were RAM-only: every
|
||||
// restart wiped the chat history, and the reset sequence counters made
|
||||
// peers drop the first post-reboot messages as (pubkey, seq) replays.
|
||||
state.load_persisted_messages().await;
|
||||
listener::spawn_message_persister(Arc::clone(&state));
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
@@ -748,6 +762,7 @@ impl MeshService {
|
||||
self.config.channel_name.clone(),
|
||||
self.config.device_kind,
|
||||
self.config.reticulum_tcp.clone(),
|
||||
self.config.manage_radio,
|
||||
shutdown_rx,
|
||||
cmd_rx,
|
||||
);
|
||||
@@ -1004,6 +1019,21 @@ impl MeshService {
|
||||
self.state.peers.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
||||
/// it — powers the hot-swap "device detected" modal's current-details
|
||||
/// view. Refuses to probe the port the live session currently occupies
|
||||
/// (the probe would steal the serial port from under the session); a
|
||||
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||
/// with the reconnect loop (whichever loses just retries).
|
||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
||||
let status = self.state.status.read().await;
|
||||
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||
}
|
||||
drop(status);
|
||||
listener::probe_device(path).await
|
||||
}
|
||||
|
||||
/// Get message history.
|
||||
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
|
||||
let messages = self.state.messages.read().await;
|
||||
|
||||
@@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
|
||||
|
||||
// ─── Response parsers ───────────────────────────────────────────────────
|
||||
|
||||
/// Decode a device/contact name from raw frame bytes, defensively.
|
||||
///
|
||||
/// Name fields sit at firmware-version-dependent offsets; when an offset
|
||||
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
|
||||
/// hand the UI replacement-character soup ("�\u{618}…"). Strict rules: valid
|
||||
/// UTF-8 up to the first NUL, no control characters, at least one
|
||||
/// non-whitespace char — anything else gets the caller's readable fallback.
|
||||
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
match std::str::from_utf8(&bytes[..end]) {
|
||||
Ok(s) => {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
|
||||
s.to_string()
|
||||
} else {
|
||||
fallback.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||
/// Returns firmware version string and device capabilities.
|
||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||
@@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
|
||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
|
||||
// Name follows after fixed fields — find it by scanning for printable ASCII
|
||||
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||
// differs would put binary here — decode defensively so the settings
|
||||
// panel never shows byte soup.
|
||||
let name_start = 4;
|
||||
let name = if data.len() > name_start {
|
||||
let name_end = data[name_start..]
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|p| name_start + p)
|
||||
.unwrap_or(data.len());
|
||||
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
|
||||
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
||||
// name at data[99..131] (32 bytes)
|
||||
let name_start = 99.min(data.len());
|
||||
let name_end = (name_start + 32).min(data.len());
|
||||
let short_id = format!("{}...", &public_key_hex[..8]);
|
||||
let advert_name = if data.len() > name_start {
|
||||
String::from_utf8_lossy(&data[name_start..name_end])
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
decode_mesh_name(&data[name_start..name_end], &short_id)
|
||||
} else {
|
||||
format!("{}...", &public_key_hex[..8])
|
||||
short_id
|
||||
};
|
||||
|
||||
// last_advert at data[131..135]
|
||||
|
||||
@@ -1083,7 +1083,7 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
|
||||
/// Send the verified RNode KISS detect sequence and look for `DETECT_RESP`.
|
||||
/// Bytes confirmed against the canonical Reticulum source — see the module
|
||||
/// doc comment and `docs/RETICULUM-TRANSPORT-PROGRESS.md`.
|
||||
async fn probe_rnode(path: &str) -> Result<()> {
|
||||
pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
||||
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
|
||||
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
|
||||
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
||||
|
||||
@@ -149,6 +149,35 @@ impl MeshcoreDevice {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The advert name learned from SELF_INFO during `initialize`.
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.advert_name.clone()
|
||||
}
|
||||
|
||||
/// Read firmware version + contact capacity via CMD_DEVICE_QUERY (0x16).
|
||||
/// Read-only — used by the hot-swap probe to show what's on a
|
||||
/// just-plugged radio. Tolerates interleaved push frames and firmware
|
||||
/// that doesn't answer the query (returns None rather than erroring).
|
||||
pub async fn query_device_info(&mut self) -> Option<(String, u16)> {
|
||||
if self
|
||||
.send_raw(&protocol::build_device_query())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for _ in 0..5 {
|
||||
match self.recv_frame_timeout(Duration::from_secs(2)).await {
|
||||
Ok(f) if f.code == protocol::RESP_DEVICE_INFO => {
|
||||
return protocol::parse_device_info(&f.data).ok();
|
||||
}
|
||||
Ok(_) => continue, // push notification — keep waiting
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Set the advertised name on the mesh network.
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.send_raw(&protocol::build_set_advert_name(name))
|
||||
@@ -543,6 +572,12 @@ pub struct DetectedDeviceInfo {
|
||||
pub pid: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
/// Unix epoch seconds of the /dev node's creation — udev recreates the
|
||||
/// node on every plug, so this changes on each replug. The UI keys its
|
||||
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
|
||||
/// unplug/replug faster than a status poll) invalidates old dismissals
|
||||
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
|
||||
pub plugged_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Like `detect_serial_devices`, but with USB metadata per port.
|
||||
@@ -550,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
||||
let mut out = Vec::new();
|
||||
for path in detect_serial_devices().await {
|
||||
let usb = usb_info_for_tty(&path).await;
|
||||
let plugged_at = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
out.push(DetectedDeviceInfo {
|
||||
path,
|
||||
vid: usb.0,
|
||||
pid: usb.1,
|
||||
product: usb.2,
|
||||
manufacturer: usb.3,
|
||||
plugged_at,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
@@ -406,7 +406,10 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
|
||||
validate_onion(onion)?;
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health")
|
||||
.service(crate::settings::transport::PeerService::Messaging)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// 12s, not 30s: this is a liveness dot, not a data transfer. A Tor
|
||||
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||
.timeout(std::time::Duration::from_secs(12))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -307,9 +307,19 @@ async fn send_handshake_message(
|
||||
|
||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||
.tag(Tag::public_key(recipient_pk));
|
||||
let _ = client.send_event_builder(builder).await;
|
||||
// Surface real delivery failures. Before 2026-07-22 this was `let _ =`,
|
||||
// so a request no relay accepted still reported ok:true to the UI and
|
||||
// the sender believed it was delivered.
|
||||
let send = client.send_event_builder(builder).await;
|
||||
client.disconnect().await;
|
||||
Ok(())
|
||||
match send {
|
||||
Ok(output) if !output.success.is_empty() => Ok(()),
|
||||
Ok(output) => anyhow::bail!(
|
||||
"no relay accepted the handshake event (tried {}, all failed)",
|
||||
output.failed.len()
|
||||
),
|
||||
Err(e) => anyhow::bail!("failed to publish handshake event: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
|
||||
|
||||
@@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.current.fyi",
|
||||
];
|
||||
|
||||
/// The union of the boot-config relay list and the user-managed (enabled)
|
||||
/// relays — the set every nostr-facing operation should use, so senders and
|
||||
/// receivers always overlap regardless of which list the operator edited.
|
||||
pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec<String> {
|
||||
let mut relays: Vec<String> = config_relays.to_vec();
|
||||
if let Ok(store) = load_relays(data_dir).await {
|
||||
for r in store.relays {
|
||||
if r.enabled && !relays.contains(&r.url) {
|
||||
relays.push(r.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
relays
|
||||
}
|
||||
|
||||
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
|
||||
let path = data_dir.join(RELAYS_FILE);
|
||||
if !path.exists() {
|
||||
|
||||
@@ -101,6 +101,15 @@ impl Server {
|
||||
}
|
||||
state_manager.update_data(data.clone()).await;
|
||||
|
||||
// Real-time wallet push: stream LND transaction events → websocket
|
||||
// revision nudges, so an incoming on-chain tx shows in the UI within
|
||||
// seconds of hitting the mempool (works whenever LND is up; retries
|
||||
// forever otherwise). User req 2026-07-22.
|
||||
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
|
||||
// LND wedge watchdog — self-heal the silent "RPC up, server never
|
||||
// ready" state instead of waiting for a human (100%-uptime req).
|
||||
crate::api::rpc::lnd::spawn_lnd_health_watchdog();
|
||||
|
||||
// Retry Tor address in background — Tor may not be ready at startup
|
||||
if data.server_info.tor_address.is_none() {
|
||||
let sm = state_manager.clone();
|
||||
@@ -203,9 +212,15 @@ impl Server {
|
||||
let did =
|
||||
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = config.nostr_relays.clone();
|
||||
// Merged relay set (config + user-managed) — publish presence
|
||||
// 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 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)
|
||||
.await;
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
@@ -247,6 +262,23 @@ impl Server {
|
||||
.await?,
|
||||
);
|
||||
|
||||
// Background handshake poll: fetch inbound nostr peer requests every
|
||||
// 5 minutes instead of only when a user presses the Federation Poll
|
||||
// button (requests used to sit on relays unseen — 2026-07-22). The
|
||||
// handler's own discoverability gate makes this a no-op until the
|
||||
// user opts in.
|
||||
{
|
||||
let rpc = api_handler.rpc_handler().clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
rpc.background_handshake_poll().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize mesh networking service (if config has enabled: true)
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<!-- Must agree with the embedding neode-ui shell (color-scheme: dark):
|
||||
browsers only honor a transparent same-origin iframe canvas when
|
||||
embedder and iframe use the SAME color-scheme — without this, the
|
||||
shell's :root{color-scheme:dark} forces this frame opaque and the
|
||||
background art behind the chat vanishes (2026-07-23). -->
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Combined test session — 2026-07-22 batch (one sitting)
|
||||
|
||||
Staged on **framework-pt** (`100.65.115.109`) AND **archi thinkpad** (this
|
||||
machine's node) so everything can be tested in one pass. Items marked ✅ were
|
||||
already verified by the agent on a node; ❑ items need a human.
|
||||
|
||||
**What's in this batch:** mesh message/DM persistence across restarts ·
|
||||
first-message + DM announce fix · 15s announce poll · radio hot-swap modal
|
||||
(probe / keep-as-is / apply-settings) · whisper beam-1 (release-gated, see §D) ·
|
||||
real-time wallet push (0-conf tx shows in seconds) · calm Lightning
|
||||
"still starting" notice · external tx-explorer fallback with consent modal +
|
||||
wallet-settings On-chain tab · apps open ABOVE modals with the launch
|
||||
animation · mempool installs no longer blocked by a resyncing ElectrumX ·
|
||||
[pending: other agents' two push sets — section F fills in when their code
|
||||
lands].
|
||||
|
||||
## H. Wallet & explorer (new — test on the thinkpad node, it's pruned)
|
||||
|
||||
1. ❑ **Real-time tx display:** send a small on-chain amount to this node's
|
||||
wallet → the balance and the yellow "unconfirmed" transaction appear
|
||||
within a few seconds of broadcast, no refresh, no wallet action.
|
||||
2. ❑ **External explorer consent:** with no local Mempool app running, tap a
|
||||
transaction → amber consent modal explains it opens on another node's
|
||||
mempool (default tx1138.com, placeholder mempool.guide, editable) →
|
||||
Open Explorer opens `<explorer>/tx/<hash>` in a new tab. Tick "don't ask
|
||||
again" and confirm the next tap opens directly.
|
||||
3. ❑ **Wallet Settings → On-chain tab:** explorer URL editable, warning shown,
|
||||
"don't warn" toggle; tabs now read Channels / Cashu / Fedi / Ark / On-chain
|
||||
and fit on one row (check mobile too).
|
||||
4. ❑ **Modal → app animation:** on a node WITH Mempool running, open
|
||||
Transactions and tap a tx → the Mempool app animates in ABOVE the modal
|
||||
(previously loaded invisibly underneath); closing it returns to the modal.
|
||||
5. ❑ **Lightning "still starting":** right after a node restart, try opening a
|
||||
channel → either it just works (silent retry) or a calm amber ⏳ notice
|
||||
appears — never the red "Failed to connect to peer" error.
|
||||
|
||||
---
|
||||
|
||||
## A. Staged state (agent-verified before you start)
|
||||
|
||||
- ✅ Dev binary (persistence + announce seeder + hot-swap) on
|
||||
`/usr/local/bin/archipelago`, service healthy, no crash-loop.
|
||||
- ✅ Frontend bundle with the new device modal at `/opt/archipelago/web-ui`.
|
||||
- ✅ Seeder re-ran: `automations.yaml` upgraded v1→v2 (first-message announce),
|
||||
`configuration.yaml` rest block at `scan_interval: 15`, HA restarted clean.
|
||||
- ✅ `mesh-messages.json` persisting + restored across a service restart.
|
||||
- ✅ `mesh.probe-device` returns real firmware details for the plugged stick.
|
||||
|
||||
## B. Mesh history survives restarts (the "messages go missing" fix)
|
||||
|
||||
1. ❑ Open Mesh chat — your existing DM/channel history from today is visible.
|
||||
2. ❑ Send one channel message and one DM (either direction).
|
||||
3. ❑ Reboot the whole node (not just the service). After it's back: history
|
||||
still there, including the two new messages, correct timestamps/senders.
|
||||
4. ❑ Send a NEW message to another node right after the reboot and confirm the
|
||||
other side receives it (this exercises the send-seq fix — before it, the
|
||||
first post-reboot sends were silently dropped by peers as replays).
|
||||
|
||||
## C. Speaker announcements
|
||||
|
||||
1. ❑ Have another node send a **public channel** message → speaker announces
|
||||
sender + text within ~15s (was ~30s).
|
||||
2. ❑ Have another node send you a **DM** → speaker announces it the same way.
|
||||
3. ❑ Restart Home Assistant (or the node) → the last old message is NOT
|
||||
re-announced (no announce storm).
|
||||
4. ❑ (First-message case — the original bug — only reproducible on a node with
|
||||
an empty history: optional, covered by agent verification of the guard.)
|
||||
|
||||
## D. Voice (regression + speed)
|
||||
|
||||
1. ❑ "Hey Jarvis, what's the block height" and one fuzzy phrasing — same
|
||||
correct answers as before (no behavior change is the pass condition).
|
||||
2. ⓘ The ~45% faster speech-to-text (whisper beam-1) ships via the **signed
|
||||
catalog in the release** — it is NOT on the node during this test session.
|
||||
Benchmarked on this exact hardware: identical transcripts, 0.94s → 0.51s.
|
||||
|
||||
## E. Radio hot-swap modal (your Reticulum stick is already plugged in)
|
||||
|
||||
1. ❑ Open the web UI anywhere — within ~30s a "Mesh Radio Detected" modal
|
||||
appears showing the stick on `/dev/ttyACM0`, with a card of what's on it
|
||||
(firmware badge: Reticulum RNode / MeshCore / Meshtastic + current
|
||||
name/region/channels where the firmware exposes them).
|
||||
2. ❑ Press **Keep As Is** → mesh connects using the radio exactly as flashed
|
||||
(check Mesh → Device tab: connected, firmware type correct; nothing on the
|
||||
radio changed).
|
||||
3. ❑ Unplug the stick, plug the old MeshCore one → the modal appears AGAIN
|
||||
(every plug re-triggers, same or different /dev path).
|
||||
4. ❑ This time press **Set Up with Archipelago Settings** → second screen
|
||||
shows channel `archipelago`, your region, and the node's RF params (the
|
||||
validated Portugal preset on this fleet) BEFORE anything is written;
|
||||
confirm → radio provisions and joins the mesh.
|
||||
5. ❑ Swap sticks once more with no UI interaction except "Keep As Is" — chat
|
||||
still works end-to-end afterwards (hot-swap without ceremony).
|
||||
|
||||
## F. Companion pairing + mobile onboarding (other agent — push set #1, MERGED)
|
||||
|
||||
1. ❑ Companion app: pair with the node via the new named QR (device tokens) —
|
||||
pairing completes instantly, device appears in the paired-devices list.
|
||||
2. ❑ Remote access now rides the embedded FIPS mesh (WireGuard replaced):
|
||||
with the phone OFF the node's WiFi, the companion still reaches the node.
|
||||
3. ❑ The reworked mobile onboarding/intro overlay screens flow correctly on
|
||||
first launch of the new APK (in-tarball APK is the 27MB build).
|
||||
4. ❑ (Push set #2 from the other agents is still pending — the release waits
|
||||
for it; this staged build does NOT include it yet.)
|
||||
|
||||
## G. Quick regressions
|
||||
|
||||
1. ❑ Pine launcher page (:10380) still shows the live node card; "Connect
|
||||
Pine to WiFi" button loads without JS errors.
|
||||
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
|
||||
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
|
||||
|
||||
---
|
||||
|
||||
After this passes: fold the batch + other agent's work into the next release
|
||||
(OTA binary + frontend tarball + catalog regen/sign/publish for pine-whisper
|
||||
3.4.2), then re-run `tests/lifecycle/run-gate.sh` on .228 (back online as
|
||||
Tailscale `shorty-s`).
|
||||
@@ -22,7 +22,7 @@ the public demo) gained a second screen:
|
||||
A single URI, also usable as an OS deep link:
|
||||
|
||||
```
|
||||
archipelago://pair?v=1&url=<percent-encoded server URL>[&pw=<percent-encoded password>]
|
||||
archipelago://pair?v=1&url=<origin>&name=<display name>[&tok=<device token>][&pw=<password>][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
@@ -31,7 +31,14 @@ Query parameters:
|
||||
|-------|----------|---------|
|
||||
| `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". |
|
||||
| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. |
|
||||
| `name`| no | Display name for the server entry. Real nodes send the configured server name, or `My Archipelago` when it's still the factory default. |
|
||||
| `tok` | no | **Device token** minted via `auth.createDeviceToken` when the QR is rendered. The app logs in with `{"method":"auth.login","params":{"token":"…"}}` — same endpoint, same rate limiter, skips TOTP (the token was minted from an authenticated session). Long-lived until re-minted (re-showing the pair screen replaces the `companion` token) or revoked (`auth.revokeDeviceToken`). Scan → instantly connected, no typing. |
|
||||
| `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. |
|
||||
| `fnpub` | no | Node's FIPS mesh identity (bech32 npub of the daemon's seed-derived key). Presence of this param means "this node speaks FIPS — mesh with it". |
|
||||
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
|
||||
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
|
||||
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
|
||||
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors, capped at 4. **The FIRST entry is the paired node itself** (`fnpub` at its current LAN host — the addr is a dial *hint*, the npub is the identity). The remaining entries are the node's seed anchors, and the node guarantees the Archipelago public anchor (vps2, `146.59.87.168:8444/tcp`) is present even if the operator trimmed their own list — so the phone can always rendezvous through the public mesh when the LAN endpoint is unreachable (away from home / NAT). |
|
||||
|
||||
Examples the web UI actually emits:
|
||||
|
||||
@@ -65,12 +72,45 @@ Examples the web UI actually emits:
|
||||
the parse on scheme.
|
||||
- Bad/foreign QR → clear error, stay on the scan screen.
|
||||
|
||||
## Notes / future extensions (not in v1)
|
||||
## Landed extensions (2026-07-22)
|
||||
|
||||
- A real-node pairing **token** instead of a password (backend mints a one-time
|
||||
token, QR carries it, app exchanges it for a session) — needs a backend RPC;
|
||||
the `v` param exists so we can bump when this lands.
|
||||
- Optional `name` param (node display name) so the app labels the entry nicely.
|
||||
- **Device token** (`tok`) — real nodes now pair instantly; see the param table.
|
||||
Minting replaces the previous `companion` token, so merely re-opening the pair
|
||||
screen invalidates a previously issued token (the phone's session/remember
|
||||
cookies keep working; a re-scan re-pairs).
|
||||
- **`name`** — the app labels the entry with the node's server name
|
||||
("My Archipelago" when unset).
|
||||
- **FIPS mesh params** (`fnpub`/`fip`/`fhost`/`fudp`/`ftcp`/`fanchors`) — the
|
||||
companion app embeds a leaf-only FIPS node (Android/rust/archy-fips-core)
|
||||
behind a split-tunnel VpnService and dials the node + rendezvous anchors on
|
||||
scan. This replaces the WireGuard install/tunnel onboarding screens entirely;
|
||||
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
|
||||
automatically when the LAN address stops answering.
|
||||
|
||||
## npub-first connectivity (2026-07-23 — REQUIRED app-side changes)
|
||||
|
||||
The QR used to be effectively IP-first: the app connected to `url`/`fhost` and
|
||||
broke as soon as the LAN renumbered or the phone left home. FIPS peers on
|
||||
**npubs**; IPs are only dial hints. The app must treat them that way:
|
||||
|
||||
1. **Identity = `fnpub`.** The saved server entry is keyed by the node's npub
|
||||
(fall back to origin only when the QR has no FIPS params). Re-scanning the
|
||||
same npub updates the entry even if every address changed.
|
||||
2. **Peer with the node itself as the first anchor** (`fanchors[0]`): on LAN
|
||||
this is direct p2p over FIPS (mDNS/known-endpoint dial via archy-fips-core
|
||||
v0.4+, which discovers LAN peers without any IP pinning), so it keeps
|
||||
working after DHCP renumbering.
|
||||
3. **Peer with the public anchors too** (remaining `fanchors` entries — the
|
||||
Archipelago vps2 anchor is always included by the node): away from LAN the
|
||||
phone routes to the node's npub via the public mesh and reaches the UI at
|
||||
`http://[<fip>]` (the fips0 ULA).
|
||||
4. **Address selection order** for the WebView: LAN `url` when it answers →
|
||||
ULA `fip` over the mesh otherwise. Never hard-fail because the scanned LAN
|
||||
IP stopped existing.
|
||||
|
||||
(Node-side counterpart shipped 2026-07-23: `fips.pair-info` always includes
|
||||
the vps2 public anchor, and the web UI prepends the node's self-anchor to
|
||||
`fanchors`.)
|
||||
|
||||
## Testing checklist (app side)
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Pine voice commands — the "what can I say" book
|
||||
|
||||
Everything here is spoken to the speaker after the wake word: **"Hey Jarvis, …"**
|
||||
|
||||
How it decides who answers you:
|
||||
- **Exact-ish phrases** (sections 1–4) are matched **locally on your node** — instant,
|
||||
free, works with no internet and no API key.
|
||||
- **Anything else** goes to **Claude ("Archy")**, which either calls the same node
|
||||
tools behind the scenes (so loose phrasings still get real numbers) or just
|
||||
answers the question. Needs the Anthropic API key that Pine seeds.
|
||||
- **Mesh announcements** need nobody to say anything — the speaker pipes up on its
|
||||
own when a mesh message arrives.
|
||||
|
||||
---
|
||||
|
||||
## 1. Bitcoin — block height (local, instant)
|
||||
|
||||
- "what's the block height"
|
||||
- "what's the current block height"
|
||||
- "what is the block height"
|
||||
- "block height"
|
||||
- "current block height"
|
||||
- "how many blocks"
|
||||
- "how many blocks are there"
|
||||
|
||||
## 2. Peers — bitcoin **and** mesh in one answer (local, instant)
|
||||
|
||||
The answer includes both your bitcoin peer count and your mesh peer count.
|
||||
|
||||
- "how many peers"
|
||||
- "how many peers do I have"
|
||||
- "how many peers is the node connected to"
|
||||
- "peer count"
|
||||
- "node peer count"
|
||||
|
||||
## 3. Sync status (local, instant)
|
||||
|
||||
Fully synced → it tells you the block it's synced to. Still syncing → the percent.
|
||||
|
||||
- "is the node synced"
|
||||
- "is bitcoin synced"
|
||||
- "is the node fully synced"
|
||||
- "is bitcoin fully synced"
|
||||
- "how synced is the node"
|
||||
- "how synced is bitcoin"
|
||||
- "sync status"
|
||||
- "bitcoin sync status"
|
||||
- "sync progress"
|
||||
- "sync percentage"
|
||||
|
||||
## 4. Lightning balance (local, instant)
|
||||
|
||||
- "what's my lightning balance"
|
||||
- "what is my lightning balance"
|
||||
- "lightning balance"
|
||||
- "how many sats do I have"
|
||||
- "how many sats are in my wallet"
|
||||
|
||||
---
|
||||
|
||||
## 5. Same questions, said like a human (Claude routes to the node)
|
||||
|
||||
The point of the AI brain: you don't have to remember the magic words. All of
|
||||
these end in the same real numbers as sections 1–4:
|
||||
|
||||
- "how tall is the chain right now"
|
||||
- "what block are we on"
|
||||
- "is my bitcoin thing done downloading yet"
|
||||
- "how far along is the sync"
|
||||
- "are we caught up with the blockchain"
|
||||
- "how's my node doing"
|
||||
- "is everything okay with the node"
|
||||
- "how many people is my node talking to"
|
||||
- "is anyone connected to my node"
|
||||
- "am I rich in lightning"
|
||||
- "how much money is in my lightning wallet"
|
||||
- "do I have any sats"
|
||||
- "what's my node's status"
|
||||
|
||||
## 6. Mesh
|
||||
|
||||
**Hands-free announcements** — when a mesh text arrives, the speaker announces it
|
||||
by itself: *"New mesh message from ⟨sender⟩: ⟨text⟩"*. Nothing to say; just have
|
||||
someone send you one.
|
||||
|
||||
**Asking about the mesh:**
|
||||
|
||||
- "how many peers" — the local answer already includes mesh peers
|
||||
- "how many mesh peers do I have"
|
||||
- "am I connected to the mesh"
|
||||
- "what was the last mesh message"
|
||||
- "who sent the last mesh message"
|
||||
- "read me the latest mesh message"
|
||||
- "did I get any mesh messages"
|
||||
|
||||
(The last-message questions go through Claude reading the mesh sensor — phrasing
|
||||
is free-form.)
|
||||
|
||||
## 7. Ask the AI anything
|
||||
|
||||
Short spoken answers, one or two sentences, no robot-reading-markdown. A sampler
|
||||
by mood:
|
||||
|
||||
**Bitcoin & lightning, explained**
|
||||
- "what actually happens when a block is mined"
|
||||
- "explain the halving like I'm five"
|
||||
- "what's the difference between on-chain and lightning"
|
||||
- "why do confirmations matter"
|
||||
- "what's a mempool"
|
||||
- "is it normal for sync to take days"
|
||||
|
||||
**Everyday brain**
|
||||
- "why is the sky blue"
|
||||
- "how long do I boil an egg"
|
||||
- "what can I cook with eggs and spinach"
|
||||
- "what's 15 percent of 84"
|
||||
- "how many ounces in a kilo"
|
||||
- "what's 21 million divided by 8 billion"
|
||||
- "how do you say good morning in Portuguese"
|
||||
- "give me a word that rhymes with orange"
|
||||
|
||||
**Fun**
|
||||
- "tell me a joke"
|
||||
- "tell me a bitcoin joke"
|
||||
- "give me a fun fact"
|
||||
- "tell me a two-sentence scary story"
|
||||
- "settle an argument: is a hotdog a sandwich"
|
||||
|
||||
**Advice-ish**
|
||||
- "what should I name my node"
|
||||
- "give me one tip for keeping my seed phrase safe"
|
||||
- "what's a good way to explain my node to my mum"
|
||||
|
||||
Follow-ups work conversationally — ask something, then "and why is that?" without
|
||||
re-explaining yourself.
|
||||
|
||||
## 8. Built-in assistant basics (Home Assistant, local)
|
||||
|
||||
- "what time is it"
|
||||
- "what's the date today"
|
||||
- "set a timer for 5 minutes" / "cancel the timer" / "how long is left on the
|
||||
timer" — *timer support depends on the PineVoice satellite build; try it once
|
||||
and you'll know.*
|
||||
- "nevermind" / "cancel" — bail out of a listening session.
|
||||
|
||||
## 9. Smart home (only if you've got devices in Home Assistant)
|
||||
|
||||
Pine rides on Home Assistant Assist, so if you ever add exposed devices
|
||||
(lights, plugs, sensors), the standard grammar lights up automatically:
|
||||
|
||||
- "turn on the living room light" / "turn off everything"
|
||||
- "is the front door locked"
|
||||
- "what's the temperature inside"
|
||||
|
||||
No devices → these politely fail; nothing to test today.
|
||||
|
||||
## 10. Known not-to-work (yet) — don't burn time on these
|
||||
|
||||
- **Sending** a mesh message by voice ("tell Bob I'm on my way") — receive/announce
|
||||
only, for now.
|
||||
- Controlling the node by voice ("restart bitcoin", "install an app") — read-only
|
||||
on purpose.
|
||||
- Long memory across sessions — each conversation is fresh.
|
||||
|
||||
---
|
||||
|
||||
## If something misbehaves
|
||||
|
||||
Say exactly what you said, what it answered (or didn't), and roughly when — the
|
||||
node keeps logs of every pipeline run and it's usually a one-look diagnosis.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Framework PT test plan — Pine voice epic (pre-release gate)
|
||||
|
||||
Target node: **framework-pt** (`100.65.115.109`, LAN 192.168.1.249). Run after
|
||||
BOTH agents' work is merged, with the dev binary sideloaded and the signed
|
||||
catalog (pine 1.3.0 + pine-openwakeword) published. Every ❑ must pass before
|
||||
the release ritual starts. Items marked **(user)** need a human in the room.
|
||||
|
||||
## A. Deploy / prerequisites
|
||||
- ❑ A1 Dev binary sideloaded, `archipelago` service active, no crash-loop in journal.
|
||||
- ❑ A2 nginx self-heal added `location /api/pine/status` to every server block; `nginx -t` passes; nginx reloaded.
|
||||
- ❑ A3 Signed catalog with pine 1.3.0 + pine-openwakeword live at the raw URL; node refreshed it (hourly sweep or "Check for updates").
|
||||
|
||||
## B. `/api/pine/status` endpoint
|
||||
- ❑ B1 Public tier through nginx (`curl http://127.0.0.1/api/pine/status`): version, uptime, bitcoin height/sync_percent/peers, mesh peers. `lightning` null, `mesh_message` absent.
|
||||
- ❑ B2 Wrong bearer token → still public-only (no balances). Correct token (from `/var/lib/archipelago/secrets/pine-status-token`) → lightning balances + latest mesh message present.
|
||||
- ❑ B3 Reachable from inside the HA container via `host.containers.internal:80`.
|
||||
- ❑ B4 Token file is 0600, owned by the service user.
|
||||
|
||||
## C. Stack / openwakeword container
|
||||
- ❑ C1 Reconcile installs `pine-openwakeword` (wyoming-openwakeword 2.1.0), healthy on :10400.
|
||||
- ❑ C2 Existing pine-whisper / pine-piper / pine were ADOPTED, not recreated — model data dirs untouched.
|
||||
- ❑ C3 `archipelago` service restart → all four pine containers come back (crash-recovery stack spec).
|
||||
- ❑ C4 UI: openwakeword listed under Services (no extra store card); Pine card shows 1.3.0.
|
||||
|
||||
## D. Home Assistant seeding
|
||||
- ❑ D1 configuration.yaml: legacy hand-staged block (bitcoind :18332 + plaintext RPC creds) fully replaced by the bounded token-based block.
|
||||
- ❑ D2 `custom_sentences/en/archy.yaml` carries all four intents.
|
||||
- ❑ D3 `.storage/core.config_entries`: wyoming entry for openwakeword (:10400) + `anthropic` entry (Claude, conversation + ai_task subentries).
|
||||
- ❑ D4 Pipeline: `conversation_engine = conversation.claude_conversation`, `prefer_local_intents: true`.
|
||||
- ❑ D5 automations.yaml: `archy_mesh_announce` seeded.
|
||||
- ❑ D6 HA restarts clean — no setup errors for anthropic / wyoming / rest / intent_script in `podman logs homeassistant`.
|
||||
- ❑ D7 Sensors report real values: archy_block_height, archy_bitcoin_sync, archy_bitcoin_peers, archy_mesh_peers, archy_lightning_balance (or clean unavailable if LND absent), archy_mesh_message.
|
||||
|
||||
## E. Voice / intents (API level first, then live speaker)
|
||||
- ❑ E1 Exact phrase "what's the block height" → answered by the LOCAL intent (correct height, no Anthropic API call in HA logs).
|
||||
- ❑ E2 Fuzzy phrase (e.g. "how tall is the chain right now") → Claude routes to the ArchyBlockHeight tool; answer contains the real height.
|
||||
- ❑ E3 "how many peers", "is the node synced", "what's my lightning balance" → correct spoken-length answers.
|
||||
- ❑ E4 Off-topic question → Claude answers, 1–2 sentences, no markdown.
|
||||
- ❑ E5 **(user)** Live speaker: "Hey Jarvis, what's the block height" → audible correct answer.
|
||||
- ❑ E6 Mesh announce: new received mesh text (or manual `assist_satellite.announce` if no radio) → speaker announces sender + text; no announce storm on HA restart.
|
||||
|
||||
## F. Pine launcher page (1.3.0)
|
||||
- ❑ F1 Page on :10380→:10381 shows the live node card (version, uptime, block, sync, peers) within ~5s.
|
||||
- ❑ F2 `/node-status` proxy works (pine nginx resolves host.containers.internal at startup — container must not crash-loop).
|
||||
- ❑ F3 "Connect Pine to WiFi" provisioner still intact (no JS errors on load).
|
||||
|
||||
## G. Cleanup / regression sweep
|
||||
- ❑ G1 Both stray socat 18332 forwarders killed; sensors still work via the endpoint.
|
||||
- ❑ G2 No bitcoind RPC credentials anywhere in HA config.
|
||||
- ❑ G3 Pre-existing HA function intact: whisper/piper entities, PineVoice satellite pairing, other integrations.
|
||||
- ❑ G4 nginx regressions: `/health`, `/bitcoin-status`, `/api/app-catalog`, `/proxy/lnd/` all still proxied post-patch.
|
||||
- ❑ G5 **(user)** Mobile Home: wallet card sits directly under My Apps; desktop layout unchanged.
|
||||
- ❑ G6 Other agent's changes re-verified after merge (their own checklist).
|
||||
|
||||
## H. Production-readiness (release ritual gate)
|
||||
- ❑ H1 `cargo test` workspace green; frontend builds; drift check `--release --strict` green.
|
||||
- ❑ H2 `tests/lifecycle/run-gate.sh` re-run ON .228 (stack membership changed → lifecycle gate rule applies).
|
||||
- ❑ H3 Catalog regenerated → signed (ceremony) → published via gitea-ai; verified at the raw URL.
|
||||
- ❑ H4 Changelog (layman-readable) + `scripts/sync-whats-new.py` + version bump; release ritual per v1.7.110 notes (push main via gitea-ai BEFORE publish; sign manifest AFTER create-release).
|
||||
- ❑ H5 No secrets in any commit; frontend tarball flat + APK policy per release notes.
|
||||
@@ -0,0 +1,84 @@
|
||||
# TV input: keyboard/gamepad inside iframe apps — design
|
||||
|
||||
**Goal (2026-07-23, user requirement):** on a TV/kiosk node, keyboard and
|
||||
gamepad control must work *inside iframe apps* (IndeeHub, Jellyfin, fedimint
|
||||
UI, AIUI…), easily and globally — no per-app hacks.
|
||||
|
||||
## What already exists
|
||||
|
||||
- `neode-ui/src/composables/useControllerNav.ts` — a complete spatial-nav
|
||||
system for the shell: reads gamepads (`navigator.getGamepads`), moves focus
|
||||
between `data-controller-container` regions, plays nav sounds. It stops at
|
||||
iframe boundaries: nothing is forwarded into frames.
|
||||
- Keyboard focus DOES enter iframes natively (click/Tab into the frame), and a
|
||||
focused iframe receives all keys — same- or cross-origin. The gap is
|
||||
gamepad→app and deliberate focus handoff shell↔frame.
|
||||
- Kiosk Chromium is our process (archipelago-kiosk-launcher), X11, and the ISO
|
||||
already ships `xdotool`. Most app iframes are **cross-origin**
|
||||
(`http://host:port`), so shell-side script injection is impossible for them;
|
||||
only the nginx-proxied `/app/...` ones are same-origin.
|
||||
|
||||
## Recommended architecture — two layers, both global
|
||||
|
||||
### Layer 1 (OS, kiosk nodes): gamepad → virtual keyboard, kernel-level
|
||||
|
||||
A small host daemon (`archipelago-gamepad-keys`) on kiosk nodes:
|
||||
|
||||
- reads game controllers via evdev (`/dev/input/event*`, capability
|
||||
BTN_GAMEPAD), hotplug-aware (udev monitor or 5s rescan — same pattern as the
|
||||
audio router);
|
||||
- emits a **uinput virtual keyboard**: D-pad/left-stick → arrow keys, A →
|
||||
Enter, B → Escape, X → Space (play/pause), Y → `f` (fullscreen in most
|
||||
players), shoulders → Tab / Shift+Tab, Start → Enter, Select → Escape;
|
||||
- ships exactly like the audio router: `image-recipe/configs/` script + unit,
|
||||
spliced into the ISO, `include_str!` self-heal in `bootstrap.rs`, gated on
|
||||
the kiosk being installed. The `archipelago` user is in `input` group OR the
|
||||
unit runs as root (uinput needs it anyway — run as root, it's ~100 lines of
|
||||
evdev→uinput with no network).
|
||||
|
||||
Why this layer wins: the browser sees a real keyboard, so **every iframe —
|
||||
any origin, any app — just works** the way it does for a physical keyboard
|
||||
today. Video players, web games, AIUI: all of them already have keyboard
|
||||
bindings. Zero app cooperation, zero web-platform security fights.
|
||||
|
||||
### Layer 2 (shell): deliberate focus handoff into/out of frames
|
||||
|
||||
Small extension to `useControllerNav`:
|
||||
|
||||
- When spatial nav selects an app-session container and the user presses
|
||||
A/Enter: call `iframe.focus()` (works cross-origin) — keys (real or
|
||||
virtual) now flow into the app.
|
||||
- A dedicated **exit chord** the daemon maps from the gamepad (e.g. Home
|
||||
button → F12 or a rarely-used key): the shell listens with a *capturing*
|
||||
window listener; on seeing it, `iframe.blur()` + return focus to the shell
|
||||
nav. Keyboard users get the same via a documented chord (e.g. long
|
||||
Escape / Ctrl+Escape — plain Escape stays with the app, players use it).
|
||||
- Same-origin frames (the `/app/...` proxied set) can additionally get the
|
||||
full spatial-nav treatment by running the existing nav over
|
||||
`iframe.contentDocument` — nice-to-have after the layers above land.
|
||||
|
||||
### Optional layer 3 (per-app polish): postMessage contract
|
||||
|
||||
For OUR app UIs only (AIUI, fedimint, launcher pages): a tiny
|
||||
`archipelago:input` postMessage contract for semantic actions (back, home,
|
||||
context-menu) where raw keys aren't expressive enough. Documented in the app
|
||||
packaging docs; never required for an app to be usable.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ CDP (`--remote-debugging-port` + Input.dispatchKeyEvent): works but adds
|
||||
a privileged debug port to the kiosk and a daemon↔browser coupling; the
|
||||
uinput route gets the same result at kernel level with no attack surface.
|
||||
- ❌ Per-app nav scripts injected into iframes: cross-origin makes this
|
||||
impossible for most apps, and it's exactly the per-app hack the requirement
|
||||
rules out.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `archipelago-gamepad-keys` daemon (evdev→uinput, ~python3 stdlib or small
|
||||
Rust bin) + unit + ISO splice + bootstrap self-heal. Test on Framework PT
|
||||
with any USB/BT controller.
|
||||
2. `useControllerNav`: A-button → `iframe.focus()` on the focused app session;
|
||||
exit-chord capture listener to reclaim focus.
|
||||
3. (Later) same-origin spatial nav inside `/app/...` frames; postMessage
|
||||
contract for our own app UIs.
|
||||
@@ -382,6 +382,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
xorg \
|
||||
xdotool \
|
||||
chromium \
|
||||
pipewire \
|
||||
pipewire-pulse \
|
||||
pipewire-alsa \
|
||||
wireplumber \
|
||||
alsa-utils \
|
||||
unclutter \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
@@ -420,7 +425,10 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
||||
|
||||
# Create archipelago user with password "archipelago"
|
||||
RUN useradd -m -s /bin/bash -G sudo,dialout archipelago && \
|
||||
# audio group: PipeWire runs under the lingering user manager (no logind seat
|
||||
# session), so udev's seat ACLs on /dev/snd never apply — group access is the
|
||||
# only way the kiosk's audio can open the hardware.
|
||||
RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \
|
||||
echo "archipelago:archipelago" | chpasswd && \
|
||||
echo "root:archipelago" | chpasswd && \
|
||||
echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago
|
||||
@@ -1157,6 +1165,20 @@ if [ "$BACKEND_CAPTURED" = "0" ] && [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bundle the Reticulum RNode daemon alongside the backend. install-to-disk
|
||||
# copies everything in archipelago/bin/ to /usr/local/bin, and the mesh
|
||||
# listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios —
|
||||
# a node imaged without it can never connect a Reticulum stick
|
||||
# (framework-pt, 2026-07-22: silent connect failures until hand-copied).
|
||||
RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}"
|
||||
if [ -f "$RETICULUM_DAEMON" ]; then
|
||||
cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon"
|
||||
chmod +x "$ARCH_DIR/bin/archy-reticulum-daemon"
|
||||
echo " ✅ Reticulum daemon bundled ($(du -h "$ARCH_DIR/bin/archy-reticulum-daemon" | cut -f1))"
|
||||
else
|
||||
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
echo " ⚠️ Could not capture from live server, building from source..."
|
||||
@@ -2862,6 +2884,62 @@ fi
|
||||
echo "KIOSKSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
# Audio router: same splice-from-configs/ pattern as the kiosk (single source
|
||||
# of truth, also embedded in the binary for bootstrap self-heal). Keeps HDMI
|
||||
# audio working: profile/default-sink routing + the ELD boot-race re-modeset.
|
||||
AUDIO_ROUTER_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.sh"
|
||||
AUDIO_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.service"
|
||||
for _audio_src in "$AUDIO_ROUTER_SRC" "$AUDIO_SERVICE_SRC"; do
|
||||
if [ ! -f "$_audio_src" ]; then
|
||||
echo "ERROR: audio config file missing: $_audio_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if grep -qx 'AUDIOROUTER' "$AUDIO_ROUTER_SRC" || grep -qx 'AUDIOSVC' "$AUDIO_SERVICE_SRC"; then
|
||||
echo "ERROR: audio config contains a reserved heredoc terminator line (AUDIOROUTER/AUDIOSVC)" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "# Audio router — HDMI profile/default-sink follow + ELD boot-race heal."
|
||||
echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)."
|
||||
echo "cat > /mnt/target/usr/local/bin/archipelago-audio-router <<'AUDIOROUTER'"
|
||||
cat "$AUDIO_ROUTER_SRC"
|
||||
echo "AUDIOROUTER"
|
||||
echo "chmod +x /mnt/target/usr/local/bin/archipelago-audio-router"
|
||||
echo ""
|
||||
echo "cat > /mnt/target/etc/systemd/system/archipelago-audio-router.service <<'AUDIOSVC'"
|
||||
cat "$AUDIO_SERVICE_SRC"
|
||||
echo "AUDIOSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
# Gamepad->keyboard bridge: same pattern (configs/ single source of truth,
|
||||
# also self-healed via the binary). TV input for every app iframe.
|
||||
GAMEPAD_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.py"
|
||||
GAMEPAD_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.service"
|
||||
for _pad_src in "$GAMEPAD_SRC" "$GAMEPAD_SERVICE_SRC"; do
|
||||
if [ ! -f "$_pad_src" ]; then
|
||||
echo "ERROR: gamepad config file missing: $_pad_src" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if grep -qx 'GAMEPADPY' "$GAMEPAD_SRC" || grep -qx 'GAMEPADSVC' "$GAMEPAD_SERVICE_SRC"; then
|
||||
echo "ERROR: gamepad config contains a reserved heredoc terminator line (GAMEPADPY/GAMEPADSVC)" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo ""
|
||||
echo "# Gamepad->keyboard bridge — controller input inside every app iframe on the TV."
|
||||
echo "cat > /mnt/target/usr/local/bin/archipelago-gamepad-keys <<'GAMEPADPY'"
|
||||
cat "$GAMEPAD_SRC"
|
||||
echo "GAMEPADPY"
|
||||
echo "chmod +x /mnt/target/usr/local/bin/archipelago-gamepad-keys"
|
||||
echo ""
|
||||
echo "cat > /mnt/target/etc/systemd/system/archipelago-gamepad-keys.service <<'GAMEPADSVC'"
|
||||
cat "$GAMEPAD_SERVICE_SRC"
|
||||
echo "GAMEPADSVC"
|
||||
} >> "$ARCH_DIR/auto-install.sh"
|
||||
|
||||
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
|
||||
|
||||
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
||||
@@ -3241,6 +3319,8 @@ chroot /mnt/target systemctl enable archipelago-first-boot-secrets.service 2>/de
|
||||
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable archipelago-gamepad-keys.service 2>/dev/null || true
|
||||
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
|
||||
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
|
||||
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal)
|
||||
# Talks to the archipelago user's PipeWire (running under the lingering user
|
||||
# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start
|
||||
# after both are plausibly up. Missing/inactive units here are harmless.
|
||||
After=user@1000.service archipelago-kiosk.service
|
||||
Wants=user@1000.service
|
||||
ConditionPathExists=/usr/local/bin/archipelago-audio-router
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=archipelago
|
||||
ExecStart=/usr/local/bin/archipelago-audio-router
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# Polls a few pactl calls every 5s — keep it invisible to the scheduler.
|
||||
Nice=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# Archipelago audio router — keep audio flowing out of the display the user is
|
||||
# actually looking at.
|
||||
#
|
||||
# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock
|
||||
# profile priorities rank the laptop's analog output above HDMI, so a node
|
||||
# driving a TV plays video sound out of its own tiny speakers (or nowhere).
|
||||
# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening
|
||||
# monitor; this daemon polls that via the card's profile availability and:
|
||||
#
|
||||
# - switches the card to the best *available* HDMI stereo profile (the
|
||||
# "+input:analog-stereo" combined variant when offered, so the mic keeps
|
||||
# working), and back to analog when HDMI is unplugged;
|
||||
# - keeps the default sink pointed at the routed output and migrates any
|
||||
# live streams so playback follows a hot-plug without a page reload;
|
||||
# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns
|
||||
# the real volume control; analog volume is left where the user set it).
|
||||
#
|
||||
# Runs as the archipelago user (systemd system unit with User=archipelago),
|
||||
# talks only to the user-session PipeWire; polling is a few pactl calls every
|
||||
# 5s — negligible. Surround profiles are deliberately ignored: stereo is the
|
||||
# lowest-common-denominator every TV decodes.
|
||||
|
||||
# - re-modesets an external output once when it is connected but no ELD
|
||||
# reports a monitor: the kiosk's boot-time Xorg modeset can beat the
|
||||
# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI
|
||||
# profile stays "available: no" forever (no sound, no error). One
|
||||
# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV).
|
||||
|
||||
RUNTIME_DIR="/run/user/$(id -u)"
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}"
|
||||
export DISPLAY="${DISPLAY:-:0}"
|
||||
|
||||
# The user manager socket-activates pipewire, but after a live package install
|
||||
# (bootstrap self-heal) nothing has poked it yet — start best-effort.
|
||||
systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true
|
||||
systemctl --user start wireplumber.service 2>/dev/null || true
|
||||
|
||||
LAST_SINK=""
|
||||
NUDGED_OUTPUTS=""
|
||||
|
||||
# Re-deliver a lost ELD by cycling the connected external output once. Guarded:
|
||||
# only when X answers, only when NO ELD anywhere reports a monitor, and only
|
||||
# once per connector while it stays connected (a display with no audio support
|
||||
# never produces an ELD — without the flag we would blank it every pass).
|
||||
eld_nudge_once() {
|
||||
# Any ELD already valid → audio path is live; reset the nudge memory.
|
||||
if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then
|
||||
NUDGED_OUTPUTS=""
|
||||
return 0
|
||||
fi
|
||||
|
||||
local xrandr_out conn name output mode
|
||||
xrandr_out=$(xrandr --query 2>/dev/null) || return 0
|
||||
|
||||
for conn in /sys/class/drm/card*-*/status; do
|
||||
[ -e "$conn" ] || continue
|
||||
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
|
||||
name=${conn%/status}; name=${name##*/card?-}
|
||||
case "$name" in eDP*|LVDS*) continue ;; esac
|
||||
case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac
|
||||
|
||||
# DRM connector names match the modesetting driver's output names.
|
||||
output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}')
|
||||
[ -n "$output" ] || continue
|
||||
|
||||
# Keep the mode the kiosk chose (it may have capped a 4K panel);
|
||||
# --auto only as a fallback.
|
||||
mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" '
|
||||
$1 == out { active = 1; next }
|
||||
active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } }
|
||||
active && /^[^[:space:]]/ { active = 0 }')
|
||||
|
||||
NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name"
|
||||
xrandr --output "$output" --off 2>/dev/null || true
|
||||
sleep 1
|
||||
if [ -n "$mode" ]; then
|
||||
xrandr --output "$output" --mode "$mode" 2>/dev/null \
|
||||
|| xrandr --output "$output" --auto 2>/dev/null || true
|
||||
else
|
||||
xrandr --output "$output" --auto 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
route_once() {
|
||||
local cards_dump card active want sink default_sink
|
||||
cards_dump=$(pactl list cards 2>/dev/null) || return 0
|
||||
[ -n "$cards_dump" ] || return 0
|
||||
|
||||
# One line per card: "<card>\t<active>\t<wanted-profile>"
|
||||
# wanted = highest-priority available output:hdmi-stereo* profile, else
|
||||
# highest-priority available output:analog* profile.
|
||||
while IFS=$'\t' read -r card active want; do
|
||||
[ -n "$card" ] && [ -n "$want" ] || continue
|
||||
if [ "$active" != "$want" ]; then
|
||||
pactl set-card-profile "$card" "$want" 2>/dev/null || true
|
||||
fi
|
||||
done < <(printf '%s\n' "$cards_dump" | awk '
|
||||
function flush() {
|
||||
if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog)
|
||||
card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1
|
||||
}
|
||||
/^Card #/ { flush() }
|
||||
/^\tName: / { card=$2 }
|
||||
/^\t\toutput:/ {
|
||||
line=$0; sub(/^\t\t/, "", line)
|
||||
prof=line; sub(/: .*/, "", prof)
|
||||
prio=0
|
||||
if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0
|
||||
avail = (line ~ /available: yes/ || line ~ /availability unknown/)
|
||||
if (!avail) next
|
||||
if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } }
|
||||
else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } }
|
||||
}
|
||||
/^\tActive Profile: / { active=$3 }
|
||||
END { flush() }
|
||||
')
|
||||
|
||||
# Point the default sink at HDMI when one exists, else the first sink.
|
||||
sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}')
|
||||
[ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}')
|
||||
[ -n "$sink" ] || return 0
|
||||
|
||||
default_sink=$(pactl get-default-sink 2>/dev/null)
|
||||
if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then
|
||||
pactl set-default-sink "$sink" 2>/dev/null || true
|
||||
pactl set-sink-mute "$sink" 0 2>/dev/null || true
|
||||
case "$sink" in
|
||||
*hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;;
|
||||
esac
|
||||
# Migrate live streams so playing audio follows the hot-plug.
|
||||
pactl list short sink-inputs 2>/dev/null | while read -r id _; do
|
||||
pactl move-sink-input "$id" "$sink" 2>/dev/null || true
|
||||
done
|
||||
LAST_SINK="$sink"
|
||||
fi
|
||||
}
|
||||
|
||||
while true; do
|
||||
eld_nudge_once
|
||||
route_once
|
||||
sleep 5
|
||||
done
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Archipelago gamepad→keyboard bridge (kiosk nodes).
|
||||
|
||||
Reads every attached game controller via evdev and mirrors it as a virtual
|
||||
uinput KEYBOARD, so gamepad input works in every app — including cross-origin
|
||||
iframes (IndeeHub, Jellyfin, …) where the web shell can never inject events.
|
||||
The browser just sees arrow/Enter/Escape keys from a real-looking keyboard;
|
||||
X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md.
|
||||
|
||||
Mapping (standard pad):
|
||||
D-pad / left stick -> arrow keys
|
||||
A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape
|
||||
X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen)
|
||||
LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab
|
||||
Start -> Enter Select -> Escape
|
||||
|
||||
*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f — either way
|
||||
one is play/pause and one is fullscreen, which is fine for a TV.
|
||||
|
||||
Pure stdlib (struct/fcntl/select) — no python3-evdev dependency on the node.
|
||||
Runs as root (uinput + /dev/input need it); hotplug via 5s rescans.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
import struct
|
||||
import time
|
||||
|
||||
# ---- kernel constants ------------------------------------------------------
|
||||
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
|
||||
SYN_REPORT = 0
|
||||
|
||||
KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57
|
||||
KEY_LEFTSHIFT, KEY_F = 42, 33
|
||||
KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108
|
||||
|
||||
BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
|
||||
BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B
|
||||
BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223
|
||||
|
||||
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
|
||||
|
||||
EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range
|
||||
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
|
||||
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
|
||||
|
||||
INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value
|
||||
|
||||
BUTTON_MAP = {
|
||||
BTN_SOUTH: (KEY_ENTER,),
|
||||
BTN_EAST: (KEY_ESC,),
|
||||
BTN_NORTH: (KEY_SPACE,),
|
||||
BTN_WEST: (KEY_F,),
|
||||
BTN_TL: (KEY_LEFTSHIFT, KEY_TAB),
|
||||
BTN_TR: (KEY_TAB,),
|
||||
BTN_START: (KEY_ENTER,),
|
||||
BTN_SELECT: (KEY_ESC,),
|
||||
BTN_DPAD_UP: (KEY_UP,),
|
||||
BTN_DPAD_DOWN: (KEY_DOWN,),
|
||||
BTN_DPAD_LEFT: (KEY_LEFT,),
|
||||
BTN_DPAD_RIGHT: (KEY_RIGHT,),
|
||||
}
|
||||
EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys}
|
||||
| {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT})
|
||||
|
||||
STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses"
|
||||
|
||||
|
||||
def is_gamepad(fd) -> bool:
|
||||
buf = bytearray(96)
|
||||
try:
|
||||
fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf)
|
||||
except OSError:
|
||||
return False
|
||||
def has(code):
|
||||
return bool(buf[code // 8] & (1 << (code % 8)))
|
||||
return has(BTN_SOUTH) or has(BTN_START)
|
||||
|
||||
|
||||
class VirtualKeyboard:
|
||||
def __init__(self):
|
||||
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
|
||||
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
|
||||
for key in EMITTED_KEYS:
|
||||
fcntl.ioctl(self.fd, UI_SET_KEYBIT, key)
|
||||
# Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects
|
||||
# + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel.
|
||||
name = b"Archipelago Gamepad Keys"
|
||||
setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0)
|
||||
setup += b"\0" * (64 * 4 * 4)
|
||||
os.write(self.fd, setup)
|
||||
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||||
|
||||
def _emit(self, etype, code, value):
|
||||
os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value))
|
||||
|
||||
def set_key(self, key, pressed):
|
||||
self._emit(EV_KEY, key, 1 if pressed else 0)
|
||||
self._emit(EV_SYN, SYN_REPORT, 0)
|
||||
|
||||
def chord(self, keys, pressed):
|
||||
seq = keys if pressed else tuple(reversed(keys))
|
||||
for k in seq:
|
||||
self._emit(EV_KEY, k, 1 if pressed else 0)
|
||||
self._emit(EV_SYN, SYN_REPORT, 0)
|
||||
|
||||
|
||||
class PadState:
|
||||
"""Per-device axis state → synthetic arrow presses."""
|
||||
|
||||
def __init__(self):
|
||||
self.axis_keys = {} # axis -> currently-pressed arrow key (or None)
|
||||
self.abs_range = {} # axis -> (min, max) for sticks
|
||||
|
||||
def arrow_for(self, axis, value):
|
||||
if axis in (ABS_HAT0X, ABS_HAT0Y):
|
||||
if value < 0:
|
||||
return KEY_LEFT if axis == ABS_HAT0X else KEY_UP
|
||||
if value > 0:
|
||||
return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN
|
||||
return None
|
||||
lo, hi = self.abs_range.get(axis, (-32768, 32767))
|
||||
span = (hi - lo) or 1
|
||||
norm = (2 * (value - lo) / span) - 1
|
||||
if norm <= -STICK_THRESHOLD:
|
||||
return KEY_LEFT if axis == ABS_X else KEY_UP
|
||||
if norm >= STICK_THRESHOLD:
|
||||
return KEY_RIGHT if axis == ABS_X else KEY_DOWN
|
||||
return None
|
||||
|
||||
|
||||
def stick_range(fd, axis):
|
||||
# EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res }
|
||||
buf = bytearray(24)
|
||||
try:
|
||||
fcntl.ioctl(fd, 0x80184540 + axis, buf)
|
||||
_, lo, hi = struct.unpack("iii", bytes(buf[:12]))
|
||||
if hi > lo:
|
||||
return (lo, hi)
|
||||
except OSError:
|
||||
pass
|
||||
return (-32768, 32767)
|
||||
|
||||
|
||||
def main():
|
||||
os.system("modprobe uinput 2>/dev/null")
|
||||
kbd = VirtualKeyboard()
|
||||
pads = {} # path -> (fd, PadState)
|
||||
last_scan = 0.0
|
||||
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if now - last_scan > 5:
|
||||
last_scan = now
|
||||
try:
|
||||
names = sorted(os.listdir("/dev/input"))
|
||||
except FileNotFoundError:
|
||||
names = []
|
||||
for name in names:
|
||||
if not name.startswith("event"):
|
||||
continue
|
||||
path = "/dev/input/" + name
|
||||
if path in pads:
|
||||
continue
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
except OSError:
|
||||
continue
|
||||
if is_gamepad(fd):
|
||||
state = PadState()
|
||||
for axis in (ABS_X, ABS_Y):
|
||||
state.abs_range[axis] = stick_range(fd, axis)
|
||||
pads[path] = (fd, state)
|
||||
print(f"gamepad attached: {path}", flush=True)
|
||||
else:
|
||||
os.close(fd)
|
||||
|
||||
if not pads:
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0)
|
||||
gone = []
|
||||
for path, (fd, state) in list(pads.items()):
|
||||
if fd not in readable:
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, INPUT_EVENT.size * 64)
|
||||
except OSError:
|
||||
gone.append(path)
|
||||
continue
|
||||
for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
|
||||
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
||||
if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1):
|
||||
keys = BUTTON_MAP[code]
|
||||
if len(keys) == 1:
|
||||
kbd.set_key(keys[0], value == 1)
|
||||
else:
|
||||
kbd.chord(keys, value == 1)
|
||||
elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y):
|
||||
want = state.arrow_for(code, value)
|
||||
held = state.axis_keys.get(code)
|
||||
if want != held:
|
||||
if held is not None:
|
||||
kbd.set_key(held, False)
|
||||
if want is not None:
|
||||
kbd.set_key(want, True)
|
||||
state.axis_keys[code] = want
|
||||
for path in gone:
|
||||
fd, state = pads.pop(path)
|
||||
for held in state.axis_keys.values():
|
||||
if held is not None:
|
||||
kbd.set_key(held, False)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"gamepad detached: {path}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Archipelago gamepad->keyboard bridge (TV/kiosk input in every app iframe)
|
||||
# Only meaningful where a display UI runs; the kiosk unit is the marker.
|
||||
ConditionPathExists=/etc/systemd/system/archipelago-kiosk.service
|
||||
ConditionPathExists=/usr/local/bin/archipelago-gamepad-keys
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Root: /dev/uinput device creation + raw /dev/input readers.
|
||||
ExecStart=/usr/bin/python3 /usr/local/bin/archipelago-gamepad-keys
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Nice=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -174,6 +174,9 @@ while true; do
|
||||
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
|
||||
# falls back to raw ALSA "default", fails to connect, and produces no audio
|
||||
# at all with no visible error (--noerrdialogs suppresses it).
|
||||
# OverlayScrollbar: thin auto-hiding scrollbars (the Chrome-on-a-remote-
|
||||
# device look) instead of classic X11 scrollbar chrome — a kiosk TV showed
|
||||
# a permanent fat scrollbar on scrollable views (Peers).
|
||||
# Force a DARK color-scheme preference. The main UI hardcodes its dark
|
||||
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
|
||||
# and defaults to its LIGHT variant (white panels) when the browser reports
|
||||
@@ -191,6 +194,7 @@ while true; do
|
||||
--no-first-run \
|
||||
--check-for-update-interval=31536000 \
|
||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||
--enable-features=OverlayScrollbar \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-save-password-bubble \
|
||||
--disable-suggestions-service \
|
||||
|
||||
@@ -236,6 +236,22 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# Pine node status — live node facts for the Pine launcher page and the
|
||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||
# backend; nginx only forwards.
|
||||
location /api/pine/status {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 5s;
|
||||
error_page 502 503 = @backend_unavailable;
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
location /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
@@ -1022,6 +1038,22 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# Pine node status — live node facts for the Pine launcher page and the
|
||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||
# backend; nginx only forwards.
|
||||
location /api/pine/status {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 5s;
|
||||
error_page 502 503 = @backend_unavailable;
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
location /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!-- Dark-only app: pin native controls dark before CSS loads. -->
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
@@ -228,7 +228,9 @@ function seedMockState() {
|
||||
return {
|
||||
analyticsEnabled: false,
|
||||
nodeVisibility: 'discoverable',
|
||||
nostrDiscovery: true,
|
||||
// Discovery starts OFF, matching the production default — enabling it in
|
||||
// the UI walks through the presence-signing overlay.
|
||||
nostrDiscovery: false,
|
||||
pendingPeerRequests: [
|
||||
{
|
||||
id: 'preq-demo-1',
|
||||
@@ -1634,7 +1636,14 @@ app.post('/rpc/v1', (req, res) => {
|
||||
return res.json({ result: { event_id: 'mock-event-id', success: 2, failed: 0 } })
|
||||
}
|
||||
case 'node.nostr-pubkey': {
|
||||
return res.json({ result: { nostr_pubkey: 'mock-nostr-pubkey-hex' } })
|
||||
// Exact shape of the real handler (node.rs handle_node_nostr_pubkey):
|
||||
// the node's dedicated discovery key — hex + genuine bech32 npub
|
||||
// (deliberately NOT one of the personal identity keys; discovery
|
||||
// presence events are always signed with this separate node key).
|
||||
return res.json({ result: {
|
||||
nostr_pubkey: 'c9f0a2e84b71d3568e0f4a6b2c8d1e97a3b5c7d9e1f2a4b6c8d0e2f4a6b8c0d2',
|
||||
nostr_npub: 'npub1e8c296ztw8f4drs0ff4jerg7j73mt37eu8e2fdkg6r30ff4ccrfq8j5vuy',
|
||||
} })
|
||||
}
|
||||
|
||||
case 'node.signChallenge': {
|
||||
@@ -2962,6 +2971,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
region: mc.enabled ? mc.lora_region : null,
|
||||
announce_block_headers: globalThis.__meshHeaders.announce_block_headers,
|
||||
receive_block_headers: globalThis.__meshHeaders.receive_block_headers,
|
||||
manage_radio: mc.manage_radio !== false,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3079,7 +3089,32 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (params && typeof params.device_kind === 'string') cfg.device_kind = params.device_kind === 'auto' ? null : params.device_kind
|
||||
if (params && typeof params.channel_name === 'string') cfg.channel_name = params.channel_name
|
||||
if (params && typeof params.advert_name === 'string') cfg.advert_name = params.advert_name
|
||||
return res.json({ result: { configured: true, enabled: cfg.enabled, lora_region: cfg.lora_region, device_kind: cfg.device_kind, ...globalThis.__meshHeaders } })
|
||||
// Hot-swap "keep as is" (2026-07-22): false = never write config to the radio.
|
||||
if (params && typeof params.manage_radio === 'boolean') cfg.manage_radio = params.manage_radio
|
||||
return res.json({ result: { configured: true, enabled: cfg.enabled, lora_region: cfg.lora_region, device_kind: cfg.device_kind, manage_radio: cfg.manage_radio !== false, ...globalThis.__meshHeaders } })
|
||||
}
|
||||
|
||||
// Read-only firmware probe backing the hot-swap device modal's
|
||||
// current-details card (2026-07-22). Real backend opens the serial
|
||||
// port; the demo answers instantly with what's "flashed" on the
|
||||
// Heltec that mesh.status advertises.
|
||||
case 'mesh.probe-device': {
|
||||
// 900ms delay lets the modal's "Reading what's on the radio…" spinner show.
|
||||
setTimeout(() => res.json({
|
||||
result: {
|
||||
path: params?.path || '/dev/ttyUSB0',
|
||||
kind: 'meshcore',
|
||||
firmware_version: 'MeshCore v2.3.1',
|
||||
node_id: 42,
|
||||
advert_name: 'archy-228',
|
||||
region: null,
|
||||
modem_preset: null,
|
||||
primary_channel: null,
|
||||
secondary_channel: null,
|
||||
max_contacts: 100,
|
||||
},
|
||||
}), 900)
|
||||
return
|
||||
}
|
||||
|
||||
case 'mesh.send-invoice': {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user