Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1931371058 | ||
|
|
693f4bb947 | ||
|
|
bb9ebb1138 | ||
|
|
cc2c06c6dc | ||
|
|
97fbaf8818 | ||
|
|
2116600e24 | ||
|
|
72f7c38701 | ||
|
|
745e180102 | ||
|
|
317a72aafe | ||
|
|
184257390d | ||
|
|
e074a117d2 | ||
|
|
b288be314c | ||
|
|
72fcf96016 | ||
|
|
50d5d4d132 | ||
|
|
73923d0ffd | ||
|
|
905e9cdb98 | ||
|
|
46cfc2ccd7 | ||
|
|
a597c1d946 | ||
|
|
84bc3d3138 | ||
|
|
856708e353 | ||
|
|
0a99de75f9 | ||
|
|
aaec25e53b | ||
|
|
172b1f3e9c | ||
|
|
c7fe688607 | ||
|
|
61d37c9798 | ||
|
|
d76f91a62f | ||
|
|
24b3d33ac3 | ||
|
|
b23f46c3d8 | ||
|
|
2a376ab275 | ||
|
|
5982fceb7c | ||
|
|
5951d31637 | ||
|
|
dc55ef8a54 | ||
|
|
97358d2314 | ||
|
|
1b78da1d7a | ||
|
|
0213da3fc5 | ||
|
|
8968d41ca9 | ||
|
|
be5122048b | ||
|
|
918b0275a9 | ||
|
|
a71a18bffd | ||
|
|
24cab326e2 | ||
|
|
811ac1ce50 | ||
|
|
69b1a45872 | ||
|
|
9823e76e8f | ||
|
|
8d4f6cb75e | ||
|
|
4bac839fb3 | ||
|
|
ea80815c98 | ||
|
|
4348581681 | ||
|
|
999a100531 | ||
|
|
a5810d4e51 | ||
|
|
4c3cd4b6ad | ||
|
|
c1dfc6672c | ||
|
|
8791ef60f5 | ||
|
|
1730d02003 | ||
|
|
146a3bc738 | ||
|
|
302f880d99 | ||
|
|
217eedc093 | ||
|
|
d79edb3a5c | ||
|
|
0f13545375 | ||
|
|
7eb4d6a7bf | ||
|
|
db008abdfd | ||
|
|
35e9c62441 | ||
|
|
cb301b1aef | ||
|
|
6a848c38cc | ||
|
|
830811372b | ||
|
|
38cb3dd252 | ||
|
|
50170b866e | ||
|
|
c91887a397 | ||
|
|
4fb200e938 | ||
|
|
5fd0d6c3c8 | ||
|
|
3ab7fb521a | ||
|
|
9e3ac9ba8f | ||
|
|
e2f83c0157 | ||
|
|
d5f709a3c3 | ||
|
|
710f576c77 | ||
|
|
c1d309f21f | ||
|
|
9c39243969 | ||
|
|
f25febf3bb | ||
|
|
0636d611e0 | ||
|
|
f13fdc6451 | ||
|
|
163bc3af01 | ||
|
|
ae12ff2517 | ||
|
|
cf4a8eef0e | ||
|
|
e5f8b5d789 | ||
|
|
aad6faa6d2 | ||
|
|
20edd31abb | ||
|
|
9a7331cead | ||
|
|
9eadec6936 | ||
|
|
6b0a84e710 | ||
|
|
fd361fb35e | ||
|
|
8bb61a51e2 | ||
|
|
17d225190a | ||
|
|
d08c0d29c7 | ||
|
|
a93bd70c5a | ||
|
|
25162ee846 | ||
|
|
837cfdfd1f | ||
|
|
573b469191 | ||
|
|
401f92a24f | ||
|
|
dc0adbef70 | ||
|
|
537c9fa70b | ||
|
|
b6468ebf3c | ||
|
|
70587210fb | ||
|
|
a27c7bafbf | ||
|
|
95b9d8f0fe | ||
|
|
918aba1de3 | ||
|
|
49b366fbe4 | ||
|
|
7eaf99873e | ||
|
|
a76a92cff8 | ||
|
|
a177ef3b38 | ||
|
|
ea0cd87b3b | ||
|
|
1ee1b56f70 | ||
|
|
73d181abea | ||
|
|
55d7f19545 | ||
|
|
46dd853614 | ||
|
|
977b8d06b8 | ||
|
|
f08f34ca10 | ||
|
|
aaa3477d5e | ||
|
|
9e264611e2 | ||
|
|
f32c4db7e2 | ||
|
|
8e13f981d0 | ||
|
|
3aebbcbbb8 | ||
|
|
90bedc2a25 | ||
|
|
a66e4bac6d | ||
|
|
af2dfd0bd6 | ||
|
|
68c25534d4 | ||
|
|
45c9def94a | ||
|
|
299f7d8f39 | ||
|
|
bb231e82b4 | ||
|
|
e2309cc2ac | ||
|
|
a48499daeb | ||
|
|
6df9afe684 | ||
|
|
9d21dd5111 | ||
|
|
ddbfaf1d00 | ||
|
|
2e5f67a59a | ||
|
|
51d1c89137 | ||
|
|
785fb3d2be | ||
|
|
2b3a18f189 | ||
|
|
3028685e6b | ||
|
|
1a3170f1c3 | ||
|
|
547f674ac8 | ||
|
|
92d221bbf1 | ||
|
|
e9cfc234cd | ||
|
|
06186ab30c | ||
|
|
e5c7210663 | ||
|
|
500472944c | ||
|
|
a687df9bd9 | ||
|
|
0aa9463b36 | ||
|
|
5cb53ade66 | ||
|
|
47dea8cd55 | ||
|
|
72d7fa07ff | ||
|
|
6dcdada371 | ||
|
|
454c4bb25c | ||
|
|
66fd121748 | ||
|
|
fa91faa67c | ||
|
|
e9c3311eff | ||
|
|
338ae9a6de | ||
|
|
8f397b03f9 | ||
|
|
48d5fd0045 | ||
|
|
a44cbe478a | ||
|
|
519b4b209a | ||
|
|
2c82b498f0 | ||
|
|
efd1ae41de | ||
|
|
0c591a997e | ||
|
|
91e1059f56 | ||
|
|
3d986b81a0 | ||
|
|
2efed23afd |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 16
|
||||
versionName = "0.4.12"
|
||||
versionCode = 19
|
||||
versionName = "0.4.15"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -111,6 +111,12 @@ dependencies {
|
||||
// OkHttp for WebSocket (remote input)
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
|
||||
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
|
||||
implementation("androidx.camera:camera-camera2:1.3.4")
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.4")
|
||||
implementation("androidx.camera:camera-view:1.3.4")
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- 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" />
|
||||
|
||||
<application
|
||||
android:name=".ArchipelagoApp"
|
||||
@@ -19,6 +22,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Archipelago.Splash"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||
@@ -26,6 +30,14 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Pairing deep link from the web UI's Companion popup:
|
||||
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
// Pairing deep link (archipelago://pair?...) from the launch intent or a
|
||||
// later one (launchMode=singleTask). Consumed by AppNavHost.
|
||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingPairUri.value = intent?.dataString
|
||||
setContent {
|
||||
ArchipelagoTheme {
|
||||
AppNavHost()
|
||||
val pairUri by pendingPairUri.collectAsState()
|
||||
AppNavHost(
|
||||
pairUri = pairUri,
|
||||
onPairUriConsumed = { pendingPairUri.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
pendingPairUri.value = intent.dataString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,39 @@ class ServerPreferences(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a server, or update the entry with the same connection identity
|
||||
* (address/port/scheme) — used by QR pairing so re-scanning a node never
|
||||
* duplicates it. A blank incoming password/name keeps the stored value
|
||||
* (a real node's QR never carries the password). Returns the merged entry.
|
||||
*/
|
||||
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
||||
var merged = server
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
|
||||
it.address == server.address &&
|
||||
it.port == server.port &&
|
||||
it.useHttps == server.useHttps
|
||||
}
|
||||
if (existing != null) {
|
||||
merged = server.copy(
|
||||
password = server.password.ifBlank { existing.password },
|
||||
name = server.name.ifBlank { existing.name },
|
||||
)
|
||||
}
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + merged.serialize()
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Result of parsing a pairing QR / deep link.
|
||||
*
|
||||
* UnsupportedVersion means the payload is structurally a pairing URI but its
|
||||
* major version is newer than this app understands — the UI should tell the
|
||||
* user to update the app rather than call the code invalid.
|
||||
*/
|
||||
sealed class PairResult {
|
||||
data class Success(val server: ServerEntry) : PairResult()
|
||||
object UnsupportedVersion : PairResult()
|
||||
object Invalid : 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>]
|
||||
*
|
||||
* - `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).
|
||||
*/
|
||||
object ServerQrParser {
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
|
||||
fun parse(raw: String): PairResult {
|
||||
val uri = try {
|
||||
Uri.parse(raw.trim())
|
||||
} catch (_: Exception) {
|
||||
return PairResult.Invalid
|
||||
}
|
||||
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
|
||||
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
|
||||
|
||||
val major = uri.getQueryParameter("v")
|
||||
?.trim()
|
||||
?.takeWhile { it.isDigit() }
|
||||
?.toIntOrNull()
|
||||
?: return PairResult.Invalid
|
||||
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
|
||||
|
||||
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
|
||||
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
|
||||
val server = Uri.parse(serverUrl)
|
||||
val scheme = server.scheme?.lowercase()
|
||||
if (scheme != "http" && scheme != "https") return PairResult.Invalid
|
||||
val host = server.host
|
||||
if (host.isNullOrBlank()) return PairResult.Invalid
|
||||
|
||||
return PairResult.Success(
|
||||
ServerEntry(
|
||||
address = host,
|
||||
useHttps = scheme == "https",
|
||||
port = if (server.port != -1) server.port.toString() else "",
|
||||
password = uri.getQueryParameter("pw") ?: "",
|
||||
name = uri.getQueryParameter("name") ?: "",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -36,6 +39,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -44,6 +48,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
@@ -75,6 +80,7 @@ fun NESMenu(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)? = null,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
@@ -88,7 +94,7 @@ fun NESMenu(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,6 +109,7 @@ private fun MenuPanel(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)?,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
@@ -235,7 +242,30 @@ private fun MenuPanel(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
if (onScanQr != null) {
|
||||
// Add server by scanning the node's pairing QR
|
||||
Box(
|
||||
Modifier
|
||||
.size(ROW_H)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onScanQr() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.add_server_qr),
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Full-screen camera overlay that scans the node pairing QR
|
||||
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
|
||||
* Handles the camera permission itself; foreign/invalid codes show a hint
|
||||
* and scanning continues.
|
||||
*/
|
||||
@Composable
|
||||
fun QrScannerOverlay(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (ServerEntry) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||
var handled by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
handled = false
|
||||
hintRes = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
// Foreign-code hint fades after a moment so scanning feels live again.
|
||||
LaunchedEffect(hintRes) {
|
||||
if (hintRes != null) {
|
||||
delay(2500)
|
||||
hintRes = null
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result.server)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// Aim frame
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Top bar: title + close
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_node_qr),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom hints
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
hintRes?.let { res ->
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = BitcoinOrange,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if (hasPermission) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_qr_hint),
|
||||
color = TextMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private 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 }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
provider = p
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||
// doubles the pixel density at negligible analysis cost.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1280, 720))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
analysisExecutor,
|
||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||
)
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
}, mainExecutor)
|
||||
|
||||
onDispose {
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||
// close range — the exhaustive search is worth the milliseconds.
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
// Copy into a rowStride-wide array; the last row of the plane buffer
|
||||
// may be short of the full stride, so the tail stays zero-padded.
|
||||
val data = ByteArray(plane.rowStride * image.height)
|
||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
}
|
||||
onDecoded(result.text)
|
||||
} catch (_: NotFoundException) {
|
||||
// No QR in this frame — keep scanning.
|
||||
} catch (_: Exception) {
|
||||
// Malformed frame; skip it.
|
||||
} finally {
|
||||
reader.reset()
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package com.archipelago.app.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
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.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
@@ -24,7 +30,10 @@ object Routes {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost() {
|
||||
fun AppNavHost(
|
||||
pairUri: String? = null,
|
||||
onPairUriConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
@@ -33,8 +42,41 @@ fun AppNavHost() {
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
// Pairing entry from a deep link that carried no password — prefills the
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||
LaunchedEffect(pairUri) {
|
||||
val raw = pairUri ?: return@LaunchedEffect
|
||||
onPairUriConsumed()
|
||||
when (val result = ServerQrParser.parse(raw)) {
|
||||
is PairResult.Success -> {
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
pairPrefill = merged
|
||||
navController.navigate(Routes.SERVER_CONNECT) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Invalid or too-new pairing link — ignore; normal startup continues.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
activeServer != null -> Routes.WEB_VIEW
|
||||
@@ -65,6 +107,7 @@ fun AppNavHost() {
|
||||
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
|
||||
}
|
||||
},
|
||||
initialServer = pairPrefill,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +124,7 @@ fun AppNavHost() {
|
||||
} else {
|
||||
WebViewScreen(
|
||||
serverUrl = server.toUrl(),
|
||||
serverPassword = server.password,
|
||||
onDisconnect = {
|
||||
scope.launch {
|
||||
prefs.clearActiveServer()
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.archipelago.app.ui.components.NESController
|
||||
import com.archipelago.app.ui.components.NESKeyboard
|
||||
import com.archipelago.app.ui.components.NESMenu
|
||||
import com.archipelago.app.ui.components.NESPortraitController
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.components.Trackpad
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
@@ -63,6 +64,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||
|
||||
@@ -216,6 +218,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onAddServer = { server ->
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onScanQr = { showQrScanner = true },
|
||||
onEditServer = { original, updated ->
|
||||
scope.launch {
|
||||
prefs.updateSavedServer(original, updated)
|
||||
@@ -247,5 +250,19 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
},
|
||||
onBackToWebView = { showModal = false; onBack() },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
|
||||
// open behind the scanner so the new entry appears as soon as it closes.
|
||||
QrScannerOverlay(
|
||||
visible = showQrScanner,
|
||||
onDismiss = { showQrScanner = false },
|
||||
onServerScanned = { server ->
|
||||
showQrScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(server)
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -72,6 +73,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
@@ -93,6 +95,9 @@ import javax.net.ssl.X509TrustManager
|
||||
fun ServerConnectScreen(
|
||||
onConnected: (String) -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
// Prefill from a pairing deep link (archipelago://pair) that carried no
|
||||
// password — opens the manual form on the password prompt for that server.
|
||||
initialServer: ServerEntry? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
@@ -109,6 +114,9 @@ fun ServerConnectScreen(
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
// The saved server currently being edited, or null when adding/connecting.
|
||||
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
|
||||
var manualMode by remember { mutableStateOf(false) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
@@ -173,6 +181,37 @@ fun ServerConnectScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun prefill(server: ServerEntry) {
|
||||
name = server.name
|
||||
address = server.address
|
||||
port = server.port
|
||||
password = server.password
|
||||
useHttps = server.useHttps
|
||||
}
|
||||
|
||||
// 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) {
|
||||
showScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scanned)
|
||||
prefill(merged)
|
||||
if (merged.password.isNotBlank()) {
|
||||
connect(merged)
|
||||
} else {
|
||||
manualMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(initialServer) {
|
||||
if (initialServer != null) {
|
||||
prefill(prefs.upsertServer(initialServer))
|
||||
manualMode = true
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -208,7 +247,10 @@ fun ServerConnectScreen(
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
// Center the content vertically — the landing (logo + two buttons) is
|
||||
// short and looks stranded at the top otherwise. Taller content (the
|
||||
// manual form, saved servers) still scrolls from the top as normal.
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
// Circular badge logo
|
||||
Image(
|
||||
@@ -226,8 +268,10 @@ fun ServerConnectScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
val showForm = manualMode || editingServer != null
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.server_address_hint),
|
||||
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -235,8 +279,25 @@ fun ServerConnectScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
if (!showForm) {
|
||||
// Landing: scan the pairing QR, or fall back to manual entry
|
||||
GlassButton(
|
||||
text = stringResource(R.string.scan_node_qr),
|
||||
onClick = { showScanner = true },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.enter_manually),
|
||||
onClick = {
|
||||
errorMessage = null
|
||||
manualMode = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Glass card with form
|
||||
Box(
|
||||
if (showForm) Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
@@ -458,16 +519,30 @@ fun ServerConnectScreen(
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Connect button — glass style
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
} else if (manualMode) {
|
||||
// Back to the Scan/Manual landing + Connect
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GlassButton(
|
||||
text = stringResource(R.string.back),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
manualMode = false
|
||||
clearForm()
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
)
|
||||
GlassButton(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.weight(2f).height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
@@ -499,6 +574,12 @@ fun ServerConnectScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QrScannerOverlay(
|
||||
visible = showScanner,
|
||||
onDismiss = { showScanner = false },
|
||||
onServerScanned = { onQrScanned(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
@@ -12,6 +15,9 @@ import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -136,12 +142,30 @@ fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
onDisconnect: () -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
// Stored password for this server (from QR pairing or manual entry). When
|
||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||
// demo flow from docs/companion-pairing-qr.md.
|
||||
serverPassword: String = "",
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var loadProgress by remember { mutableIntStateOf(0) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Web-page camera access (wallet QR scanner). The WebView's default
|
||||
// WebChromeClient silently denies getUserMedia, so grant video capture —
|
||||
// asking for the app-level CAMERA permission first when needed.
|
||||
val webViewContext = LocalContext.current
|
||||
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
|
||||
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
pendingWebPermission?.let { req ->
|
||||
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
|
||||
}
|
||||
pendingWebPermission = null
|
||||
}
|
||||
|
||||
// A node app that refused iframing, opened in a local WebView overlay.
|
||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
@@ -310,6 +334,12 @@ fun WebViewScreen(
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
|
||||
// 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)) {
|
||||
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
@@ -361,6 +391,25 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Wallet QR scanner: grant the page camera access.
|
||||
// Only video capture is granted — anything else the
|
||||
// page asks for is denied as before.
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
val hasCamera = ContextCompat.checkSelfPermission(
|
||||
webViewContext, Manifest.permission.CAMERA,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasCamera) {
|
||||
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
|
||||
} else {
|
||||
pendingWebPermission = request
|
||||
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||
// for an app that can't be iframed. Capture the target
|
||||
// URL via a throwaway WebView and route it ourselves.
|
||||
@@ -506,6 +555,18 @@ private fun InAppBrowser(
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
|
||||
// Same camera bridge as the main WebView — node apps opened in the overlay
|
||||
// (e.g. anything with a QR scanner) get getUserMedia too.
|
||||
var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) }
|
||||
val webCameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
pendingWebPermission?.let { req ->
|
||||
if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny()
|
||||
}
|
||||
pendingWebPermission = null
|
||||
}
|
||||
|
||||
// Seed the loading-screen icon immediately from a best-effort favicon
|
||||
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
||||
// loader shows an icon right away instead of staying blank until the page
|
||||
@@ -555,6 +616,22 @@ private fun InAppBrowser(
|
||||
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||
if (icon != null) favicon = icon
|
||||
}
|
||||
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
val hasCamera = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.CAMERA,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
if (hasCamera) {
|
||||
request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
|
||||
} else {
|
||||
pendingWebPermission = request
|
||||
webCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
@@ -700,3 +777,48 @@ private fun InAppBrowser(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JS that fills the web UI login form (Login.vue's #login-password) with the
|
||||
* stored password and submits it once the form is interactive — the one-step
|
||||
* pairing flow. No-ops when the login step never appears (already
|
||||
* authenticated, first-boot setup, TOTP). At most two attempts per page load,
|
||||
* then it stops for good so a wrong stored password can't spam the node.
|
||||
*/
|
||||
private fun buildAutoLoginScript(password: String): String {
|
||||
val quoted = org.json.JSONObject.quote(password)
|
||||
return """
|
||||
(function () {
|
||||
if (window.__archyAutoLogin) return;
|
||||
window.__archyAutoLogin = true;
|
||||
var pw = $quoted;
|
||||
var attempts = 0;
|
||||
var started = Date.now();
|
||||
var timer = setInterval(function () {
|
||||
if (Date.now() - started > 45000) { clearInterval(timer); return; }
|
||||
var el = document.getElementById('login-password');
|
||||
if (!el) {
|
||||
// Field gone after we submitted = success or step change - stop.
|
||||
if (attempts > 0) clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
if (el.disabled) return; // form waits on serverReady
|
||||
if (attempts >= 2) { clearInterval(timer); return; }
|
||||
attempts++;
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(el, pw);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// Let Vue re-render before submitting: a synchronous Enter arrives
|
||||
// while the login button is still disabled, and the web UI's
|
||||
// controller-nav "Enter in input clicks the next enabled button"
|
||||
// pattern then hits Replay Intro instead — restarting the intro
|
||||
// cinematic on every connect (two frames = value flush + render).
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
});
|
||||
});
|
||||
}, 1500);
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
<string name="server_name_label">Server Name (optional)</string>
|
||||
<string name="server_name_placeholder">My Archipelago</string>
|
||||
<string name="edit_server">Edit</string>
|
||||
<string name="scan_node_qr">Scan Node\'s QR</string>
|
||||
<string name="enter_manually">Enter Manually</string>
|
||||
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
|
||||
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
|
||||
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
|
||||
<string name="grant_camera_access">Grant Camera Access</string>
|
||||
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
|
||||
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
|
||||
<string name="add_server_qr">Add server by QR</string>
|
||||
<string name="edit_server_title">Edit Server</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares.
|
||||
- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately.
|
||||
- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else.
|
||||
- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build).
|
||||
- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer.
|
||||
- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required."
|
||||
- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen.
|
||||
|
||||
## v1.7.109-alpha (2026-07-21)
|
||||
|
||||
- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node.
|
||||
- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other.
|
||||
- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows.
|
||||
|
||||
## v1.7.108-alpha (2026-07-20)
|
||||
|
||||
- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded.
|
||||
- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling.
|
||||
- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried.
|
||||
- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged).
|
||||
- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose.
|
||||
- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle.
|
||||
|
||||
## v1.7.106-alpha (2026-07-20)
|
||||
|
||||
- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster.
|
||||
- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too.
|
||||
- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output.
|
||||
- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed.
|
||||
- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical.
|
||||
|
||||
## v1.7.105-alpha (2026-07-20)
|
||||
|
||||
- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown.
|
||||
- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one.
|
||||
- Fixed the white screen some laptop displays showed right after the intro on v1.7.104.
|
||||
- The companion phone app no longer suggests installing the companion app from inside itself.
|
||||
- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up.
|
||||
- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps.
|
||||
- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes.
|
||||
|
||||
## v1.7.104-alpha (2026-07-19)
|
||||
|
||||
- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start.
|
||||
- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed.
|
||||
- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots.
|
||||
- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress.
|
||||
|
||||
## v1.7.103-alpha (2026-07-18)
|
||||
|
||||
- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password.
|
||||
- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app.
|
||||
- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit.
|
||||
|
||||
## v1.7.102-alpha (2026-07-17)
|
||||
|
||||
- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display.
|
||||
- Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running.
|
||||
- Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere.
|
||||
- First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection.
|
||||
- The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed.
|
||||
- The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white.
|
||||
- Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it.
|
||||
- Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish.
|
||||
- Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring.
|
||||
|
||||
## v1.7.101-alpha (2026-07-15)
|
||||
|
||||
- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip.
|
||||
@@ -7,10 +90,10 @@
|
||||
- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs".
|
||||
- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename.
|
||||
- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup.
|
||||
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, and a search that also finds files shared by your federated peer nodes.
|
||||
- The first-login dashboard entrance animation is back, and "Replay Intro" in Settings actually replays it.
|
||||
- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window.
|
||||
- The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly.
|
||||
- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel.
|
||||
- The public demo is richer and truer: Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
||||
- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break.
|
||||
- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport).
|
||||
|
||||
## v1.7.100-alpha (2026-07-14)
|
||||
|
||||
@@ -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": [
|
||||
@@ -370,6 +370,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,9 +10,16 @@ app:
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoin backend container is bitcoin-knots OR bitcoin-core depending
|
||||
# on which version the node runs (multi-version switch) — probe which name
|
||||
# resolves on archy-net instead of hardcoding knots, which left electrumx
|
||||
# permanently disconnected (block index 0) on core nodes.
|
||||
custom_args:
|
||||
- >-
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||
for h in bitcoin-knots bitcoin-core; do
|
||||
if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi;
|
||||
done;
|
||||
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/";
|
||||
exec electrumx_server
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASS
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Home Assistant - uses official image
|
||||
FROM homeassistant/home-assistant:2024.1
|
||||
FROM homeassistant/home-assistant:2026.7.3
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: homeassistant
|
||||
name: Home Assistant
|
||||
version: 2024.1.0
|
||||
version: 2026.7.3
|
||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
|
||||
image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,70 @@
|
||||
app:
|
||||
id: pine-piper
|
||||
name: Pine Piper (TTS)
|
||||
version: "2.2.2"
|
||||
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
|
||||
category: home
|
||||
|
||||
# Hyphen name matches the runtime references (stack member table / startup
|
||||
# order) + the live container, so on an existing node the orchestrator ADOPTS
|
||||
# the running engine rather than recreating it (downloaded voices under /data
|
||||
# preserved).
|
||||
container_name: pine-piper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-piper:2.2.2
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-piper]
|
||||
# The image entrypoint already binds tcp://0.0.0.0:10200; this arg only
|
||||
# picks the voice (mirrors the pine ha-stack.yml compose command).
|
||||
custom_args: ["--voice", "en_GB-alba-medium"]
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
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 # downloads the voice into /data on first run
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||
# host.containers.internal:10200 (the Wyoming integration endpoint).
|
||||
- host: 10200
|
||||
container: 10200
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine-piper
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:10200
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s # first start downloads the voice
|
||||
|
||||
metadata:
|
||||
author: Rhasspy / Home Assistant
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming-piper
|
||||
repo: https://github.com/rhasspy/wyoming-piper
|
||||
license: MIT
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- text-to-speech
|
||||
- wyoming
|
||||
@@ -0,0 +1,70 @@
|
||||
app:
|
||||
id: pine-whisper
|
||||
name: Pine Whisper (STT)
|
||||
version: "3.4.1"
|
||||
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
|
||||
|
||||
# Hyphen name matches the runtime references (stack member table / startup
|
||||
# order) + the live container, so on an existing node the orchestrator ADOPTS
|
||||
# the running engine rather than recreating it (downloaded models under /data
|
||||
# preserved).
|
||||
container_name: pine-whisper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-whisper:3.4.1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
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"]
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 2Gi
|
||||
|
||||
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 # downloads the whisper model into /data on first run
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# Published so Home Assistant (on the pasta net) can reach the engine via
|
||||
# host.containers.internal:10300 (the Wyoming integration endpoint).
|
||||
- host: 10300
|
||||
container: 10300
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine-whisper
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:10300
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s # first start downloads the model
|
||||
|
||||
metadata:
|
||||
author: Rhasspy / Home Assistant
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming-faster-whisper
|
||||
repo: https://github.com/rhasspy/wyoming-faster-whisper
|
||||
license: MIT
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- speech-to-text
|
||||
- wyoming
|
||||
@@ -0,0 +1,395 @@
|
||||
app:
|
||||
id: pine
|
||||
name: Pine
|
||||
version: "1.3.0"
|
||||
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
|
||||
category: home
|
||||
|
||||
# The user-facing launcher (app_id + container both "pine", matching the
|
||||
# runtime references + the live container so the orchestrator adopts it). A
|
||||
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
|
||||
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
|
||||
# stack members.
|
||||
container_name: pine
|
||||
|
||||
container:
|
||||
image: docker.io/library/nginx:1.27-alpine
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine]
|
||||
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
|
||||
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
|
||||
# (https or localhost), so the launcher terminates TLS with a self-signed
|
||||
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
|
||||
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
|
||||
# secure-context fix (#15).
|
||||
generated_certs:
|
||||
- crt: /var/lib/archipelago/pine/tls.crt
|
||||
key: /var/lib/archipelago/pine/tls.key
|
||||
|
||||
dependencies:
|
||||
- app_id: pine-whisper
|
||||
- app_id: pine-piper
|
||||
- app_id: pine-openwakeword
|
||||
- storage: 128Mi
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||||
# workers) binds :443 inside the container — needs the worker-drop caps +
|
||||
# NET_BIND_SERVICE for the privileged port.
|
||||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
# 10380 (http) is the Open target — the UI launches apps as
|
||||
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
|
||||
# listener on 10381, so the new tab lands on a secure context where
|
||||
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
|
||||
- host: 10380
|
||||
container: 80
|
||||
protocol: tcp
|
||||
- host: 10381
|
||||
container: 443
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/nginx.conf
|
||||
target: /etc/nginx/conf.d/default.conf
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/tls.crt
|
||||
target: /etc/nginx/tls.crt
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/tls.key
|
||||
target: /etc/nginx/tls.key
|
||||
options: [ro]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/pine/index.html
|
||||
target: /usr/share/nginx/html/index.html
|
||||
options: [ro]
|
||||
|
||||
environment: []
|
||||
|
||||
files:
|
||||
- path: /var/lib/archipelago/pine/nginx.conf
|
||||
overwrite: true
|
||||
content: |
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host:10381$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
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
|
||||
overwrite: true
|
||||
content: |
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pine — connect your speaker</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
|
||||
background: #0f1512; color: #e7efe9; }
|
||||
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
|
||||
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
|
||||
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
|
||||
h1 { font-size: 26px; margin: 0; }
|
||||
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
|
||||
.card { background: #16201b; border: 1px solid #24332b;
|
||||
border-radius: 14px; padding: 20px; margin: 20px 0; }
|
||||
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
|
||||
.status .row b { min-width: 84px; color: #9fd3b6; }
|
||||
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
|
||||
font-size: 13px; color: #cfe9d9; }
|
||||
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
|
||||
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
|
||||
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
|
||||
outline: none; }
|
||||
input:focus { border-color: #4fae82; }
|
||||
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
|
||||
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
|
||||
border-radius: 10px; cursor: pointer; transition: filter .15s; }
|
||||
button:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
|
||||
font-family: ui-monospace, monospace; white-space: pre-wrap;
|
||||
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
|
||||
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
|
||||
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
|
||||
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
|
||||
.ha b { color: #9fbfad; }
|
||||
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
|
||||
color: #e8c878; font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
|
||||
<rect x="236" y="396" width="40" height="72" rx="6"/>
|
||||
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
|
||||
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
|
||||
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
|
||||
</g></svg>
|
||||
<div><h1>Pine</h1>
|
||||
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
|
||||
</header>
|
||||
|
||||
<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
|
||||
certificate) and the button below will work.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<label for="ssid">WiFi network (SSID)</label>
|
||||
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
|
||||
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
|
||||
<input id="pass" type="password" autocomplete="off">
|
||||
<button id="go">Connect Pine to WiFi</button>
|
||||
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
"use strict";
|
||||
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
|
||||
const SVC = "00467768-6228-2272-4663-277478268000";
|
||||
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
|
||||
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
|
||||
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
|
||||
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
|
||||
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
|
||||
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
|
||||
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
|
||||
4:"not authorized — press the button on the device",5:"bad hostname",
|
||||
255:"unknown device error"};
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const log = (msg, cls) => { const l = document.createElement("div");
|
||||
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
|
||||
logEl.scrollTop = logEl.scrollHeight; };
|
||||
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
|
||||
.map(b => b.toString(16).padStart(2, "0")).join(" ");
|
||||
|
||||
const btn = document.getElementById("go");
|
||||
if (!navigator.bluetooth) {
|
||||
document.getElementById("insecure").style.display = "block";
|
||||
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
|
||||
}
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
const ssid = document.getElementById("ssid").value.trim();
|
||||
const pass = document.getElementById("pass").value;
|
||||
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
|
||||
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
|
||||
btn.disabled = true; logEl.textContent = "";
|
||||
let device;
|
||||
try {
|
||||
log("Opening Bluetooth device chooser…");
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
|
||||
optionalServices: [SVC],
|
||||
});
|
||||
log(`Selected: ${device.name || "(unnamed)"}`);
|
||||
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
|
||||
log("Connecting…");
|
||||
const gatt = await device.gatt.connect();
|
||||
const svc = await gatt.getPrimaryService(SVC);
|
||||
const stateChar = await svc.getCharacteristic(CHAR_STATE);
|
||||
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
|
||||
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
|
||||
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
|
||||
|
||||
let done, fail;
|
||||
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
|
||||
let authorize; const authorized = new Promise(res => { authorize = res; });
|
||||
let state = -1;
|
||||
const onState = (s, via = "") => {
|
||||
if (s === state) return; state = s;
|
||||
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
|
||||
if (s >= 2) authorize();
|
||||
if (s === 4) done(null);
|
||||
};
|
||||
stateChar.addEventListener("characteristicvaluechanged",
|
||||
e => onState(e.target.value.getUint8(0)));
|
||||
errorChar.addEventListener("characteristicvaluechanged", e => {
|
||||
const c = e.target.value.getUint8(0);
|
||||
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
|
||||
resultChar.addEventListener("characteristicvaluechanged", e => {
|
||||
const v = e.target.value; log(`RPC result: ${hex(v)}`);
|
||||
if (v.byteLength > 2 && v.getUint8(1) > 0) {
|
||||
const n = v.getUint8(2); const b = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
|
||||
done(new TextDecoder().decode(b)); } });
|
||||
await stateChar.startNotifications();
|
||||
await errorChar.startNotifications();
|
||||
await resultChar.startNotifications();
|
||||
onState((await stateChar.readValue()).getUint8(0));
|
||||
|
||||
const poller = setInterval(async () => {
|
||||
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
|
||||
const ec = (await errorChar.readValue()).getUint8(0);
|
||||
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
|
||||
|
||||
let nextUrl;
|
||||
try {
|
||||
if (state === 1) {
|
||||
log("Authorization required — press the centre button on the speaker now.", "warn");
|
||||
await Promise.race([authorized, outcome,
|
||||
new Promise((_, rej) => setTimeout(
|
||||
() => rej(new Error("timed out waiting for the button press")), 60000))]);
|
||||
log("Authorized.", "ok");
|
||||
}
|
||||
const enc = new TextEncoder();
|
||||
const sb = enc.encode(ssid), pb = enc.encode(pass);
|
||||
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
|
||||
const pkt = new Uint8Array([1, data.length, ...data, 0]);
|
||||
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
|
||||
log(`Sending WiFi credentials for “${ssid}”…`);
|
||||
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
|
||||
else await rpcChar.writeValue(pkt);
|
||||
log("Credentials delivered — speaker acknowledged.", "ok");
|
||||
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
|
||||
const timeout = new Promise((_, rej) => setTimeout(
|
||||
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
|
||||
nextUrl = await Promise.race([outcome, timeout]);
|
||||
} finally { clearInterval(poller); }
|
||||
|
||||
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
|
||||
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
|
||||
try { gatt.disconnect(); } catch {}
|
||||
} catch (err) {
|
||||
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
|
||||
if (err.name === "NotFoundError")
|
||||
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
|
||||
+ "Hold the dot button 15s to reset.", "warn");
|
||||
if (err.name === "NetworkError")
|
||||
log("BLE link dropped — move closer, and make sure the Mac isn't already "
|
||||
+ "paired to the speaker (it can hold the link exclusively).", "warn");
|
||||
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>
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:443
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Pine
|
||||
description: Connect your speaker to WiFi and check the voice assistant
|
||||
type: ui
|
||||
port: 10380
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: Archipelago
|
||||
icon: /assets/img/app-icons/pine.svg
|
||||
website: https://github.com/rhasspy/wyoming
|
||||
repo: https://github.com/rhasspy/wyoming
|
||||
license: MIT
|
||||
category: home
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
tags:
|
||||
- home
|
||||
- voice
|
||||
- assistant
|
||||
- privacy
|
||||
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.100-alpha"
|
||||
version = "1.7.111-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.100-alpha"
|
||||
version = "1.7.111-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -194,6 +194,43 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
|
||||
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
|
||||
/// browser download. The archive is passphrase-encrypted at rest; the
|
||||
/// session gate at the route controls who can fetch it.
|
||||
async fn handle_backup_download(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
let id = path.strip_prefix("/api/blob/backup/").unwrap_or("");
|
||||
// Backup ids are UUIDs — reject anything that could traverse paths.
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("backups")
|
||||
.join(format!("{id}.bak"));
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"archipelago-backup-{id}.bak\""),
|
||||
)
|
||||
.header("Content-Length", bytes.len())
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))),
|
||||
Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"backup not found"}"#),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a 401 Unauthorized JSON response.
|
||||
fn unauthorized() -> Response<hyper::Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
@@ -434,6 +471,16 @@ impl ApiHandler {
|
||||
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
|
||||
}
|
||||
|
||||
// Backup archive download — session-gated. Lives under /api/blob/
|
||||
// so the existing nginx `location /api/blob` prefix proxies it on
|
||||
// every fleet node without a config change.
|
||||
(Method::GET, p) if p.starts_with("/api/blob/backup/") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
self.handle_backup_download(p).await
|
||||
}
|
||||
|
||||
// Blob upload — local/session use only. Session-authenticated so
|
||||
// only the node owner can push attachments into the blob store.
|
||||
(Method::POST, "/api/blob") => {
|
||||
@@ -533,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
|
||||
|
||||
@@ -147,6 +147,15 @@ impl RpcHandler {
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
tracing::info!("[onboarding] user setup complete");
|
||||
|
||||
// The install-time password must also become the OS login for the
|
||||
// archipelago user — otherwise the console/SSH keeps the image default
|
||||
// ("archipelago") after the user has picked a real password (#97).
|
||||
// Best-effort: a failure here must not break onboarding.
|
||||
match crate::auth::change_ssh_password(password).await {
|
||||
Ok(()) => tracing::info!("[onboarding] system login password synced"),
|
||||
Err(e) => tracing::warn!("[onboarding] system login password sync failed: {e}"),
|
||||
}
|
||||
|
||||
// Persist the pending onboarding seed as the encrypted backup now that
|
||||
// a passphrase (the login password) finally exists — otherwise "Reveal
|
||||
// recovery phrase" has nothing to decrypt on this node, ever.
|
||||
|
||||
@@ -456,6 +456,8 @@ impl RpcHandler {
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
"system.kiosk-display.set" => self.handle_system_kiosk_display_set(params).await,
|
||||
|
||||
// Opt-in anonymous analytics
|
||||
"analytics.get-status" => self.handle_analytics_get_status().await,
|
||||
|
||||
@@ -28,13 +28,20 @@ impl RpcHandler {
|
||||
));
|
||||
}
|
||||
|
||||
// Zero-amount invoices need the amount supplied by the payer; LND's
|
||||
// REST API takes it as an `amt` string alongside the payment request.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
|
||||
info!("Paying Lightning invoice");
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let pay_body = serde_json::json!({
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
if let Some(amt) = amount_sats {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
|
||||
@@ -95,33 +95,54 @@ impl RpcHandler {
|
||||
.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Amount must be at least 546 sats (dust limit)"
|
||||
));
|
||||
}
|
||||
if amount > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
// send_all sweeps the entire confirmed on-chain balance (LND computes
|
||||
// the amount after fees); amount is required otherwise.
|
||||
let send_all = params
|
||||
.get("send_all")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let amount = if send_all {
|
||||
None
|
||||
} else {
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
if amount < 546 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Amount must be at least 546 sats (dust limit)"
|
||||
));
|
||||
}
|
||||
if amount > 21_000_000 * 100_000_000 {
|
||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||
}
|
||||
Some(amount)
|
||||
};
|
||||
|
||||
// Validate Bitcoin address format (basic: length and allowed chars)
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
info!(addr = addr, amount = amount, "Sending on-chain Bitcoin");
|
||||
info!(
|
||||
addr = addr,
|
||||
amount = amount,
|
||||
send_all = send_all,
|
||||
"Sending on-chain Bitcoin"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
});
|
||||
let send_body = match amount {
|
||||
Some(amount) => serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"addr": addr,
|
||||
"send_all": true,
|
||||
}),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
|
||||
@@ -158,6 +158,29 @@ impl RpcHandler {
|
||||
anyhow::bail!("Unknown LoRa region: {trimmed}");
|
||||
}
|
||||
}
|
||||
// Meshcore LoRa PHY params (freq/bw/sf/cr, firmware field units — see
|
||||
// mesh::LoraRadioParams). Validated against the firmware's accepted
|
||||
// ranges here so a bad value errors at the API instead of being sent
|
||||
// to the radio and rejected on-device. `null` clears the setting.
|
||||
if let Some(rp) = params.get("lora_radio_params") {
|
||||
if rp.is_null() {
|
||||
config.lora_radio_params = None;
|
||||
} else {
|
||||
let parsed: mesh::LoraRadioParams = serde_json::from_value(rp.clone())
|
||||
.map_err(|e| anyhow::anyhow!("Invalid lora_radio_params: {e}"))?;
|
||||
anyhow::ensure!(
|
||||
(150_000..=2_500_000).contains(&parsed.freq_khz),
|
||||
"freq_khz out of range (150000..=2500000)"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
(7_000..=500_000).contains(&parsed.bw_hz),
|
||||
"bw_hz out of range (7000..=500000)"
|
||||
);
|
||||
anyhow::ensure!((5..=12).contains(&parsed.sf), "sf out of range (5..=12)");
|
||||
anyhow::ensure!((5..=8).contains(&parsed.cr), "cr out of range (5..=8)");
|
||||
config.lora_radio_params = Some(parsed);
|
||||
}
|
||||
}
|
||||
// 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()) {
|
||||
|
||||
@@ -41,6 +41,10 @@ impl RpcHandler {
|
||||
// live radio-reported `region`): the configured LoRa region and
|
||||
// the firmware pin ("meshcore"|"meshtastic"|"reticulum"|null=auto).
|
||||
obj.insert("lora_region".into(), config.lora_region.clone().into());
|
||||
obj.insert(
|
||||
"lora_radio_params".into(),
|
||||
serde_json::to_value(config.lora_radio_params).unwrap_or_default(),
|
||||
);
|
||||
obj.insert(
|
||||
"device_kind".into(),
|
||||
config
|
||||
|
||||
@@ -27,6 +27,7 @@ mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
mod router;
|
||||
mod security;
|
||||
@@ -438,7 +439,13 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("RPC error on {}: {}", rpc_req.method, e);
|
||||
// `{:#}` renders the whole anyhow context chain. Logging only the
|
||||
// outermost context threw away the actual cause: a peer-files
|
||||
// failure logged just "Failed to connect to peer", with the real
|
||||
// error (Tor SOCKS failure, FIPS resolve, timeout) discarded — so
|
||||
// the logs couldn't distinguish a dead peer from a slow circuit.
|
||||
// The client-facing message below stays `{}` so internals aren't leaked.
|
||||
error!("RPC error on {}: {:#}", rpc_req.method, e);
|
||||
let user_message = sanitize_error_message(&e.to_string());
|
||||
RpcResponse {
|
||||
result: None,
|
||||
|
||||
@@ -496,6 +496,13 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"netbird-dashboard".into(),
|
||||
"netbird-server".into(),
|
||||
],
|
||||
// Pine voice-assistant stack: launcher + the two Wyoming engines.
|
||||
"pine" => vec![
|
||||
"pine".into(),
|
||||
"pine-whisper".into(),
|
||||
"pine-piper".into(),
|
||||
"pine-openwakeword".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
|
||||
@@ -246,6 +246,11 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// 36 × 5s = 3 minutes of bounded waiting.
|
||||
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
|
||||
/// Separate, much larger budget while a dependency is still INSTALLING
|
||||
/// (image pulling, container not created yet): 360 × 5s = 30 minutes. A
|
||||
/// fresh-node Bitcoin pull routinely takes >3 minutes, and rejecting LND
|
||||
/// mid-pull was the confirmed "first install fails, second works" failure.
|
||||
pub(super) const DEP_INSTALLING_WAIT_MAX_ATTEMPTS: u32 = 360;
|
||||
|
||||
/// Marker error: the install was rejected by the dependency gate BEFORE any
|
||||
/// resource (container, image, data dir) was created for the package. The
|
||||
@@ -317,8 +322,12 @@ pub(super) struct DepProbe {
|
||||
/// Which dependency services are currently Running.
|
||||
pub running: RunningDeps,
|
||||
/// Container/package names that EXIST in any state — installed, but
|
||||
/// possibly not running yet (`podman ps -a` ∪ package-state entries).
|
||||
/// possibly not running yet (`podman ps -a`).
|
||||
pub existing: Vec<String>,
|
||||
/// Package ids currently mid-install (state Installing/Updating) —
|
||||
/// no container exists yet, but one is on the way, so the gate must
|
||||
/// wait for the install to finish instead of failing fast.
|
||||
pub installing: Vec<String>,
|
||||
}
|
||||
|
||||
/// All container names known to podman in any state (`podman ps -a`).
|
||||
@@ -366,8 +375,13 @@ where
|
||||
LF: std::future::Future<Output = ()>,
|
||||
{
|
||||
let mut waited_attempts = 0u32;
|
||||
let mut installing_attempts = 0u32;
|
||||
loop {
|
||||
let DepProbe { running, existing } = probe().await?;
|
||||
let DepProbe {
|
||||
running,
|
||||
existing,
|
||||
installing,
|
||||
} = probe().await?;
|
||||
let missing = missing_install_deps(package_id, &running);
|
||||
if missing.is_empty() {
|
||||
// Keep behavior in lockstep with the canonical gate (covers any
|
||||
@@ -377,11 +391,12 @@ where
|
||||
}
|
||||
|
||||
// Fail fast if any missing dependency has no installed container
|
||||
// under any name variant — waiting cannot satisfy it.
|
||||
// under any name variant AND no install in flight — waiting cannot
|
||||
// satisfy it.
|
||||
let some_dep_not_installed = missing.iter().any(|dep| {
|
||||
!dep.containers
|
||||
.iter()
|
||||
.any(|c| existing.iter().any(|e| e == c))
|
||||
.any(|c| existing.iter().any(|e| e == c) || installing.iter().any(|i| i == c))
|
||||
});
|
||||
if some_dep_not_installed {
|
||||
let msg = match check_install_deps(package_id, &running) {
|
||||
@@ -391,8 +406,38 @@ where
|
||||
return Err(anyhow::Error::new(DependencyGateError(msg)));
|
||||
}
|
||||
|
||||
// A dependency still mid-install (no container yet) gets its own,
|
||||
// much larger budget: a fresh-node image pull can take far longer
|
||||
// than the started-but-not-running window.
|
||||
let some_dep_installing = missing.iter().any(|dep| {
|
||||
dep.containers
|
||||
.iter()
|
||||
.any(|c| installing.iter().any(|i| i == c))
|
||||
});
|
||||
|
||||
let labels = join_dep_labels(&missing);
|
||||
if some_dep_installing {
|
||||
if installing_attempts >= DEP_INSTALLING_WAIT_MAX_ATTEMPTS {
|
||||
return Err(anyhow::Error::new(DependencyGateError(format!(
|
||||
"{labels} is still installing after {} seconds. Wait for it \
|
||||
to finish, then install {package_id} again.",
|
||||
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
|
||||
))));
|
||||
}
|
||||
installing_attempts += 1;
|
||||
if installing_attempts == 1 {
|
||||
info!(
|
||||
"Install {package_id}: dependency {labels} is still installing — \
|
||||
waiting up to {}s for it to finish",
|
||||
u64::from(DEP_INSTALLING_WAIT_MAX_ATTEMPTS) * interval.as_secs()
|
||||
);
|
||||
}
|
||||
on_waiting(format!("Waiting for {labels} to finish installing…")).await;
|
||||
tokio::time::sleep(interval).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if waited_attempts >= max_attempts {
|
||||
let labels = join_dep_labels(&missing);
|
||||
return Err(anyhow::Error::new(DependencyGateError(format!(
|
||||
"{labels} is installed but did not reach the running state within \
|
||||
{} seconds. Start {labels}, then install {package_id} again.",
|
||||
@@ -401,7 +446,6 @@ where
|
||||
}
|
||||
waited_attempts += 1;
|
||||
|
||||
let labels = join_dep_labels(&missing);
|
||||
if waited_attempts == 1 {
|
||||
info!(
|
||||
"Install {package_id}: dependency {labels} installed but not running yet — \
|
||||
@@ -551,6 +595,10 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
| "nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "pine"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -582,6 +630,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-openwakeword", "pine"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -874,9 +923,19 @@ mod tests {
|
||||
}
|
||||
|
||||
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
|
||||
probe_with_installing(has_bitcoin, has_electrumx, existing, &[])
|
||||
}
|
||||
|
||||
fn probe_with_installing(
|
||||
has_bitcoin: bool,
|
||||
has_electrumx: bool,
|
||||
existing: &[&str],
|
||||
installing: &[&str],
|
||||
) -> DepProbe {
|
||||
DepProbe {
|
||||
running: deps(has_bitcoin, has_electrumx),
|
||||
existing: existing.iter().map(|s| s.to_string()).collect(),
|
||||
installing: installing.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -965,6 +1024,71 @@ mod tests {
|
||||
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waits_while_dependency_is_still_installing_then_passes() {
|
||||
// Bitcoin has NO container yet (image still pulling) but its
|
||||
// package state is Installing. Old behavior failed fast here —
|
||||
// the confirmed fresh-node LND "first install fails" bug. The
|
||||
// gate must wait past the normal started-but-not-running budget
|
||||
// (max_attempts=1 below) while the install is in flight.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
let result = wait_for_install_deps(
|
||||
"lnd",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
Ok(match n {
|
||||
// pulling: no container, package Installing
|
||||
0 | 1 => probe_with_installing(false, false, &[], &["bitcoin-knots"]),
|
||||
// container created, starting
|
||||
2 => probe(false, false, &["bitcoin-knots"]),
|
||||
// running
|
||||
_ => probe(true, false, &["bitcoin-knots"]),
|
||||
})
|
||||
}
|
||||
},
|
||||
sink,
|
||||
1,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 4);
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(
|
||||
labels.as_slice(),
|
||||
[
|
||||
"Waiting for Bitcoin to finish installing…",
|
||||
"Waiting for Bitcoin to finish installing…",
|
||||
"Waiting for Bitcoin to start…",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_fast_when_dependency_neither_installed_nor_installing() {
|
||||
// installing list has unrelated packages only — no reason to wait.
|
||||
let calls = AtomicU32::new(0);
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"lnd",
|
||||
|| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(probe_with_installing(false, false, &[], &["grafana"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(labels.lock().unwrap().is_empty());
|
||||
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn times_out_when_installed_dependency_never_runs() {
|
||||
let (labels, sink) = label_sink();
|
||||
|
||||
@@ -294,6 +294,9 @@ impl RpcHandler {
|
||||
if package_id == "netbird" {
|
||||
return self.install_netbird_stack().await;
|
||||
}
|
||||
if package_id == "pine" {
|
||||
return self.install_pine_stack().await;
|
||||
}
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
@@ -984,6 +987,24 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Package ids currently mid-install/update per the state manager — used
|
||||
/// by the dependency gate so an app whose dependency is still pulling its
|
||||
/// image (no container yet) waits instead of failing fast.
|
||||
async fn packages_currently_installing(&self) -> Vec<String> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
data.package_data
|
||||
.iter()
|
||||
.filter(|(_, entry)| {
|
||||
matches!(
|
||||
entry.state,
|
||||
crate::data_model::PackageState::Installing
|
||||
| crate::data_model::PackageState::Updating
|
||||
)
|
||||
})
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn running_deps_for_install(&self, package_id: &str) -> Result<RunningDeps> {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let cached = detect_running_deps_from_package_data(&data.package_data);
|
||||
@@ -1006,6 +1027,7 @@ impl RpcHandler {
|
||||
Ok(DepProbe {
|
||||
running: self.running_deps_for_install(package_id).await?,
|
||||
existing: detect_existing_containers().await,
|
||||
installing: self.packages_currently_installing().await,
|
||||
})
|
||||
},
|
||||
|msg| async move { self.set_install_message(package_id, &msg).await },
|
||||
@@ -1089,46 +1111,96 @@ impl RpcHandler {
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
// 5-minute per-URL budget. A full install tries each configured mirror
|
||||
// once, so a two-registry setup fails visibly in roughly 10 minutes
|
||||
// instead of staying in Installing for up to an hour.
|
||||
const PULL_URL_TIMEOUT_SECS: u64 = 300;
|
||||
let pull_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(PULL_URL_TIMEOUT_SECS),
|
||||
async {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
// Stall-aware budget instead of a hard wall clock. The old fixed
|
||||
// 300s cap killed slow-but-progressing pulls (LND on fresh-node
|
||||
// WiFi routinely needs longer), which surfaced as "first install
|
||||
// fails, second succeeds" once the layer cache was warm. A pull is
|
||||
// only killed when NOTHING is observably happening — no stderr
|
||||
// output and no byte growth in podman's staging dir — for
|
||||
// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling.
|
||||
// Dead registries still fail fast: no bytes ever land, so the
|
||||
// stall window (3 min) is the effective bound.
|
||||
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
|
||||
const PULL_URL_MAX_SECS: u64 = 1800;
|
||||
const PULL_POLL_INTERVAL_SECS: u64 = 5;
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
// Seconds-since-start of the last stderr line, updated by the
|
||||
// reader task. u64::MAX sentinel = no line seen yet (treated as
|
||||
// activity at t=0 so the stall window starts at spawn).
|
||||
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
let line_clock = std::sync::Arc::clone(&last_line_at);
|
||||
let started_reader = started;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
line_clock.store(
|
||||
started_reader.elapsed().as_secs(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
|
||||
}
|
||||
}
|
||||
child.wait().await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
match pull_result {
|
||||
Ok(Ok(status)) => Ok(status.success()),
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||
Ok(false)
|
||||
let mut last_staged_bytes = dir_size_bytes(user_tmp);
|
||||
let mut last_staged_change = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status.success()),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("Image pull process error on {}: {}", url, e);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
|
||||
|
||||
if started.elapsed() > std::time::Duration::from_secs(PULL_URL_MAX_SECS) {
|
||||
tracing::warn!(
|
||||
"Image pull timed out after {}s: {}",
|
||||
PULL_URL_TIMEOUT_SECS,
|
||||
"Image pull exceeded absolute {}s ceiling: {}",
|
||||
PULL_URL_MAX_SECS,
|
||||
url
|
||||
);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
Ok(false)
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Activity signal 1: stderr output from podman.
|
||||
let line_age = started
|
||||
.elapsed()
|
||||
.as_secs()
|
||||
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
|
||||
// Activity signal 2: podman stages layer downloads in TMPDIR
|
||||
// (user_tmp) — any size change there means bytes are moving.
|
||||
let staged = dir_size_bytes(user_tmp);
|
||||
if staged != last_staged_bytes {
|
||||
last_staged_bytes = staged;
|
||||
last_staged_change = std::time::Instant::now();
|
||||
}
|
||||
|
||||
let stalled = line_age > PULL_STALL_TIMEOUT_SECS
|
||||
&& last_staged_change.elapsed()
|
||||
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS);
|
||||
if stalled {
|
||||
tracing::warn!(
|
||||
"Image pull stalled ({}s with no output and no staged bytes): {}",
|
||||
PULL_STALL_TIMEOUT_SECS,
|
||||
url
|
||||
);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1491,6 +1563,15 @@ autopilot.active=false\n",
|
||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||
/// Critical hooks (credential setup, config) are awaited; UI container builds are background.
|
||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
||||
if matches!(package_id, "homeassistant" | "home-assistant")
|
||||
&& super::pine_ha::pine_engines_installed().await
|
||||
{
|
||||
// Pine was installed first: wire HA to its Wyoming engines now,
|
||||
// then restart so the seeded storage is what HA loads.
|
||||
if super::pine_ha::seed_home_assistant_pine_defaults().await {
|
||||
super::pine_ha::restart_home_assistant_if_running().await;
|
||||
}
|
||||
}
|
||||
if package_id == "filebrowser" {
|
||||
// Generate a random password (32 bytes, hex-encoded)
|
||||
let mut buf = [0u8; 32];
|
||||
@@ -2690,6 +2771,31 @@ async fn persist_install_version_selection(app_id: &str, version: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
|
||||
/// "bytes are moving" signal for pull stall detection — exact size doesn't
|
||||
/// matter, only whether it CHANGES between polls. Errors count as 0.
|
||||
fn dir_size_bytes(path: &str) -> u64 {
|
||||
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
|
||||
if depth > 8 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut total = 0u64;
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
total = total.saturating_add(walk(&entry.path(), depth + 1));
|
||||
} else {
|
||||
total = total.saturating_add(meta.len());
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
walk(std::path::Path::new(path), 0)
|
||||
}
|
||||
|
||||
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
|
||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
//! Home Assistant <-> Pine voice-stack provisioning.
|
||||
//!
|
||||
//! 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
|
||||
//! entries that already exist, so re-running is idempotent.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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); 3] = [
|
||||
("faster-whisper", "host.containers.internal", 10300),
|
||||
("piper", "host.containers.internal", 10200),
|
||||
("openwakeword", "host.containers.internal", 10400),
|
||||
];
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Returns true when anything was written (caller should restart HA).
|
||||
pub(super) async fn seed_home_assistant_pine_defaults() -> bool {
|
||||
let storage = std::path::Path::new(HA_STORAGE_DIR);
|
||||
if let Err(e) = tokio::fs::create_dir_all(storage).await {
|
||||
warn!("pine/HA seed: cannot create {}: {}", HA_STORAGE_DIR, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
let entries_changed = seed_wyoming_config_entries(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;
|
||||
let automation_changed = seed_mesh_announce_automation().await;
|
||||
entries_changed || claude.changed || pipeline_changed || intents_changed || automation_changed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice intents + REST sensors (configuration.yaml + custom_sentences)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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}"
|
||||
scan_interval: 30
|
||||
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(HA_CONFIG_DIR);
|
||||
let config_yaml = config_dir.join("configuration.yaml");
|
||||
|
||||
let Some(token) = ensure_status_token().await else {
|
||||
return false;
|
||||
};
|
||||
let desired_block = voice_config_block(&token);
|
||||
|
||||
// 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!(
|
||||
"pine/HA seed: cannot create {}: {}",
|
||||
sentences_dir.display(),
|
||||
e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let sentences_path = sentences_dir.join("archy.yaml");
|
||||
let sentences_changed = tokio::fs::read_to_string(&sentences_path)
|
||||
.await
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
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}");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &config_yaml).await {
|
||||
warn!("pine/HA seed: replacing configuration.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
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).
|
||||
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 not in ['unknown', 'unavailable', 'None']
|
||||
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) {
|
||||
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");
|
||||
let mut store = if tokio::fs::metadata(&path).await.is_ok() {
|
||||
match read_store(&path).await {
|
||||
Some(v) => v,
|
||||
// Present but unreadable/corrupt: never overwrite it with a
|
||||
// fresh store — that would throw away every integration.
|
||||
None => return false,
|
||||
}
|
||||
} 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 false;
|
||||
};
|
||||
|
||||
let mut added = false;
|
||||
for (title, host, port) in PINE_ENGINES {
|
||||
let exists = entries.iter().any(|e| {
|
||||
e.get("domain").and_then(Value::as_str) == Some("wyoming")
|
||||
&& e.get("data")
|
||||
.and_then(|d| d.get("host"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(host)
|
||||
&& e.get("data")
|
||||
.and_then(|d| d.get("port"))
|
||||
.and_then(Value::as_u64)
|
||||
== Some(port as u64)
|
||||
});
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
let entry_id: [u8; 16] = rand::random();
|
||||
entries.push(json!({
|
||||
"entry_id": hex::encode(entry_id),
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"domain": "wyoming",
|
||||
"title": title,
|
||||
"data": { "host": host, "port": port },
|
||||
"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": []
|
||||
}));
|
||||
info!("pine/HA seed: added wyoming config entry {title} ({host}:{port})");
|
||||
added = true;
|
||||
}
|
||||
|
||||
if added && !write_store(&path, &store).await {
|
||||
return false;
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assist pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ensure an Assist pipeline uses the Pine engines. Creates the store when
|
||||
/// 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 {
|
||||
let Some(items) = store
|
||||
.get_mut("data")
|
||||
.and_then(|d| d.get_mut("items"))
|
||||
.and_then(|i| i.as_array_mut())
|
||||
else {
|
||||
return 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");
|
||||
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 changed {
|
||||
return write_store(&path, &store).await;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ULID-shaped id (26 chars, Crockford base32) — HA only needs uniqueness.
|
||||
let id: String = {
|
||||
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
let raw: [u8; 26] = rand::random();
|
||||
raw.iter()
|
||||
.map(|b| ALPHABET[(*b % 32) as usize] as char)
|
||||
.collect()
|
||||
};
|
||||
let store = json!({
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "assist_pipeline.pipelines",
|
||||
"data": {
|
||||
"items": [{
|
||||
"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",
|
||||
"tts_language": "en_GB",
|
||||
"tts_voice": null,
|
||||
"wake_word_entity": null,
|
||||
"wake_word_id": null
|
||||
}],
|
||||
"preferred_item": id
|
||||
}
|
||||
});
|
||||
if write_store(&path, &store).await {
|
||||
info!("pine/HA seed: created default Assist pipeline (whisper + piper)");
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
tokio::fs::metadata("/var/lib/archipelago/pine")
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// 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(HA_CONFIG_DIR).await.is_ok()
|
||||
}
|
||||
|
||||
/// Restart the HA container so it loads the seeded storage. Best-effort: when
|
||||
/// the container doesn't exist (not yet created) the next start picks the
|
||||
/// seeds up anyway.
|
||||
pub(super) async fn restart_home_assistant_if_running() {
|
||||
let running = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
"name=homeassistant",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.any(|l| l.trim() == "homeassistant")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !running {
|
||||
return;
|
||||
}
|
||||
match tokio::process::Command::new("podman")
|
||||
.args(["restart", "homeassistant"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) if o.status.success() => info!("pine/HA seed: restarted homeassistant"),
|
||||
Ok(o) => warn!(
|
||||
"pine/HA seed: podman restart homeassistant failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
Err(e) => warn!("pine/HA seed: podman restart homeassistant failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_store(path: &std::path::Path) -> Option<Value> {
|
||||
let raw = tokio::fs::read_to_string(path).await.ok()?;
|
||||
match serde_json::from_str(&raw) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"pine/HA seed: {} is not valid JSON ({}), leaving untouched",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_store(path: &std::path::Path, store: &Value) -> bool {
|
||||
let pretty = match serde_json::to_string_pretty(store) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!("pine/HA seed: serialize {} failed: {}", path.display(), e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// Write via temp + rename so HA never reads a half-written store.
|
||||
let tmp = path.with_extension("tmp-seed");
|
||||
if let Err(e) = tokio::fs::write(&tmp, pretty).await {
|
||||
warn!("pine/HA seed: write {} failed: {}", tmp.display(), e);
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, path).await {
|
||||
warn!("pine/HA seed: rename into {} failed: {}", path.display(), e);
|
||||
return false;
|
||||
}
|
||||
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:"));
|
||||
}
|
||||
}
|
||||
@@ -621,8 +621,33 @@ async fn install_stack_via_orchestrator(
|
||||
))
|
||||
.await;
|
||||
|
||||
// Phase: PullingImage — each member's orchestrator.install covers its
|
||||
// whole pipeline (pull + create + start + health wait), and on a fresh
|
||||
// node the pull dominates wall-clock. The X-of-N counter below is what
|
||||
// the UI interpolates across the PullingImage band (20→70%); without
|
||||
// these updates an orchestrator-installed stack sat at "Preparing… 5%"
|
||||
// for the entire multi-gigabyte pull (indeedhub: 7 images).
|
||||
let total = app_ids.len() as u64;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PullingImage)
|
||||
.await;
|
||||
|
||||
let mut installed = 0usize;
|
||||
for app_id in app_ids {
|
||||
for (idx, app_id) in app_ids.iter().enumerate() {
|
||||
handler
|
||||
.set_install_progress(stack_name, idx as u64, total)
|
||||
.await;
|
||||
// Message after progress: set_install_progress resets the label,
|
||||
// set_install_message preserves the phase + counters just written.
|
||||
let component = app_id
|
||||
.strip_prefix(&format!("{stack_name}-"))
|
||||
.unwrap_or(app_id);
|
||||
handler
|
||||
.set_install_message(
|
||||
stack_name,
|
||||
&format!("Installing {} ({} of {})…", component, idx + 1, total),
|
||||
)
|
||||
.await;
|
||||
match orchestrator.install(app_id).await {
|
||||
Ok(container_name) => {
|
||||
installed += 1;
|
||||
@@ -672,6 +697,18 @@ async fn install_stack_via_orchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
// Truthful end-of-install signal, mirroring the legacy stack installers:
|
||||
// the real readiness gate is the scanner's next sweep, this just settles
|
||||
// the bar at 95→100→done instead of leaving it mid-band.
|
||||
handler.set_install_progress(stack_name, total, total).await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PostInstall)
|
||||
.await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::Done)
|
||||
.await;
|
||||
handler.clear_install_progress(stack_name).await;
|
||||
|
||||
install_log(&format!("INSTALL ORCH OK: {} stack", stack_name)).await;
|
||||
Ok(Some(serde_json::json!({
|
||||
"success": true,
|
||||
@@ -707,6 +744,15 @@ fn netbird_stack_app_ids() -> &'static [&'static str] {
|
||||
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||
}
|
||||
|
||||
fn pine_stack_app_ids() -> &'static [&'static str] {
|
||||
// Dependency/startup order: the two Wyoming engines (STT + TTS) first — they
|
||||
// own their downloaded model/voice under /data and publish 10300/10200 —
|
||||
// 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-openwakeword", "pine"]
|
||||
}
|
||||
|
||||
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||
// Dependency order: backends + their generated secrets first, then the api
|
||||
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
|
||||
@@ -1870,6 +1916,54 @@ impl RpcHandler {
|
||||
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Pine voice-assistant stack (Whisper STT + Piper TTS + the
|
||||
/// setup/status launcher). Manifest-driven only, like netbird: render the
|
||||
/// 3-member stack from apps/pine-*/manifest.yml via the orchestrator
|
||||
/// (archy-net + network_aliases, published Wyoming ports 10300/10200 so
|
||||
/// Home Assistant reaches the engines via host.containers.internal, the
|
||||
/// launcher's setup page written via `files:`). The manifests use the exact
|
||||
/// live container names, so on an existing node this ADOPTS the running
|
||||
/// stack rather than recreating it (downloaded models/voices preserved).
|
||||
///
|
||||
/// There is no in-Rust hardcoded fallback: the signed catalog always ships
|
||||
/// apps/pine-*/manifest.yml. If the orchestrator doesn't know these app_ids
|
||||
/// and no running stack exists to adopt, install errors rather than
|
||||
/// silently diverging from the manifest contract.
|
||||
pub(super) async fn install_pine_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "pine", pine_stack_app_ids()).await?
|
||||
{
|
||||
Self::seed_pine_ha_defaults().await;
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"pine manifests not available on this node — the signed catalog must provide apps/pine-*/manifest.yml"
|
||||
)
|
||||
}
|
||||
|
||||
/// After a Pine install, wire Home Assistant to the new engines when HA is
|
||||
/// on this node (the HA post-install hook covers the reverse order).
|
||||
async fn seed_pine_ha_defaults() {
|
||||
if !super::pine_ha::home_assistant_installed().await {
|
||||
return;
|
||||
}
|
||||
if super::pine_ha::seed_home_assistant_pine_defaults().await {
|
||||
super::pine_ha::restart_home_assistant_if_running().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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("", ""));
|
||||
}
|
||||
}
|
||||
@@ -402,6 +402,16 @@ async fn sync_hostname_side_effects(hostname: &str) {
|
||||
Err(e) => warn!("/etc/hosts hostname sync failed: {}", e),
|
||||
}
|
||||
|
||||
// The kiosk Chromium's profile lock is a symlink encoding <hostname>-<pid>;
|
||||
// after a rename the stale lock reads as "another computer" holding the
|
||||
// profile, Chromium refuses to start (--noerrdialogs hides the dialog), and
|
||||
// the kiosk black-screens on the next boot (#98). Clear it here — Chromium
|
||||
// recreates the files on launch, and the kiosk launcher pkills any running
|
||||
// instance before starting a new one.
|
||||
for f in ["SingletonLock", "SingletonCookie", "SingletonSocket"] {
|
||||
let _ = tokio::fs::remove_file(format!("/var/lib/archipelago/chromium-kiosk/{f}")).await;
|
||||
}
|
||||
|
||||
let republished = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/avahi-set-host-name", hostname])
|
||||
.output()
|
||||
@@ -709,4 +719,94 @@ impl RpcHandler {
|
||||
_ => anyhow::bail!("Unknown setting: {}", key),
|
||||
}
|
||||
}
|
||||
|
||||
/// system.kiosk-display.get — Current kiosk display preset + whether this
|
||||
/// node has a kiosk at all (no kiosk unit -> the Settings section hides).
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_get(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let has_kiosk = tokio::fs::metadata("/etc/systemd/system/archipelago-kiosk.service")
|
||||
.await
|
||||
.is_ok();
|
||||
let conf = tokio::fs::read_to_string(KIOSK_DISPLAY_CONF)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let preset = if conf.contains("ARCHIPELAGO_KIOSK_SCALE=1") {
|
||||
"native"
|
||||
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920") {
|
||||
"balanced"
|
||||
} else if conf.contains("ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280") {
|
||||
"large"
|
||||
} else {
|
||||
"auto"
|
||||
};
|
||||
Ok(serde_json::json!({ "has_kiosk": has_kiosk, "preset": preset }))
|
||||
}
|
||||
|
||||
/// system.kiosk-display.set — Write the kiosk display preset and restart
|
||||
/// the kiosk (only if it is running) so it takes effect immediately. The
|
||||
/// launcher sources /etc/archipelago/kiosk-display.conf at startup.
|
||||
pub(in crate::api::rpc) async fn handle_system_kiosk_display_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let preset = params
|
||||
.get("preset")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing preset"))?;
|
||||
|
||||
let conf = match preset {
|
||||
// Resolution-derived default: 4K -> 2.0 (1920-wide layout),
|
||||
// 1080p TV -> 1.5, laptop panels -> 1.0.
|
||||
"auto" => String::new(),
|
||||
// Biggest UI: every panel targets a 1280-wide layout.
|
||||
"large" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280\n".to_string(),
|
||||
// Full-HD layout on any panel that can carry it.
|
||||
"balanced" => "ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920\n".to_string(),
|
||||
// No scaling: native CSS viewport, most content, smallest UI.
|
||||
"native" => "ARCHIPELAGO_KIOSK_SCALE=1\n".to_string(),
|
||||
other => anyhow::bail!("Unknown display preset: {other}"),
|
||||
};
|
||||
|
||||
host_sudo(&["/usr/bin/mkdir", "-p", "/etc/archipelago"]).await?;
|
||||
if conf.is_empty() {
|
||||
let _ = host_sudo(&["/usr/bin/rm", "-f", KIOSK_DISPLAY_CONF]).await;
|
||||
} else {
|
||||
// tee via sudo — the backend runs unprivileged and /etc is root's.
|
||||
let mut child = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/bin/tee", KIOSK_DISPLAY_CONF])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("spawn sudo tee for kiosk display conf")?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(conf.as_bytes()).await?;
|
||||
}
|
||||
let out = child.wait_with_output().await?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"writing {} failed: {}",
|
||||
KIOSK_DISPLAY_CONF,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// try-restart: only bounces a kiosk that is actually running, so this
|
||||
// never starts a kiosk an operator disabled.
|
||||
let _ = host_sudo(&[
|
||||
"/usr/bin/systemctl",
|
||||
"try-restart",
|
||||
"archipelago-kiosk.service",
|
||||
])
|
||||
.await;
|
||||
|
||||
info!(preset, "Kiosk display preset applied");
|
||||
Ok(serde_json::json!({ "preset": preset, "applied": true }))
|
||||
}
|
||||
}
|
||||
|
||||
const KIOSK_DISPLAY_CONF: &str = "/etc/archipelago/kiosk-display.conf";
|
||||
|
||||
@@ -4,9 +4,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
impl RpcHandler {
|
||||
/// List all configured hidden services with their .onion addresses.
|
||||
/// Services for known-but-uninstalled apps are hidden (issue #79).
|
||||
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
|
||||
let config_dir = self.config.data_dir.join("tor-config");
|
||||
let services = list_services(&config_dir).await?;
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let mut apps = AppInstallState {
|
||||
known: Default::default(),
|
||||
installed: Default::default(),
|
||||
};
|
||||
for (id, pkg) in &data.package_data {
|
||||
apps.known.insert(id.clone());
|
||||
if pkg.installed.is_some() {
|
||||
apps.installed.insert(id.clone());
|
||||
}
|
||||
}
|
||||
let services = list_services(&config_dir, Some(&apps)).await?;
|
||||
let tor_running = check_tor_running().await;
|
||||
Ok(serde_json::json!({ "services": services, "tor_running": tor_running }))
|
||||
}
|
||||
|
||||
@@ -228,15 +228,67 @@ pub(super) async fn sync_all_hostname_copies(config: &ServicesConfig) {
|
||||
|
||||
// ─── Service Listing ─────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<TorService>> {
|
||||
/// Which packages the node knows about and which are installed — used to
|
||||
/// hide hidden services for apps that aren't installed. ISO first-boot used
|
||||
/// to pre-bake onions for a fixed app list (bitcoin/electrumx/lnd/btcpay/
|
||||
/// mempool/fedimint), so fresh nodes showed Tor sites for apps that were
|
||||
/// never installed (issue #79).
|
||||
pub(super) struct AppInstallState {
|
||||
pub known: std::collections::HashSet<String>,
|
||||
pub installed: std::collections::HashSet<String>,
|
||||
}
|
||||
|
||||
/// Package ids a Tor service name may correspond to. Service names predate
|
||||
/// the catalog app ids (the ISO baked "bitcoin"/"btcpay"), so one service
|
||||
/// can map to several package ids.
|
||||
fn service_alias_candidates(name: &str) -> Vec<&str> {
|
||||
match name {
|
||||
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => {
|
||||
vec!["bitcoin", "bitcoin-knots", "bitcoin-core"]
|
||||
}
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx", "electrs", "mempool-electrs"]
|
||||
}
|
||||
"btcpay" | "btcpay-server" | "btcpayserver" => {
|
||||
vec!["btcpay", "btcpay-server", "btcpayserver"]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec!["mempool", "mempool-web"],
|
||||
other => vec![other],
|
||||
}
|
||||
}
|
||||
|
||||
impl AppInstallState {
|
||||
/// A service is listed unless it names a known-but-uninstalled app.
|
||||
/// The node's own service, the content relay, and custom user-created
|
||||
/// services (names matching no catalog package) always show.
|
||||
fn service_visible(&self, name: &str) -> bool {
|
||||
if name == "archipelago" || name == "relay" {
|
||||
return true;
|
||||
}
|
||||
let candidates = service_alias_candidates(name);
|
||||
if !candidates.iter().any(|c| self.known.contains(*c)) {
|
||||
return true; // not an app — custom hidden service
|
||||
}
|
||||
candidates.iter().any(|c| self.installed.contains(*c))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn list_services(
|
||||
config_dir: &std::path::Path,
|
||||
apps: Option<&AppInstallState>,
|
||||
) -> Result<Vec<TorService>> {
|
||||
let base = detect_hidden_service_base();
|
||||
let config = load_services_config(config_dir).await;
|
||||
let mut services = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let visible = |name: &str| apps.map(|a| a.service_visible(name)).unwrap_or(true);
|
||||
|
||||
for entry in &config.services {
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
seen.insert(entry.name.clone());
|
||||
if !visible(&entry.name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&entry.name).await;
|
||||
services.push(TorService {
|
||||
name: entry.name.clone(),
|
||||
local_port: entry.local_port,
|
||||
@@ -260,9 +312,12 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
|
||||
if seen.contains(&service_name) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(service_name.clone());
|
||||
if !visible(&service_name) {
|
||||
continue;
|
||||
}
|
||||
let onion = read_onion_address(&service_name).await;
|
||||
let port = known_service_port(&service_name);
|
||||
seen.insert(service_name.clone());
|
||||
let is_proto = is_protocol_service(&service_name);
|
||||
services.push(TorService {
|
||||
name: service_name,
|
||||
|
||||
@@ -437,6 +437,23 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "added": true, "npub": npub }))
|
||||
}
|
||||
|
||||
/// The host address a WireGuard peer should dial — prefer the configured
|
||||
/// host IP, then public-IP lookup, then first local address.
|
||||
async fn current_wg_endpoint_host(&self) -> String {
|
||||
if self.config.host_ip != "127.0.0.1" {
|
||||
return self.config.host_ip.clone();
|
||||
}
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
}
|
||||
|
||||
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||
pub(super) async fn handle_vpn_create_peer(
|
||||
&self,
|
||||
@@ -501,22 +518,7 @@ impl RpcHandler {
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
|
||||
};
|
||||
|
||||
// Detect host IP — prefer config, then nvpn, then system detection
|
||||
let host_ip = if self.config.host_ip != "127.0.0.1" {
|
||||
self.config.host_ip.clone()
|
||||
} else {
|
||||
// Fallback: get public IP via external service
|
||||
tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||
};
|
||||
let endpoint = format!("{}:51820", host_ip);
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
|
||||
// Allocate a peer IP (simple: hash the peer name)
|
||||
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
|
||||
@@ -667,15 +669,41 @@ impl RpcHandler {
|
||||
let content = tokio::fs::read_to_string(&peer_file)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
|
||||
let peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
let mut peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
let stored = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No config stored for peer '{}' — recreate the device to get a new QR code",
|
||||
name
|
||||
)
|
||||
})?;
|
||||
|
||||
// The stored Endpoint is the node's address at creation time; after
|
||||
// the node moves networks it points at a dead IP and the QR produces
|
||||
// a tunnel that can never connect. Refresh it to the current address.
|
||||
let endpoint = format!("{}:51820", self.current_wg_endpoint_host().await);
|
||||
let config: String = stored
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.trim_start().starts_with("Endpoint") {
|
||||
format!("Endpoint = {}", endpoint)
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if config != stored {
|
||||
if let Some(obj) = peer.as_object_mut() {
|
||||
obj.insert("config".to_string(), config.clone().into());
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(&peer) {
|
||||
if tokio::fs::write(&peer_file, json).await.is_ok() {
|
||||
info!("VPN peer '{}' endpoint refreshed to {}", name, endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let qr = qrcode::QrCode::new(config.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||
let svg = qr
|
||||
|
||||
@@ -50,6 +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-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).
|
||||
@@ -75,7 +76,14 @@ pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
|
||||
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
|
||||
/// they drive archy-mempool-web), otherwise the app itself.
|
||||
fn owning_package(app_id: &str) -> &str {
|
||||
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
|
||||
const STACKS: &[&str] = &[
|
||||
"immich",
|
||||
"indeedhub",
|
||||
"btcpay-server",
|
||||
"netbird",
|
||||
"mempool",
|
||||
"pine",
|
||||
];
|
||||
for stack in STACKS {
|
||||
if stack_member_app_ids(stack).contains(&app_id) {
|
||||
return stack;
|
||||
|
||||
@@ -360,7 +360,7 @@ fn validate_password_strength(password: &str) -> Result<()> {
|
||||
/// Change the archipelago user's SSH/login password.
|
||||
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
||||
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
||||
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
pub(crate) async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||
let ssh_user =
|
||||
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ const BACKUP_DIRS: &[&str] = &[
|
||||
"credentials",
|
||||
"tor-config",
|
||||
"content",
|
||||
// Per-node service secrets. Without these a restored node can't
|
||||
// decrypt identity/lnd_aezeed.enc (the Lightning seed backup is
|
||||
// encrypted with secrets/lnd-wallet-password) or reuse its service
|
||||
// credentials. The archive itself is passphrase-encrypted.
|
||||
"secrets",
|
||||
];
|
||||
|
||||
/// Files within data_dir to include in a full backup.
|
||||
@@ -101,7 +106,9 @@ pub async fn create_full_backup(
|
||||
// Step 4: Write metadata
|
||||
let metadata = BackupMetadata {
|
||||
id: backup_id,
|
||||
version: 2,
|
||||
// v3: archive additionally carries the `secrets` dir (needed to
|
||||
// decrypt identity/lnd_aezeed.enc on a restored node).
|
||||
version: 3,
|
||||
created_at: timestamp,
|
||||
encrypted: true,
|
||||
size_bytes: encrypted.len() as u64,
|
||||
@@ -193,6 +200,14 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
|
||||
.context("Failed to create rollback directory")?;
|
||||
|
||||
for dir_name in BACKUP_DIRS {
|
||||
// Only displace live data the backup will actually replace —
|
||||
// older archives don't contain every current BACKUP_DIRS entry
|
||||
// (e.g. `secrets` was added later), and moving a live dir to
|
||||
// rollback with nothing staged to take its place would DELETE it
|
||||
// at cleanup time.
|
||||
if !staging_dir.join(dir_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(dir_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(dir_name);
|
||||
@@ -207,6 +222,9 @@ pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &
|
||||
}
|
||||
}
|
||||
for file_name in BACKUP_FILES {
|
||||
if !staging_dir.join(file_name).exists() {
|
||||
continue;
|
||||
}
|
||||
let src = data_dir.join(file_name);
|
||||
if src.exists() {
|
||||
let dst = rollback_dir.join(file_name);
|
||||
@@ -732,6 +750,50 @@ mod tests {
|
||||
assert!(!bad_result.valid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_dir_rides_backup_and_restore() {
|
||||
// The Lightning seed backup (identity/lnd_aezeed.enc) is encrypted
|
||||
// with secrets/lnd-wallet-password — a backup that omits it can't
|
||||
// recover the Lightning wallet on restore.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "s3cret").unwrap();
|
||||
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
std::fs::remove_dir_all(dir.path().join("secrets")).unwrap();
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "s3cret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restoring_old_backup_without_secrets_keeps_live_secrets() {
|
||||
// Archives created before `secrets` joined BACKUP_DIRS don't stage
|
||||
// one — the restore must NOT displace (and then delete) the node's
|
||||
// live secrets in that case.
|
||||
let dir = TempDir::new().unwrap();
|
||||
setup_data_dir(dir.path());
|
||||
|
||||
// Backup taken while no secrets dir existed (mimics an old archive).
|
||||
let meta = create_full_backup(dir.path(), "pass", None).await.unwrap();
|
||||
|
||||
// Live secrets appear afterwards.
|
||||
std::fs::create_dir_all(dir.path().join("secrets")).unwrap();
|
||||
std::fs::write(dir.path().join("secrets/lnd-wallet-password"), "keep-me").unwrap();
|
||||
|
||||
restore_full_backup(dir.path(), &meta.id, "pass")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pw = std::fs::read_to_string(dir.path().join("secrets/lnd-wallet-password")).unwrap();
|
||||
assert_eq!(pw, "keep-me");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_and_restore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -80,6 +80,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
|
||||
@@ -137,6 +144,13 @@ pub async fn ensure_doctor_installed() {
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_polkit_networkmanager_repair().await {
|
||||
Ok(true) => info!(
|
||||
"Installed NetworkManager polkit rule for the archipelago user — Wi-Fi setup enabled"
|
||||
),
|
||||
Ok(false) => debug!("NetworkManager polkit rule already present"),
|
||||
Err(e) => warn!("polkit NetworkManager repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_journald_dropin().await {
|
||||
Ok(true) => info!("Installed journald log-volume policy drop-in"),
|
||||
Ok(false) => debug!("journald log-volume policy already in place"),
|
||||
@@ -442,6 +456,68 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
/// Self-heal Wi-Fi setup on nodes that predate the polkit fix (issue #99).
|
||||
///
|
||||
/// Archipelago drives NetworkManager from a system-level systemd service
|
||||
/// (`User=archipelago`, no logind seat), so the stock NM polkit rule — which
|
||||
/// only authorizes `subject.local && subject.active` sessions — denies it, and
|
||||
/// "connect to Wi-Fi" fails with "Insufficient privileges". Fresh ISO installs
|
||||
/// since 2026-05 ship the rule below, but nodes that reached this build over
|
||||
/// OTA never got it (OTA replaces the binary + web UI, not host system config).
|
||||
///
|
||||
/// Install the scoped rule if it is missing, and best-effort ensure `polkitd`
|
||||
/// itself is present (without the daemon the rule is inert). Both are wrapped
|
||||
/// so an offline/locked apt or a missing package can never fail startup — the
|
||||
/// rule is still written so it takes effect once polkitd arrives (e.g. after an
|
||||
/// ISO reflash). Idempotent: keyed on the rule's unique `subject.user` marker.
|
||||
async fn run_polkit_networkmanager_repair() -> Result<bool> {
|
||||
let script = r#"
|
||||
set -u
|
||||
RULE=/etc/polkit-1/rules.d/49-archipelago-networkmanager.rules
|
||||
MARKER='subject.user == "archipelago"'
|
||||
# Rule already installed — nothing to do.
|
||||
if grep -qF "$MARKER" "$RULE" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
# The rule is inert without the polkit daemon. Older nodes (the ones that hit
|
||||
# issue #99) shipped without it. Try to install it, but never let apt failure
|
||||
# (offline node, locked dpkg, package unavailable) abort the heal — the rule is
|
||||
# written regardless so it activates whenever polkitd lands.
|
||||
if [ ! -d /usr/share/polkit-1 ] && ! command -v pkaction >/dev/null 2>&1; then
|
||||
timeout 240 apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1 \
|
||||
|| timeout 240 sh -c 'apt-get update >/dev/null 2>&1 && apt-get install -y --no-install-recommends polkitd >/dev/null 2>&1' \
|
||||
|| true
|
||||
fi
|
||||
mkdir -p /etc/polkit-1/rules.d
|
||||
cat > "$RULE" <<'RULEEOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user == "archipelago" && action.id.indexOf("org.freedesktop.NetworkManager.") == 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
RULEEOF
|
||||
chmod 644 "$RULE"
|
||||
# Pick up the new rule. polkitd re-reads rules.d on reload; restart as a
|
||||
# fallback. Non-fatal if the unit name differs or the daemon is absent.
|
||||
systemctl reload polkit 2>/dev/null \
|
||||
|| systemctl restart polkit 2>/dev/null \
|
||||
|| systemctl restart polkit.service 2>/dev/null \
|
||||
|| true
|
||||
exit 2
|
||||
"#;
|
||||
let status = host_sudo(&["sh", "-lc", script])
|
||||
.await
|
||||
.context("install NetworkManager polkit rule")?;
|
||||
match status.code() {
|
||||
Some(0) => Ok(false),
|
||||
Some(2) => Ok(true),
|
||||
_ => {
|
||||
warn!("polkit NetworkManager repair helper exited with {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_bitcoin_rpc_repair() -> Result<bool> {
|
||||
// Older installs can have a container-owned bitcoin.conf with only rpcauth
|
||||
// and printtoconsole. Repair it at startup so OTA fixes existing nodes
|
||||
@@ -785,6 +861,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/")
|
||||
@@ -793,6 +870,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
|
||||
{
|
||||
@@ -838,6 +916,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.
|
||||
|
||||
@@ -57,6 +57,9 @@ impl DockerPackageScanner {
|
||||
"indeedhub-build_ffmpeg-worker_1",
|
||||
"netbird-server",
|
||||
"netbird-dashboard",
|
||||
"pine-whisper",
|
||||
"pine-piper",
|
||||
"pine-openwakeword",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
|
||||
@@ -301,6 +301,33 @@ fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashS
|
||||
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Per-container timestamp of the last volume-ownership sweep. The sweep's
|
||||
/// write-probes are `podman exec`s into EVERY running container; running them
|
||||
/// on every 30s reconcile tick meant six-plus cross-context exec attempts per
|
||||
/// tick forever — a permanent conmon "Failed to create container" storm on
|
||||
/// hosts where exec from the backend's cgroup context fails (Debian 13 first
|
||||
/// boot, 2026-07-19). Ownership drift is an install/OTA-time event, not a
|
||||
/// steady-state one: sweep each container on the first pass after it appears,
|
||||
/// then at most once per hour.
|
||||
fn ownership_sweep_due(name: &str) -> bool {
|
||||
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
static LAST: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let map = LAST.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
let Ok(mut map) = map.lock() else {
|
||||
return true;
|
||||
};
|
||||
let now = std::time::Instant::now();
|
||||
match map.get(name) {
|
||||
Some(last) if now.duration_since(*last) < SWEEP_INTERVAL => false,
|
||||
_ => {
|
||||
map.insert(name.to_string(), now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
|
||||
/// container.
|
||||
///
|
||||
@@ -1739,6 +1766,11 @@ impl ProdContainerOrchestrator {
|
||||
if crate::app_ops::lifecycle_op_in_flight(&c.name) {
|
||||
continue;
|
||||
}
|
||||
// Throttled: first pass after the container appears, then
|
||||
// hourly — not on every 30s tick (see ownership_sweep_due).
|
||||
if !ownership_sweep_due(&c.name) {
|
||||
continue;
|
||||
}
|
||||
if ensure_running_container_ownership(&c.name).await {
|
||||
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -372,6 +372,28 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
||||
/// Recover containers that were running before a crash.
|
||||
/// Attempts to start each container, logging success/failure.
|
||||
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
|
||||
// Snapshot entries can outlive their containers (removed while we were
|
||||
// down, or podman storage partially reset by an unclean poweroff).
|
||||
// `podman start` on those fails permanently, and recovery runs BEFORE the
|
||||
// server binds its port and notifies systemd ready — burning retries on
|
||||
// them pushed recovery past TimeoutStartSec and brick-looped the node
|
||||
// (killed mid-recovery → next boot sees a crash again, forever).
|
||||
let containers: Vec<&RunningContainerRecord> = match existing_container_names().await {
|
||||
Some(existing) => {
|
||||
let (present, missing): (Vec<_>, Vec<_>) =
|
||||
containers.iter().partition(|r| existing.contains(&r.name));
|
||||
if !missing.is_empty() {
|
||||
warn!(
|
||||
"Skipping {} snapshot container(s) that no longer exist: {:?}",
|
||||
missing.len(),
|
||||
missing.iter().map(|r| r.name.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
present
|
||||
}
|
||||
None => containers.iter().collect(),
|
||||
};
|
||||
|
||||
let mut report = RecoveryReport {
|
||||
total: containers.len(),
|
||||
recovered: 0,
|
||||
@@ -386,6 +408,15 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
record.name, record.image
|
||||
);
|
||||
|
||||
// Recovery counts against systemd's start timeout; a heavy node
|
||||
// legitimately needs several minutes for dozens of containers. Push
|
||||
// the deadline out ahead of each container so systemd only kills us
|
||||
// if we stop making progress (360s covers one full attempt chain).
|
||||
let _ = sd_notify::notify(
|
||||
false,
|
||||
&[sd_notify::NotifyState::ExtendTimeoutUsec(360_000_000)],
|
||||
);
|
||||
|
||||
// Rate-limit container starts to avoid overwhelming podman on low-resource systems
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
@@ -427,6 +458,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
attempt + 1,
|
||||
stderr.trim()
|
||||
);
|
||||
// The container is gone (raced past the pre-filter, or the
|
||||
// filter query failed) — retrying can never succeed.
|
||||
if stderr.contains("no such container") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
@@ -448,6 +484,26 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
report
|
||||
}
|
||||
|
||||
/// All container names podman knows about (running or not). `None` if the
|
||||
/// query fails — callers fail open and attempt every snapshot entry.
|
||||
async fn existing_container_names() -> Option<std::collections::HashSet<String>> {
|
||||
let output = podman_output(
|
||||
&["ps", "-a", "--format", "{{.Names}}"],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RecoveryReport {
|
||||
pub total: usize,
|
||||
@@ -710,6 +766,10 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
|
||||
| "netbird-server"
|
||||
| "netbird-dashboard"
|
||||
| "netbird"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
| "pine"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -771,6 +831,21 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
|
||||
containers: &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
anchor: "netbird-server",
|
||||
},
|
||||
StackRecoverySpec {
|
||||
name: "pine",
|
||||
network: "archy-net",
|
||||
aliases: &[
|
||||
("pine-whisper", "pine-whisper"),
|
||||
("pine-piper", "pine-piper"),
|
||||
("pine-openwakeword", "pine-openwakeword"),
|
||||
("pine", "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).
|
||||
anchor: "pine-whisper",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,7 @@ pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
|
||||
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
|
||||
/// implicitly by `load()` on nodes that have never edited their anchor
|
||||
/// list, so every node dials it without operator action.
|
||||
/// The upstream public anchor as a ready-to-apply `SeedAnchor`.
|
||||
pub fn default_public_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
|
||||
@@ -61,6 +59,38 @@ pub fn default_public_anchor() -> SeedAnchor {
|
||||
}
|
||||
}
|
||||
|
||||
// Archipelago-operated anchor on vps2 (the OTA/registry host, 146.59.87.168).
|
||||
// Every node already reaches this host for updates, so it is reachable from
|
||||
// networks that the upstream anchor is not — which is most of them (the
|
||||
// upstream anchor answers on one IPv4 that many home/office networks can't
|
||||
// reach, and its DNS resolves IPv6-first while the daemon is IPv4-only).
|
||||
// TCP because that traverses NAT/firewalls best; 8444 because 8443 on that
|
||||
// host is already taken by a container.
|
||||
pub const ARCHY_ANCHOR_NPUB: &str =
|
||||
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak";
|
||||
pub const ARCHY_ANCHOR_ADDR: &str = "146.59.87.168:8444";
|
||||
pub const ARCHY_ANCHOR_TRANSPORT: &str = "tcp";
|
||||
|
||||
/// The Archipelago-operated anchor as a ready-to-apply `SeedAnchor`.
|
||||
pub fn archy_anchor() -> SeedAnchor {
|
||||
SeedAnchor {
|
||||
npub: ARCHY_ANCHOR_NPUB.to_string(),
|
||||
address: ARCHY_ANCHOR_ADDR.to_string(),
|
||||
transport: ARCHY_ANCHOR_TRANSPORT.to_string(),
|
||||
label: "Archipelago anchor (vps2)".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
|
||||
/// node: `fipsctl connect` is attempted for each, and whichever the node's
|
||||
/// 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()]
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
|
||||
@@ -94,7 +124,7 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
|
||||
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
let path = anchors_path(data_dir);
|
||||
if !path.exists() {
|
||||
return Ok(vec![default_public_anchor()]);
|
||||
return Ok(default_public_anchors());
|
||||
}
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
@@ -268,28 +298,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_missing_seeds_default_public_anchor() {
|
||||
// A node that has never edited its anchor list should still get
|
||||
// the public anchor so it can bootstrap the mesh out of the box.
|
||||
async fn load_missing_seeds_default_public_anchors() {
|
||||
// A node that has never edited its anchor list should still get the
|
||||
// full default anchor set so it can bootstrap the mesh out of the box.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![default_public_anchor()]);
|
||||
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
|
||||
assert_eq!(got[0].transport, "tcp");
|
||||
assert!(got[0].address.ends_with(":8443"));
|
||||
assert_eq!(got, default_public_anchors());
|
||||
// The Archipelago-operated anchor must come first (widest reachability)
|
||||
// and the upstream anchor must remain present as a fallback.
|
||||
assert_eq!(got[0], archy_anchor());
|
||||
assert!(got.contains(&default_public_anchor()));
|
||||
// Every default must be a TCP form (traverses NAT/firewalls), never the
|
||||
// dead udp:8668 the upstream anchor never answers on.
|
||||
assert!(got.iter().all(|a| a.transport == "tcp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_default_persists_as_empty() {
|
||||
// Once the operator removes the default, a file exists and is
|
||||
// authoritative — we must not silently re-seed it on next load.
|
||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||
// Editing the anchor list (here removing one default) makes the file
|
||||
// authoritative: the removed anchor must not be silently re-seeded on
|
||||
// next load, and the remaining default must stay.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = remove(dir.path(), ARCHY_ANCHOR_NPUB).await.unwrap();
|
||||
assert!(!list.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
|
||||
assert!(list.contains(&default_public_anchor()));
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, list, "edited list is authoritative; no re-seed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_all_defaults_persists_as_empty() {
|
||||
// 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();
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
assert!(got.is_empty(), "defaults must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! whitelists `install` into `/etc/fips/`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -17,47 +18,145 @@ use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
};
|
||||
|
||||
/// Write the FIPS daemon config based on the local npub and default
|
||||
/// transports. Overwrites any existing file — callers are expected to
|
||||
/// Header prepended to the generated YAML. serde doesn't emit comments, so
|
||||
/// this is concatenated onto the serialised body.
|
||||
const CONFIG_HEADER: &str = "# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n";
|
||||
|
||||
/// Typed mirror of the subset of upstream `fips.yaml` that archipelago owns.
|
||||
///
|
||||
/// This was previously built by `format!`-ing a string literal. Upstream's
|
||||
/// config structs are `#[serde(deny_unknown_fields)]`, so a key we get wrong
|
||||
/// doesn't degrade gracefully — the daemon refuses to start and the node drops
|
||||
/// off the mesh. Serialising from typed structs lets the compiler and the
|
||||
/// tests below catch drift, instead of a node discovering it at boot after an
|
||||
/// upgrade.
|
||||
///
|
||||
/// Schema verified field-by-field against jmcorgan/fips **v0.4.1** (2026-07-20).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct FipsConfig {
|
||||
pub node: NodeSection,
|
||||
pub tun: TunSection,
|
||||
pub dns: DnsSection,
|
||||
pub transports: TransportsSection,
|
||||
/// Static peers. Always empty: archipelago feeds peers dynamically via the
|
||||
/// seed-anchors apply loop and federation-invite hooks.
|
||||
pub peers: Vec<PeerEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct NodeSection {
|
||||
pub identity: IdentitySection,
|
||||
pub discovery: DiscoverySection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct IdentitySection {
|
||||
/// With `persistent: true` the daemon reuses the key file at
|
||||
/// config-dir/fips.key (= `DAEMON_KEY_PATH`) instead of generating an
|
||||
/// ephemeral identity on every start.
|
||||
pub persistent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DiscoverySection {
|
||||
pub lan: LanDiscoverySection,
|
||||
}
|
||||
|
||||
/// mDNS / DNS-SD discovery on the local link (`node.discovery.lan.*`), added
|
||||
/// upstream in v0.4.0 and opt-in there (upstream default is `false`).
|
||||
///
|
||||
/// We enable it so co-located nodes peer directly instead of depending on the
|
||||
/// public anchor being reachable — an anchor blackhole on one network segment
|
||||
/// otherwise islands a node completely.
|
||||
///
|
||||
/// Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
|
||||
/// has no `lan` field *and* no `deny_unknown_fields`, so a v0.3.0 daemon ignores
|
||||
/// this key harmlessly (verified against the v0.3.0 source). It therefore starts
|
||||
/// working on its own when a node upgrades, with no second config migration.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct LanDiscoverySection {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TunSection {
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
pub mtu: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DnsSection {
|
||||
pub enabled: bool,
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
/// Both UDP and TCP are enabled: the public anchor answers on TCP/8443 only,
|
||||
/// and networks that block outbound UDP can still bootstrap over TCP.
|
||||
/// Upstream dropped the `tor:` transport variant — archipelago's own Tor
|
||||
/// fallback handles that layer.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TransportsSection {
|
||||
pub udp: TransportBind,
|
||||
pub tcp: TransportBind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct TransportBind {
|
||||
/// Upstream takes `bind_addr` ("host:port"), not `enabled` + `port`.
|
||||
pub bind_addr: String,
|
||||
}
|
||||
|
||||
/// A static peer entry. Never constructed today (see `FipsConfig::peers`), but
|
||||
/// typed so the shape is checked if static peering is ever needed.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct PeerEntry {
|
||||
pub npub: String,
|
||||
pub address: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
impl Default for FipsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
node: NodeSection {
|
||||
identity: IdentitySection { persistent: true },
|
||||
discovery: DiscoverySection {
|
||||
lan: LanDiscoverySection { enabled: true },
|
||||
},
|
||||
},
|
||||
tun: TunSection {
|
||||
enabled: true,
|
||||
name: "fips0".to_string(),
|
||||
mtu: 1280,
|
||||
},
|
||||
dns: DnsSection {
|
||||
enabled: true,
|
||||
bind_addr: "127.0.0.1".to_string(),
|
||||
},
|
||||
transports: TransportsSection {
|
||||
udp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_UDP_PORT}"),
|
||||
},
|
||||
tcp: TransportBind {
|
||||
bind_addr: format!("0.0.0.0:{DEFAULT_TCP_PORT}"),
|
||||
},
|
||||
},
|
||||
peers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the FIPS daemon config. Overwrites any existing file — callers
|
||||
/// re-run this whenever the key or daemon version changes.
|
||||
///
|
||||
/// Schema is intentionally minimal: node identity comes from the key
|
||||
/// file on disk (the daemon handles it), transports enable UDP + TCP
|
||||
/// (matching upstream factory default), IPv6 TUN + DNS on defaults.
|
||||
/// Static peer list is empty — archipelago feeds peers dynamically via
|
||||
/// the seed-anchors apply loop and federation-invite hooks.
|
||||
/// Node identity comes from the key file on disk; the static peer list stays
|
||||
/// empty because peers are fed dynamically at runtime.
|
||||
pub fn render_config_yaml() -> String {
|
||||
// Schema matches upstream jmcorgan/fips as of 2026-04. With
|
||||
// `node.identity.persistent: true` the daemon reuses the key file at
|
||||
// config-dir/fips.key (= DAEMON_KEY_PATH). Transports take `bind_addr`
|
||||
// rather than `enabled: true / port: N`. Both UDP and TCP are
|
||||
// enabled by default because the public anchor (fips.v0l.io)
|
||||
// currently answers on TCP/8443 only, and networks that block UDP
|
||||
// outbound can still bootstrap via TCP. Upstream fips no longer
|
||||
// has a `tor:` transport variant — archipelago's own Tor fallback
|
||||
// handles that layer.
|
||||
format!(
|
||||
"# Generated by archipelago — do not edit by hand.\n\
|
||||
# Regenerated on every key change and daemon upgrade.\n\
|
||||
node:\n \
|
||||
identity:\n \
|
||||
persistent: true\n\
|
||||
tun:\n \
|
||||
enabled: true\n \
|
||||
name: fips0\n \
|
||||
mtu: 1280\n\
|
||||
dns:\n \
|
||||
enabled: true\n \
|
||||
bind_addr: \"127.0.0.1\"\n\
|
||||
transports:\n \
|
||||
udp:\n \
|
||||
bind_addr: \"0.0.0.0:{udp}\"\n \
|
||||
tcp:\n \
|
||||
bind_addr: \"0.0.0.0:{tcp}\"\n\
|
||||
peers: []\n",
|
||||
udp = DEFAULT_UDP_PORT,
|
||||
tcp = DEFAULT_TCP_PORT,
|
||||
)
|
||||
let body = serde_yaml::to_string(&FipsConfig::default())
|
||||
.expect("FipsConfig is a plain struct tree and cannot fail to serialise");
|
||||
format!("{CONFIG_HEADER}{body}")
|
||||
}
|
||||
|
||||
/// Install the local FIPS key + rendered config into `/etc/fips/`.
|
||||
@@ -205,6 +304,60 @@ mod tests {
|
||||
assert!(!yaml.contains("tor:"));
|
||||
}
|
||||
|
||||
/// Exact-output snapshot. Upstream's config structs are
|
||||
/// `deny_unknown_fields`, so an accidental key rename/addition means the
|
||||
/// daemon won't start. Pinning the full rendering makes any such change
|
||||
/// fail here — where it's cheap — instead of on a node after an upgrade.
|
||||
/// If this fails, re-verify against the upstream schema before updating it.
|
||||
#[test]
|
||||
fn test_rendered_yaml_exact_snapshot() {
|
||||
let expected = "\
|
||||
# Generated by archipelago — do not edit by hand.
|
||||
# Regenerated on every key change and daemon upgrade.
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
discovery:
|
||||
lan:
|
||||
enabled: true
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: 127.0.0.1
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: 0.0.0.0:8668
|
||||
tcp:
|
||||
bind_addr: 0.0.0.0:8443
|
||||
peers: []
|
||||
";
|
||||
assert_eq!(render_config_yaml(), expected);
|
||||
}
|
||||
|
||||
/// The rendered config must parse as YAML and carry the mDNS opt-in at the
|
||||
/// exact path upstream reads (`node.discovery.lan.enabled`) — a typo there
|
||||
/// would silently leave LAN discovery off rather than erroring.
|
||||
#[test]
|
||||
fn test_lan_discovery_enabled_at_upstream_path() {
|
||||
let yaml = render_config_yaml();
|
||||
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("renders valid YAML");
|
||||
assert_eq!(
|
||||
parsed["node"]["discovery"]["lan"]["enabled"],
|
||||
serde_yaml::Value::Bool(true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Rendering is deterministic: the startup drift check in server.rs compares
|
||||
/// the freshly rendered config against what's on disk, so any instability
|
||||
/// here would cause an endless reinstall+restart loop of the daemon.
|
||||
#[test]
|
||||
fn test_render_is_deterministic() {
|
||||
assert_eq!(render_config_yaml(), render_config_yaml());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_refuses_when_key_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -475,7 +475,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
|
||||
|
||||
let podman_health = parse_podman_health(c, &state);
|
||||
let host_ports = host_tcp_ports_from_container(c);
|
||||
let host_port_ready = if host_ports.is_empty() {
|
||||
// Only raw-probe published ports for containers WITHOUT their own
|
||||
// podman healthcheck. The healthcheck is the better signal, and the
|
||||
// bare TCP connect+close is noisy against TLS listeners — LND logged
|
||||
// "http: TLS handshake error … EOF" on every monitor cycle because
|
||||
// this probe hit its REST/gRPC ports and hung up mid-handshake.
|
||||
let host_port_ready = if host_ports.is_empty() || podman_health.is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(host_ports_ready(&host_ports).await)
|
||||
|
||||
@@ -98,6 +98,40 @@ async fn main() -> Result<()> {
|
||||
return ceremony::run();
|
||||
}
|
||||
|
||||
// Plain CLI flags must never boot the daemon (a stray `--version` used to
|
||||
// start a second instance next to the systemd one). Handled before any
|
||||
// tracing/state init so stdout stays clean.
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
Some("--version") | Some("-V") => {
|
||||
println!(
|
||||
"archipelago {}-{}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
option_env!("GIT_HASH").unwrap_or("dev")
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Some("--help") | Some("-h") => {
|
||||
println!("Archipelago Bitcoin Node OS");
|
||||
println!();
|
||||
println!("Usage: archipelago [COMMAND]");
|
||||
println!();
|
||||
println!("Running with no arguments starts the node daemon.");
|
||||
println!();
|
||||
println!("Commands:");
|
||||
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
|
||||
println!();
|
||||
println!("Options:");
|
||||
println!(" -V, --version Print version and exit");
|
||||
println!(" -h, --help Print this help and exit");
|
||||
return Ok(());
|
||||
}
|
||||
Some(other) if other.starts_with('-') => {
|
||||
eprintln!("archipelago: unknown option '{other}' (see --help)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let startup_start = std::time::Instant::now();
|
||||
crash_recovery::init_start_time();
|
||||
|
||||
|
||||
@@ -421,6 +421,7 @@ pub fn spawn_mesh_listener(
|
||||
our_x25519_pubkey_hex: String,
|
||||
server_name: Option<String>,
|
||||
lora_region: Option<String>,
|
||||
lora_radio_params: Option<super::LoraRadioParams>,
|
||||
channel_name: Option<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||
@@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
|
||||
&our_x25519_pubkey_hex,
|
||||
server_name.as_deref(),
|
||||
lora_region.as_deref(),
|
||||
lora_radio_params,
|
||||
channel_name.as_deref(),
|
||||
device_kind,
|
||||
reticulum_tcp.clone(),
|
||||
|
||||
@@ -836,6 +836,10 @@ const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
|
||||
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Same retry-cap idea as the region, for the Meshcore radio-params write.
|
||||
static RADIO_PARAMS_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// Same retry-cap idea as the region, for the shared-channel write.
|
||||
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
std::sync::atomic::AtomicU32::new(0);
|
||||
@@ -851,6 +855,7 @@ pub(super) async fn run_mesh_session(
|
||||
our_x25519_pubkey_hex: &str,
|
||||
server_name: Option<&str>,
|
||||
lora_region: Option<&str>,
|
||||
lora_radio_params: Option<crate::mesh::LoraRadioParams>,
|
||||
channel_name: Option<&str>,
|
||||
device_kind: Option<DeviceType>,
|
||||
reticulum_tcp: Option<ReticulumTcpConfig>,
|
||||
@@ -978,6 +983,62 @@ pub(super) async fn run_mesh_session(
|
||||
);
|
||||
}
|
||||
|
||||
// Provision Meshcore LoRa PHY params (freq/bw/sf/cr) when the operator has
|
||||
// configured them. Meshcore-only: Meshtastic radios get region+preset via
|
||||
// ensure_lora_region above, and Reticulum carries its own RNode profile.
|
||||
// Gated on a persisted marker of the last-applied params rather than the
|
||||
// device's SELF_INFO readback (its field offsets shift across firmware
|
||||
// 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) {
|
||||
let marker_path = data_dir.join("meshcore-radio-params.json");
|
||||
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok());
|
||||
if applied != Some(params) {
|
||||
let attempts = RADIO_PARAMS_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match dev
|
||||
.set_radio_params(params.freq_khz, params.bw_hz, params.sf, params.cr)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(json) = serde_json::to_vec(¶ms) {
|
||||
if let Err(e) = tokio::fs::write(&marker_path, json).await {
|
||||
warn!("Failed to persist radio-params marker: {}", e);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
freq_khz = params.freq_khz,
|
||||
bw_hz = params.bw_hz,
|
||||
sf = params.sf,
|
||||
cr = params.cr,
|
||||
"Provisioned Meshcore radio params — radio rebooting, \
|
||||
restarting mesh session"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
RADIO_PARAMS_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("Failed to provision Meshcore radio params: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
"Meshcore radio rejected the configured radio params after \
|
||||
repeated attempts — continuing with the device's own settings."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
RADIO_PARAMS_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -322,6 +322,22 @@ pub(crate) async fn seed_federation_peers_into_mesh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Operator-configured LoRa PHY parameters for a Meshcore radio, in the
|
||||
/// firmware's own field units: `freq_khz` = MHz×1000 (869618 → 869.618 MHz),
|
||||
/// `bw_hz` = kHz×1000 (62500 → 62.5 kHz), `sf` 5..=12, `cr` 5..=8. These are
|
||||
/// region/deployment-specific (e.g. the Portugal preset 869618/62500/8/8) and
|
||||
/// MUST match every radio on the local mesh — a mismatched radio hears RF
|
||||
/// energy but demodulates nothing. None (the default) leaves the device's own
|
||||
/// settings untouched, so nodes outside the configured deployment are never
|
||||
/// affected.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LoraRadioParams {
|
||||
pub freq_khz: u32,
|
||||
pub bw_hz: u32,
|
||||
pub sf: u8,
|
||||
pub cr: u8,
|
||||
}
|
||||
|
||||
/// Mesh configuration (persisted to disk).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MeshConfig {
|
||||
@@ -340,6 +356,11 @@ pub struct MeshConfig {
|
||||
/// unset/None.
|
||||
#[serde(default)]
|
||||
pub lora_region: Option<String>,
|
||||
/// Meshcore LoRa PHY parameters (freq/bw/sf/cr). Provisioned onto the
|
||||
/// radio on connect when set; None leaves the device untouched. Ignored
|
||||
/// for Meshtastic (region/preset covers it) and Reticulum.
|
||||
#[serde(default)]
|
||||
pub lora_radio_params: Option<LoraRadioParams>,
|
||||
/// Whether to periodically broadcast our identity.
|
||||
#[serde(default)]
|
||||
pub broadcast_identity: bool,
|
||||
@@ -422,6 +443,7 @@ impl Default for MeshConfig {
|
||||
device_path: None,
|
||||
channel_name: Some("archipelago".to_string()),
|
||||
lora_region: None,
|
||||
lora_radio_params: None,
|
||||
broadcast_identity: true,
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
@@ -722,6 +744,7 @@ impl MeshService {
|
||||
self.our_x25519_pubkey_hex.clone(),
|
||||
self.server_name.clone(),
|
||||
self.config.lora_region.clone(),
|
||||
self.config.lora_radio_params,
|
||||
self.config.channel_name.clone(),
|
||||
self.config.device_kind,
|
||||
self.config.reticulum_tcp.clone(),
|
||||
|
||||
@@ -210,6 +210,24 @@ pub fn build_set_device_time(unix_secs: u64) -> Vec<u8> {
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SET_RADIO_PARAMS (0x0B): set the LoRa PHY config. The device reboots to
|
||||
/// apply. `freq_field` and `bw_field` are the raw firmware fields (freq =
|
||||
/// MHz×1000 e.g. 869618 for 869.618 MHz; bw = kHz×1000 e.g. 62500 for 62.5 kHz);
|
||||
/// `sf` is 5..=12 and `cr` is 5..=8. Wire format verified against the MeshCore
|
||||
/// companion firmware handler (`examples/companion_radio/MyMesh.cpp`,
|
||||
/// `CMD_SET_RADIO_PARAMS`): `[11][freq:u32 LE][bw:u32 LE][sf:u8][cr:u8]`. The
|
||||
/// same fields (same units) come back in the SELF_INFO reply, so a caller can
|
||||
/// read them to detect drift. Values outside the firmware's accepted ranges are
|
||||
/// rejected by the device (it replies with an error frame), not clamped here.
|
||||
pub fn build_set_radio_params(freq_field: u32, bw_field: u32, sf: u8, cr: u8) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_RADIO_PARAMS];
|
||||
data.extend_from_slice(&freq_field.to_le_bytes());
|
||||
data.extend_from_slice(&bw_field.to_le_bytes());
|
||||
data.push(sf);
|
||||
data.push(cr);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SET_ADVERT_NAME (0x08): Set the node's advertised name on the mesh.
|
||||
pub fn build_set_advert_name(name: &str) -> Vec<u8> {
|
||||
let mut data = vec![CMD_SET_ADVERT_NAME];
|
||||
@@ -730,6 +748,29 @@ mod tests {
|
||||
assert_eq!(frame[4], PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_set_radio_params_wire_layout() {
|
||||
// Portugal preset: 869.618 MHz, 62.5 kHz BW, SF 8, CR 8.
|
||||
// freq field = MHz*1000 = 869618; bw field = kHz*1000 = 62500.
|
||||
let frame = build_set_radio_params(869_618, 62_500, 8, 8);
|
||||
assert_eq!(frame[0], OUTBOUND_MARKER);
|
||||
// payload length = 1 (cmd) + 4 (freq) + 4 (bw) + 1 (sf) + 1 (cr) = 11
|
||||
assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 11);
|
||||
let data = &frame[3..];
|
||||
assert_eq!(data[0], CMD_SET_RADIO_PARAMS);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([data[1], data[2], data[3], data[4]]),
|
||||
869_618
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_le_bytes([data[5], data[6], data[7], data[8]]),
|
||||
62_500
|
||||
);
|
||||
assert_eq!(data[9], 8); // sf
|
||||
assert_eq!(data[10], 8); // cr
|
||||
assert_eq!(data.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_frame_complete() -> Result<()> {
|
||||
// Simulate an inbound frame: < + len(2) + [RESP_OK]
|
||||
|
||||
@@ -164,6 +164,29 @@ impl MeshcoreDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the radio's LoRa PHY parameters (freq/bw/sf/cr, firmware field
|
||||
/// units — see `protocol::build_set_radio_params`). On RESP_OK the
|
||||
/// firmware persists the params and reboots to apply them, so the caller
|
||||
/// must treat the session as gone and reconnect.
|
||||
pub async fn set_radio_params(
|
||||
&mut self,
|
||||
freq_khz: u32,
|
||||
bw_hz: u32,
|
||||
sf: u8,
|
||||
cr: u8,
|
||||
) -> Result<()> {
|
||||
self.send_raw(&protocol::build_set_radio_params(freq_khz, bw_hz, sf, cr))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Set radio params failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast our advertisement to the mesh.
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_raw(&protocol::build_send_self_advert()).await?;
|
||||
|
||||
@@ -688,12 +688,27 @@ impl Server {
|
||||
let fips_peer_registry = fips_peer_registry.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
// Steady cadence, but retry fast right after a daemon restart:
|
||||
// regenerating fips.yaml (this build does, once, on first boot
|
||||
// after the OTA) restarts the fips daemon, and for a few seconds
|
||||
// `/run/fips/control.sock` is gone so every `fipsctl connect`
|
||||
// fails and the node islands until the next tick. Detect that
|
||||
// exact failure and retry in 15s instead of 5 min — bounded, so a
|
||||
// node with no fips daemon falls back to the steady cadence
|
||||
// rather than busy-looping.
|
||||
const STEADY: Duration = Duration::from_secs(300);
|
||||
const FAST: Duration = Duration::from_secs(15);
|
||||
const MAX_FAST_RETRIES: u32 = 8; // ≤2 min of fast retries/episode
|
||||
let mut fast_retries: u32 = 0;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let mut daemon_restarting = false;
|
||||
match crate::fips::anchors::load(&data_dir).await {
|
||||
Ok(list) if !list.is_empty() => {
|
||||
let _ = crate::fips::anchors::apply(&list).await;
|
||||
let results = crate::fips::anchors::apply(&list).await;
|
||||
daemon_restarting = !results.is_empty()
|
||||
&& results
|
||||
.iter()
|
||||
.all(|r| !r.ok && r.message.contains("control.sock"));
|
||||
}
|
||||
Ok(_) => { /* no seed anchors configured yet */ }
|
||||
Err(e) => {
|
||||
@@ -717,6 +732,15 @@ impl Server {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
}
|
||||
}
|
||||
|
||||
let next = if daemon_restarting && fast_retries < MAX_FAST_RETRIES {
|
||||
fast_retries += 1;
|
||||
FAST
|
||||
} else {
|
||||
fast_retries = 0;
|
||||
STEADY
|
||||
};
|
||||
tokio::time::sleep(next).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ pub static DOWNLOAD_CANCEL: AtomicBool = AtomicBool::new(false);
|
||||
/// confidence than "looks stuck at 0%".
|
||||
pub static DOWNLOAD_PROGRESS_AT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Serializes the mutating update operations (download, apply, and the
|
||||
/// staging wipe in cancel). The .198 v1.7.103 bricking (2026-07-18) was
|
||||
/// exactly this race: two concurrent `update.download` RPCs shared one
|
||||
/// staging file, a cancel wiped staging mid-flight, a third download began
|
||||
/// re-filling it, and `apply_update` mv'd the 3-second-old 17MB partial of
|
||||
/// a 49MB binary into /usr/local/bin → SEGV boot loop. Writers take this
|
||||
/// via `try_lock` so a concurrent caller gets an explicit "already running"
|
||||
/// error instead of silently interleaving.
|
||||
static UPDATE_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
@@ -976,6 +986,9 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// verified over the complete file at the end of each component, so a
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
state = check_for_updates(data_dir).await?;
|
||||
@@ -1133,7 +1146,6 @@ async fn download_component_resumable(
|
||||
dest: &Path,
|
||||
prior_total: u64,
|
||||
) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
const MAX_ATTEMPTS: u32 = 6;
|
||||
const BACKOFFS: [u64; 5] = [5, 15, 30, 60, 120];
|
||||
@@ -1145,8 +1157,19 @@ async fn download_component_resumable(
|
||||
Err(_) => 0,
|
||||
};
|
||||
if existing_len >= component.size_bytes {
|
||||
// File is already complete — break out and go verify.
|
||||
break;
|
||||
// File is already complete (a resumed run finished it, or a
|
||||
// leftover from an earlier attempt) — verify it instead of
|
||||
// trusting it. The old code `break`d here, which skipped
|
||||
// verification entirely AND landed on the error return below
|
||||
// ("download failed without a captured error").
|
||||
match verify_component_on_disk(component, dest).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if attempt > 1 {
|
||||
let delay = BACKOFFS[(attempt as usize - 2).min(BACKOFFS.len() - 1)];
|
||||
@@ -1294,44 +1317,86 @@ async fn download_component_resumable(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Full file — verify hash.
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash == component.sha256 {
|
||||
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
|
||||
// match too. SHA-256 stays the mandatory gate during migration;
|
||||
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
|
||||
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
|
||||
// — treat it like a SHA mismatch and re-download.
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
));
|
||||
continue;
|
||||
}
|
||||
// Full file — verify hashes. On mismatch the file on disk is
|
||||
// garbage: nuke it and start over from scratch on the next attempt.
|
||||
match verify_component_on_disk(component, dest).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(e);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// SHA mismatch — the file on disk is garbage. Nuke it and
|
||||
// start over from scratch on the next attempt.
|
||||
let _ = tokio::fs::remove_file(dest).await;
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
}
|
||||
|
||||
/// Verify a fully-downloaded component file on disk: SHA-256 is the
|
||||
/// mandatory gate; when the manifest also pins a BLAKE3 digest it must
|
||||
/// match too (BLAKE3 is the hash the iroh swarm fetches/verifies by, so
|
||||
/// a present-but-wrong BLAKE3 means the bytes aren't swarm-consistent —
|
||||
/// treated exactly like a SHA mismatch). Err = mismatch; the caller
|
||||
/// decides whether to remove the file and retry.
|
||||
async fn verify_component_on_disk(component: &ComponentUpdate, dest: &Path) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let bytes = tokio::fs::read(dest)
|
||||
.await
|
||||
.context("read staging file for hash check")?;
|
||||
let hash = hex::encode(Sha256::digest(&bytes));
|
||||
if hash != component.sha256 {
|
||||
anyhow::bail!(
|
||||
"SHA256 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
component.sha256,
|
||||
hash
|
||||
));
|
||||
);
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed without a captured error")))
|
||||
if let Some(b3) = component.blake3.as_deref() {
|
||||
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
|
||||
let actual = crate::content_hash::blake3_hex(&bytes);
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
anyhow::bail!(
|
||||
"BLAKE3 mismatch for {}: expected {}, got {}",
|
||||
component.name,
|
||||
expected,
|
||||
actual
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-verify every manifest component against the bytes actually sitting
|
||||
/// in staging, immediately before install. The download path verifies as
|
||||
/// it goes, but staging can change between download and apply — on .198
|
||||
/// (v1.7.103, 2026-07-18) a concurrent download was re-filling a wiped
|
||||
/// staging dir when apply ran, and a 17MB partial of the 49MB binary got
|
||||
/// installed. This apply-time gate is the one that must never be skipped.
|
||||
async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest) -> Result<()> {
|
||||
for component in &manifest.components {
|
||||
let dest = staging_dir.join(&component.name);
|
||||
let len = tokio::fs::metadata(&dest)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
if len != component.size_bytes {
|
||||
anyhow::bail!(
|
||||
"staged component {} is {} bytes but the manifest says {} — \
|
||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
||||
component.name,
|
||||
len,
|
||||
component.size_bytes
|
||||
);
|
||||
}
|
||||
verify_component_on_disk(component, &dest)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"staged component {} failed verification — refusing to apply",
|
||||
component.name
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel an in-flight download. Sets the cancellation flag so the
|
||||
@@ -1343,11 +1408,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
||||
DOWNLOAD_CANCEL.store(true, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
// Only wipe staging when no download/apply holds the op lock. Wiping
|
||||
// under a live operation is how .198 ended up applying a re-filling
|
||||
// staging dir; with the lock held elsewhere we just set the cancel
|
||||
// flag and let the in-flight loop bail at its next chunk boundary
|
||||
// (partials are size+hash revalidated on the next resume anyway).
|
||||
let staging = data_dir.join("update-staging");
|
||||
let wiped = if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
let wiped = match UPDATE_OP_LOCK.try_lock() {
|
||||
Ok(_op) => {
|
||||
if staging.exists() {
|
||||
tokio::fs::remove_dir_all(&staging).await.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
// Clear the "downloaded, ready to apply" marker too — a canceled
|
||||
// download is not a staged update.
|
||||
@@ -1398,11 +1473,34 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
||||
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
})?;
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
if !staging_dir.exists() {
|
||||
anyhow::bail!("No staged update found. Download first.");
|
||||
}
|
||||
|
||||
// Gate 1: the completion marker is written only after EVERY component
|
||||
// downloaded and hash-verified. A staging dir without it is a partial
|
||||
// or in-flight download — exactly what got installed on .198.
|
||||
if !has_staged_update(data_dir).await {
|
||||
anyhow::bail!(
|
||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
||||
);
|
||||
}
|
||||
|
||||
// Gate 2: re-verify the actual staged bytes against the manifest.
|
||||
let manifest = load_state(data_dir)
|
||||
.await?
|
||||
.available_update
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no update manifest in state to verify staged files against — re-download the update"
|
||||
)
|
||||
})?;
|
||||
verify_staged_components(&staging_dir, &manifest).await?;
|
||||
|
||||
let backup_dir = data_dir.join("update-backup");
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.await
|
||||
@@ -1690,6 +1788,30 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Install the OTA crash-loop guard as a drop-in on existing
|
||||
// nodes (fresh ISOs carry it in the unit file itself). The
|
||||
// guard restores the update-backup binary when a freshly
|
||||
// applied binary SEGVs before it can run its own post-OTA
|
||||
// verification — the .198 v1.7.103 truncated-binary loop.
|
||||
// Best-effort: `+-` in the drop-in means a missing script can
|
||||
// never block the service, and a failed install here must not
|
||||
// abort the apply.
|
||||
if Path::new("/opt/archipelago/scripts/ota-crash-guard.sh").exists() {
|
||||
let dropin_dir = "/etc/systemd/system/archipelago.service.d";
|
||||
let _ = host_sudo(&["mkdir", "-p", dropin_dir]).await;
|
||||
let _ = host_sudo(&[
|
||||
"bash",
|
||||
"-c",
|
||||
&format!(
|
||||
"printf '%s\\n' '[Service]' \
|
||||
'ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh' \
|
||||
> {}/ota-crash-guard.conf",
|
||||
dropin_dir
|
||||
),
|
||||
])
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
let _ =
|
||||
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await;
|
||||
@@ -2443,6 +2565,72 @@ mod tests {
|
||||
assert!(!persisted.update_in_progress);
|
||||
}
|
||||
|
||||
/// apply_update takes the global single-flight UPDATE_OP_LOCK, so tests
|
||||
/// that call it must not run concurrently — one would see the other's
|
||||
/// lock and fail with "another update operation is already running".
|
||||
static APPLY_TEST_SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_refuses_unmarked_staging() {
|
||||
let _serial = APPLY_TEST_SERIAL.lock().await;
|
||||
// Regression: .198 v1.7.103 bricking — apply ran against a staging
|
||||
// dir that a concurrent download was still filling. Without the
|
||||
// .download-complete marker, apply must refuse before touching
|
||||
// anything.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"partial")
|
||||
.await
|
||||
.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("completion marker"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_refuses_staged_bytes_that_mismatch_manifest() {
|
||||
let _serial = APPLY_TEST_SERIAL.lock().await;
|
||||
// Marker present (a complete download once existed) but the staged
|
||||
// bytes no longer match the manifest — apply must re-verify and
|
||||
// refuse rather than install whatever is on disk.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let staging = dir.path().join("update-staging");
|
||||
tokio::fs::create_dir_all(&staging).await.unwrap();
|
||||
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(staging.join("archipelago"), b"truncated-garbage")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = UpdateState {
|
||||
available_update: Some(UpdateManifest {
|
||||
version: "999.0.0".to_string(),
|
||||
release_date: "2026-07-18".to_string(),
|
||||
changelog: vec![],
|
||||
components: vec![ComponentUpdate {
|
||||
name: "archipelago".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "999.0.0".to_string(),
|
||||
download_url: "http://example.invalid/archipelago".to_string(),
|
||||
sha256: "0".repeat(64),
|
||||
size_bytes: 49_949_048,
|
||||
blake3: None,
|
||||
}],
|
||||
}),
|
||||
update_in_progress: true,
|
||||
..UpdateState::default()
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("refusing to apply"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dismiss_update_clears_available() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -263,28 +263,38 @@ impl PodmanClient {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
// Stall-aware + retried, mirroring the legacy installer's
|
||||
// pull_one_url_with_progress. The old single-attempt hard 600s wall
|
||||
// clock killed slow-but-progressing pulls, which every orchestrator
|
||||
// stack (btcpay, indeedhub, …) surfaced as "first install fails,
|
||||
// second succeeds" once the layer cache was warm. Podman keeps
|
||||
// completed layers between attempts, so retries resume cheaply.
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const BACKOFF_SECS: [u64; 2] = [5, 15];
|
||||
|
||||
let mut last_err = anyhow::anyhow!("podman pull {image}: no attempt ran");
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match pull_image_stall_aware(image).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Image pull failed for {} (attempt {}/{}): {:#}",
|
||||
image,
|
||||
attempt,
|
||||
MAX_ATTEMPTS,
|
||||
e
|
||||
);
|
||||
last_err = e;
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(
|
||||
BACKOFF_SECS[(attempt - 1) as usize],
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd.arg(image);
|
||||
|
||||
let output = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600), // 10 min for large images
|
||||
cmd.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Image pull timed out after 10 minutes"))?
|
||||
.context("Failed to execute podman pull")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
pub async fn create_container(&self, manifest: &AppManifest, name: &str) -> Result<String> {
|
||||
@@ -710,6 +720,155 @@ fn podman_network_settings(
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/// One `podman pull` attempt with a stall-aware budget instead of a hard
|
||||
/// wall clock. A pull is only killed when NOTHING is observably happening —
|
||||
/// no stderr output AND no byte growth in podman's TMPDIR staging dir — for
|
||||
/// PULL_STALL_TIMEOUT_SECS, or at a generous absolute ceiling. Dead
|
||||
/// registries still fail fast (no bytes ever land, so the 3-minute stall
|
||||
/// window is the effective bound), while a slow-but-moving multi-GB pull is
|
||||
/// left alone. Same design as the legacy installer's
|
||||
/// pull_one_url_with_progress (LND "first install fails" fix).
|
||||
async fn pull_image_stall_aware(image: &str) -> Result<()> {
|
||||
const PULL_STALL_TIMEOUT_SECS: u64 = 180;
|
||||
const PULL_MAX_SECS: u64 = 1800;
|
||||
const PULL_POLL_INTERVAL_SECS: u64 = 5;
|
||||
|
||||
// Rootless podman's user namespace makes /var/tmp read-only; stage into
|
||||
// the user's containers tmp (same dir every other pull path uses) — it
|
||||
// doubles as the "bytes are moving" signal for stall detection.
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string())
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&user_tmp);
|
||||
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
if image_uses_insecure_registry(image) {
|
||||
cmd.arg("--tls-verify=false");
|
||||
}
|
||||
cmd.arg(image);
|
||||
cmd.env("TMPDIR", &user_tmp);
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
cmd.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().context("Failed to start podman pull")?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
// Seconds-since-start of the last stderr line, updated by the reader
|
||||
// task. Starts at 0 so the stall window opens at spawn.
|
||||
let last_line_at = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
// Ring of recent stderr lines so a failed pull reports podman's actual
|
||||
// error, not just "exit status 125".
|
||||
let recent_stderr = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<
|
||||
String,
|
||||
>::with_capacity(8)));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut lines = tokio::io::BufReader::new(stderr).lines();
|
||||
let line_clock = std::sync::Arc::clone(&last_line_at);
|
||||
let stderr_ring = std::sync::Arc::clone(&recent_stderr);
|
||||
let started_reader = started;
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
line_clock.store(
|
||||
started_reader.elapsed().as_secs(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
if let Ok(mut ring) = stderr_ring.lock() {
|
||||
if ring.len() >= 8 {
|
||||
ring.pop_front();
|
||||
}
|
||||
ring.push_back(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stderr_tail = |ring: &std::sync::Mutex<std::collections::VecDeque<String>>| {
|
||||
ring.lock()
|
||||
.map(|r| r.iter().cloned().collect::<Vec<_>>().join(" | "))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut last_staged_bytes = dir_size_bytes(&user_tmp);
|
||||
let mut last_staged_change = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => return Ok(()),
|
||||
Ok(Some(status)) => {
|
||||
anyhow::bail!(
|
||||
"podman pull {image} failed ({status}): {}",
|
||||
stderr_tail(&recent_stderr)
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
return Err(anyhow::anyhow!("podman pull {image} process error: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(PULL_POLL_INTERVAL_SECS)).await;
|
||||
|
||||
if started.elapsed() > std::time::Duration::from_secs(PULL_MAX_SECS) {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
anyhow::bail!("podman pull {image} exceeded absolute {PULL_MAX_SECS}s ceiling");
|
||||
}
|
||||
|
||||
// Activity signal 1: stderr output from podman.
|
||||
let line_age = started
|
||||
.elapsed()
|
||||
.as_secs()
|
||||
.saturating_sub(last_line_at.load(std::sync::atomic::Ordering::Relaxed));
|
||||
// Activity signal 2: staged layer bytes growing in TMPDIR.
|
||||
let staged = dir_size_bytes(&user_tmp);
|
||||
if staged != last_staged_bytes {
|
||||
last_staged_bytes = staged;
|
||||
last_staged_change = std::time::Instant::now();
|
||||
}
|
||||
|
||||
if line_age > PULL_STALL_TIMEOUT_SECS
|
||||
&& last_staged_change.elapsed()
|
||||
> std::time::Duration::from_secs(PULL_STALL_TIMEOUT_SECS)
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
anyhow::bail!(
|
||||
"podman pull {image} stalled ({PULL_STALL_TIMEOUT_SECS}s with no output and no staged bytes): {}",
|
||||
stderr_tail(&recent_stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes under `path` (bounded recursive walk). Used as a coarse
|
||||
/// "bytes are moving" signal for pull stall detection — exact size doesn't
|
||||
/// matter, only whether it CHANGES between polls. Errors count as 0.
|
||||
fn dir_size_bytes(path: &str) -> u64 {
|
||||
fn walk(dir: &std::path::Path, depth: u32) -> u64 {
|
||||
if depth > 8 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut total = 0u64;
|
||||
for entry in entries.flatten() {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
total = total.saturating_add(walk(&entry.path(), depth + 1));
|
||||
} else {
|
||||
total = total.saturating_add(meta.len());
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
walk(std::path::Path::new(path), 0)
|
||||
}
|
||||
|
||||
fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
||||
let mut ports = Vec::new();
|
||||
if let Some(obj) = bindings.as_object() {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# Handoff — 2026-07-20 — peer-files diagnosis, FIPS 0.4.1, mobile transport pill
|
||||
|
||||
Written for a fresh session that will **cut the OTA release and build the ISO**.
|
||||
Everything below is already committed and pushed to `gitea-ai/main`. Last release
|
||||
was `v1.7.105-alpha` (`e2f83c01`); the next one should be **`v1.7.106-alpha`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this release carries (3 commits on top of v1.7.105-alpha)
|
||||
|
||||
| Commit | What | User-visible? |
|
||||
|---|---|---|
|
||||
| `9e3ac9ba` | Show the FIPS/Tor transport pill on **mobile** peer files | Yes |
|
||||
| `3ab7fb52` | Log the full anyhow error chain on RPC failures | No (diagnostics) |
|
||||
| `5fd0d6c3` | Generate `fips.yaml` from typed structs + enable **mDNS LAN discovery** | Indirectly |
|
||||
|
||||
### `9e3ac9ba` — mobile transport pill
|
||||
`PeerFiles.vue:15` wraps the peer title in `hidden md:block` (the global header
|
||||
carries the name on mobile), and the transport pill was nested inside it — so it
|
||||
vanished below 768px. Added a separate `md:hidden` pill next to the peer icon.
|
||||
Frontend was rebuilt and the class verified present in the emitted bundle.
|
||||
|
||||
Caveats worth knowing (pre-existing, not introduced here):
|
||||
- On this code path the backend only ever emits `fips` or `tor`, so the `mesh`
|
||||
and `lan` branches in `transportPill` (`PeerFiles.vue:609-627`) are dead.
|
||||
- For **received** mesh messages, `mesh/mod.rs:1519-1533` falls back to a
|
||||
hardcoded `"tor"` when the transport is unknown — that pill can genuinely lie.
|
||||
The peer-files pill does not.
|
||||
|
||||
### `3ab7fb52` — full error chain in logs
|
||||
`api/rpc/mod.rs:441` logged only the outermost anyhow context, so every
|
||||
peer-files failure read exactly `RPC error on content.browse-peer: Failed to
|
||||
connect to peer` with the real cause discarded. Now `{:#}`. The client-facing
|
||||
message still goes through `sanitize_error_message(&e.to_string())` (`{}`), so
|
||||
no internal detail leaks. **This fix applies to every RPC method, not just
|
||||
browse-peer.**
|
||||
|
||||
### `5fd0d6c3` — typed FIPS config + mDNS
|
||||
`fips/config.rs` built `/etc/fips/fips.yaml` by `format!`-ing a string literal.
|
||||
Upstream's config structs are `#[serde(deny_unknown_fields)]`, so a wrong key
|
||||
does not degrade — **the daemon refuses to start and the node leaves the mesh**.
|
||||
Now a typed serde struct tree, verified field-by-field against jmcorgan/fips
|
||||
**v0.4.1**, with 4 tests: exact-output snapshot, determinism, mDNS key path, and
|
||||
the pre-existing schema test. All pass.
|
||||
|
||||
Also enables `node.discovery.lan.enabled` (mDNS/DNS-SD, new upstream in v0.4.0)
|
||||
so co-located nodes peer directly instead of depending on the public anchor.
|
||||
|
||||
> ⚠️ **Expected one-time behaviour on first boot after this lands:** the startup
|
||||
> drift check at `server.rs:864` compares the freshly rendered config against
|
||||
> what's on disk. The render differs now, so it reinstalls the config and
|
||||
> restarts the FIPS daemon **once**. This is the intended self-healing path and
|
||||
> settles immediately. Do not mistake it for a regression.
|
||||
|
||||
Emitted unconditionally rather than version-gated: v0.3.0's `DiscoveryConfig`
|
||||
has no `lan` field **and** no `deny_unknown_fields`, so v0.3.0 daemons ignore it
|
||||
harmlessly (verified against the v0.3.0 source). It self-activates on upgrade.
|
||||
|
||||
---
|
||||
|
||||
## 2. FIPS 0.4.1 — validated, but the fleet is NOT rolled
|
||||
|
||||
Fleet was on FIPS **0.3.0 / 0.3.0-dev** (2026-05-11). Upstream is **v0.4.1**
|
||||
(2026-07-19). Verified before touching anything:
|
||||
|
||||
- **Wire-compatible** 0.3.0 → 0.4.0 → 0.4.1. Rolling upgrade, any order, no flag day.
|
||||
- **Config forward-compatible** — every key we emit exists in 0.4.1.
|
||||
- **Asset names match** what `fips/update.rs` expects (`fips_<ver>_<arch>.deb` +
|
||||
`checksums-linux.txt`), so the in-product updater should work.
|
||||
|
||||
### Upgraded so far (2 of N)
|
||||
| Node | Before | After | Result |
|
||||
|---|---|---|---|
|
||||
| OptiPlex `.198` / `100.114.134.21` | `0.3.0-dev-1` | **0.4.1** | ✅ anchor connected, `is_parent: true`, tree `depth: 4` |
|
||||
| thinkpad (this machine) | `0.3.0` | **0.4.1** | ✅ service active, but still islanded (see §4) |
|
||||
|
||||
The OptiPlex was still running the **old string-rendered config** and 0.4.1
|
||||
accepted it — empirical confirmation of the compat analysis, not just desk work.
|
||||
|
||||
### Upgrade recipe (nodes cannot reach GitHub — sideload)
|
||||
```bash
|
||||
# 1. On a host with GitHub access:
|
||||
curl -sL -o fips_0.4.1_amd64.deb \
|
||||
https://github.com/jmcorgan/fips/releases/download/v0.4.1/fips_0.4.1_amd64.deb
|
||||
curl -sL -o checksums-linux.txt \
|
||||
https://github.com/jmcorgan/fips/releases/download/v0.4.1/checksums-linux.txt
|
||||
sha256sum fips_0.4.1_amd64.deb # must match checksums-linux.txt
|
||||
# expected: 9befcc0990c7e08742b5a88f75d753a1088134b20525156688d559a317334ded
|
||||
|
||||
# 2. Sideload:
|
||||
scp fips_0.4.1_amd64.deb archipelago@<node>:/tmp/
|
||||
|
||||
# 3. On the node — the same command update.rs uses:
|
||||
sudo -n systemd-run --collect --wait --quiet --pipe -- \
|
||||
env DEBIAN_FRONTEND=noninteractive dpkg --force-confold --force-downgrade -i \
|
||||
/tmp/fips_0.4.1_amd64.deb
|
||||
|
||||
# 4. Restart the ACTIVE unit — it is archipelago-fips.service,
|
||||
# NOT fips.service (which is inactive on these nodes):
|
||||
sudo -n systemctl restart archipelago-fips.service
|
||||
|
||||
# 5. Verify:
|
||||
fipsctl --version
|
||||
sudo -n fipsctl show links # expect anchor 185.18.221.160:8443 connected
|
||||
sudo -n fipsctl show tree # expect is_root: false, depth > 0
|
||||
```
|
||||
|
||||
### ISO implication (important)
|
||||
`image-recipe/build/auto-installer/Dockerfile.rootfs:23` builds FIPS from
|
||||
**unpinned upstream main** (`git clone --depth 1`, no rev/tag/checksum, amd64
|
||||
only). So a freshly built ISO will pick up whatever main is that day — probably
|
||||
≥0.4.1, but it is not deterministic. Pinning is an open item in
|
||||
`docs/1.8.0-RELEASE-HARDENING-PLAN.md:319-322`. **Consider pinning to v0.4.1
|
||||
before building the release ISO** so the shipped version is knowable.
|
||||
|
||||
---
|
||||
|
||||
## 3. The original bug — peer cloud files not loading
|
||||
|
||||
**Status: root-caused for the thinkpad; NOT fully explained.** Being explicit
|
||||
because it would be easy to read this as closed.
|
||||
|
||||
What is established:
|
||||
- FIPS was fully down on the thinkpad: `fipsctl show peers` → `[]`, `show links`
|
||||
→ `[]`, `show tree` → `is_root: true, depth 0`. An island.
|
||||
- Cause is **network egress**, not FIPS config: the thinkpad cannot reach the
|
||||
public anchor `185.18.221.160` (`fips.v0l.io`) **at all** — 100% packet loss on
|
||||
ICMP, 443/8443/8668 all time out. `show transports` showed
|
||||
`packets_sent: 760, packets_recv: 0` on both UDP and TCP.
|
||||
- Local firewall is **not** the cause (nft/iptables policy `accept`; only stock
|
||||
Tailscale anti-spoof DROPs).
|
||||
- The OptiPlex, on the same `/24`, reaches the anchor fine → it's the thinkpad's
|
||||
WiFi segment (`wlp3s0`), which also blocks L2 to `.198` (`ip neigh` → `FAILED`).
|
||||
- With no FIPS tree, everything falls back to Tor. Every peer in
|
||||
`federation/nodes.json` reads `last_transport: "tor"`, never `"fips"`.
|
||||
- **Tor itself is healthy**: fetched the OptiPlex's `/content` over Tor 3×,
|
||||
HTTP 200 in 4.1–8.5s — well inside the 30s budget at `content.rs:349`.
|
||||
|
||||
What is **not** established: why three specific `content.browse-peer` calls
|
||||
failed today (05:25, 16:37, 16:43 UTC). Tor tested healthy and was never
|
||||
reproduced. Two hypotheses were tested and **disproved**: the Tor fallback logic
|
||||
is correct (FIPS-unreachable returns `None` and falls through in Auto mode), and
|
||||
the legs get independent timeouts (Tor gets a fresh 30s). Best remaining guess is
|
||||
cold-circuit timeouts on first fetch after idle — **a guess, not a finding.**
|
||||
`3ab7fb52` means the next occurrence will log the actual cause.
|
||||
|
||||
### Corrections to earlier claims in this session
|
||||
- "Point FIPS at the Tailscale IP" was **wrong**. FIPS routes by npub; the
|
||||
`ip:port` in `fipsctl connect` is only an underlay endpoint hint.
|
||||
- "The public anchor may be dead fleet-wide" was **wrong**. Its peer is healthy
|
||||
(`delivery_ratio` 1.0 both directions, bloom filter syncing). The
|
||||
`bytes_recv: 0` link counters are simply uninstrumented in 0.3.0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open items — decisions NOT taken
|
||||
|
||||
1. **Second FIPS anchor (user asked for this; not built).** Needs a host running
|
||||
FIPS that is reachable from the restricted WiFi. Candidate found: OVH
|
||||
**`146.59.87.168`** — pings fine from the thinkpad and general egress works
|
||||
(github 200), while the upstream anchor fails even ICMP there. But it does not
|
||||
run FIPS yet, so this means **installing FIPS on the box that hosts Gitea** —
|
||||
a production change, deliberately not made unprompted. Code side is easy after:
|
||||
`fips/anchors.rs:47-50` is a single hardcoded anchor that should become a list
|
||||
(`default_public_anchor()` → `default_public_anchors() -> Vec<SeedAnchor>`).
|
||||
2. **Fleet rollout of FIPS 0.4.1** — only 2 nodes done. `.228`
|
||||
(`100.64.204.114`) has been **offline ~20h** and could not be included.
|
||||
3. **Deploying the archipelago binary** carrying `5fd0d6c3` — no node has it yet,
|
||||
so mDNS is not actually live anywhere. That is what this OTA is for.
|
||||
4. **mDNS caveat:** on the thinkpad's WiFi, multicast may also be blocked, so
|
||||
mDNS may not rescue that particular node even after the OTA. It will help
|
||||
co-located nodes on sane networks.
|
||||
5. **Pin FIPS in the ISO build** (see §2) — recommended before the release ISO.
|
||||
|
||||
---
|
||||
|
||||
## 5. Release ritual (from prior sessions — follow exactly)
|
||||
|
||||
Working tree at handoff had pre-existing unrelated dirt: `core/Cargo.lock`,
|
||||
`release-manifest.json`, `releases/manifest.json` modified, and an untracked
|
||||
`neode-ui/vite.preview.config.mts`. **Stage explicitly by path** — another
|
||||
agent may share this tree; never `git add -A`.
|
||||
|
||||
```bash
|
||||
V=1.7.106-alpha
|
||||
|
||||
# Frontend build — MUST verify dist actually changed (build can silently no-op)
|
||||
cd neode-ui && npm run build # → web/dist/neode-ui/
|
||||
grep -r "md:hidden" ../web/dist/neode-ui/assets/PeerFiles-*.js # sanity
|
||||
|
||||
# Backend
|
||||
cd core && cargo build --release -p archipelago
|
||||
# If you hit `rust-lld: undefined hidden symbol`, it's incremental-cache
|
||||
# corruption — rebuild with CARGO_INCREMENTAL=0
|
||||
|
||||
# Tarball MUST be flat (files at root, no neode-ui/ wrapper) or every fleet UI 403s
|
||||
tar -czf releases/v$V/archipelago-frontend-$V.tar.gz -C web/dist/neode-ui .
|
||||
tar -tzf releases/v$V/archipelago-frontend-$V.tar.gz | head -3 # ./ then ./index.html
|
||||
# Exclude the ~17MB companion APK from tarballs.
|
||||
|
||||
# Ship
|
||||
scripts/create-release.sh $V
|
||||
scripts/publish-release-assets.sh $V gitea-vps2
|
||||
git push origin main && git push origin --tags # tag or the Releases page stays empty
|
||||
git push gitea-ai main # main is protected; use the `ai` account
|
||||
|
||||
# Verify the live manifest
|
||||
curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
|
||||
```
|
||||
|
||||
Notes: vps2 (`146.59.87.168`) is the **primary** OTA manifest host. Signing is
|
||||
done at the **user's TTY** — do not attempt it unattended. Clean `/tmp` first
|
||||
(past releases hit ENOSPC). Changelogs must be **layman-readable**, leading with
|
||||
user benefit.
|
||||
|
||||
### ISO
|
||||
```bash
|
||||
UNBUNDLED=1 bash image-recipe/build-debian-iso.sh
|
||||
```
|
||||
ISO builds are **always unbundled** — the default env silently builds the wrong
|
||||
full-bundle variant. Only filebrowser + fmcd are baked in. Verify the output
|
||||
filename contains `unbundled` and is ≈2.4G. The ISO's frontend source is
|
||||
`/opt/archipelago/web-ui` — rsync dist there first and verify **inside** the ISO.
|
||||
|
||||
---
|
||||
|
||||
## 6. Node access quick reference
|
||||
|
||||
- **thinkpad (`.116`) is the local machine** — do not SSH to it; read
|
||||
`journalctl -u archipelago` and `/var/lib/archipelago/**` directly.
|
||||
- **OptiPlex `.198`** = Tailscale `archipelago-5` / `100.114.134.21`, user
|
||||
`archipelago`. Its LAN IP is unreachable from the thinkpad — use Tailscale.
|
||||
- `.228` = `archipelago-2` / `100.64.204.114` — **offline as of 2026-07-20**, and
|
||||
it is in real use; don't touch uninvited.
|
||||
- `archipelago-1` (`100.82.34.38`) is a Ryzen AI Max desktop, **not** the OptiPlex.
|
||||
- Nodes have no `sqlite3` — use `sudo -n python3` to read the JSON stores.
|
||||
- `fipsctl` needs `sudo -n` (socket is `root:fips` 0660).
|
||||
- **Never run `archipelago --version` on fleet nodes** (deployed binaries predate #74).
|
||||
@@ -0,0 +1,81 @@
|
||||
# Companion app pairing QR — integration handoff
|
||||
|
||||
**Status:** web-UI side SHIPPED (CompanionIntroOverlay.vue, 2026-07-16). This doc is
|
||||
the contract + requirements for the companion-app side (worked on separately, on
|
||||
the Mac).
|
||||
|
||||
## What the web UI now does
|
||||
|
||||
The "Remote Companion" intro modal (shown once after first dashboard login, and in
|
||||
the public demo) gained a second screen:
|
||||
|
||||
1. **Screen 1 (existing):** APK download QR (desktop) / download button, plus a new
|
||||
**"I've installed it"** button to the right of the download button.
|
||||
2. **Screen 2 (new, slide transition):** a **pairing QR** the companion app scans to
|
||||
auto-fill the server entry, with a **Back** button returning to screen 1. On
|
||||
small screens (where you can't scan your own display) an
|
||||
**"Open in companion app"** deep-link button is shown above Back, using the same
|
||||
URI as the QR.
|
||||
|
||||
## The QR payload / deep link (the contract)
|
||||
|
||||
A single URI, also usable as an OS deep link:
|
||||
|
||||
```
|
||||
archipelago://pair?v=1&url=<percent-encoded server URL>[&pw=<percent-encoded password>]
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
|
||||
| param | required | meaning |
|
||||
|-------|----------|---------|
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
Examples the web UI actually emits:
|
||||
|
||||
- Demo: `archipelago://pair?v=1&url=https%3A%2F%2Fdemo.archipelago-foundation.org&pw=entertoexit`
|
||||
- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.168.1.228`
|
||||
- Real node kiosk (UI runs on localhost, so it advertises the mDNS name from
|
||||
`system.get-hostname`): `archipelago://pair?v=1&url=http%3A%2F%2Farchipelago.local`
|
||||
|
||||
## Companion app requirements
|
||||
|
||||
1. **Scan entry point:** the app's action is labeled **"Scan Node's QR"**
|
||||
(implemented 2026-07-17; the modal copy in CompanionIntroOverlay.vue was
|
||||
updated to match).
|
||||
2. **Parse the URI** (from camera scan AND from an OS deep-link intent —
|
||||
register the `archipelago://` scheme so the "Open in companion app" button on
|
||||
phones works).
|
||||
3. On success, **create/update a saved server entry**:
|
||||
- Server address = `url` exactly as given (respect the scheme — the demo is
|
||||
https, LAN nodes are typically http, `.local` mDNS names must work).
|
||||
- If `pw` present, prefill the password and attempt auto-login; otherwise land
|
||||
on the password prompt for that server.
|
||||
- If an entry with the same origin already exists, update it rather than
|
||||
duplicating.
|
||||
4. **Demo flow (the showcase):** scanning the demo QR should take a fresh install
|
||||
to a logged-in demo session in one step — url `https://demo.archipelago-foundation.org`,
|
||||
password `entertoexit`, no manual typing.
|
||||
5. **Robustness:**
|
||||
- Tolerate unknown extra query params (forward compat — we may add `name`,
|
||||
`cert` fingerprint, etc. under `v=1`).
|
||||
- Self-signed HTTPS on `.local`/LAN addresses may appear later; don't hard-fail
|
||||
the parse on scheme.
|
||||
- Bad/foreign QR → clear error, stay on the scan screen.
|
||||
|
||||
## Notes / future extensions (not in v1)
|
||||
|
||||
- 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.
|
||||
|
||||
## Testing checklist (app side)
|
||||
|
||||
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
||||
- [ ] Scan a real node's QR (LAN IP origin) → entry created, password prompt shown.
|
||||
- [ ] Scan a kiosk node's QR (`http://<name>.local`) → mDNS resolution works on the phone.
|
||||
- [ ] Tap "Open in companion app" on a phone browser → deep link opens the app with the same behavior.
|
||||
- [ ] Re-scan same node → no duplicate entry.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Add an existing Nostr identity to the node — UX & implementation plan
|
||||
|
||||
**Status:** plan only (2026-07-16), no code. Companion research: `docs/nostr-signer-login-research.md`.
|
||||
|
||||
## Where it lives
|
||||
|
||||
The **Nostr Identities** screen (`Web5Identities.vue`, backed by `identity.list` /
|
||||
`identity.create`). Today every identity is **seed-derived** (`identity_manager.rs`
|
||||
derives ed25519 + nostr keys from the BIP-39 master seed at an index). "Add existing"
|
||||
introduces a second class of identity: one whose key material comes from *outside* the
|
||||
seed.
|
||||
|
||||
## Two import kinds (both needed, different guarantees)
|
||||
|
||||
1. **Full import (nsec)** — the node holds the secret key. The identity behaves exactly
|
||||
like a seed-derived one (can sign in embedded apps, publish, encrypt). NOT covered by
|
||||
seed backup — flag it visibly and include it in the encrypted node backup.
|
||||
2. **Linked signer (npub only)** — the node stores just the public key; signing is
|
||||
delegated to the user's own signer (browser extension NIP-07, or a NIP-46 remote
|
||||
signer later). Zero key custody; some features (background publishing) unavailable —
|
||||
the UI should badge what works.
|
||||
|
||||
## The UX (matching the house style)
|
||||
|
||||
**Entry point:** next to "Create identity" on Nostr Identities, an **"Add existing"**
|
||||
glass-button. Opens a modal with three tabs (same tab pattern as the send/receive
|
||||
modals):
|
||||
|
||||
1. **Browser extension** (default when `window.nostr` exists)
|
||||
- One button: "Connect with extension". Flow: `getPublicKey()` → show the npub +
|
||||
resolved profile (kind-0 fetched via the node's relays: avatar, name — instant
|
||||
recognition) → "Add this identity".
|
||||
- Creates a **linked signer** identity. A challenge signature
|
||||
(`signEvent` on a throwaway event) proves key possession before adding — never add
|
||||
an unverified npub as "yours".
|
||||
2. **Secret key (nsec)**
|
||||
- Paste field (masked, `nsec1…` or hex), inline validation + derived npub preview
|
||||
with the same kind-0 profile card before confirming.
|
||||
- Scary-clear copy: "Your key will be stored on this node, encrypted at rest. It is
|
||||
NOT part of your seed backup — back it up separately." Confirm step requires the
|
||||
profile card to load or an explicit "add anyway".
|
||||
- Creates a **full** identity.
|
||||
3. **Public key (npub)** — watch-only
|
||||
- Paste an npub for a linked identity without any signer attached yet (useful to
|
||||
reserve the profile, upgrade to extension/NIP-46 signing later).
|
||||
|
||||
**After adding:** the identity appears in the same grid with a small origin badge —
|
||||
`seed` / `imported` / `linked` — and the imported profile picture/name pulled from
|
||||
relays. Everything else (picker in apps, rename, avatar) behaves uniformly.
|
||||
|
||||
**Removal:** existing delete flow; for `imported` identities the confirm dialog warns
|
||||
the key is destroyed unless exported first (offer "Export nsec" in the identity's detail
|
||||
sheet, gated behind password re-entry).
|
||||
|
||||
## Backend work
|
||||
|
||||
- `identity_manager.rs`: identity records gain `origin: Seed { index } | Imported |
|
||||
Linked`, optional `nostr_secret_hex` absent for Linked. Storage: reuse the existing
|
||||
encrypted identity file; imported secrets included in node backup.
|
||||
- New RPCs:
|
||||
- `identity.import-nostr` `{ nsec | npub, name?, verify_sig? }` → validates, derives
|
||||
npub, rejects duplicates (same pubkey as any existing identity), returns the new
|
||||
identity.
|
||||
- `identity.fetch-profile` `{ pubkey }` → kind-0 lookup via `nostr_relays.rs` for the
|
||||
preview card (frontend could also do this, but the node already has relay plumbing
|
||||
and avoids CORS).
|
||||
- `identity.nostr-sign` (used by the iframe NIP-07 bridge): for `Linked` identities
|
||||
return a typed error the bridge translates into "ask the user's extension instead" —
|
||||
phase 2; phase 1 simply hides linked identities from the in-app signer picker.
|
||||
|
||||
## Demo mode
|
||||
|
||||
Mock `identity.import-nostr` + `identity.fetch-profile` in mock-backend.js (canned
|
||||
profile: picture + name for any pasted npub) so the whole add-existing flow is
|
||||
demoable without real relays.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Phase 1 (small):** nsec + npub tabs, origin badges, backup inclusion, mock.
|
||||
2. **Phase 2:** extension tab with possession-proof + kind-0 preview cards everywhere.
|
||||
3. **Phase 3:** NIP-46 remote-signer identities + login integration (shares the QR
|
||||
plumbing from the signer-login work).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Sign in to the node with a Nostr signer — research & recommendation
|
||||
|
||||
**Status:** research only (2026-07-16), no code. Companion plan: `docs/nostr-identity-import-plan.md`.
|
||||
|
||||
## What's already in the tree (and what it isn't)
|
||||
|
||||
The IndeeHub "sign in with signer" work is the *inverse* of this feature: the node acts
|
||||
as a NIP-07 **provider** for embedded iframe apps, signing with node-held keys
|
||||
(`useNostrBridge.ts` postMessage bridge → `identity.nostr-sign` etc., picker UI in
|
||||
`NostrIdentityPicker.vue`). It never verifies an external signer — but the UI patterns
|
||||
(picker modal, QR rendering) and the backend crypto are reusable:
|
||||
|
||||
- **`nostr-sdk 0.44` is already a core dependency** (`nostr_handshake.rs` runs a real
|
||||
relay client) — schnorr event verification and NIP-46 client support are essentially
|
||||
free on the Rust side.
|
||||
- Auth today is single-password + optional TOTP, and TOTP already uses a **two-step
|
||||
login** (`auth.login` → `auth.login.totp`) — the exact slot where a parallel
|
||||
`auth.login.nostr.*` path fits.
|
||||
- The node can host its own relay (strfry app), and the frontend already bundles `qrcode`.
|
||||
|
||||
## Candidate flows, ranked by friction
|
||||
|
||||
### A. Browser extension (NIP-07) — lowest friction on desktop (2 clicks)
|
||||
Login page shows "Sign in with extension" when `window.nostr` exists. Server issues a
|
||||
random challenge → extension signs a **kind 22242** auth event carrying the challenge →
|
||||
server verifies signature + challenge + `created_at` freshness + that the pubkey is
|
||||
enrolled → normal session cookie. ~50 lines of frontend, ~80 lines of Rust. No relay
|
||||
involved at all.
|
||||
|
||||
### B. QR scan with a mobile signer (NIP-46 `nostrconnect://`) — the headline UX (scan + 1 tap)
|
||||
1. Backend generates an ephemeral client keypair and renders a
|
||||
`nostrconnect://<pubkey>?relay=<url>&secret=<rand>&perms=sign_event:22242&name=Archipelago` QR.
|
||||
2. User scans with **Amber** (Android reference signer; Aegis/Nowser also scan;
|
||||
nsec.app is paste-based; Alby is *not* a NIP-46 signer).
|
||||
3. Phone connects to the relay, acks the secret; backend requests one
|
||||
`sign_event:22242` over the encrypted NIP-46 channel, verifies, issues the session.
|
||||
|
||||
**Key architectural choice:** make the **Rust backend the NIP-46 client** (rust-nostr's
|
||||
`nostr-connect` crate), talking to the relay over localhost — the browser only polls our
|
||||
own RPC for "signer connected". No websocket/mixed-content issues in the Vue app.
|
||||
|
||||
**Relay topology:** no public relay is required by the spec — and public relays often
|
||||
rate-limit ephemeral NIP-46 traffic. The node's own strfry is the ideal relay (private,
|
||||
LAN-fast); the QR should carry a relay URL derived from the Host the browser used
|
||||
(LAN IP / Tailscale IP — not `.local`, which Android often can't resolve).
|
||||
**One empirical blocker to test first: does Amber accept plain `ws://` LAN relays?**
|
||||
(Self-signed `wss://` will likely fail cert validation.) If not, route `wss://` through
|
||||
the existing nginx/HTTPS cert story.
|
||||
|
||||
### C. Remembered NIP-46 session (persisted bunker pointer) — zero-tap repeat logins
|
||||
Same as B but persists the pairing so future logins auto-approve. Adds state,
|
||||
revocation surface, and "bunker offline = silent hang" failure modes. **Defer** — B
|
||||
re-scans in ~5 seconds anyway.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Ship **A + B behind one "Sign in with Nostr" button**; skip C for now. Password (+TOTP)
|
||||
stays the permanent fallback — exactly as the user proposed, the signer is enrolled in a
|
||||
step *after* password creation, never instead of it. The verification core is one shared
|
||||
Rust function (sig + challenge + freshness + enrolled-pubkey → session).
|
||||
|
||||
- **Onboarding:** after the password (and seed) steps, an optional "Connect a signer"
|
||||
card: QR (nostrconnect) + "Use browser extension" + Skip. Success enrolls the npub as
|
||||
a login key.
|
||||
- **Settings (next to TOTP):** list enrolled npubs (added date + method), "Add npub"
|
||||
(paste, becomes usable after a challenge-verify), "Connect another signer" (same
|
||||
QR/extension modal), "Remove" (requires password confirm; removing the last npub never
|
||||
locks the account — password always works).
|
||||
- **Libraries:** hand-roll the 22242 event for NIP-07 (window.nostr is a browser global);
|
||||
rust-nostr `nostr-connect` for NIP-46. Avoid the 2.4 MB `nostr-login` JS bundle —
|
||||
wrong fit for a self-hosted box (defaults to public bunkers); it's UX prior art only.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Only pubkeys enrolled **while authenticated** (or during onboarding) may log in —
|
||||
a simple `login_npubs` list next to the TOTP data in `auth.rs`.
|
||||
- Challenge: 32-byte random, single-use, 2–5 min TTL, `created_at` ±60 s, deleted on
|
||||
first verify attempt; pin an origin/host tag. Rate-limit like password attempts.
|
||||
- The `secret` in the nostrconnect URI is a bearer token — one QR per attempt, expires
|
||||
with the challenge.
|
||||
- Policy call: signer approval should count as the second factor for TOTP accounts
|
||||
(possession of phone/extension key), so nostr login doesn't silently bypass TOTP.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Amber + `ws://` LAN relay — needs a 10-minute on-device test before committing.
|
||||
2. Which relay URL to embed (LAN vs Tailscale vs onion) — derive from browser Host.
|
||||
3. NIP-46 encryption: spec says NIP-44, some signers still NIP-04 — rust-nostr handles
|
||||
both; verify against current Amber.
|
||||
4. Track draft **NIP-97 "Login with Nostr"** (matches this UX exactly, unmerged) —
|
||||
align, don't depend.
|
||||
|
||||
**Prior art:** no mainstream self-hosted node OS (Umbrel, Start9, Alby Hub) ships Nostr
|
||||
QR login for its own UI — this would be genuinely differentiating, and every building
|
||||
block is already in the tree.
|
||||
@@ -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.
|
||||
@@ -254,17 +254,29 @@ container_pull() {
|
||||
echo "📦 Step 1: Building root filesystem..."
|
||||
|
||||
ROOTFS_TAR="$WORK_DIR/archipelago-rootfs.tar"
|
||||
ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "$1" == "--rebuild" ]; then
|
||||
# The cached rootfs must be invalidated when its recipe changes: a stale
|
||||
# archipelago-rootfs.tar on the build machine shipped ISOs with NO
|
||||
# wpasupplicant/iw/rfkill (WiFi dead on laptops) long after those packages
|
||||
# were added to the Dockerfile below — the cache condition never looked at
|
||||
# the recipe. Hash the rootfs-defining region of this script; any edit to it
|
||||
# forces a rebuild. `--rebuild` still forces one unconditionally.
|
||||
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
|
||||
|
||||
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
|
||||
echo " Using Docker to create Debian root filesystem..."
|
||||
|
||||
# Create a Dockerfile for building the rootfs
|
||||
cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE
|
||||
# ─── Stage 1: Build the FIPS mesh daemon .deb from upstream main ─────────
|
||||
# ─── Stage 1: Build the FIPS mesh daemon .deb at a pinned tag ────────────
|
||||
#
|
||||
# FIPS (github.com/jmcorgan/fips) is a fast Nostr-keyed mesh routing
|
||||
# protocol archipelago uses as its preferred non-Tor transport. We track
|
||||
# upstream main per project decision (2026-04) — v0.2.0 isn't stable yet.
|
||||
# protocol archipelago uses as its preferred non-Tor transport.
|
||||
# Pinned so the shipped version is knowable: an unpinned --depth 1 clone of
|
||||
# main made every ISO carry whatever upstream happened to be that day.
|
||||
# v0.4.1 is the version fips/config.rs renders its typed config against and
|
||||
# the one validated in the field. Bump the two together.
|
||||
# The .deb is rebuilt every ISO build; Docker layer caching keeps the
|
||||
# incremental cost low. Failure here fails the ISO build on purpose:
|
||||
# we don't want to ship an ISO that silently skips FIPS.
|
||||
@@ -282,7 +294,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
clang libclang-dev libnftnl-dev libmnl-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo install --locked cargo-deb
|
||||
RUN git clone --depth 1 https://github.com/jmcorgan/fips.git /src/fips
|
||||
ARG FIPS_VERSION=v0.4.1
|
||||
RUN git clone --depth 1 --branch "\$FIPS_VERSION" \\
|
||||
https://github.com/jmcorgan/fips.git /src/fips
|
||||
WORKDIR /src/fips
|
||||
# fips-gateway is gated behind the `gateway` Cargo feature (depends on
|
||||
# `rustables`). Without the feature, cargo doesn't build it, and
|
||||
@@ -370,6 +384,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
chromium \
|
||||
unclutter \
|
||||
fonts-liberation \
|
||||
fonts-noto-color-emoji \
|
||||
xfonts-base \
|
||||
plymouth \
|
||||
plymouth-themes \
|
||||
@@ -694,6 +709,7 @@ SYSTEMDSERVICE
|
||||
$CONTAINER_CMD export archipelago-rootfs-tmp > "$ROOTFS_TAR"
|
||||
$CONTAINER_CMD rm archipelago-rootfs-tmp
|
||||
|
||||
echo "$RECIPE_HASH" > "$ROOTFS_STAMP"
|
||||
echo "✅ Root filesystem created: $(du -h "$ROOTFS_TAR" | cut -f1)"
|
||||
else
|
||||
echo "✅ Using cached root filesystem: $(du -h "$ROOTFS_TAR" | cut -f1)"
|
||||
@@ -1168,39 +1184,13 @@ BACKENDFILE
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract NostrVPN binary from container image (native system service, not a container app)
|
||||
# NOTE: The container image must be built against Debian 13's GLIBC (2.40).
|
||||
# If built against a newer GLIBC, the binary will fail at runtime.
|
||||
# Rebuild with: FROM debian:13 AS builder
|
||||
echo " Extracting NostrVPN binary..."
|
||||
_NVPN_IMG="${NOSTR_VPN_IMAGE:-146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7}"
|
||||
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
|
||||
if [ -z "$NVPN_IMAGE_ID" ]; then
|
||||
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
|
||||
fi
|
||||
NVPN_CONTAINER=$($CONTAINER_CMD create "$_NVPN_IMG" 2>/dev/null) || true
|
||||
if [ -n "$NVPN_CONTAINER" ]; then
|
||||
$CONTAINER_CMD cp "$NVPN_CONTAINER:/usr/local/bin/nvpn" "$ARCH_DIR/bin/nvpn" 2>/dev/null && \
|
||||
chmod +x "$ARCH_DIR/bin/nvpn" && \
|
||||
echo " ✅ NostrVPN binary extracted ($(du -h "$ARCH_DIR/bin/nvpn" | cut -f1))"
|
||||
$CONTAINER_CMD rm "$NVPN_CONTAINER" 2>/dev/null || true
|
||||
# Check GLIBC compatibility — Debian 13 (Trixie) has GLIBC 2.40
|
||||
if [ -f "$ARCH_DIR/bin/nvpn" ]; then
|
||||
NVPN_GLIBC=$(objdump -T "$ARCH_DIR/bin/nvpn" 2>/dev/null | grep -oP 'GLIBC_\K[0-9.]+' | sort -V | tail -1)
|
||||
if [ -n "$NVPN_GLIBC" ]; then
|
||||
# Compare: if required GLIBC > 2.40, warn
|
||||
if printf '%s\n' "2.40" "$NVPN_GLIBC" | sort -V | tail -1 | grep -qv "^2\.40$"; then
|
||||
echo " ⚠ WARNING: nvpn binary requires GLIBC $NVPN_GLIBC but Debian 13 has 2.40"
|
||||
echo " ⚠ The nvpn daemon will fail at runtime. Rebuild the container against Debian 13."
|
||||
echo " ⚠ VPN invite/status will still work via Rust backend config.toml fallback."
|
||||
else
|
||||
echo " ✅ nvpn GLIBC compatibility OK (requires $NVPN_GLIBC, target has 2.40)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " ⚠ NostrVPN image not available — nvpn binary will be missing"
|
||||
fi
|
||||
# NostrVPN (the native `nvpn` mesh-VPN daemon) has been removed from the
|
||||
# product — the active VPN path is WireGuard/Tailscale (see core vpn.rs). Its
|
||||
# service is already masked below (ln -sf /dev/null nostr-vpn.service) and the
|
||||
# binary is never spawned at runtime, so we no longer extract it. The
|
||||
# nostr-vpn image was deleted from the registry, which is why hard-requiring
|
||||
# it here bricked the build. Intentionally left out; do not re-add without
|
||||
# restoring the daemon.
|
||||
|
||||
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
|
||||
echo " Extracting nostr-rs-relay binary..."
|
||||
@@ -1210,7 +1200,11 @@ if [ -z "$RELAY_IMAGE" ]; then
|
||||
fi
|
||||
RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
|
||||
if [ -n "$RELAY_CONTAINER" ]; then
|
||||
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
|
||||
# The relay image builds to its WORKDIR /usr/src/app and execs
|
||||
# ./nostr-rs-relay from there (not /usr/local/bin — that path was from an
|
||||
# older image build and silently broke extraction once the image was
|
||||
# rebuilt to the standard layout).
|
||||
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/src/app/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
|
||||
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
|
||||
echo " ✅ nostr-rs-relay binary extracted ($(du -h "$ARCH_DIR/bin/nostr-rs-relay" | cut -f1))"
|
||||
$CONTAINER_CMD rm "$RELAY_CONTAINER" 2>/dev/null || true
|
||||
@@ -1218,6 +1212,23 @@ else
|
||||
echo " ⚠ nostr-rs-relay image not available — relay binary will be missing"
|
||||
fi
|
||||
|
||||
# A missing nostr-rs-relay used to be a warning, and the resulting ISO shipped
|
||||
# an enabled nostr-relay unit that crash-looped on every install. Refuse to
|
||||
# produce that ISO unless explicitly overridden. (nvpn is intentionally no
|
||||
# longer required — NostrVPN was removed; see the note above.)
|
||||
MISSING_VPN_BINARIES=""
|
||||
[ -f "$ARCH_DIR/bin/nostr-rs-relay" ] || MISSING_VPN_BINARIES="$MISSING_VPN_BINARIES nostr-rs-relay"
|
||||
if [ -n "$MISSING_VPN_BINARIES" ]; then
|
||||
if [ "${ALLOW_MISSING_VPN_BINARIES:-0}" = "1" ]; then
|
||||
echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)"
|
||||
else
|
||||
echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES"
|
||||
echo " The registry (146.59.87.168:3000) must be reachable and hold the images,"
|
||||
echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy WireGuard helper script
|
||||
if [ -f "$WORK_DIR/archipelago-wg" ]; then
|
||||
cp "$WORK_DIR/archipelago-wg" "$ARCH_DIR/bin/archipelago-wg"
|
||||
@@ -1349,21 +1360,36 @@ if [ "$UNBUNDLED" = "1" ]; then
|
||||
# Clean stale images from previous builds (e.g. bundled build tars leaking into unbundled)
|
||||
rm -rf "$IMAGES_DIR"
|
||||
mkdir -p "$IMAGES_DIR"
|
||||
# FileBrowser is a core dependency (powers the Cloud file manager) — always bundle it
|
||||
CORE_IMAGE="${FILEBROWSER_IMAGE}"
|
||||
CORE_FILE="filebrowser.tar"
|
||||
if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then
|
||||
echo " ✅ Using cached: $CORE_FILE"
|
||||
else
|
||||
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
|
||||
if container_pull "$CORE_IMAGE"; then
|
||||
$CONTAINER_CMD save "$CORE_IMAGE" -o "$IMAGES_DIR/$CORE_FILE" 2>/dev/null && \
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))" || \
|
||||
echo " ⚠️ Failed to save $CORE_IMAGE"
|
||||
# Core baseline apps created by first-boot-containers.sh even in
|
||||
# unbundled mode — their images must ride on the ISO so a fresh install
|
||||
# works with no internet: FileBrowser (Cloud file manager) and fmcd
|
||||
# (fedimint-clientd, ecash/sats out of the box).
|
||||
# Shipped zstd-compressed: podman load auto-detects compression, and an
|
||||
# uncompressed fmcd.tar alone added ~220MB to the ISO (RC9 size regression).
|
||||
CORE_BUNDLE="
|
||||
${FILEBROWSER_IMAGE} filebrowser.tar.zst
|
||||
${FMCD_IMAGE} fmcd.tar.zst
|
||||
"
|
||||
echo "$CORE_BUNDLE" | while read -r CORE_IMAGE CORE_FILE; do
|
||||
[ -n "$CORE_IMAGE" ] || continue
|
||||
if [ -f "$IMAGES_DIR/$CORE_FILE" ]; then
|
||||
echo " ✅ Using cached: $CORE_FILE"
|
||||
else
|
||||
echo " ⚠️ Failed to pull $CORE_IMAGE — Cloud will not work until installed"
|
||||
echo " Pulling $CORE_IMAGE ($CONTAINER_PLATFORM)..."
|
||||
if container_pull "$CORE_IMAGE"; then
|
||||
RAW_TAR="$IMAGES_DIR/${CORE_FILE%.zst}"
|
||||
if $CONTAINER_CMD save "$CORE_IMAGE" -o "$RAW_TAR" 2>/dev/null && \
|
||||
zstd -q -T0 -15 --rm "$RAW_TAR" -o "$IMAGES_DIR/$CORE_FILE"; then
|
||||
echo " ✅ Saved core: $CORE_FILE ($(du -h "$IMAGES_DIR/$CORE_FILE" | cut -f1))"
|
||||
else
|
||||
rm -f "$RAW_TAR" "$IMAGES_DIR/$CORE_FILE"
|
||||
echo " ⚠️ Failed to save $CORE_IMAGE"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ Failed to pull $CORE_IMAGE — baseline app won't work offline"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "📦 Step 3b: Bundling container images for offline use..."
|
||||
|
||||
@@ -1472,6 +1498,13 @@ LOADSERVICE
|
||||
cat > "$WORK_DIR/load-container-images.sh" <<'LOADSCRIPT'
|
||||
#!/bin/bash
|
||||
# Load pre-bundled container images into Podman
|
||||
#
|
||||
# CRITICAL: all Archipelago containers run ROOTLESS as the archipelago user.
|
||||
# This script runs as root (systemd oneshot), so a plain `podman load` here
|
||||
# puts the images into root's storage where the rootless runtime can never
|
||||
# see them — containers then silently depend on registry pulls, and a fresh
|
||||
# install without internet gets no apps at all. Always load into the
|
||||
# archipelago user's storage.
|
||||
|
||||
IMAGES_DIR="/opt/archipelago/container-images"
|
||||
LOG_FILE="/var/log/archipelago-images.log"
|
||||
@@ -1483,21 +1516,32 @@ if [ ! -d "$IMAGES_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for tarfile in "$IMAGES_DIR"/*.tar; do
|
||||
ARCH_UID=$(id -u archipelago)
|
||||
# Linger gives the archipelago user a runtime dir (/run/user/UID) at boot,
|
||||
# before any login — required for rootless podman.
|
||||
loginctl enable-linger archipelago 2>/dev/null || true
|
||||
for _ in $(seq 1 30); do
|
||||
[ -d "/run/user/$ARCH_UID" ] && break
|
||||
sleep 1
|
||||
done
|
||||
PODMAN="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$ARCH_UID podman"
|
||||
$PODMAN system migrate >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
for tarfile in "$IMAGES_DIR"/*.tar "$IMAGES_DIR"/*.tar.zst; do
|
||||
if [ -f "$tarfile" ]; then
|
||||
echo "$(date): Loading $(basename "$tarfile")..." >> "$LOG_FILE"
|
||||
podman load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
|
||||
$PODMAN load -i "$tarfile" >> "$LOG_FILE" 2>&1 && \
|
||||
echo "$(date): Successfully loaded $(basename "$tarfile")" >> "$LOG_FILE" || \
|
||||
echo "$(date): Failed to load $(basename "$tarfile")" >> "$LOG_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# Ensure archy-net exists for mempool stack (db, api, frontend)
|
||||
podman network create archy-net 2>/dev/null || true
|
||||
$PODMAN network create archy-net 2>/dev/null || true
|
||||
|
||||
echo "$(date): Container image load complete" >> "$LOG_FILE"
|
||||
echo "$(date): Available images:" >> "$LOG_FILE"
|
||||
podman images >> "$LOG_FILE" 2>&1
|
||||
$PODMAN images >> "$LOG_FILE" 2>&1
|
||||
LOADSCRIPT
|
||||
|
||||
chmod +x "$WORK_DIR/load-container-images.sh"
|
||||
@@ -1625,17 +1669,17 @@ LOG="/var/log/archipelago-tor.log"
|
||||
|
||||
mkdir -p "$ARCHY_TOR_DIR" "$TOR_CONFIG_DIR"
|
||||
|
||||
# Write services.json for the backend to read
|
||||
# First boot only: seed services.json + torrc. The unit runs on EVERY boot
|
||||
# (oneshot, multi-user.target), and rewriting these unconditionally clobbered
|
||||
# hidden services the backend added after app installs. Only the node's own
|
||||
# service is pre-baked — apps get their hidden service created on install
|
||||
# (auto_add_tor_service / tor.create-service), never pre-created for apps
|
||||
# that may never be installed (issue #79).
|
||||
if [ ! -f "$TOR_CONFIG_DIR/services.json" ]; then
|
||||
cat > "$ARCHY_TOR_DIR/services.json" <<TORJSON
|
||||
{
|
||||
"services": [
|
||||
{"name": "archipelago", "local_port": 80, "enabled": true},
|
||||
{"name": "bitcoin", "local_port": 8333, "enabled": true},
|
||||
{"name": "electrumx", "local_port": 50001, "enabled": true},
|
||||
{"name": "lnd", "local_port": 9735, "enabled": true},
|
||||
{"name": "btcpay", "local_port": 23000, "enabled": true},
|
||||
{"name": "mempool", "local_port": 4080, "enabled": true},
|
||||
{"name": "fedimint", "local_port": 8175, "enabled": true}
|
||||
{"name": "archipelago", "local_port": 80, "enabled": true}
|
||||
]
|
||||
}
|
||||
TORJSON
|
||||
@@ -1655,33 +1699,16 @@ SocksPolicy reject *
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_archipelago
|
||||
HiddenServicePort 80 127.0.0.1:80
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_bitcoin
|
||||
HiddenServicePort 8333 127.0.0.1:8333
|
||||
HiddenServicePort 8332 127.0.0.1:8332
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_electrumx
|
||||
HiddenServicePort 50001 127.0.0.1:50001
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_lnd
|
||||
HiddenServicePort 9735 127.0.0.1:9735
|
||||
HiddenServicePort 8080 127.0.0.1:8080
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_btcpay
|
||||
HiddenServicePort 23000 127.0.0.1:23000
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_mempool
|
||||
HiddenServicePort 4080 127.0.0.1:4080
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_fedimint
|
||||
HiddenServicePort 8175 127.0.0.1:8175
|
||||
|
||||
HiddenServiceDir $TOR_DIR/hidden_service_relay
|
||||
HiddenServicePort 7777 127.0.0.1:7777
|
||||
TORRC
|
||||
else
|
||||
echo "$(date): tor already initialized — leaving services.json/torrc alone" >> "$LOG"
|
||||
fi
|
||||
|
||||
# Create hidden service dirs with correct ownership and permissions (700, not 750)
|
||||
# Tor refuses to start if permissions are too permissive
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
|
||||
for svc in archipelago relay; do
|
||||
mkdir -p "$TOR_DIR/hidden_service_$svc"
|
||||
chown debian-tor:debian-tor "$TOR_DIR/hidden_service_$svc"
|
||||
chmod 700 "$TOR_DIR/hidden_service_$svc"
|
||||
@@ -1726,7 +1753,7 @@ done
|
||||
# Sync hostnames to backend-readable directory
|
||||
HOSTNAMES_DIR="/var/lib/archipelago/tor-hostnames"
|
||||
mkdir -p "$HOSTNAMES_DIR"
|
||||
for svc in archipelago bitcoin electrumx lnd btcpay mempool fedimint relay; do
|
||||
for svc in archipelago relay; do
|
||||
if [ -f "$TOR_DIR/hidden_service_${svc}/hostname" ]; then
|
||||
cp "$TOR_DIR/hidden_service_${svc}/hostname" "$HOSTNAMES_DIR/$svc"
|
||||
echo "$(date): Synced hostname: $svc" >> "$LOG"
|
||||
@@ -2464,6 +2491,17 @@ HandleLidSwitchExternalPower=ignore
|
||||
HandleLidSwitchDocked=ignore
|
||||
LIDCONF
|
||||
|
||||
# Kiosk appliance: a short press or accidental brush of the power button used
|
||||
# to shut the node down instantly (confirmed on a Framework node — "Power key
|
||||
# pressed short -> Powering off"). Ignore the short press; keep a deliberate
|
||||
# long press (hold ~2s) as the intentional power-off, so the node isn't taken
|
||||
# down by accident but can still be shut down on purpose without a keyboard.
|
||||
cat > /mnt/target/etc/systemd/logind.conf.d/power-key.conf <<'PWRCONF'
|
||||
[Login]
|
||||
HandlePowerKey=ignore
|
||||
HandlePowerKeyLongPress=poweroff
|
||||
PWRCONF
|
||||
|
||||
# Copy Archipelago binaries and files
|
||||
if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then
|
||||
cp -r "$BOOT_MEDIA/archipelago/bin/"* /mnt/target/usr/local/bin/ 2>/dev/null || true
|
||||
@@ -2494,7 +2532,7 @@ fi
|
||||
if [ -d "$BOOT_MEDIA/archipelago/container-images" ]; then
|
||||
echo " Copying container images (this may take a moment)..."
|
||||
mkdir -p /mnt/target/opt/archipelago/container-images
|
||||
cp -r "$BOOT_MEDIA/archipelago/container-images/"*.tar /mnt/target/opt/archipelago/container-images/ 2>/dev/null || true
|
||||
cp -r "$BOOT_MEDIA/archipelago/container-images/"*.tar* /mnt/target/opt/archipelago/container-images/ 2>/dev/null || true
|
||||
|
||||
# Copy first-boot loader script and service
|
||||
mkdir -p /mnt/target/opt/archipelago/scripts
|
||||
@@ -2623,6 +2661,11 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
|
||||
W='\033[1;37m'
|
||||
N='\033[0m'
|
||||
|
||||
# The logo uses UTF-8 block-drawing glyphs. Switching back from the kiosk
|
||||
# (Xorg on vt1) can leave the console VT out of UTF-8 mode, which renders
|
||||
# them as garbage bytes ("sometimes corrupt"). ESC % G forces the VT into
|
||||
# UTF-8 mode so the logo always draws correctly.
|
||||
printf '\033%%G' 2>/dev/null || true
|
||||
clear
|
||||
echo -e " ${O}▄▀█ █▀▄ █▀▀ █ █ █ █▀█ █▀▀ █ ▄▀█ █▀▀ █▀█${N}"
|
||||
echo -e " ${O}█▀█ █▀▄ █ █▀█ █ █▀▀ ██▀ █ █▀█ █ █ █ █${N}"
|
||||
@@ -2638,10 +2681,14 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then
|
||||
if [ -b /dev/mapper/archipelago-data ] || [ -b /dev/mapper/archipelago_crypt ]; then
|
||||
echo -e " ${OD}storage LUKS2 encrypted${N}"
|
||||
fi
|
||||
# The kiosk's Xorg runs on vt1 (see archipelago-kiosk-launcher: "Xorg :0
|
||||
# vt1"), so Ctrl+Alt+F1 IS the kiosk and a terminal is on another VT (F2,
|
||||
# where systemd auto-spawns a getty). The old hints had these backwards —
|
||||
# they sent you to an empty black VT7 and there was no way back to the kiosk.
|
||||
if systemctl is-active archipelago-kiosk.service >/dev/null 2>&1; then
|
||||
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F1 for terminal)${N}"
|
||||
echo -e " ${OD}display Kiosk active (Ctrl+Alt+F2 for terminal)${N}"
|
||||
else
|
||||
echo -e " ${OD}display Console (Ctrl+Alt+F7 for kiosk)${N}"
|
||||
echo -e " ${OD}display Console (Ctrl+Alt+F1 for kiosk)${N}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
@@ -3312,6 +3359,11 @@ echo ""
|
||||
echo "=== Done ==="
|
||||
DIAGSCRIPT
|
||||
chmod +x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh
|
||||
# v1.7.104 shipped installs where this script was missing while its unit was
|
||||
# enabled (203/EXEC forever). Verify the write actually landed, loudly.
|
||||
if [ ! -x /mnt/target/opt/archipelago/scripts/first-boot-diag.sh ]; then
|
||||
echo "ERROR: first-boot-diag.sh was not written to the target" >&2
|
||||
fi
|
||||
|
||||
# Systemd oneshot service for first-boot diagnostics
|
||||
cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
|
||||
@@ -3319,6 +3371,8 @@ cat > /mnt/target/etc/systemd/system/archipelago-diag.service <<'DIAGSVC'
|
||||
Description=Archipelago First Boot Diagnostics
|
||||
After=multi-user.target archipelago.service nginx.service
|
||||
ConditionPathExists=!/var/log/archipelago-first-boot-diag.log
|
||||
# Skip cleanly (instead of failing 203/EXEC) if the script is missing.
|
||||
ConditionPathExists=/opt/archipelago/scripts/first-boot-diag.sh
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
@@ -3587,10 +3641,27 @@ echo " Step 5 complete (GRUB + ISOLINUX configured)"
|
||||
echo ""
|
||||
echo "Step 6: Creating bootable ISO..."
|
||||
|
||||
if [ "$UNBUNDLED" = "1" ]; then
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}.iso"
|
||||
# Release-candidate suffix: rebuilds of the SAME version used to overwrite
|
||||
# the previous ISO under an identical filename, so a flashed stick was
|
||||
# indistinguishable from a newer build. Auto-increment an RC counter per
|
||||
# version (persists next to the build counter); override with RC=n env.
|
||||
RC_COUNTER_FILE="/opt/archipelago/rc-counter-${BUILD_VERSION}"
|
||||
if [ -n "${RC:-}" ]; then
|
||||
RC_NUM="$RC"
|
||||
else
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}.iso"
|
||||
if [ -f "$RC_COUNTER_FILE" ]; then
|
||||
RC_NUM=$(( $(cat "$RC_COUNTER_FILE") + 1 ))
|
||||
else
|
||||
RC_NUM=1
|
||||
fi
|
||||
fi
|
||||
echo "$RC_NUM" | sudo tee "$RC_COUNTER_FILE" > /dev/null 2>/dev/null || true
|
||||
echo " Release candidate: RC${RC_NUM}"
|
||||
|
||||
if [ "$UNBUNDLED" = "1" ]; then
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-unbundled-${ARCH}_RC${RC_NUM}.iso"
|
||||
else
|
||||
OUTPUT_ISO="$OUTPUT_DIR/archipelago-installer-${BUILD_VERSION}-${ARCH}_RC${RC_NUM}.iso"
|
||||
fi
|
||||
|
||||
# Use the proven MBR code for hybrid USB boot
|
||||
|
||||
@@ -22,6 +22,11 @@ if [ "$X_READY" != "true" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Settings-managed display overrides (system.kiosk-display.set writes this
|
||||
# file, then restarts the kiosk): may set ARCHIPELAGO_KIOSK_SCALE,
|
||||
# ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH or ARCHIPELAGO_KIOSK_MAX_WIDTH.
|
||||
[ -f /etc/archipelago/kiosk-display.conf ] && . /etc/archipelago/kiosk-display.conf
|
||||
|
||||
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
|
||||
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
|
||||
|
||||
@@ -54,6 +59,25 @@ configure_display() {
|
||||
' /tmp/archipelago-kiosk-xrandr.txt)
|
||||
[ -n "$mode" ] || mode=1920x1080
|
||||
|
||||
# Optional resolution cap for weak hardware: ARCHIPELAGO_KIOSK_MAX_WIDTH=1920
|
||||
# drops a 4K panel to its best <=1920-wide mode (the TV upscales) so the
|
||||
# software rasterizer paints 1/4 the pixels. Off by default — native mode
|
||||
# is sharp and fits the tile budget now that --window-size is in DIPs.
|
||||
local max_w=${ARCHIPELAGO_KIOSK_MAX_WIDTH:-0}
|
||||
if [ "$max_w" -gt 0 ] 2>/dev/null && [ "${mode%x*}" -gt "$max_w" ] 2>/dev/null; then
|
||||
local capped
|
||||
capped=$(awk -v out="$output" -v maxw="$max_w" '
|
||||
$1 == out { active = 1; next }
|
||||
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
|
||||
split($1, wh, "x")
|
||||
if (wh[1] + 0 <= maxw && wh[1] + 0 > best_w) { best_w = wh[1] + 0; best = $1 }
|
||||
}
|
||||
active && /^[^[:space:]]/ { active = 0 }
|
||||
END { if (best) print best }
|
||||
' /tmp/archipelago-kiosk-xrandr.txt)
|
||||
[ -n "$capped" ] && mode=$capped
|
||||
fi
|
||||
|
||||
# Kiosk should use one native output. A spanning desktop makes Chromium land
|
||||
# on the laptop panel or stretch across both outputs.
|
||||
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
|
||||
@@ -82,6 +106,40 @@ configure_display() {
|
||||
|
||||
configure_display
|
||||
|
||||
# --- Kiosk UI scaling for large / high-res displays -----------------------
|
||||
# REVERT: set env ARCHIPELAGO_KIOSK_SCALE=1 (per-node, no rebuild), or restore
|
||||
# the hardcoded --force-device-scale-factor=1 below to disable entirely.
|
||||
#
|
||||
# A big TV reports its full native resolution as the CSS viewport, so a 4K
|
||||
# panel becomes a 3840px-wide viewport and the UI renders tiny — and a
|
||||
# keyboard-less kiosk can't zoom. Derive Chromium's device-scale-factor from
|
||||
# the detected panel width so the *effective* CSS viewport lands near a
|
||||
# comfortable target. Panels >=2560 wide get a 1920-wide layout (4K -> scale
|
||||
# 2.0 — user-validated on a 72" TV: spacious desktop layout, 2x sharp);
|
||||
# smaller panels get a 1280-wide layout (1920 -> 1.50, laptops -> 1.0).
|
||||
if [ -z "${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-}" ]; then
|
||||
if [ "$KIOSK_MODE_W" -ge 2560 ] 2>/dev/null; then
|
||||
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920
|
||||
else
|
||||
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280
|
||||
fi
|
||||
fi
|
||||
KIOSK_TARGET_CSS_WIDTH=$ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH
|
||||
if [ -n "${ARCHIPELAGO_KIOSK_SCALE:-}" ]; then
|
||||
KIOSK_SCALE=$ARCHIPELAGO_KIOSK_SCALE
|
||||
else
|
||||
KIOSK_SCALE=$(awk -v w="$KIOSK_MODE_W" -v t="$KIOSK_TARGET_CSS_WIDTH" \
|
||||
'BEGIN{if(t<=0)t=1280; s=w/t; s=int(s*4+0.5)/4; if(s<1)s=1; if(s>3)s=3; printf "%.2f", s}')
|
||||
fi
|
||||
|
||||
# Chromium's --window-size is in DIPs (CSS px), not physical pixels: at scale S
|
||||
# the window paints S x larger. This X session has no window manager, so
|
||||
# --kiosk/--start-fullscreen cannot snap an oversized window back to the panel
|
||||
# — it just hangs off the right/bottom edges (content cropped, background art
|
||||
# offscreen). Size the window in DIPs so DIPs x scale = the panel exactly.
|
||||
KIOSK_WIN_W=$(awk -v w="$KIOSK_MODE_W" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", w/s}')
|
||||
KIOSK_WIN_H=$(awk -v h="$KIOSK_MODE_H" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", h/s}')
|
||||
|
||||
xhost +SI:localuser:archipelago 2>/dev/null || true
|
||||
xsetroot -solid black 2>/dev/null || true
|
||||
xset s off 2>/dev/null || true
|
||||
@@ -106,12 +164,27 @@ fi
|
||||
ARCHIPELAGO_UID=$(id -u archipelago)
|
||||
|
||||
while true; do
|
||||
# A profile lock left by a previous boot encodes <hostname>-<pid>; after a
|
||||
# hostname change (node rename) Chromium reads it as another computer
|
||||
# holding the profile and refuses to start — with --noerrdialogs that is an
|
||||
# invisible failure and the kiosk black-screens forever. Any Chromium that
|
||||
# owned the lock is dead by now (pkill above / previous loop iteration).
|
||||
rm -f /var/lib/archipelago/chromium-kiosk/Singleton{Lock,Cookie,Socket}
|
||||
# XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio
|
||||
# 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).
|
||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
|
||||
# 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
|
||||
# no preference — which a minimal kiosk X session does. Two independent dark
|
||||
# signals so this survives a Chromium enum change: GTK_THEME is version-
|
||||
# independent and can never force light (worst case: no effect), and the
|
||||
# blink-settings flag (0 = kDark in modern Chromium) reinforces it. Neither
|
||||
# can regress the always-dark main UI.
|
||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago GTK_THEME=Adwaita:dark XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
|
||||
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
|
||||
--blink-settings=preferredColorScheme=0 \
|
||||
--noerrdialogs \
|
||||
--disable-infobars \
|
||||
--disable-translate \
|
||||
@@ -124,10 +197,10 @@ while true; do
|
||||
--disable-component-update \
|
||||
$GPU_FLAGS \
|
||||
--renderer-process-limit=2 \
|
||||
--window-size=${KIOSK_MODE_W},${KIOSK_MODE_H} \
|
||||
--window-size=${KIOSK_WIN_W},${KIOSK_WIN_H} \
|
||||
--window-position=0,0 \
|
||||
--start-fullscreen \
|
||||
--force-device-scale-factor=1 \
|
||||
--force-device-scale-factor=${KIOSK_SCALE} \
|
||||
--disable-background-networking \
|
||||
--disable-background-timer-throttling \
|
||||
--disable-backgrounding-occluded-windows \
|
||||
@@ -135,7 +208,7 @@ while true; do
|
||||
--disable-metrics \
|
||||
--disable-metrics-reporting \
|
||||
--disable-domain-reliability \
|
||||
--js-flags="--max-old-space-size=128" \
|
||||
--js-flags="--max-old-space-size=256" \
|
||||
--user-data-dir=/var/lib/archipelago/chromium-kiosk
|
||||
sleep 3
|
||||
done
|
||||
|
||||
@@ -25,6 +25,11 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown
|
||||
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
|
||||
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
|
||||
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
|
||||
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
|
||||
# in-binary post-OTA probe never runs — this restores the update-backup binary
|
||||
# after 5 failed start attempts while the pending-verify marker exists.
|
||||
# "-" so a missing/failed guard can never block the service itself.
|
||||
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
|
||||
ExecStart=/usr/local/bin/archipelago
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,9 @@ Description=Archipelago Private Nostr Relay
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=nostr-vpn.service
|
||||
# An ISO built without the relay binary (registry unreachable at build time)
|
||||
# must not crash-loop every 3s forever — skip cleanly instead.
|
||||
ConditionPathExists=/usr/local/bin/nostr-rs-relay
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -4,6 +4,9 @@ After=network-online.target tor.service archipelago.service
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=10
|
||||
# An ISO built without the nvpn binary (registry unreachable at build time)
|
||||
# must not restart-loop — skip cleanly instead.
|
||||
ConditionPathExists=/usr/local/bin/nvpn
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -103,27 +103,10 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
|
||||
# and rewrite its absolute asset paths (/assets, /, src, href) to the
|
||||
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
|
||||
location ^~ /app/indeedhub/ {
|
||||
proxy_pass https://indee.tx1138.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host indee.tx1138.com;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
sub_filter_types text/html text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/indeedhub/';
|
||||
sub_filter 'src="/' 'src="/app/indeedhub/';
|
||||
sub_filter "href='/" "href='/app/indeedhub/";
|
||||
sub_filter "src='/" "src='/app/indeedhub/";
|
||||
sub_filter 'from"/' 'from"/app/indeedhub/';
|
||||
sub_filter 'url(/' 'url(/app/indeedhub/';
|
||||
}
|
||||
# IndeeHub is no longer proxied same-origin — the sub_filter rewrite
|
||||
# approach broke the SPA's runtime-built asset URLs. The demo now opens
|
||||
# the real site (https://indee.tx1138.com/) externally instead, via
|
||||
# DEMO_EXTERNAL_URLS in useDemoIntro.ts.
|
||||
|
||||
# Mempool is NOT proxied upstream anymore — the mock backend serves a
|
||||
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
|
||||
@@ -187,8 +170,9 @@ http {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
# Cache static assets (media too — the intro video/audio are versioned
|
||||
# with ?v=N query busters, so immutable is safe)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|mp4|webm|mp3|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* End-to-end verification of the FIRST-VISIT cinematic:
|
||||
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
|
||||
* → Archipelago logo) → onboarding intro → login (video background, finale
|
||||
* armed) → dashboard full reveal (background zoom + interface assembly +
|
||||
* oomph) → welcome typing — and that a SECOND login stays deliberately
|
||||
* low-key.
|
||||
*
|
||||
* Run against the local demo stack:
|
||||
* DEMO=1 node mock-backend.js (port 5959)
|
||||
* VITE_DEMO=1 npx vite --port 8100
|
||||
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
|
||||
*
|
||||
* Audio can't be heard headless, so every HTMLMediaElement.play() and
|
||||
* WebAudio oscillator start is recorded into window.__audioLog via an init
|
||||
* script — the assertions check the right cues fired at the right phases.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
// Real Chrome (new headless): the bundled headless-shell has no working video
|
||||
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
|
||||
// meaningless there. Requires Google Chrome installed.
|
||||
test.use({ channel: 'chrome' })
|
||||
|
||||
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__audioLog: string[]
|
||||
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
|
||||
__revealSeen: { zoom?: boolean; glass?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
async function instrumentAudioAndVideo(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__audioLog = []
|
||||
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
|
||||
|
||||
// The dashboard reveal classes live only ~8s; a slow run can burn that
|
||||
// window between two sequential expect() polls. Record their appearance
|
||||
// the moment it happens instead, and let the test assert on the record.
|
||||
window.__revealSeen = {}
|
||||
new MutationObserver(() => {
|
||||
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
|
||||
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
const origPlay = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function (...args) {
|
||||
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
|
||||
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
|
||||
if (this.tagName === 'VIDEO') {
|
||||
const v = this as HTMLVideoElement
|
||||
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
|
||||
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
|
||||
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
|
||||
}
|
||||
return origPlay.apply(this, args)
|
||||
}
|
||||
|
||||
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
|
||||
const OrigOsc = OscillatorNode.prototype.start
|
||||
OscillatorNode.prototype.start = function (...args) {
|
||||
window.__audioLog.push('osc-start')
|
||||
return OrigOsc.apply(this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function freshVisit(page: Page) {
|
||||
await page.goto(BASE + '/')
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
|
||||
await page.goto(BASE + '/')
|
||||
}
|
||||
|
||||
/** Walk the full splash from tap-to-start through completion (real time). */
|
||||
async function runSplash(page: Page, { skip }: { skip: boolean }) {
|
||||
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
|
||||
// on click, so don't wait for post-click actionability)
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
|
||||
|
||||
// Phase 2: alien typing begins (first line types out)
|
||||
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
|
||||
.toBeVisible({ timeout: 10_000 })
|
||||
|
||||
if (skip) {
|
||||
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
|
||||
} else {
|
||||
// Let all four lines type out for real (~20s)
|
||||
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
|
||||
}
|
||||
|
||||
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
|
||||
// text is only on screen ~6s, so verify the phases via durable signals:
|
||||
// the video's live health here, and the audio log (speech/song) after.
|
||||
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
|
||||
// Wait for actual smooth playback (cold-cache buffering right after mount
|
||||
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
|
||||
await page.waitForFunction(() => {
|
||||
const v = document.querySelector('video')
|
||||
return !!v && v.readyState >= 3 && v.currentTime > 0.3
|
||||
}, { timeout: 20_000 })
|
||||
const s1 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')!
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
await page.waitForTimeout(2_000)
|
||||
const s2 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')
|
||||
if (!v) return null
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
expect(s2).not.toBeNull()
|
||||
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
|
||||
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
|
||||
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
|
||||
// Steady-state frame drops over the sample window (startup catch-up excluded).
|
||||
const dTotal = s2!.total - s1.total
|
||||
const dDropped = s2!.dropped - s1.dropped
|
||||
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
|
||||
|
||||
// Splash completes → demo routes to the onboarding intro
|
||||
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function enterDemoAndLogin(page: Page) {
|
||||
// The CTA unmounts mid-click when the router transitions away — dispatch
|
||||
// once, swallow the detach retry, and trust the URL change instead.
|
||||
const cta = page.getByRole('button', { name: /Enter the demo/ })
|
||||
await cta.waitFor({ timeout: 15_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/login', { timeout: 15_000 }),
|
||||
cta.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Demo prefills the password; the finale flag must be armed at this point.
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
|
||||
const loginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await loginBtn.waitFor({ timeout: 10_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
}
|
||||
|
||||
test.describe('first-visit cinematic', () => {
|
||||
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: false })
|
||||
|
||||
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
|
||||
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// FULL first-entry reveal: big background zoom + glass assembly classes
|
||||
// (recorded by the init-script observer the instant they appear — the
|
||||
// classes only live ~8s and sequential polling can miss the window).
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
|
||||
.toMatchObject({ zoom: true, glass: true })
|
||||
|
||||
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
|
||||
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The reveal runs 8s, then the zoom layer class clears.
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
|
||||
|
||||
// The dashboard oomph is WebAudio oscillators — at least the login pop +
|
||||
// oomph layers must have started after the splash's own sounds.
|
||||
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
|
||||
expect(oscCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: true })
|
||||
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
|
||||
// End the session the way logout does (auth token only — the intro/login
|
||||
// flags survive) and revisit login directly. An authenticated /login visit
|
||||
// would just bounce back to the dashboard via the router guard.
|
||||
await page.evaluate(() => localStorage.removeItem('neode-auth'))
|
||||
// Direct /login navigation (no splash — not a root boot) and re-login.
|
||||
await page.goto(BASE + '/login')
|
||||
await page.locator('#login-password').waitFor({ timeout: 15_000 })
|
||||
// First login happened → static rotated background, not the video.
|
||||
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
|
||||
|
||||
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
|
||||
// reset with the /login reload, so it would have caught even a transient
|
||||
// reveal since then.
|
||||
await page.waitForTimeout(1500)
|
||||
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// Reload at root = a fresh boot → the splash must return even though
|
||||
// neode_intro_seen is now set.
|
||||
await page.goto(BASE + '/')
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('video is warmed before the splash needs it', async ({ page }) => {
|
||||
await freshVisit(page)
|
||||
// The warm-up is a detached <video preload=auto> kept on window (Chromium
|
||||
// has no <link rel=preload as=video>). Give it a moment to buffer.
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => {
|
||||
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
|
||||
return w ? { src: w.src, readyState: w.readyState } : null
|
||||
}), { timeout: 15_000 })
|
||||
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
|
||||
const ready = await page.evaluate(() =>
|
||||
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
|
||||
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
|
||||
})
|
||||
})
|
||||
@@ -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': {
|
||||
@@ -2032,6 +2041,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
about: 'Self-sovereign Bitcoin node',
|
||||
nip05: 'satoshi@archipelago.local',
|
||||
lud16: 'satoshi@getalby.com',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2044,7 +2054,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
is_default: false,
|
||||
nostr_pubkey: 'f6e5d4c3b2a19876543210fedcba9876543210fedcba9876543210fedcba98',
|
||||
nostr_npub: 'npub1mockanonidentitypubkeyvalue000000000000000000000000000xyz',
|
||||
profile: { display_name: 'Anon' },
|
||||
profile: { display_name: 'Anon', picture: '/demo-avatars/anon.svg' },
|
||||
},
|
||||
{
|
||||
id: 'id-merchant',
|
||||
@@ -2056,7 +2066,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
is_default: false,
|
||||
nostr_pubkey: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
nostr_npub: 'npub1mockmerchantidentitypubkeyvalue000000000000000000000000def',
|
||||
profile: { display_name: 'My Shop', website: 'https://myshop.onion' },
|
||||
profile: { display_name: 'My Shop', website: 'https://myshop.onion', picture: '/demo-avatars/business.svg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2079,6 +2089,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
profile: {
|
||||
display_name: 'Archipelago Node',
|
||||
about: 'Self-sovereign Bitcoin node',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2121,6 +2132,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
about: 'Running a sovereign Bitcoin node',
|
||||
nip05: 'satoshi@archipelago.local',
|
||||
lud16: 'satoshi@getalby.com',
|
||||
picture: '/demo-avatars/satoshi.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2136,6 +2148,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
profile: {
|
||||
display_name: 'Archy Consulting',
|
||||
about: 'Bitcoin infrastructure services',
|
||||
picture: '/demo-avatars/business.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2146,7 +2159,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
did: 'did:key:z6MknGc3ocHs3zdPiJbnaaqDi5hjrZo4HzmQnwzaxWhAbWAs',
|
||||
created_at: '2026-03-01T18:00:00Z',
|
||||
is_default: false,
|
||||
profile: {},
|
||||
profile: { picture: '/demo-avatars/anon.svg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2163,7 +2176,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
nostr_pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
nostr_npub: 'npub1598eg0y7m08htfzjfmzv6zjvf5u7p00dr9w0yfamaxqhkwlryckq5dh9ee',
|
||||
is_default: true, created_at: '2026-01-10T08:00:00Z',
|
||||
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node' },
|
||||
profile: { display_name: 'Satoshi', about: 'Running a sovereign Bitcoin node', picture: '/demo-avatars/satoshi.svg' },
|
||||
},
|
||||
}
|
||||
return res.json({ result: identities[id] || identities['id-primary'] })
|
||||
@@ -2235,12 +2248,40 @@ app.post('/rpc/v1', (req, res) => {
|
||||
bytes_out: 642_889_310,
|
||||
} })
|
||||
}
|
||||
// Fake tunnel provisioning so the companion remote-access onboarding
|
||||
// (CompanionIntroOverlay) walks the same steps as a real node. The keys
|
||||
// are not real; importing this config yields a harmless dead tunnel.
|
||||
case 'vpn.list-peers': {
|
||||
return res.json({ result: { peers: mockState.vpnPeers || [] } })
|
||||
}
|
||||
case 'vpn.create-peer':
|
||||
case 'vpn.peer-config': {
|
||||
const peerName = params?.name || 'device'
|
||||
if (!mockState.vpnPeers) mockState.vpnPeers = []
|
||||
if (!mockState.vpnPeers.some((p) => p.name === peerName)) {
|
||||
mockState.vpnPeers.push({ name: peerName, ip: '10.44.0.7' })
|
||||
}
|
||||
const config = [
|
||||
'[Interface]',
|
||||
'PrivateKey = 2DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem0=',
|
||||
'Address = 10.44.0.7/32',
|
||||
'DNS = 1.1.1.1',
|
||||
'',
|
||||
'[Peer]',
|
||||
'PublicKey = 3DEMOdemoDEMOdemoDEMOdemoDEMOdemoDEMOdem1=',
|
||||
'Endpoint = demo.archipelago-foundation.org:51820',
|
||||
'AllowedIPs = 10.44.0.0/16',
|
||||
'PersistentKeepalive = 25',
|
||||
].join('\n')
|
||||
return res.json({ result: { config, peer_ip: '10.44.0.7' } })
|
||||
}
|
||||
|
||||
// Node visibility — interactive (persisted per demo session)
|
||||
case 'network.get-visibility': {
|
||||
// No onion_address in the demo: the public showcase shouldn't display
|
||||
// a Tor address (even a fake one) on the Node Visibility card.
|
||||
return res.json({ result: {
|
||||
visibility: mockState.nodeVisibility,
|
||||
onion_address: mockData['server-info']['tor-address'],
|
||||
} })
|
||||
}
|
||||
case 'network.set-visibility': {
|
||||
@@ -2248,7 +2289,6 @@ app.post('/rpc/v1', (req, res) => {
|
||||
if (['hidden', 'discoverable', 'public'].includes(v)) mockState.nodeVisibility = v
|
||||
return res.json({ result: {
|
||||
visibility: mockState.nodeVisibility,
|
||||
onion_address: mockData['server-info']['tor-address'],
|
||||
} })
|
||||
}
|
||||
|
||||
@@ -2351,6 +2391,23 @@ app.post('/rpc/v1', (req, res) => {
|
||||
case 'streaming.list-services': {
|
||||
return res.json({ result: { services: mockState.streamingServices || [] } })
|
||||
}
|
||||
case 'streaming.list-sessions': {
|
||||
const mkTime = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
|
||||
return res.json({ result: {
|
||||
sessions: [
|
||||
{ id: 'sess-demo-1', peer_id: 'npub1walker…k3u9', service_id: 'content-download', metric: 'bytes', allotment: 524_288_000, used: 231_211_008, paid_sats: 500, created_at: mkTime(134), last_topup_at: mkTime(22), expires_at: '', active: true },
|
||||
{ id: 'sess-demo-2', peer_id: 'npub1sailor…m2xq', service_id: 'nostr-relay', metric: 'milliseconds', allotment: 10_800_000, used: 4_920_000, paid_sats: 30, created_at: mkTime(82), last_topup_at: mkTime(82), expires_at: mkTime(-98), active: true },
|
||||
{ id: 'sess-demo-3', peer_id: 'npub1nomad…t7rd', service_id: 'content-download', metric: 'bytes', allotment: 104_857_600, used: 88_080_384, paid_sats: 100, created_at: mkTime(9), last_topup_at: mkTime(9), expires_at: '', active: true },
|
||||
],
|
||||
total_active: 3,
|
||||
total_revenue_sats: 770_000,
|
||||
revenue_by_service: {
|
||||
'content-download': 512_400,
|
||||
'nostr-relay': 201_600,
|
||||
'api-access': 56_000,
|
||||
},
|
||||
} })
|
||||
}
|
||||
case 'streaming.configure-service': {
|
||||
const p = params || {}
|
||||
const list = mockState.streamingServices || []
|
||||
@@ -3363,14 +3420,19 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'lnd.listchannels': {
|
||||
// Shape matches the real backend: status + channel_point are required
|
||||
// by the channels panel; totals feed the liquidity summary tiles.
|
||||
const channels = [
|
||||
{ chan_id: '840921088114688', remote_pubkey: '031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581', capacity: 1500000, local_balance: 950000, remote_balance: 550000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Olympus by ZEUS' },
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
||||
]
|
||||
return res.json({
|
||||
result: {
|
||||
channels: [
|
||||
{ chan_id: '840921088114688', remote_pubkey: '02778f4a', capacity: 5000000, local_balance: 2450000, remote_balance: 2550000, active: true, peer_alias: 'ACINQ Signet' },
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: true, peer_alias: 'Kraken' },
|
||||
],
|
||||
channels,
|
||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3414,7 +3476,10 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'lnd.sendcoins': {
|
||||
const amt = params?.amount || params?.amt || 50000
|
||||
// send_all sweeps the entire on-chain balance (minus a mock fee)
|
||||
const amt = params?.send_all
|
||||
? Math.max(0, walletState.onchain_sats - 250)
|
||||
: (params?.amount || params?.amt || 50000)
|
||||
walletState.onchain_sats = Math.max(0, walletState.onchain_sats - amt)
|
||||
const txid = randomHex(32)
|
||||
walletState.transactions.unshift({
|
||||
@@ -3535,13 +3600,32 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'wallet.networking-profits': {
|
||||
// Deterministic-but-varied week of profit events for the dashboard
|
||||
// chart (source/timestamp/sats mirror wallet::profits::ProfitEntry).
|
||||
const profitSources = ['streaming_revenue', 'content_sale', 'routing_fee']
|
||||
const profitNotes = ['Paid streaming session', 'Ecash content sale', 'Lightning routing fees']
|
||||
const recent = []
|
||||
const nowMs = Date.now()
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const daysAgo = (i * 3) % 7
|
||||
const hour = (i * 5) % 24
|
||||
recent.push({
|
||||
source: profitSources[i % 3],
|
||||
amount_sats: 800 + ((i * 7919) % 14000),
|
||||
timestamp: new Date(nowMs - daysAgo * 86_400_000 - hour * 3_600_000).toISOString(),
|
||||
description: profitNotes[i % 3],
|
||||
})
|
||||
}
|
||||
recent.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
return res.json({
|
||||
result: {
|
||||
total_sats: 5_231_978,
|
||||
content_sales_sats: 3_180_000,
|
||||
routing_fees_sats: 1_281_978,
|
||||
relay_sats: 770_000,
|
||||
streaming_revenue_sats: 770_000,
|
||||
recent,
|
||||
// legacy aliases kept for older UI builds
|
||||
relay_sats: 770_000,
|
||||
total_earned_sats: 5_231_978,
|
||||
total_forwarded_sats: 1_281_978,
|
||||
forward_count: 1284,
|
||||
@@ -3576,15 +3660,29 @@ app.post('/rpc/v1', (req, res) => {
|
||||
}
|
||||
|
||||
case 'bitcoin.getinfo': {
|
||||
// Demo IBD simulation: the first call of a session arms a ~90s ramp
|
||||
// from 98.2% → 100% so the setup wizard can demo the live sync timer
|
||||
// and the "finish setup" toast that fires when IBD completes.
|
||||
// (The real backend returns { block_height, sync_progress } — a 0–1
|
||||
// fraction — which is what the frontend reads; the bitcoin-core-style
|
||||
// fields are kept for any legacy consumers.)
|
||||
if (!walletState.ibd_started_at) walletState.ibd_started_at = Date.now()
|
||||
const IBD_RAMP_MS = 90_000
|
||||
const elapsed = Date.now() - walletState.ibd_started_at
|
||||
const syncProgress = Math.min(1, 0.982 + 0.018 * (elapsed / IBD_RAMP_MS))
|
||||
const tipHeight = 892451
|
||||
const height = Math.round(tipHeight * syncProgress)
|
||||
return res.json({
|
||||
result: {
|
||||
chain: 'signet',
|
||||
blocks: 892451,
|
||||
headers: 892451,
|
||||
block_height: height,
|
||||
sync_progress: syncProgress,
|
||||
blocks: height,
|
||||
headers: tipHeight,
|
||||
bestblockhash: 'a1b2c3d4e5f6' + '0'.repeat(58),
|
||||
difficulty: 0.001126515290698186,
|
||||
mediantime: Math.floor(Date.now() / 1000) - 300,
|
||||
verificationprogress: 1.0,
|
||||
verificationprogress: syncProgress,
|
||||
chainwork: '000000000000000000000000000000000000000000000000000000000001a2b3',
|
||||
size_on_disk: 210_000_000,
|
||||
pruned: false,
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.100-alpha",
|
||||
"version": "1.7.111-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.100-alpha",
|
||||
"version": "1.7.111-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrloop": "^1.4.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
@@ -3896,6 +3899,12 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
@@ -4727,7 +4736,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4801,6 +4809,31 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -4901,10 +4934,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4922,7 +4954,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
@@ -5099,6 +5131,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/charenc": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
|
||||
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
@@ -5499,6 +5540,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypt": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
|
||||
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
||||
@@ -7408,7 +7458,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7564,6 +7613,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
|
||||
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
@@ -8358,6 +8413,17 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/md5": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
|
||||
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"charenc": "0.0.2",
|
||||
"crypt": "0.0.2",
|
||||
"is-buffer": "~1.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
@@ -9193,6 +9259,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qr-scanner": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
|
||||
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/offscreencanvas": "^2019.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
@@ -9317,6 +9392,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrloop": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/qrloop/-/qrloop-1.4.1.tgz",
|
||||
"integrity": "sha512-LXkwCl1Qd8imTHb+KqjMn+cHmuncyFT81AXoooWJvbG3+g9q61l9udSRPgY4cgl+5goQHuAK4teEdUF6UErYXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"md5": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.100-alpha",
|
||||
"version": "1.7.111-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -26,13 +26,16 @@
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrloop": "^1.4.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Pine">
|
||||
<rect width="512" height="512" fill="#ffffff"/>
|
||||
<g fill="#000000">
|
||||
<!-- trunk -->
|
||||
<rect x="236" y="396" width="40" height="72" rx="6"/>
|
||||
<!-- three tiers of the evergreen, top to bottom -->
|
||||
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
|
||||
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
|
||||
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -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": [
|
||||
@@ -370,6 +370,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||
"repoUrl": "https://github.com/rhasspy/wyoming"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#7e22ce;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#231133;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#d8b4fe;stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- hooded figure, face in shadow -->
|
||||
<path d="M120 44 C86 44 72 76 72 106 L72 132 L168 132 L168 106 C168 76 154 44 120 44 Z" fill="url(#fig)"/>
|
||||
<ellipse cx="120" cy="104" rx="30" ry="34" fill="rgba(35,17,51,0.9)"/>
|
||||
<path d="M45 240 C45 182 78 150 120 150 C162 150 195 182 195 240 Z" fill="url(#fig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1000 B |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#c2540a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b1c0a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:#fdba74;stop-opacity:0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- head + suited shoulders -->
|
||||
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
|
||||
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
|
||||
<!-- collar + tie -->
|
||||
<path d="M104 156 L120 176 L136 156 L120 148 Z" fill="rgba(59,28,10,0.85)"/>
|
||||
<path d="M115 176 L125 176 L123 214 L120 220 L117 214 Z" fill="rgba(59,28,10,0.85)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1d4ed8;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#16213e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fig" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:#93c5fd;stop-opacity:0.55" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="240" fill="url(#bg)"/>
|
||||
<circle cx="120" cy="78" r="70" fill="rgba(255,255,255,0.06)"/>
|
||||
<!-- head + shoulders -->
|
||||
<circle cx="120" cy="92" r="38" fill="url(#fig)"/>
|
||||
<path d="M45 240 C45 178 78 152 120 152 C162 152 195 178 195 240 Z" fill="url(#fig)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 860 B |
@@ -124,6 +124,20 @@ function syncKioskSafeArea() {
|
||||
if (typeof document === 'undefined') return
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
// Very first kiosk boot: the launcher opens /kiosk before the route
|
||||
// guard has had a chance to persist localStorage.kiosk.
|
||||
|| window.location.pathname === '/kiosk'
|
||||
// Global styling hook: kiosk chromium runs software-composited
|
||||
// (--in-process-gpu / --disable-gpu), where heavy compositing effects
|
||||
// (3D bg layers, animated mix-blend overlays) fail and paint black.
|
||||
document.documentElement.classList.toggle('kiosk-mode', isKiosk)
|
||||
if (isKiosk) {
|
||||
// AIUI (same-origin iframe) themes from localStorage['aiui-theme'], and a
|
||||
// stored value beats its prefers-color-scheme fallback. Kiosk profiles
|
||||
// that ran before the launcher forced a dark color-scheme have "light"
|
||||
// baked in — white panels on an otherwise dark TV UI — so pin it dark.
|
||||
try { localStorage.setItem('aiui-theme', 'dark') } catch { /* storage full/denied */ }
|
||||
}
|
||||
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
|
||||
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
|
||||
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
|
||||
@@ -145,13 +159,12 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
|
||||
if (authenticated) {
|
||||
messageToast.startPolling()
|
||||
screensaverStore.resetInactivityTimer()
|
||||
// Don't start relay on kiosk — kiosk gets input via xdotool (system-level),
|
||||
// relay would duplicate every keystroke/click as DOM events
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
if (!isKiosk) {
|
||||
startRemoteRelay()
|
||||
}
|
||||
// Kiosk included: the backend's remote-input path is relay-only (it
|
||||
// validates and broadcasts; it never runs xdotool), so the browser-side
|
||||
// relay is the ONLY consumer of companion input. Skipping it on kiosk —
|
||||
// an assumption from a long-gone system-level xdotool injector — left
|
||||
// TVs deaf to the companion remote.
|
||||
startRemoteRelay()
|
||||
} else {
|
||||
messageToast.stopPolling()
|
||||
toastMessage.value = { show: false, text: '', fromPubkey: '' }
|
||||
@@ -400,19 +413,51 @@ onMounted(async () => {
|
||||
// onboarded node).
|
||||
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
|
||||
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
|
||||
// The route object still holds the router's START_LOCATION ('/') here —
|
||||
// the initial navigation resolves asynchronously — so a deep-route load
|
||||
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
|
||||
// bar is the only truthful source this early.
|
||||
//
|
||||
// The kiosk chromium opens /kiosk — a marker route whose beforeEnter
|
||||
// stamps localStorage.kiosk and immediately redirects to '/'. That is a
|
||||
// root boot, not a deep route: without normalizing it, the fresh-install
|
||||
// kiosk never plays the typing intro (deep-route suppression regression).
|
||||
const rawBootPath = window.location.pathname
|
||||
const bootPath = rawBootPath === '/kiosk' ? '/' : rawBootPath
|
||||
// Public demo: every fresh boot at the root (first visit or a browser
|
||||
// refresh) starts with the typing splash for the full effect. In-session SPA
|
||||
// navigation never remounts App, and deep-route refreshes keep their place.
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO && route.path === '/') replayRequested = true
|
||||
if (IS_DEMO && bootPath === '/') replayRequested = true
|
||||
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
const splashCandidate = !seenIntro
|
||||
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
|
||||
// Root boots always ask the backend — even when this browser thinks it has
|
||||
// seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete`
|
||||
// are per-origin browser state: after a reinstall (or another node coming
|
||||
// up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute
|
||||
// a fresh install's intro / misroute it to login.
|
||||
const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')
|
||||
|
||||
if (splashCandidate && onboardingComplete !== true) {
|
||||
if (splashCandidate) {
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
onboardingComplete = await checkOnboardingStatus()
|
||||
// Bound the pre-splash status check: its retry ladder can spend ~30s
|
||||
// against a still-booting backend, and this await holds the black
|
||||
// "!isReady" screen the whole time — exactly where the typing intro
|
||||
// should be playing on a fresh kiosk boot. Unknown within 2.5s → let
|
||||
// the splash play (a fresh install IS the slow-backend case; onboarded
|
||||
// nodes answer in milliseconds, so their suppression path is intact).
|
||||
// handleSplashComplete re-checks with full retries after the intro.
|
||||
const live = await Promise.race([
|
||||
checkOnboardingStatus(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
|
||||
])
|
||||
if (live !== null) onboardingComplete = live
|
||||
if (live === false && seenIntro) {
|
||||
// Backend-confirmed fresh node behind a browser with a stale flag —
|
||||
// drop it so this boot (and every later one) plays the intro.
|
||||
try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ }
|
||||
seenIntro = false
|
||||
}
|
||||
} catch {
|
||||
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
|
||||
}
|
||||
@@ -427,12 +472,30 @@ onMounted(async () => {
|
||||
|
||||
if (shouldShowIntroSplash({
|
||||
seenIntro,
|
||||
routePath: route.path,
|
||||
routePath: bootPath,
|
||||
fromBoot,
|
||||
devMode: import.meta.env.VITE_DEV_MODE,
|
||||
onboardingComplete,
|
||||
replayRequested,
|
||||
})) {
|
||||
// The intro is definitely playing — unmute the whole cinematic (speech,
|
||||
// synthwave, pops) even on browsers whose localStorage says onboarding is
|
||||
// complete; the sound gate otherwise silences replays.
|
||||
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
|
||||
enableCinematicSounds()
|
||||
// Kick off the intro video download NOW: the splash's <video> element only
|
||||
// mounts ~20s in (after the typing sequence), and on a cold cache the file
|
||||
// would otherwise start fetching mid-sequence and stutter. A detached
|
||||
// <video preload=auto> is used because Chromium does not support
|
||||
// <link rel=preload as=video> — the media cache then serves the splash's
|
||||
// element, which uses the identical URL.
|
||||
try {
|
||||
const warm = document.createElement('video')
|
||||
warm.muted = true
|
||||
warm.preload = 'auto'
|
||||
warm.src = '/assets/video/video-intro.mp4?v=8'
|
||||
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
|
||||
} catch { /* ignore */ }
|
||||
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
|
||||
showSplash.value = true
|
||||
} else {
|
||||
@@ -494,42 +557,61 @@ function onShareToMeshMessage(ev: MessageEvent) {
|
||||
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||
*/
|
||||
async function handleSplashComplete() {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
sessionStorage.setItem('archipelago_from_splash', '1')
|
||||
|
||||
// Commit the destination route BEFORE revealing RouterView. Revealing
|
||||
// first rendered whatever route was current — '/' → RootRedirect's
|
||||
// spinner — for a beat between the intro's final frame and the
|
||||
// onboarding/login screen actually mounting: a visible flash on every
|
||||
// fresh install. Await the push so the first RouterView render is
|
||||
// already the destination.
|
||||
const reveal = () => {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.push('/login').catch(() => {})
|
||||
await router.push('/login').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
|
||||
// Demo: the cinematic always continues into the onboarding intro page —
|
||||
// the mock backend reports "onboarded", which would otherwise route
|
||||
// straight to /login and cut the sequence short.
|
||||
{
|
||||
const { IS_DEMO } = await import('@/composables/useDemoIntro')
|
||||
if (IS_DEMO) {
|
||||
await router.push('/onboarding/intro').catch(() => {})
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
|
||||
const seenOnboarding = await checkOnboardingStatus()
|
||||
if (seenOnboarding === true) {
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
if (seenOnboarding === false) {
|
||||
router.push('/onboarding/intro').catch(() => {})
|
||||
return
|
||||
}
|
||||
// Backend unreachable after retries. Prefer the localStorage
|
||||
// cache on THIS browser (if a prior successful check set it) —
|
||||
// otherwise defer to RootRedirect which polls + retries rather
|
||||
// than forcing an already-onboarded user through the wizard.
|
||||
if (localStorage.getItem('neode_onboarding_complete') === '1') {
|
||||
router.push('/login').catch(() => {})
|
||||
await router.push('/login').catch(() => {})
|
||||
} else if (seenOnboarding === false) {
|
||||
await router.push('/onboarding/intro').catch(() => {})
|
||||
} else if (localStorage.getItem('neode_onboarding_complete') === '1') {
|
||||
// Backend unreachable after retries. Prefer the localStorage
|
||||
// cache on THIS browser (if a prior successful check set it) —
|
||||
// otherwise defer to RootRedirect which polls + retries rather
|
||||
// than forcing an already-onboarded user through the wizard.
|
||||
await router.push('/login').catch(() => {})
|
||||
} else {
|
||||
router.push('/').catch(() => {})
|
||||
await router.push('/').catch(() => {})
|
||||
}
|
||||
} catch {
|
||||
// Do NOT default to /onboarding/intro here. RootRedirect has retry
|
||||
// + polling + boot-screen handling; let it decide.
|
||||
router.push('/').catch(() => {})
|
||||
await router.push('/').catch(() => {})
|
||||
}
|
||||
reveal()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,66 +8,248 @@
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||
<div
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
|
||||
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0 overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors z-10"
|
||||
aria-label="Close companion modal"
|
||||
@click="dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Transition :name="slideName" mode="out-in">
|
||||
<!-- Screen 1: get the app -->
|
||||
<div v-if="step === 'download'" key="download">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
|
||||
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
|
||||
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
|
||||
<circle cx="16" cy="11" r="1.2" fill="currentColor" />
|
||||
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download companion app
|
||||
</a>
|
||||
<div class="hidden md:flex justify-center">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="Companion app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
<div class="hidden md:flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="qrDataUrl"
|
||||
:src="qrDataUrl"
|
||||
alt="Companion app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download app
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="advanceFromDownload"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="companionDownloadUrl"
|
||||
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Download companion app
|
||||
</a>
|
||||
<!-- Screen 2 (remote access): install WireGuard -->
|
||||
<div v-else-if="step === 'wireguard'" key="wireguard">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.5 12l1.8 1.8L14.8 10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Install WireGuard for remote access</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
WireGuard gives your phone a private tunnel to this node, so the companion app works away from home too.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex justify-center mb-4">
|
||||
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="wgApkQrDataUrl"
|
||||
:src="wgApkQrDataUrl"
|
||||
alt="WireGuard app download QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="wireguardDownloadUrl"
|
||||
class="flex-1 inline-flex items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-3 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors text-center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
>
|
||||
Download WireGuard
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/5 border border-white/15 px-3 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showWgQrScreen"
|
||||
>
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full mt-2 py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showDownloadScreen"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Screen 3 (remote access): scan the tunnel config in WireGuard -->
|
||||
<div v-else-if="step === 'wgqr'" key="wgqr">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v8m-4-4h8" />
|
||||
<rect x="3.5" y="3.5" width="17" height="17" rx="4" stroke-width="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Connect the tunnel</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
In WireGuard, tap <strong>+</strong>, scan this code, then switch the new tunnel on.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-3">
|
||||
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="wgQrDataUrl"
|
||||
:src="wgQrDataUrl"
|
||||
alt="WireGuard tunnel config QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5 flex items-center justify-center">
|
||||
<svg v-if="wgLoading" class="w-6 h-6 animate-spin text-white/40" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wgError" class="text-xs text-red-400 text-center mb-3">{{ wgError }}</p>
|
||||
<button
|
||||
v-if="wgError && !wgLoading"
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-3"
|
||||
@click="retryWgPeer"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
|
||||
<!-- Same-device path: a phone can't scan its own screen, so offer
|
||||
the config as a file WireGuard can import. -->
|
||||
<button
|
||||
v-if="wgConfig"
|
||||
type="button"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-white/5 border border-white/15 px-4 py-2.5 text-sm font-medium text-white/80 hover:bg-white/10 hover:text-white transition-colors mb-2"
|
||||
@click="downloadWgConfig"
|
||||
>
|
||||
Download tunnel config
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors mb-2"
|
||||
@click="showPairScreen"
|
||||
>
|
||||
I'm connected
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showWireguardScreen('back')"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Screen 2: pair the app with this node -->
|
||||
<div v-else key="pair">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<rect x="13" y="4" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5" stroke-width="1.5" />
|
||||
<path d="M13 13h3v3h-3zM17 17h3v3h-3z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">Connect your app</h3>
|
||||
<p class="text-sm text-white/60 leading-relaxed">
|
||||
In the companion app, choose “Scan Node's QR” and point your phone here — the server details fill in automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<!-- Bigger than the download QR: this one is scanned by the
|
||||
companion app camera, and physical size drives decode. -->
|
||||
<div class="w-[192px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
|
||||
<img
|
||||
v-if="pairQrDataUrl"
|
||||
:src="pairQrDataUrl"
|
||||
alt="Companion app pairing QR code"
|
||||
class="block w-full max-w-full h-auto rounded-lg bg-white"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Same-device path: a QR on the phone's own screen can't be
|
||||
scanned, so offer the deep link directly on small screens. -->
|
||||
<a
|
||||
v-if="pairingUrl"
|
||||
:href="pairingUrl"
|
||||
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors mb-2"
|
||||
>
|
||||
Open in companion app
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 rounded-lg bg-white/5 border border-white/15 text-white/80 text-sm font-medium hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="backFromPair"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -75,9 +257,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import { IS_DEMO, DEMO_PASSWORD } from '@/composables/useDemoIntro'
|
||||
import { companionIntroRequested } from '@/composables/useCompanionIntro'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
@@ -89,36 +274,322 @@ const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
|
||||
// Deep-link scheme the companion app registers; carries the server entry the
|
||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||
const PAIR_SCHEME = 'archipelago://pair'
|
||||
const DEMO_SERVER_URL = 'https://demo.archipelago-foundation.org'
|
||||
|
||||
// Remote-access onboarding: inserts the WireGuard install + tunnel-QR steps
|
||||
// between "I've installed it" and the pairing QR. Real nodes pair against the
|
||||
// node's VPN address instead of the LAN origin; the demo walks the same steps
|
||||
// with a mock tunnel config (its pairing QR still carries the public demo
|
||||
// URL). Set to false to restore the original two-screen flow (instant revert).
|
||||
const REMOTE_ACCESS_STEPS = true
|
||||
|
||||
// The WireGuard peer config only routes the VPN subnet (AllowedIPs
|
||||
// 10.44.0.0/16 — core/archipelago/src/api/rpc/vpn.rs), so away from the LAN
|
||||
// the node is reachable exclusively at its tunnel address. The pairing QR
|
||||
// must therefore advertise this, not the browser's LAN origin. The flow has
|
||||
// the user switch the tunnel on right before pairing, so it validates.
|
||||
const VPN_NODE_URL = 'http://10.44.0.1'
|
||||
|
||||
// Official WireGuard Android app (com.wireguard.android 1.0.20260315,
|
||||
// sha256 f92971bc…, mirrored from download.wireguard.com), served like the
|
||||
// companion APK: gitea raw-on-main for nodes, own origin for the demo.
|
||||
const WIREGUARD_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/wireguard.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/wireguard.apk'
|
||||
|
||||
// Name of the auto-created VPN peer for the phone running the companion.
|
||||
const WG_PEER_NAME = 'companion-phone'
|
||||
|
||||
const visible = ref(false)
|
||||
const step = ref<'download' | 'wireguard' | 'wgqr' | 'pair'>('download')
|
||||
const slideName = ref('slide-forward')
|
||||
const qrDataUrl = ref('')
|
||||
const pairQrDataUrl = ref('')
|
||||
const pairingUrl = ref('')
|
||||
const wgApkQrDataUrl = ref('')
|
||||
const wgQrDataUrl = ref('')
|
||||
const wgConfig = ref('')
|
||||
const wgLoading = ref(false)
|
||||
const wgError = ref('')
|
||||
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
|
||||
const wireguardDownloadUrl = import.meta.env.VITE_WIREGUARD_APK_URL || WIREGUARD_DOWNLOAD_URL
|
||||
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
// Base delay before the popup may appear, and extra breathing room after the
|
||||
// dashboard entrance cinematic ends so the popup never cuts into the reveal.
|
||||
const BASE_DELAY_MS = 5000
|
||||
const POST_INTRO_GRACE_MS = 2000
|
||||
|
||||
let calmTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Running inside the companion app's own WebView (it injects this JS bridge).
|
||||
// The "get the companion app" pitch is nonsense there — the user is already in
|
||||
// it — and the WG steps mid-pairing race the phone's changing network (the
|
||||
// "Failed to fetch" dead-end QR). Server/tunnel management for connected
|
||||
// companions lives in the NESMenu instead.
|
||||
const IN_COMPANION_APP = typeof (window as { ArchipelagoNative?: unknown }).ArchipelagoNative !== 'undefined'
|
||||
|
||||
onMounted(() => {
|
||||
if (IN_COMPANION_APP) return
|
||||
try {
|
||||
if (localStorage.getItem(STORAGE_KEY) !== '1') {
|
||||
// Delay slightly so it doesn't compete with login animation
|
||||
setTimeout(() => { visible.value = true }, 5000)
|
||||
setTimeout(maybeShow, BASE_DELAY_MS)
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
})
|
||||
|
||||
function maybeShow() {
|
||||
// Show only after the scene has been CONTINUOUSLY calm (no reveal
|
||||
// cinematic) for the full grace window. The previous point-in-time check
|
||||
// raced a slow-starting reveal — on a cold cache the entrance video can
|
||||
// begin buffering after the 5s base delay, so the flag was still false
|
||||
// when sampled and the popup cut straight into the cinematic.
|
||||
let calmSince = loginTransition.introCinematicPlaying ? null : Date.now()
|
||||
calmTicker = setInterval(() => {
|
||||
if (loginTransition.introCinematicPlaying) {
|
||||
calmSince = null
|
||||
return
|
||||
}
|
||||
if (calmSince === null) calmSince = Date.now()
|
||||
if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) {
|
||||
if (calmTicker) clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
visible.value = true
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// Manual open (App Store banner etc.) — ignores the once-per-browser gate.
|
||||
watch(companionIntroRequested, (requested) => {
|
||||
if (!requested) return
|
||||
companionIntroRequested.value = false
|
||||
if (calmTicker) {
|
||||
clearInterval(calmTicker)
|
||||
calmTicker = null
|
||||
}
|
||||
step.value = 'download'
|
||||
visible.value = true
|
||||
})
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (!isVisible) return
|
||||
// Generate large and let CSS scale down — at 112px source a ~45-module QR
|
||||
// is 2.5px/module, which camera decoders (the companion app included)
|
||||
// routinely fail on. 512px keeps every module crisp.
|
||||
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
|
||||
width: 112,
|
||||
margin: 1,
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
if (REMOTE_ACCESS_STEPS && !wgApkQrDataUrl.value) {
|
||||
wgApkQrDataUrl.value = await QRCode.toDataURL(wireguardDownloadUrl, {
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
/**
|
||||
* The server URL the companion app should connect to. The demo advertises its
|
||||
* public https origin; a real node advertises whatever address the browser is
|
||||
* already using — except on the kiosk, where that is localhost and useless to
|
||||
* a phone, so fall back to the node's mDNS .local name.
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
// Remote-access flow: the app must connect through the tunnel the user just
|
||||
// switched on — the LAN origin is unreachable from outside AllowedIPs.
|
||||
if (REMOTE_ACCESS_STEPS) return VPN_NODE_URL
|
||||
const { hostname, origin } = window.location
|
||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
|
||||
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
||||
} catch {
|
||||
// RPC unavailable — fall through to the (localhost) origin
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
async function buildPairingUrl(): Promise<string> {
|
||||
const params = new URLSearchParams({ v: '1', url: await resolveServerUrl() })
|
||||
// Only the shared demo password ever rides in the QR; real node passwords
|
||||
// are never available to the frontend and stay typed-by-hand in the app.
|
||||
if (IS_DEMO) params.set('pw', DEMO_PASSWORD)
|
||||
return `${PAIR_SCHEME}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function showPairScreen() {
|
||||
slideName.value = 'slide-forward'
|
||||
step.value = 'pair'
|
||||
if (!pairQrDataUrl.value) {
|
||||
pairingUrl.value = await buildPairingUrl()
|
||||
// Large source + a real quiet zone; this QR is scanned by the companion
|
||||
// app's camera, so give it every advantage (see download QR note above).
|
||||
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function showDownloadScreen() {
|
||||
slideName.value = 'slide-back'
|
||||
step.value = 'download'
|
||||
}
|
||||
|
||||
function advanceFromDownload() {
|
||||
if (REMOTE_ACCESS_STEPS) showWireguardScreen('forward')
|
||||
else void showPairScreen()
|
||||
}
|
||||
|
||||
function showWireguardScreen(direction: 'forward' | 'back' = 'forward') {
|
||||
slideName.value = direction === 'forward' ? 'slide-forward' : 'slide-back'
|
||||
step.value = 'wireguard'
|
||||
}
|
||||
|
||||
function showWgQrScreen() {
|
||||
slideName.value = 'slide-forward'
|
||||
step.value = 'wgqr'
|
||||
void loadWgPeer()
|
||||
}
|
||||
|
||||
function backFromPair() {
|
||||
if (REMOTE_ACCESS_STEPS) {
|
||||
slideName.value = 'slide-back'
|
||||
step.value = 'wgqr'
|
||||
} else {
|
||||
showDownloadScreen()
|
||||
}
|
||||
}
|
||||
|
||||
// Network-class failures: the node itself was unreachable (as opposed to the
|
||||
// backend answering with an RPC error). Includes the client's own timeout.
|
||||
const WG_NETWORK_ERR = /failed to fetch|networkerror|load failed|abort|request timeout/i
|
||||
|
||||
// Auto-retry ladder for network-class failures. On a first install this step
|
||||
// is often reached while the backend is still settling (services starting,
|
||||
// backend restarting during container orchestration) — a single failed fetch
|
||||
// left a permanently blank QR unless the user spotted the retry button.
|
||||
const WG_RETRY_DELAYS_MS = [2000, 4000, 8000]
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
const stillOnWgStep = () => visible.value && step.value === 'wgqr'
|
||||
|
||||
// Create (or fetch) the phone's VPN peer and render its config as a QR.
|
||||
// Reuses the same RPCs as the Server page's Add Device modal; the peer is
|
||||
// looked up first so reopening the modal never duplicates it.
|
||||
async function loadWgPeer() {
|
||||
if (wgQrDataUrl.value || wgLoading.value) return
|
||||
wgLoading.value = true
|
||||
wgError.value = ''
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await provisionWgPeer()
|
||||
break
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : ''
|
||||
const isNetworkErr = WG_NETWORK_ERR.test(raw)
|
||||
const retryDelay = WG_RETRY_DELAYS_MS[attempt]
|
||||
if (isNetworkErr && retryDelay !== undefined && stillOnWgStep()) {
|
||||
await sleep(retryDelay)
|
||||
if (stillOnWgStep()) continue
|
||||
}
|
||||
// fetch()'s raw "Failed to fetch" means the node itself was unreachable —
|
||||
// after the retry ladder that's usually the phone's network mid-change
|
||||
// (WiFi drop, or a half-configured tunnel already routing 10.44.0.0/16).
|
||||
// Say so, and leave a Retry path instead of a dead end.
|
||||
wgError.value = isNetworkErr
|
||||
? "Can't reach your node. Check the phone is on the same network as the node (and any half-set-up tunnel is switched off), then tap Try again."
|
||||
: raw || 'Failed to generate the tunnel config'
|
||||
break
|
||||
}
|
||||
}
|
||||
wgLoading.value = false
|
||||
}
|
||||
|
||||
async function provisionWgPeer() {
|
||||
const listed = await rpcClient
|
||||
.call<{ peers: { name: string }[] }>({ method: 'vpn.list-peers' })
|
||||
.catch(() => null)
|
||||
// list-peers unreachable → don't guess "doesn't exist": create-peer on an
|
||||
// existing name would fail. Try create first, fall back to peer-config.
|
||||
const exists = listed === null
|
||||
? null
|
||||
: (listed.peers || []).some((p) => p.name === WG_PEER_NAME)
|
||||
let res: { config: string; peer_ip: string }
|
||||
if (exists === true) {
|
||||
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
|
||||
} else {
|
||||
try {
|
||||
res = await rpcClient.call({ method: 'vpn.create-peer', params: { name: WG_PEER_NAME } })
|
||||
} catch (e) {
|
||||
// Peer already provisioned on a previous visit (list failed or raced).
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
if (/exist|duplicate/i.test(msg)) {
|
||||
res = await rpcClient.call({ method: 'vpn.peer-config', params: { name: WG_PEER_NAME } })
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
wgConfig.value = res.config
|
||||
wgQrDataUrl.value = await QRCode.toDataURL(res.config, {
|
||||
width: 512,
|
||||
margin: 3,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111111',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function retryWgPeer() {
|
||||
wgError.value = ''
|
||||
void loadWgPeer()
|
||||
}
|
||||
|
||||
// Same-device path: hand the config to the WireGuard app as an importable
|
||||
// .conf file (a phone can't scan its own screen).
|
||||
function downloadWgConfig() {
|
||||
if (!wgConfig.value) return
|
||||
const blob = new Blob([wgConfig.value], { type: 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'archipelago.conf'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
visible.value = false
|
||||
step.value = 'download'
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, '1')
|
||||
} catch {
|
||||
@@ -134,4 +605,14 @@ function dismiss() {
|
||||
.overlay-fade-leave-to { opacity: 0; }
|
||||
.overlay-fade-enter-active .glass-card { transition: transform 0.3s ease; }
|
||||
.overlay-fade-enter-from .glass-card { transform: translateY(20px); }
|
||||
|
||||
/* Horizontal slide between the download and pairing screens */
|
||||
.slide-forward-enter-active,
|
||||
.slide-forward-leave-active,
|
||||
.slide-back-enter-active,
|
||||
.slide-back-leave-active { transition: transform 0.25s ease, opacity 0.25s ease; }
|
||||
.slide-forward-enter-from { transform: translateX(40px); opacity: 0; }
|
||||
.slide-forward-leave-to { transform: translateX(-40px); opacity: 0; }
|
||||
.slide-back-enter-from { transform: translateX(-40px); opacity: 0; }
|
||||
.slide-back-leave-to { transform: translateX(40px); opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zeus channel suggestion -->
|
||||
<div class="glass-card p-4 mb-4 border border-orange-500/25">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<img
|
||||
src="/assets/img/app-icons/zeus.webp"
|
||||
alt="Zeus"
|
||||
class="w-12 h-12 rounded-xl shrink-0 border border-white/10"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
|
||||
<p class="text-white/55 text-xs leading-relaxed">
|
||||
Open a channel with Zeus Olympus node and start sending and receiving Lightning
|
||||
payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex sm:flex-col items-center gap-2 shrink-0">
|
||||
<button
|
||||
@click="openZeusChannel"
|
||||
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap"
|
||||
>
|
||||
Open Channel
|
||||
</button>
|
||||
<a
|
||||
href="https://zeusln.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-orange-400/80 hover:text-orange-300 whitespace-nowrap"
|
||||
>Get Zeus →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open Channel Button -->
|
||||
<div class="flex justify-end mb-4">
|
||||
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
@@ -74,15 +106,15 @@
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-400': ch.status === 'active',
|
||||
'bg-yellow-400': ch.status === 'pending_open',
|
||||
'bg-red-400': ch.status === 'inactive',
|
||||
'bg-green-400': channelStatus(ch) === 'active',
|
||||
'bg-yellow-400': channelStatus(ch) === 'pending_open',
|
||||
'bg-red-400': channelStatus(ch) === 'inactive',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="ch.status !== 'pending_open'"
|
||||
v-if="channelStatus(ch) !== 'pending_open'"
|
||||
@click="confirmClose(ch)"
|
||||
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
|
||||
>
|
||||
@@ -270,8 +302,13 @@ interface Channel {
|
||||
local_balance: number
|
||||
remote_balance: number
|
||||
active: boolean
|
||||
status: string
|
||||
channel_point: string
|
||||
status?: string
|
||||
channel_point?: string
|
||||
}
|
||||
|
||||
/** Status with a fallback derived from `active` for backends that omit it */
|
||||
function channelStatus(ch: Channel): string {
|
||||
return ch.status ?? (ch.active ? 'active' : 'inactive')
|
||||
}
|
||||
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
@@ -288,6 +325,11 @@ const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||
|
||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||
// Channel limits: min 150,000 / max 1,500,000 sats.
|
||||
const OLYMPUS_PEER_URI =
|
||||
'031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735'
|
||||
|
||||
const showOpenModal = ref(false)
|
||||
const defaultOpenForm = () => ({
|
||||
peerUri: '',
|
||||
@@ -297,6 +339,19 @@ const defaultOpenForm = () => ({
|
||||
customConfTarget: null as number | null,
|
||||
customSatPerVbyte: null as number | null,
|
||||
})
|
||||
|
||||
/** Prefill the open-channel modal for a Zeus (Olympus) channel */
|
||||
function openZeusChannel() {
|
||||
openForm.value = {
|
||||
...defaultOpenForm(),
|
||||
peerUri: OLYMPUS_PEER_URI,
|
||||
amount: 150000,
|
||||
// Olympus only accepts unannounced channels
|
||||
private: true,
|
||||
}
|
||||
openError.value = null
|
||||
showOpenModal.value = true
|
||||
}
|
||||
const openForm = ref(defaultOpenForm())
|
||||
const openingChannel = ref(false)
|
||||
const openError = ref<string | null>(null)
|
||||
@@ -313,7 +368,7 @@ function formatSats(sats: number): string {
|
||||
}
|
||||
|
||||
function fundingTxid(ch: Channel): string {
|
||||
const txid = ch.channel_point.split(':')[0] || ''
|
||||
const txid = ch.channel_point?.split(':')[0] || ''
|
||||
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,10 @@ const show = computed(() =>
|
||||
)
|
||||
|
||||
function backUpNow() {
|
||||
router.push(LND_DETAILS_PATH).catch(() => {})
|
||||
// seed-backup=1 makes the LND page open the reveal flow immediately —
|
||||
// landing on the app page with the card below the fold read as
|
||||
// "clicking the notification did nothing".
|
||||
router.push({ path: LND_DETAILS_PATH, query: { 'seed-backup': '1' } }).catch(() => {})
|
||||
}
|
||||
|
||||
function snooze() {
|
||||
|
||||
@@ -25,16 +25,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useLoginTransitionStore } from '@/stores/loginTransition'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const showUpdatePrompt = ref(false)
|
||||
let updateCallback: (() => Promise<void>) | null = null
|
||||
|
||||
// Reload for a genuine SW update — but never mid-cinematic. The splash
|
||||
// typing intro and the dashboard-reveal cinematic are the two moments a
|
||||
// surprise reload reads as "the app just reset itself" (kiosk and demo
|
||||
// replay the intro on every boot/visit, so an update landing during it
|
||||
// restarted the whole sequence). Wait until both are over, then reload.
|
||||
function reloadAfterCinematic() {
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
const calm = () =>
|
||||
document.body.classList.contains('splash-complete') &&
|
||||
!loginTransition.introCinematicPlaying
|
||||
if (calm()) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
const poll = setInterval(() => {
|
||||
if (calm()) {
|
||||
clearInterval(poll)
|
||||
window.location.reload()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The public demo has no version to update to — the prompt is noise, and
|
||||
// both accept-paths end in a reload that replays the demo intro ("the site
|
||||
// just reset itself"). skipWaiting/clientsClaim are off, so ignoring the
|
||||
// waiting worker is safe: this page keeps its complete old cache, and the
|
||||
// new build activates on the next visit.
|
||||
if (IS_DEMO) return
|
||||
// Listen for service worker updates
|
||||
if ('serviceWorker' in navigator) {
|
||||
// On the very first visit the page loads with no controlling SW; the
|
||||
// freshly-installed worker then activates and claims the page
|
||||
// (autoUpdate → clientsClaim), firing controllerchange. Reloading on
|
||||
// that first claim caused the fresh-install "loads, then reloads"
|
||||
// jank (worst on kiosk). Only reload when an existing controller is
|
||||
// REPLACED — i.e. a genuine update.
|
||||
let hadController = !!navigator.serviceWorker.controller
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
// Service worker updated, reload the page
|
||||
window.location.reload()
|
||||
if (!hadController) {
|
||||
hadController = true
|
||||
return
|
||||
}
|
||||
reloadAfterCinematic()
|
||||
})
|
||||
|
||||
// Check for updates periodically
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
|
||||
<!-- On-chain -->
|
||||
<div v-if="receiveMethod === 'onchain'">
|
||||
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
|
||||
{{ note }}
|
||||
</div>
|
||||
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
|
||||
@@ -69,6 +72,12 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
Scan
|
||||
</button>
|
||||
<button @click="receive" :disabled="processing" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('receiveBitcoin.processing') : receiveMethod === 'onchain' ? t('receiveBitcoin.generateAddress') : receiveMethod === 'lightning' ? t('receiveBitcoin.createInvoice') : receiveMethod === 'ark' ? 'Get Ark address' : t('receiveBitcoin.receive') }}
|
||||
</button>
|
||||
@@ -77,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -85,8 +94,20 @@ import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; received: [] }>()
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
|
||||
note?: string
|
||||
/** Generate an on-chain address immediately when the modal opens */
|
||||
autoGenerate?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
|
||||
void receive()
|
||||
}
|
||||
})
|
||||
|
||||
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
|
||||
const invoiceAmount = ref<number>(0)
|
||||
|
||||
@@ -16,8 +16,30 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<button
|
||||
v-if="sendMethod === 'onchain'"
|
||||
@click="toggleSendAll"
|
||||
class="text-xs px-2 py-0.5 rounded border transition-colors"
|
||||
:class="sendAll
|
||||
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
|
||||
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
|
||||
>
|
||||
Send all funds
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="amount"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="sendAll ? '' : '1000'"
|
||||
:disabled="sendAll"
|
||||
class="w-full input-glass disabled:opacity-50"
|
||||
/>
|
||||
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
|
||||
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||
@@ -47,7 +69,13 @@
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||
</svg>
|
||||
Scan
|
||||
</button>
|
||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ processing ? t('common.sending') : t('common.send') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -55,7 +83,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -63,7 +91,7 @@ import BaseModal from '@/components/BaseModal.vue'
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; sent: [] }>()
|
||||
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
||||
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
||||
const amount = ref<number>(0)
|
||||
@@ -75,6 +103,23 @@ const resultHash = ref('')
|
||||
const resultArk = ref('')
|
||||
const ecashToken = ref('')
|
||||
|
||||
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
|
||||
const sendAll = ref(false)
|
||||
const onchainBalance = ref<number | null>(null)
|
||||
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
|
||||
|
||||
function toggleSendAll() {
|
||||
sendAll.value = !sendAll.value
|
||||
if (sendAll.value && onchainBalance.value === null) {
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
|
||||
.catch(() => { /* balance hint is best-effort */ })
|
||||
}
|
||||
}
|
||||
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
|
||||
const effectiveMethod = computed(() => {
|
||||
if (sendMethod.value !== 'auto') return sendMethod.value
|
||||
const amt = amount.value || 0
|
||||
@@ -98,7 +143,8 @@ function copyText(text: string) {
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!amount.value || processing.value) return
|
||||
if (processing.value) return
|
||||
if (!amount.value && !isSweep.value) return
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
ecashToken.value = ''
|
||||
@@ -134,7 +180,9 @@ async function send() {
|
||||
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr: dest.value.trim(), amount: amount.value },
|
||||
params: isSweep.value
|
||||
? { addr: dest.value.trim(), send_all: true }
|
||||
: { addr: dest.value.trim(), amount: amount.value },
|
||||
})
|
||||
resultTxid.value = res.txid
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
preload="auto"
|
||||
poster="/assets/img/bg-intro.jpg"
|
||||
>
|
||||
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||||
<source src="/assets/video/video-intro.mp4?v=8" type="video/mp4">
|
||||
<!-- Fallback to image if video fails -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
@@ -266,16 +266,12 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Check if user has seen intro
|
||||
// Also detect returning users who cleared cache: if we're on a /dashboard route,
|
||||
// the backend session is still active so the user has been here before.
|
||||
const storedSeenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||
const isOnDashboard = window.location.pathname.startsWith('/dashboard')
|
||||
const seenIntro = storedSeenIntro || isOnDashboard
|
||||
// Persist the flag so subsequent loads don't re-check
|
||||
if (!storedSeenIntro && isOnDashboard) {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
}
|
||||
// NOTE: whether the intro should play is decided ONCE by App.vue
|
||||
// (shouldShowIntroSplash) — this component is only mounted when the answer was
|
||||
// yes, so it must not re-check neode_intro_seen itself. The old internal
|
||||
// re-check silently skipped the whole sequence for any browser that had seen
|
||||
// the intro before, even when App.vue explicitly asked for a replay (demo
|
||||
// fresh visits, the Replay Intro link).
|
||||
|
||||
function handleEnterKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && showTapToStart.value && !tapStartTransitioning.value) {
|
||||
@@ -468,13 +464,7 @@ function startAlienIntro() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (seenIntro) {
|
||||
showSplash.value = false
|
||||
document.body.classList.add('splash-complete')
|
||||
emit('complete')
|
||||
} else {
|
||||
window.addEventListener('keydown', handleEnterKey)
|
||||
}
|
||||
window.addEventListener('keydown', handleEnterKey)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -23,7 +23,14 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-white/90">{{ toast.message }}</span>
|
||||
<button
|
||||
v-if="toast.action"
|
||||
@click.stop="runAction(toast)"
|
||||
class="block mt-1 text-sm font-semibold text-orange-400 hover:text-orange-300 transition-colors"
|
||||
>{{ toast.action.label }} →</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -32,10 +39,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { ToastVariant } from '@/composables/useToast'
|
||||
import type { ToastItem, ToastVariant } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
function runAction(toast: ToastItem | Readonly<ToastItem>) {
|
||||
toast.action?.onClick()
|
||||
dismiss(toast.id)
|
||||
}
|
||||
|
||||
function variantClass(variant: ToastVariant): string {
|
||||
switch (variant) {
|
||||
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'
|
||||
|
||||