Compare commits
226
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2b6028ca2 | ||
|
|
e679b4e886 | ||
|
|
d045f0b499 | ||
|
|
1e20b79c3c | ||
|
|
1bcf6ccadb | ||
|
|
9f6294d169 | ||
|
|
a0f688b522 | ||
|
|
f0926ece94 | ||
|
|
b28e5f2ef6 | ||
|
|
3037e9808e | ||
|
|
881b9ee0bf | ||
|
|
5ba3c161c4 | ||
|
|
2ad57c63f1 | ||
|
|
6c48de20e3 | ||
|
|
e17de63016 | ||
|
|
6ba4540c53 | ||
|
|
5454bed038 | ||
|
|
128b78d083 | ||
|
|
75ecd1d3ea | ||
|
|
82fcccd113 | ||
|
|
1e89362e71 | ||
|
|
908e6185c8 | ||
|
|
0064cfccac | ||
|
|
53037b2b71 | ||
|
|
db2cafc657 | ||
|
|
e49af6ff40 | ||
|
|
a865306488 | ||
|
|
979113c598 | ||
|
|
420f756947 | ||
|
|
35849c88c6 | ||
|
|
08927b88a8 | ||
|
|
0b7a1b84e4 | ||
|
|
bb4166954a | ||
|
|
ed2c14c88a | ||
|
|
42e98d6a86 | ||
|
|
3e5feebd49 | ||
|
|
4199c43cc9 | ||
|
|
54ddd043bd | ||
|
|
402183c0ae | ||
|
|
80875a2ed4 | ||
|
|
f72d4b92ac | ||
|
|
5d2c28da7b | ||
|
|
fbed32f95d | ||
|
|
d5fc3d01a4 | ||
|
|
87c324e36c | ||
|
|
15954aec14 | ||
|
|
1df075f7b1 | ||
|
|
ff4013bd3b | ||
|
|
37c8992d21 | ||
|
|
1ecb5c9aa3 | ||
|
|
5bae8273a2 | ||
|
|
265196bc2c | ||
|
|
9f1daa0f8c | ||
|
|
960e41e164 | ||
|
|
6502f13e29 | ||
|
|
c58f84c6e9 | ||
|
|
e59ea1da69 | ||
|
|
be6f06a573 | ||
|
|
8a450bb8f8 | ||
|
|
852ffc5b18 | ||
|
|
db6003aef6 | ||
|
|
b991c18e73 | ||
|
|
fb72e3e159 | ||
|
|
f339358109 | ||
|
|
df9b9905b6 | ||
|
|
e68fe071a7 | ||
|
|
04876f3bef | ||
|
|
05fc49820c | ||
|
|
cbb108a636 | ||
|
|
bfd8bc5b9a | ||
|
|
6347d16a2f | ||
|
|
b193e64ffb | ||
|
|
6a3b46b5a9 | ||
|
|
770e3386f4 | ||
|
|
8f4c32b93f | ||
|
|
30a4343b83 | ||
|
|
cad68eb941 | ||
|
|
197e351880 | ||
|
|
8213b0aee3 | ||
|
|
5f01ec31ed | ||
|
|
d86043193b | ||
|
|
b705ed7715 | ||
|
|
581dbd6337 | ||
|
|
b3796546aa | ||
|
|
c0aef2f03e | ||
|
|
5e7e928650 | ||
|
|
088b3e255a | ||
|
|
97464779d4 | ||
|
|
72c1bdd57d | ||
|
|
1a30f984d5 | ||
|
|
4b91765cc7 | ||
|
|
b88609e0ff | ||
|
|
8b744d377c | ||
|
|
2f26cb2bc4 | ||
|
|
27331d66e4 | ||
|
|
a9cd164301 | ||
|
|
6171168927 | ||
|
|
7e7b6bd474 | ||
|
|
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 |
@@ -18,7 +18,7 @@ on:
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
- '.gitea/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -19,3 +19,7 @@ local.properties
|
||||
# updates then install over the top without an uninstall. Debug keys are not
|
||||
# secret (well-known password "android"); never commit a real release keystore.
|
||||
!/app/debug.keystore
|
||||
|
||||
# Rust build outputs (archy-fips-core → jniLibs via buildRustArm64)
|
||||
/rust/archy-fips-core/target
|
||||
/app/src/main/jniLibs
|
||||
|
||||
@@ -11,12 +11,17 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 18
|
||||
versionName = "0.4.14"
|
||||
versionCode = 29
|
||||
versionName = "0.5.9"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
// The embedded FIPS mesh (libarchy_fips_core.so) is built arm64-only,
|
||||
// matching real handsets. FipsNative.available gates every call, so
|
||||
// the app still runs as a plain companion elsewhere (e.g. x86 emu).
|
||||
ndk { abiFilters += "arm64-v8a" }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -80,6 +85,44 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded FIPS mesh: cross-compile Android/rust/archy-fips-core via cargo-ndk
|
||||
// into jniLibs before the native-libs merge, so a plain `gradlew assembleDebug`
|
||||
// builds the Rust too. Requires rustup target aarch64-linux-android, cargo-ndk,
|
||||
// and an NDK (ANDROID_NDK_HOME or the SDK's ndk/ dir).
|
||||
// ---------------------------------------------------------------------------
|
||||
val rustCrateDir = layout.projectDirectory.dir("../rust/archy-fips-core")
|
||||
val jniLibsDir = layout.projectDirectory.dir("src/main/jniLibs")
|
||||
|
||||
tasks.register<Exec>("buildRustArm64") {
|
||||
workingDir = rustCrateDir.asFile
|
||||
inputs.dir(rustCrateDir.dir("src"))
|
||||
inputs.file(rustCrateDir.file("Cargo.toml"))
|
||||
outputs.dir(jniLibsDir)
|
||||
// cargo/cargo-ndk live in ~/.cargo/bin, which Gradle's env may not have.
|
||||
val home = System.getProperty("user.home")
|
||||
environment("PATH", "$home/.cargo/bin:${System.getenv("PATH")}")
|
||||
if (System.getenv("ANDROID_NDK_HOME") == null) {
|
||||
val sdkNdk = file("$home/Library/Android/sdk/ndk")
|
||||
.listFiles()?.maxByOrNull { it.name }
|
||||
if (sdkNdk != null) environment("ANDROID_NDK_HOME", sdkNdk.absolutePath)
|
||||
}
|
||||
commandLine(
|
||||
"cargo", "ndk",
|
||||
"-t", "arm64-v8a",
|
||||
"--platform", "26",
|
||||
"-o", jniLibsDir.asFile.absolutePath,
|
||||
"build", "--release",
|
||||
)
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in listOf(
|
||||
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
)
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||
implementation(composeBom)
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".ArchipelagoApp"
|
||||
@@ -19,6 +23,18 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="35">
|
||||
|
||||
<!-- Party-screen "Share this app": exposes the copied APK from
|
||||
cache/share/ to the system share sheet, nothing else. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -39,6 +55,21 @@
|
||||
<data android:scheme="archipelago" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
configured entirely by scanning the node's pairing QR. -->
|
||||
<service
|
||||
android:name=".fips.ArchyVpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="mesh-vpn-tunnel" />
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -19,25 +19,45 @@ data class ServerEntry(
|
||||
val port: String = "",
|
||||
val password: String = "",
|
||||
val name: String = "",
|
||||
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
||||
val meshIp: String = "",
|
||||
/** Node's FIPS npub — the durable identity. When present it, not the
|
||||
* address, is what identifies the entry: FIPS peers on npubs, IPs are
|
||||
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
|
||||
val npub: String = "",
|
||||
) {
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
|
||||
private fun urlHost(host: String): String =
|
||||
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
|
||||
fun toUrl(): String {
|
||||
val scheme = if (useHttps) "https" else "http"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
return "$scheme://${urlHost(address)}$portSuffix"
|
||||
}
|
||||
|
||||
fun toWsUrl(): String {
|
||||
val scheme = if (useHttps) "wss" else "ws"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
return "$scheme://$address$portSuffix"
|
||||
return "$scheme://${urlHost(address)}$portSuffix"
|
||||
}
|
||||
|
||||
// name is the trailing field so entries saved before naming existed
|
||||
// (4 fields) still deserialize, with name defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
|
||||
/** Mesh-address UI URL, or null when the node never advertised one. */
|
||||
fun toMeshUrl(): String? =
|
||||
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
||||
|
||||
// name/meshIp/npub are trailing fields so entries saved before they
|
||||
// existed (4/5/6 fields) still deserialize, defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
|
||||
|
||||
/** Same node as [other]? npub identity wins; address/port/scheme is the
|
||||
* fallback for LAN-only entries that never advertised FIPS. */
|
||||
fun sameNode(other: ServerEntry): Boolean =
|
||||
(npub.isNotBlank() && npub == other.npub) ||
|
||||
(address == other.address && port == other.port && useHttps == other.useHttps)
|
||||
|
||||
companion object {
|
||||
fun deserialize(raw: String): ServerEntry? {
|
||||
@@ -49,6 +69,8 @@ data class ServerEntry(
|
||||
port = parts.getOrElse(2) { "" },
|
||||
password = parts.getOrElse(3) { "" },
|
||||
name = parts.getOrElse(4) { "" },
|
||||
meshIp = parts.getOrElse(5) { "" },
|
||||
npub = parts.getOrElse(6) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +83,11 @@ class ServerPreferences(private val context: Context) {
|
||||
private val activePortKey = stringPreferencesKey("active_port")
|
||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||
private val activeNameKey = stringPreferencesKey("active_name")
|
||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||
private val activeNpubKey = stringPreferencesKey("active_npub")
|
||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||
val address = prefs[activeAddressKey] ?: return@map null
|
||||
@@ -72,6 +97,8 @@ class ServerPreferences(private val context: Context) {
|
||||
port = prefs[activePortKey] ?: "",
|
||||
password = prefs[activePasswordKey] ?: "",
|
||||
name = prefs[activeNameKey] ?: "",
|
||||
meshIp = prefs[activeMeshIpKey] ?: "",
|
||||
npub = prefs[activeNpubKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,6 +111,11 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[gestureHintSeenKey] ?: false
|
||||
}
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[activeAddressKey] = server.address
|
||||
@@ -91,6 +123,8 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[activePortKey] = server.port
|
||||
prefs[activePasswordKey] = server.password
|
||||
prefs[activeNameKey] = server.name
|
||||
prefs[activeMeshIpKey] = server.meshIp
|
||||
prefs[activeNpubKey] = server.npub
|
||||
}
|
||||
addSavedServer(server)
|
||||
}
|
||||
@@ -102,6 +136,8 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs.remove(activePortKey)
|
||||
prefs.remove(activePasswordKey)
|
||||
prefs.remove(activeNameKey)
|
||||
prefs.remove(activeMeshIpKey)
|
||||
prefs.remove(activeNpubKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,63 +149,66 @@ class ServerPreferences(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a saved server in place. Matches the existing entry by connection
|
||||
* identity (address/port/scheme) so edits that change the name or password —
|
||||
* or that touch a legacy 4-field entry — still update the right record. If the
|
||||
* edited server is also the active one, the active record is kept in sync.
|
||||
* Replace a saved server in place. Matches the existing entry by node
|
||||
* identity — npub first, address/port/scheme as the LAN-only fallback
|
||||
* (ServerEntry.sameNode) — so edits that change the name, password or even
|
||||
* every address still update the right record. An edit form that doesn't
|
||||
* carry the npub keeps the stored one. If the edited server is also the
|
||||
* active one, the active record is kept in sync.
|
||||
*/
|
||||
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == original.address &&
|
||||
e.port == original.port &&
|
||||
e.useHttps == original.useHttps
|
||||
ServerEntry.deserialize(raw)?.sameNode(original) == true
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + updated.serialize()
|
||||
prefs[savedServersKey] = filtered + toStore.serialize()
|
||||
|
||||
val isActive = prefs[activeAddressKey] == original.address &&
|
||||
(prefs[activePortKey] ?: "") == original.port &&
|
||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||
val activeNpub = prefs[activeNpubKey] ?: ""
|
||||
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
|
||||
(
|
||||
prefs[activeAddressKey] == original.address &&
|
||||
(prefs[activePortKey] ?: "") == original.port &&
|
||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||
)
|
||||
if (isActive) {
|
||||
prefs[activeAddressKey] = updated.address
|
||||
prefs[activeHttpsKey] = updated.useHttps
|
||||
prefs[activePortKey] = updated.port
|
||||
prefs[activePasswordKey] = updated.password
|
||||
prefs[activeNameKey] = updated.name
|
||||
prefs[activeAddressKey] = toStore.address
|
||||
prefs[activeHttpsKey] = toStore.useHttps
|
||||
prefs[activePortKey] = toStore.port
|
||||
prefs[activePasswordKey] = toStore.password
|
||||
prefs[activeNameKey] = toStore.name
|
||||
prefs[activeMeshIpKey] = toStore.meshIp
|
||||
prefs[activeNpubKey] = toStore.npub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Add a server, or update the entry for the same node — npub first,
|
||||
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
|
||||
* used by QR pairing so re-scanning a node never duplicates it, even after
|
||||
* the LAN renumbered and every address changed (npub-first contract in
|
||||
* docs/companion-pairing-qr.md). 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
|
||||
}
|
||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
|
||||
.firstOrNull { it.sameNode(server) }
|
||||
if (existing != null) {
|
||||
merged = server.copy(
|
||||
password = server.password.ifBlank { existing.password },
|
||||
name = server.name.ifBlank { existing.name },
|
||||
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
||||
npub = server.npub.ifBlank { existing.npub },
|
||||
)
|
||||
}
|
||||
val filtered = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
ServerEntry.deserialize(raw)?.sameNode(merged) == true
|
||||
}.toSet()
|
||||
prefs[savedServersKey] = filtered + merged.serialize()
|
||||
}
|
||||
@@ -179,15 +218,11 @@ class ServerPreferences(private val context: Context) {
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
// Match by connection identity (address/port/scheme) rather than the
|
||||
// exact serialized string, so a rename — or the legacy 4-field format
|
||||
// saved before names existed — still removes the right entry.
|
||||
// Match by node identity (npub, else address/port/scheme) rather
|
||||
// than the exact serialized string, so a rename — or a legacy
|
||||
// short-format entry — still removes the right record.
|
||||
prefs[savedServersKey] = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
ServerEntry.deserialize(raw)?.sameNode(server) == true
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
@@ -197,4 +232,10 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[introSeenKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markGestureHintSeen() {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[gestureHintSeenKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.archipelago.app.data
|
||||
|
||||
import android.net.Uri
|
||||
import com.archipelago.app.fips.AnchorPeer
|
||||
import com.archipelago.app.fips.FipsPairInfo
|
||||
|
||||
/**
|
||||
* Result of parsing a pairing QR / deep link.
|
||||
@@ -10,7 +12,11 @@ import android.net.Uri
|
||||
* user to update the app rather than call the code invalid.
|
||||
*/
|
||||
sealed class PairResult {
|
||||
data class Success(val server: ServerEntry) : PairResult()
|
||||
data class Success(
|
||||
val server: ServerEntry,
|
||||
/** Mesh info when the node advertises FIPS (fnpub present). */
|
||||
val fips: FipsPairInfo? = null,
|
||||
) : PairResult()
|
||||
object UnsupportedVersion : PairResult()
|
||||
object Invalid : PairResult()
|
||||
}
|
||||
@@ -19,13 +25,18 @@ sealed class PairResult {
|
||||
* Parser for the companion pairing QR / OS deep link. Contract:
|
||||
* docs/companion-pairing-qr.md (repo root).
|
||||
*
|
||||
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
|
||||
* archipelago://pair?v=1&url=<origin>&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…]
|
||||
*
|
||||
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
|
||||
* for the public demo); trailing slashes are normalized away.
|
||||
* - `pw` is only ever present for the public demo.
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1 —
|
||||
* `name` is already honored if present).
|
||||
* - `tok` is a device token minted by the node; it goes through the password
|
||||
* field on purpose — the whole password auto-login path (WebSocket auth +
|
||||
* WebView form injection) then works unchanged, and the backend accepts
|
||||
* device tokens wherever it accepts the password. `pw` (demo only) wins if
|
||||
* both are ever present.
|
||||
* - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their
|
||||
* absence just means LAN-only pairing (older node, or FIPS not provisioned).
|
||||
* - Unknown extra query params are tolerated (forward compat under v=1).
|
||||
*/
|
||||
object ServerQrParser {
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
@@ -54,14 +65,54 @@ object ServerQrParser {
|
||||
val host = server.host
|
||||
if (host.isNullOrBlank()) return PairResult.Invalid
|
||||
|
||||
val fips = parseFips(uri)
|
||||
val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() }
|
||||
?: uri.getQueryParameter("tok")
|
||||
?: ""
|
||||
|
||||
return PairResult.Success(
|
||||
ServerEntry(
|
||||
server = ServerEntry(
|
||||
address = host,
|
||||
useHttps = scheme == "https",
|
||||
port = if (server.port != -1) server.port.toString() else "",
|
||||
password = uri.getQueryParameter("pw") ?: "",
|
||||
password = credential,
|
||||
name = uri.getQueryParameter("name") ?: "",
|
||||
)
|
||||
meshIp = fips?.ula ?: "",
|
||||
// npub is the durable identity — saved-server upserts match on
|
||||
// it, so re-scanning after a LAN renumber updates in place.
|
||||
npub = fips?.npub ?: "",
|
||||
),
|
||||
fips = fips,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||
val host = uri.getQueryParameter("fhost")?.trim()
|
||||
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||
return FipsPairInfo(
|
||||
npub = npub,
|
||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||
host = host,
|
||||
udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121,
|
||||
tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443,
|
||||
anchors = parseAnchors(uri.getQueryParameter("fanchors")),
|
||||
)
|
||||
}
|
||||
|
||||
/** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */
|
||||
private fun parseAnchors(raw: String?): List<AnchorPeer> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return raw.split(",").mapNotNull { item ->
|
||||
val at = item.indexOf('@')
|
||||
val slash = item.lastIndexOf('/')
|
||||
if (at <= 0 || slash <= at) return@mapNotNull null
|
||||
val npub = item.substring(0, at).trim()
|
||||
val addr = item.substring(at + 1, slash).trim()
|
||||
val transport = item.substring(slash + 1).trim().lowercase()
|
||||
if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null
|
||||
if (transport != "udp" && transport != "tcp") return@mapNotNull null
|
||||
AnchorPeer(npub = npub, addr = addr, transport = transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.archipelago.app.MainActivity
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* VpnService hosting the embedded FIPS mesh node.
|
||||
*
|
||||
* Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so
|
||||
* normal phone traffic is untouched — this is mesh reachability, not a
|
||||
* default-route VPN. The established fd is detached and handed to the Rust
|
||||
* node (Node::start_with_tun_fd); the node owns it until stop.
|
||||
*/
|
||||
class ArchyVpnService : VpnService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var warmerJob: Job? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
shutdown()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
scope.launch { startMesh() }
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private suspend fun startMesh() {
|
||||
if (!FipsNative.available) {
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
val prefs = FipsPreferences(this)
|
||||
val identity = FipsManager.ensureIdentity(prefs)
|
||||
val peersJson = prefs.combinedPeersJson()
|
||||
if (identity == null || peersJson == "[]") {
|
||||
Log.w(TAG, "mesh not configured — stopping")
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
// Party mode: fixed inbound UDP bind so a nearby phone can dial us
|
||||
// directly over a shared LAN/hotspot (no internet required).
|
||||
val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0
|
||||
|
||||
// A fresh app open re-triggers the service. Tearing a HEALTHY mesh
|
||||
// down to rebuild it costs ~8s of anchor+session bring-up on every
|
||||
// launch (observed live: stop 00:34:38 → session back 00:34:49) and
|
||||
// is what made "freshly loading the app" slow. Keep a running node;
|
||||
// restart only when it's dead or a pairing changed the peer set.
|
||||
if (FipsNative.isRunning() && !FipsManager.peersDirty) {
|
||||
Log.i(TAG, "mesh already running — keeping warm sessions")
|
||||
startSessionWarmer()
|
||||
return
|
||||
}
|
||||
FipsManager.peersDirty = false
|
||||
// Re-establishing while running would strand the old fd; restart clean.
|
||||
if (FipsNative.isRunning()) FipsNative.stop()
|
||||
|
||||
val pfd = try {
|
||||
Builder()
|
||||
.setSession("Archipelago Mesh")
|
||||
.setMtu(1280)
|
||||
.addAddress(identity.address, 128)
|
||||
.addRoute("fd00::", 8)
|
||||
// The TUN is IPv6-only. Android blocks every address family
|
||||
// the VPN has no address for — without this, bringing the
|
||||
// mesh up cut ALL of the phone's IPv4 internet.
|
||||
.allowFamily(android.system.OsConstants.AF_INET)
|
||||
// And let apps that bind their own network skip the TUN
|
||||
// entirely — this is mesh reachability, not a privacy VPN.
|
||||
.allowBypass()
|
||||
.apply {
|
||||
// Android 10+ treats VPN networks as METERED by default,
|
||||
// which flips the whole phone into data-saver behaviour
|
||||
// (background sync off, "metered" warnings) while the
|
||||
// mesh is up. It inherits the underlying network's real
|
||||
// metered state instead.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false)
|
||||
}
|
||||
.establish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "VPN establish failed", e)
|
||||
null
|
||||
}
|
||||
if (pfd == null) {
|
||||
shutdown()
|
||||
return
|
||||
}
|
||||
|
||||
val fd = pfd.detachFd()
|
||||
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
|
||||
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
|
||||
if (result.contains("\"error\"")) {
|
||||
shutdown()
|
||||
} else {
|
||||
startSessionWarmer()
|
||||
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
||||
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
||||
// Mutual pairing: when a phone that scanned OUR QR announces
|
||||
// itself, store it as a party peer and re-run the mesh config so
|
||||
// this side gets the peer + chat entry without scanning back.
|
||||
FlareServer.onHello = { peer ->
|
||||
scope.launch {
|
||||
prefs.upsertPartyPeer(peer)
|
||||
FipsManager.requestMeshRestart(this@ArchyVpnService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm + keep-warm mesh sessions to every known node ULA.
|
||||
*
|
||||
* Discovery + first session through the public tree can take 15s+
|
||||
* (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the
|
||||
* moment the tunnel is up, means the connect probe and WebView hit an
|
||||
* established session instead of timing out on a cold one. The periodic
|
||||
* touch afterwards keeps the session from idling out. Failed connects
|
||||
* are expected and cheap; the attempt itself is what drives discovery.
|
||||
*/
|
||||
private fun startSessionWarmer() {
|
||||
warmerJob?.cancel()
|
||||
warmerJob = scope.launch {
|
||||
val prefs = ServerPreferences(this@ArchyVpnService)
|
||||
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
|
||||
var round = 0
|
||||
while (isActive && FipsNative.isRunning()) {
|
||||
val targets = try {
|
||||
prefs.savedServers.first()
|
||||
.mapNotNull { it.meshIp.ifBlank { null } }
|
||||
.map { it to 80 } +
|
||||
// Party phones answer on the flare port, not :80.
|
||||
fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}.distinct()
|
||||
for ((ula, port) in targets) {
|
||||
try {
|
||||
java.net.Socket().use { s ->
|
||||
s.connect(
|
||||
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
|
||||
20_000,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Cold path / node away — the connect attempt still
|
||||
// drove session establishment; try again next round.
|
||||
}
|
||||
}
|
||||
round++
|
||||
// Aggressive for the first ~minute (session bring-up), then a
|
||||
// slow keep-warm tick that costs nearly nothing.
|
||||
delay(if (round < 12) 5_000 else 60_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
warmerJob?.cancel()
|
||||
FlareServer.stop()
|
||||
FipsNative.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
FipsNative.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
// User pulled VPN permission from system settings.
|
||||
shutdown()
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Mesh connection",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
).apply { description = "Keeps the node reachable from anywhere" }
|
||||
)
|
||||
}
|
||||
val tapIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Connected to your Archipelago")
|
||||
.setContentText("Secure mesh link active")
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(tapIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "com.archipelago.app.fips.STOP"
|
||||
private const val CHANNEL_ID = "archy_mesh"
|
||||
private const val NOTIFICATION_ID = 4841
|
||||
private const val TAG = "ArchyVpnService"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Glue between pairing and the mesh: persists the node peer from a scanned
|
||||
* QR and asks the UI to bring the tunnel up. There is deliberately no
|
||||
* settings surface — scanning a node's QR is the entire configuration.
|
||||
*
|
||||
* The one unavoidable interaction is Android's VPN consent dialog
|
||||
* (VpnService.prepare), which only an Activity can launch; [consentNeeded]
|
||||
* signals AppNavHost to run it, once, on first pairing.
|
||||
*/
|
||||
object FipsManager {
|
||||
|
||||
/** Set when a pairing registered mesh info and the VPN needs starting. */
|
||||
private val _consentNeeded = MutableStateFlow(false)
|
||||
val consentNeeded: StateFlow<Boolean> = _consentNeeded
|
||||
|
||||
/** True after a pairing changed the peer set while the node was running —
|
||||
* tells the service a restart is genuinely needed (the ONLY case; a
|
||||
* routine app open must keep the warm mesh, not rebuild it). */
|
||||
@Volatile
|
||||
var peersDirty: Boolean = false
|
||||
|
||||
fun consentHandled() {
|
||||
_consentNeeded.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist mesh info from a pairing scan and request tunnel start.
|
||||
* No-op on devices without the native lib (non-arm64).
|
||||
*/
|
||||
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
|
||||
if (info == null || !FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
|
||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||
suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? {
|
||||
prefs.identity()?.let { return it }
|
||||
val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null
|
||||
prefs.saveIdentity(generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service if this device is paired and the user has already
|
||||
* consented to the VPN (prepare() == null). Called on app start so the
|
||||
* tunnel comes back without any interaction; first-time consent goes
|
||||
* through AppNavHost instead.
|
||||
*/
|
||||
suspend fun autoStartIfReady(context: Context) {
|
||||
if (!FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return
|
||||
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
|
||||
startService(context)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
val intent = Intent(context, ArchyVpnService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the mesh with current prefs (party listen toggled, peer added).
|
||||
* Marks the peer set dirty so startMesh genuinely restarts the node —
|
||||
* otherwise the keep-warm fast path would skip the new config.
|
||||
* First-timers go through the consent flow.
|
||||
*/
|
||||
fun requestMeshRestart(context: Context) {
|
||||
if (!FipsNative.available) return
|
||||
peersDirty = true
|
||||
if (VpnService.prepare(context) == null) {
|
||||
startService(context)
|
||||
} else {
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
val intent = Intent(context, ArchyVpnService::class.java)
|
||||
.setAction(ArchyVpnService.ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core,
|
||||
* built into libarchy_fips_core.so by the buildRustArm64 gradle task).
|
||||
*
|
||||
* All calls return JSON strings; failures come back as {"error": "…"} rather
|
||||
* than exceptions. [available] is false on ABIs the .so isn't built for
|
||||
* (anything but arm64) — every caller must gate on it so the app still runs
|
||||
* as a plain companion there.
|
||||
*/
|
||||
object FipsNative {
|
||||
val available: Boolean = try {
|
||||
System.loadLibrary("archy_fips_core")
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
external fun generateIdentity(): String
|
||||
external fun deriveIdentity(secret: String): String
|
||||
|
||||
/**
|
||||
* [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
|
||||
* that port so a nearby phone can dial us directly (party mode); the node
|
||||
* stays leaf-only either way.
|
||||
*/
|
||||
external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
|
||||
external fun stop()
|
||||
external fun isRunning(): Boolean
|
||||
external fun statusJson(): String
|
||||
|
||||
data class Identity(val secret: String, val npub: String, val address: String)
|
||||
|
||||
/** Parse an identity JSON reply; null on {"error": …} or malformed. */
|
||||
fun parseIdentity(json: String): Identity? = try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.has("error")) null
|
||||
else Identity(
|
||||
secret = obj.getString("secret"),
|
||||
npub = obj.getString("npub"),
|
||||
address = obj.getString("address"),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
/**
|
||||
* Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp —
|
||||
* docs/companion-pairing-qr.md). The phone's embedded FIPS node dials
|
||||
* host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers
|
||||
* without registration, so possession of these params is all pairing takes.
|
||||
*/
|
||||
data class FipsPairInfo(
|
||||
val npub: String,
|
||||
/** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */
|
||||
val ula: String,
|
||||
val host: String,
|
||||
val udpPort: Int,
|
||||
val tcpPort: Int,
|
||||
/**
|
||||
* Public rendezvous anchors (the node's seed-anchor list). The phone
|
||||
* peers with these too, so it can route to the node via the mesh when
|
||||
* the node's LAN endpoint isn't directly dialable.
|
||||
*/
|
||||
val anchors: List<AnchorPeer> = emptyList(),
|
||||
)
|
||||
|
||||
/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */
|
||||
data class AnchorPeer(
|
||||
val npub: String,
|
||||
val addr: String,
|
||||
val transport: String,
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
|
||||
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
|
||||
|
||||
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
|
||||
// both paths — direct LAN p2p to the node AND a public rendezvous for
|
||||
// away-from-home — even when the scanned node is old enough that its QR
|
||||
// carries no fanchors. Keep in lockstep with
|
||||
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
|
||||
internal const val ARCHY_ANCHOR_NPUB =
|
||||
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
|
||||
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
||||
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
||||
|
||||
/**
|
||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||
* storage model as ServerPreferences (the server password lives there the
|
||||
* same way); the mesh secret only grants mesh membership, not node login.
|
||||
*/
|
||||
class FipsPreferences(private val context: Context) {
|
||||
|
||||
private val secretKey = stringPreferencesKey("fips_secret")
|
||||
private val npubKey = stringPreferencesKey("fips_npub")
|
||||
private val addressKey = stringPreferencesKey("fips_address")
|
||||
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
|
||||
private val peersKey = stringPreferencesKey("fips_node_peers")
|
||||
/** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
|
||||
private val partyPeersKey = stringPreferencesKey("fips_party_peers")
|
||||
/** Party mode: accept a direct inbound mesh link (UDP 2121). */
|
||||
private val partyListenKey = booleanPreferencesKey("fips_party_listen")
|
||||
/** Name shown in this phone's party QR and outgoing flares. */
|
||||
private val partyNameKey = stringPreferencesKey("fips_party_name")
|
||||
|
||||
suspend fun identity(): FipsNative.Identity? {
|
||||
val prefs = context.fipsDataStore.data.first()
|
||||
val secret = prefs[secretKey] ?: return null
|
||||
return FipsNative.Identity(
|
||||
secret = secret,
|
||||
npub = prefs[npubKey] ?: "",
|
||||
address = prefs[addressKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun saveIdentity(identity: FipsNative.Identity) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
prefs[secretKey] = identity.secret
|
||||
prefs[npubKey] = identity.npub
|
||||
prefs[addressKey] = identity.address
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun peersJson(): String {
|
||||
val prefs = context.fipsDataStore.data.first()
|
||||
return prefs[peersKey] ?: "[]"
|
||||
}
|
||||
|
||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
||||
|
||||
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
|
||||
|
||||
suspend fun partyListen(): Boolean =
|
||||
context.fipsDataStore.data.first()[partyListenKey] ?: false
|
||||
|
||||
val partyListenFlow: Flow<Boolean>
|
||||
get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
|
||||
|
||||
suspend fun setPartyListen(enabled: Boolean) {
|
||||
context.fipsDataStore.edit { it[partyListenKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun partyName(): String =
|
||||
context.fipsDataStore.data.first()[partyNameKey]
|
||||
?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
|
||||
|
||||
suspend fun setPartyName(name: String) {
|
||||
context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
|
||||
}
|
||||
|
||||
val partyPeersFlow: Flow<List<PartyPeer>>
|
||||
get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
|
||||
|
||||
suspend fun partyPeers(): List<PartyPeer> =
|
||||
parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
|
||||
|
||||
/** Matched by npub, so re-scanning updates the direct-dial address in place. */
|
||||
suspend fun upsertPartyPeer(peer: PartyPeer) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
|
||||
prefs[partyPeersKey] = toJson(kept + peer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removePartyPeer(npub: String) {
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
|
||||
prefs[partyPeersKey] = toJson(kept)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node peers + direct-dial party peers, in the fips PeerConfig JSON the
|
||||
* Rust node deserializes. Party peers get the best priority: on a shared
|
||||
* LAN/hotspot the direct link beats every anchor path, and while off-LAN
|
||||
* the failed dial is cheap (auto-reconnect keeps retrying, which is
|
||||
* exactly what makes the link snap up the moment both phones share WiFi).
|
||||
* Party peers without an underlay address are mesh-routed and need no
|
||||
* entry here at all.
|
||||
*/
|
||||
suspend fun combinedPeersJson(): String {
|
||||
val merged = JSONArray(peersJson())
|
||||
val party = partyPeers()
|
||||
for (peer in party) {
|
||||
if (peer.ip.isBlank() || peer.port <= 0) continue
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", peer.npub)
|
||||
put("alias", peer.name.ifBlank { "Party phone" })
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${peer.ip}:${peer.port}")
|
||||
put("priority", 5)
|
||||
}))
|
||||
})
|
||||
}
|
||||
// A party-only phone (never paired with a node) still needs a public
|
||||
// rendezvous to reach its peers ACROSS the internet — without it, two
|
||||
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
|
||||
// this anchor in; do the same when there are party peers.
|
||||
if (party.isNotEmpty() &&
|
||||
(0 until merged.length()).none {
|
||||
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
|
||||
}
|
||||
) {
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "Archipelago anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
put("priority", 40)
|
||||
}))
|
||||
})
|
||||
}
|
||||
return merged.toString()
|
||||
}
|
||||
|
||||
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val npub = o.optString("npub")
|
||||
val ula = o.optString("ula")
|
||||
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
|
||||
PartyPeer(
|
||||
npub = npub,
|
||||
ula = ula,
|
||||
name = o.optString("name").ifBlank { "Phone" },
|
||||
ip = o.optString("ip"),
|
||||
port = o.optInt("port"),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun toJson(peers: List<PartyPeer>): String {
|
||||
val arr = JSONArray()
|
||||
for (p in peers) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("npub", p.npub)
|
||||
put("ula", p.ula)
|
||||
put("name", p.name)
|
||||
put("ip", p.ip)
|
||||
put("port", p.port)
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update the node peer plus its rendezvous anchors (each matched
|
||||
* by npub, so re-pairing updates addresses instead of duplicating).
|
||||
* Stored directly in the fips PeerConfig JSON shape the Rust side
|
||||
* deserializes. The node's direct addresses get the best priorities;
|
||||
* anchors trail so the mesh prefers the direct path when it works.
|
||||
*/
|
||||
suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) {
|
||||
val incoming = mutableListOf<JSONObject>()
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", info.npub)
|
||||
put("alias", alias.ifBlank { "Archipelago" })
|
||||
val addresses = JSONArray()
|
||||
if (info.udpPort > 0) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${info.host}:${info.udpPort}")
|
||||
put("priority", 10)
|
||||
})
|
||||
}
|
||||
if (info.tcpPort > 0) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "tcp")
|
||||
put("addr", "${info.host}:${info.tcpPort}")
|
||||
put("priority", 20)
|
||||
})
|
||||
}
|
||||
put("addresses", addresses)
|
||||
}
|
||||
for (anchor in info.anchors) {
|
||||
if (anchor.npub == info.npub) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", anchor.npub)
|
||||
put("alias", "Mesh anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", anchor.transport)
|
||||
put("addr", anchor.addr)
|
||||
put("priority", 30)
|
||||
}))
|
||||
}
|
||||
}
|
||||
// Guarantee the public anchor: without it, a QR from an older node
|
||||
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
|
||||
if (info.npub != ARCHY_ANCHOR_NPUB &&
|
||||
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
|
||||
) {
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "Archipelago anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
put("priority", 40)
|
||||
}))
|
||||
}
|
||||
}
|
||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
val merged = JSONArray()
|
||||
for (i in 0 until current.length()) {
|
||||
val existing = current.optJSONObject(i) ?: continue
|
||||
if (existing.optString("npub") !in incomingNpubs) merged.put(existing)
|
||||
}
|
||||
incoming.forEach { merged.put(it) }
|
||||
prefs[peersKey] = merged.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** One chat/photo message in a party conversation, keyed by the peer's npub. */
|
||||
data class FlareMessage(
|
||||
val id: String,
|
||||
val peerNpub: String,
|
||||
val fromMe: Boolean,
|
||||
val name: String,
|
||||
val text: String = "",
|
||||
val photoPath: String = "",
|
||||
val ts: Long,
|
||||
val status: Status = Status.RECEIVED,
|
||||
) {
|
||||
enum class Status { SENDING, SENT, FAILED, RECEIVED }
|
||||
}
|
||||
|
||||
/** In-memory conversation store (demo scope — nothing persists across restarts). */
|
||||
object FlareStore {
|
||||
private val _messages = MutableStateFlow<List<FlareMessage>>(emptyList())
|
||||
val messages: StateFlow<List<FlareMessage>> = _messages
|
||||
|
||||
fun add(message: FlareMessage) {
|
||||
_messages.value = _messages.value + message
|
||||
}
|
||||
|
||||
fun setStatus(id: String, status: FlareMessage.Status) {
|
||||
_messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is
|
||||
* fine there because FIPS is the encryption + peer-identity layer (same
|
||||
* stance as the node's ULA-only peer listener). This is what makes the phone
|
||||
* a *server* on the mesh: another phone (or `curl -6` from any mesh node)
|
||||
* reaches it by npub-derived address with no port forwarding, DNS, or CA.
|
||||
*
|
||||
* FIPS authenticates the node, not the request (project doctrine), so inputs
|
||||
* are still validated at this boundary: size caps, JSON shape, no
|
||||
* client-controlled paths.
|
||||
*/
|
||||
object FlareServer {
|
||||
private const val TAG = "FlareServer"
|
||||
private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||
private const val MAX_TEXT_CHARS = 4_000
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
|
||||
private var socket: ServerSocket? = null
|
||||
private var pool: ExecutorService? = null
|
||||
@Volatile private var identityName = "Phone"
|
||||
@Volatile private var identityNpub = ""
|
||||
@Volatile private var photoDir: File? = null
|
||||
|
||||
/** Invoked when a peer announces itself (POST /hello) — pairing used to
|
||||
* be one-way: only the SCANNING phone learned the other side, so the
|
||||
* scanned phone had no peer, no chat entry, no way in. The VPN service
|
||||
* wires this to upsert the peer + restart the mesh config. */
|
||||
@Volatile var onHello: ((PartyPeer) -> Unit)? = null
|
||||
|
||||
@Synchronized
|
||||
fun start(context: Context, ula: String, myNpub: String, myName: String) {
|
||||
stop()
|
||||
identityNpub = myNpub
|
||||
identityName = myName
|
||||
photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
|
||||
val pool = Executors.newCachedThreadPool().also { this.pool = it }
|
||||
pool.execute {
|
||||
try {
|
||||
val server = ServerSocket().apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
|
||||
}
|
||||
socket = server
|
||||
Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
|
||||
while (!server.isClosed) {
|
||||
val client = try {
|
||||
server.accept()
|
||||
} catch (_: Exception) {
|
||||
break
|
||||
}
|
||||
pool.execute { handle(client) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "flare server died: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
pool?.shutdownNow()
|
||||
pool = null
|
||||
}
|
||||
|
||||
private fun handle(client: Socket) {
|
||||
client.use { sock ->
|
||||
sock.soTimeout = 30_000
|
||||
try {
|
||||
val input = BufferedInputStream(sock.getInputStream())
|
||||
val requestLine = readLine(input) ?: return
|
||||
val parts = requestLine.trim().split(" ")
|
||||
if (parts.size < 2) return respond(sock, 400, json("bad_request"))
|
||||
val (method, path) = parts[0] to parts[1]
|
||||
|
||||
var contentLength = 0
|
||||
var from = ""
|
||||
var fromName = ""
|
||||
var headerBytes = requestLine.length
|
||||
while (true) {
|
||||
val line = readLine(input) ?: return
|
||||
if (line.isEmpty()) break
|
||||
headerBytes += line.length
|
||||
if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
|
||||
val idx = line.indexOf(':')
|
||||
if (idx <= 0) continue
|
||||
val key = line.substring(0, idx).trim().lowercase()
|
||||
val value = line.substring(idx + 1).trim()
|
||||
when (key) {
|
||||
"content-length" -> contentLength = value.toIntOrNull() ?: 0
|
||||
"x-from" -> from = value.take(80)
|
||||
"x-name" -> fromName = value.take(80)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
method == "GET" && (path == "/" || path.startsWith("/?")) ->
|
||||
respondHtml(sock, profilePage())
|
||||
method == "POST" && path == "/hello" -> {
|
||||
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
||||
val body = readExactly(input, contentLength) ?: return
|
||||
receiveHello(String(body, Charsets.UTF_8))
|
||||
respond(sock, 200, """{"ok":true}""")
|
||||
}
|
||||
method == "POST" && path == "/flare" -> {
|
||||
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
||||
val body = readExactly(input, contentLength) ?: return
|
||||
receiveFlare(String(body, Charsets.UTF_8))
|
||||
respond(sock, 200, """{"ok":true}""")
|
||||
}
|
||||
method == "POST" && path == "/photo" -> {
|
||||
if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
|
||||
if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
|
||||
val body = readExactly(input, contentLength) ?: return
|
||||
receivePhoto(from, fromName, body)
|
||||
respond(sock, 200, """{"ok":true}""")
|
||||
}
|
||||
else -> respond(sock, 404, json("not_found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "request failed: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A peer that scanned OUR QR announces itself — mutual pairing. */
|
||||
private fun receiveHello(body: String) {
|
||||
val o = try {
|
||||
JSONObject(body)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
val from = o.optString("from")
|
||||
val ula = o.optString("ula")
|
||||
if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
|
||||
if (from == identityNpub) return
|
||||
val peer = PartyPeer(
|
||||
npub = from.take(80),
|
||||
ula = ula.take(64),
|
||||
name = o.optString("name").take(24).ifBlank { "Phone" },
|
||||
ip = o.optString("ip").take(40),
|
||||
port = o.optInt("port", 0),
|
||||
)
|
||||
onHello?.invoke(peer)
|
||||
// Seed the conversation so the chat has a visible entry on this side.
|
||||
FlareStore.add(
|
||||
FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = peer.npub,
|
||||
fromMe = false,
|
||||
name = peer.name,
|
||||
text = "👋 ${peer.name} joined the party",
|
||||
ts = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun receiveFlare(body: String) {
|
||||
val o = try {
|
||||
JSONObject(body)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
val from = o.optString("from")
|
||||
if (!from.startsWith("npub1")) return
|
||||
val text = o.optString("text").take(MAX_TEXT_CHARS)
|
||||
if (text.isBlank()) return
|
||||
FlareStore.add(
|
||||
FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = from,
|
||||
fromMe = false,
|
||||
name = o.optString("name").take(80).ifBlank { "Phone" },
|
||||
text = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
|
||||
// Server-generated filename — the sender never controls the path.
|
||||
val dir = photoDir ?: return
|
||||
val file = File(dir, "${UUID.randomUUID()}.jpg")
|
||||
file.writeBytes(bytes)
|
||||
FlareStore.add(
|
||||
FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = from,
|
||||
fromMe = false,
|
||||
name = fromName.ifBlank { "Phone" },
|
||||
photoPath = file.absolutePath,
|
||||
ts = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun profilePage(): String {
|
||||
val npub = identityNpub
|
||||
val name = identityName
|
||||
return """
|
||||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>$name — on the mesh</title>
|
||||
<style>
|
||||
body{background:#0a0a0a;color:#eee;font-family:monospace;
|
||||
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
||||
.card{border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:32px;
|
||||
max-width:560px;background:rgba(255,255,255,.04)}
|
||||
h1{color:#f7931a;margin:0 0 8px;font-size:22px}
|
||||
.npub{word-break:break-all;color:#888;font-size:12px;margin:12px 0}
|
||||
p{line-height:1.5}
|
||||
</style></head><body><div class="card">
|
||||
<h1>⚡ $name</h1>
|
||||
<div class="npub">$npub</div>
|
||||
<p>This page is being served <b>by a phone</b>, addressed by its
|
||||
cryptographic identity over the FIPS mesh.</p>
|
||||
<p>No port forwarding. No DNS. No certificate authority. No cloud.
|
||||
The key <i>is</i> the address — and the transport underneath can be
|
||||
5G, WiFi, or a hotspot with no internet at all.</p>
|
||||
</div></body></html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
// ── tiny HTTP plumbing ──────────────────────────────────────────────────
|
||||
|
||||
/** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
|
||||
private fun readLine(input: InputStream): String? {
|
||||
val sb = StringBuilder()
|
||||
while (true) {
|
||||
val b = input.read()
|
||||
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
|
||||
if (b == '\n'.code) return sb.toString().trimEnd('\r')
|
||||
sb.append(b.toChar())
|
||||
if (sb.length > MAX_HEADER_BYTES) return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readExactly(input: InputStream, length: Int): ByteArray? {
|
||||
val buf = ByteArray(length)
|
||||
var off = 0
|
||||
while (off < length) {
|
||||
val n = input.read(buf, off, length - off)
|
||||
if (n == -1) return null
|
||||
off += n
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
|
||||
|
||||
private fun respond(sock: Socket, status: Int, body: String) =
|
||||
writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun respondHtml(sock: Socket, body: String) =
|
||||
writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
|
||||
val reason = when (status) {
|
||||
200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
|
||||
413 -> "Payload Too Large"; 431 -> "Headers Too Large"
|
||||
else -> "Error"
|
||||
}
|
||||
val head = "HTTP/1.1 $status $reason\r\n" +
|
||||
"Content-Type: $contentType\r\n" +
|
||||
"Content-Length: ${body.size}\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
sock.getOutputStream().apply {
|
||||
write(head.toByteArray(Charsets.ISO_8859_1))
|
||||
write(body)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
|
||||
object FlareClient {
|
||||
// Connect timeout must outlive cold mesh-session establishment (~15s via
|
||||
// the public tree per HANDOFF-2026-07-23); the attempt itself drives
|
||||
// session setup, same trick as the VPN service's session warmer.
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(25, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
|
||||
|
||||
/** Announce myself to a freshly scanned peer so pairing becomes MUTUAL —
|
||||
* their phone gets me as a peer + a chat entry without scanning back.
|
||||
* Blocking — call from Dispatchers.IO. */
|
||||
fun sendHello(
|
||||
peer: PartyPeer,
|
||||
myNpub: String,
|
||||
myName: String,
|
||||
myUla: String,
|
||||
myIp: String?,
|
||||
myPort: Int,
|
||||
): Boolean = try {
|
||||
val body = JSONObject()
|
||||
.put("from", myNpub)
|
||||
.put("name", myName)
|
||||
.put("ula", myUla)
|
||||
.put("ip", myIp ?: "")
|
||||
.put("port", if (myIp != null) myPort else 0)
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
http.newCall(
|
||||
Request.Builder().url("${base(peer)}/hello").post(body).build()
|
||||
).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Blocking — call from Dispatchers.IO. */
|
||||
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
|
||||
val body = JSONObject()
|
||||
.put("from", myNpub)
|
||||
.put("name", myName)
|
||||
.put("text", text)
|
||||
.put("ts", System.currentTimeMillis())
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
http.newCall(
|
||||
Request.Builder().url("${base(peer)}/flare").post(body).build()
|
||||
).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Blocking — call from Dispatchers.IO. */
|
||||
fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
|
||||
http.newCall(
|
||||
Request.Builder()
|
||||
.url("${base(peer)}/photo")
|
||||
.header("X-From", myNpub)
|
||||
.header("X-Name", myName)
|
||||
.post(jpeg.toRequestBody("image/jpeg".toMediaType()))
|
||||
.build()
|
||||
).execute().use { it.isSuccessful }
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.archipelago.app.fips
|
||||
|
||||
import android.net.Uri
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
|
||||
/**
|
||||
* Phone↔phone mesh pairing ("Mesh Party") QR contract:
|
||||
*
|
||||
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
|
||||
*
|
||||
* npub + ula alone are enough to chat *through* the mesh (anchors route by
|
||||
* node address, no underlay info needed). ip/port are present only while the
|
||||
* showing phone has its inbound UDP listener up (party mode) — the scanner
|
||||
* then also gets a direct-dial link that works on a shared LAN/hotspot with
|
||||
* no internet at all. Same versioning stance as the node pairing QR
|
||||
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
|
||||
*/
|
||||
data class PartyPeer(
|
||||
val npub: String,
|
||||
val ula: String,
|
||||
val name: String,
|
||||
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
|
||||
val ip: String = "",
|
||||
val port: Int = 0,
|
||||
)
|
||||
|
||||
object PartyQr {
|
||||
const val SCHEME_HOST = "party"
|
||||
private const val SUPPORTED_MAJOR = 1
|
||||
|
||||
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
|
||||
const val PARTY_UDP_PORT = 2121
|
||||
|
||||
/** Application-layer chat/beam port, bound only on the mesh ULA. */
|
||||
const val FLARE_PORT = 5680
|
||||
|
||||
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
|
||||
val b = Uri.Builder()
|
||||
.scheme("archipelago")
|
||||
.authority(SCHEME_HOST)
|
||||
.appendQueryParameter("v", "1")
|
||||
.appendQueryParameter("npub", npub)
|
||||
.appendQueryParameter("ula", ula)
|
||||
.appendQueryParameter("name", name)
|
||||
if (!ip.isNullOrBlank() && port > 0) {
|
||||
b.appendQueryParameter("ip", ip)
|
||||
b.appendQueryParameter("port", port.toString())
|
||||
}
|
||||
return b.build().toString()
|
||||
}
|
||||
|
||||
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
|
||||
fun parse(raw: String): PartyPeer? {
|
||||
val uri = try {
|
||||
Uri.parse(raw.trim())
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
|
||||
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
|
||||
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
|
||||
if (major != SUPPORTED_MAJOR) return null
|
||||
|
||||
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
|
||||
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
|
||||
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
|
||||
return PartyPeer(
|
||||
npub = npub,
|
||||
ula = ula,
|
||||
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
|
||||
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
|
||||
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
|
||||
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
|
||||
* the hotspot-host phone advertises the address its guests can reach.
|
||||
*/
|
||||
fun localWifiIpv4(): String? {
|
||||
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
|
||||
try {
|
||||
for (nif in NetworkInterface.getNetworkInterfaces()) {
|
||||
if (!nif.isUp || nif.isLoopback) continue
|
||||
for (addr in nif.inetAddresses) {
|
||||
if (addr is Inet4Address && addr.isSiteLocalAddress) {
|
||||
candidates += nif.name to addr.hostAddress.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
val hotspot = candidates.firstOrNull {
|
||||
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
|
||||
}
|
||||
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
|
||||
?.second
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
|
||||
@Composable
|
||||
fun GamepadLayout(
|
||||
onKey: (String) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onThreeFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val surface = Neo.surface()
|
||||
@@ -54,9 +54,9 @@ fun GamepadLayout(
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
|
||||
if (a.size < 2) t = 0L
|
||||
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
|
||||
if (a.size < 3) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* First-launch teaching overlay for the three-finger hold gesture. Three
|
||||
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
|
||||
* short caption explains what the gesture opens. Dismissed by tapping
|
||||
* anywhere (or automatically after a few seconds) — shown once, ever.
|
||||
*/
|
||||
@Composable
|
||||
fun GestureHintOverlay(onDismiss: () -> Unit) {
|
||||
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
|
||||
LaunchedEffect(Unit) {
|
||||
delay(6500)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "gesture-hint")
|
||||
// Fingertips press down together…
|
||||
val press by transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.86f,
|
||||
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
|
||||
label = "press",
|
||||
)
|
||||
// …while a ring ripples outward on each press cycle.
|
||||
val ripple by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
|
||||
label = "ripple",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.72f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Hand: three fingertip dots in a natural arc + ripple ring.
|
||||
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(150.dp)
|
||||
.scale(0.4f + ripple * 0.6f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
|
||||
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
|
||||
FingerDot(x = 44.dp, y = 8.dp, scale = press)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(28.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 48.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White.copy(alpha = 0.12f))
|
||||
.clickable(onClick = onDismiss)
|
||||
.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.gesture_hint_got_it),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
|
||||
Box(
|
||||
Modifier
|
||||
.offset(x = x, y = y)
|
||||
.size(26.dp)
|
||||
.scale(scale)
|
||||
.background(Color.White.copy(alpha = 0.92f), CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.ui.screens.PixelArtLogo
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
|
||||
/**
|
||||
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
|
||||
* dialing the node over the mesh (relaunch race, post-scan first connect),
|
||||
* instead of an anonymous spinner. The point of the brand: what's loading
|
||||
* is a connection to a cryptographic identity, not an IP.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
PixelArtLogo(Modifier.size(96.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "F*CK IPs MESH",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ fun NESController(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.twoFingerHold(onMenu)
|
||||
.threeFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Two-finger hold gesture modifier */
|
||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
|
||||
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
var t = 0L; var fired = false
|
||||
do {
|
||||
val ev = awaitPointerEvent()
|
||||
val a = ev.changes.filter { !it.changedToUp() }
|
||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||
if (a.size < 2) t = 0L
|
||||
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||
if (a.size < 3) t = 0L
|
||||
} while (ev.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ fun NESMenu(
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
) {
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
@@ -94,7 +95,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, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +116,7 @@ private fun MenuPanel(
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
onMeshParty: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
// The saved server being edited, or null when adding a new one.
|
||||
@@ -284,6 +286,11 @@ private fun MenuPanel(
|
||||
onClick = onToggleStyle,
|
||||
)
|
||||
|
||||
// Phone↔phone mesh pairing + chat
|
||||
if (onMeshParty != null) {
|
||||
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
||||
}
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ fun NESPortraitController(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.twoFingerHold(onMenu)
|
||||
.threeFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -87,7 +87,7 @@ fun NESPortraitController(
|
||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||
onClick = { onMouseClick(it) },
|
||||
onScroll = { dy -> onMouseScroll(dy) },
|
||||
onTwoFingerHold = onMenu,
|
||||
onThreeFingerHold = onMenu,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
|
||||
@@ -56,7 +56,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
@@ -82,7 +81,7 @@ import java.util.concurrent.Executors
|
||||
fun QrScannerOverlay(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (ServerEntry) -> Unit,
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -131,7 +130,7 @@ fun QrScannerOverlay(
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result.server)
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
@@ -218,13 +217,20 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||
@Composable
|
||||
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||
// hole in the window, which black-flashes inside Compose fades and
|
||||
// ignores rounded-corner clipping (wallet modal).
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -283,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
||||
)
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||
// frames skipped here are simply dropped, so decodes stay current.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttempt < 140) {
|
||||
image.close()
|
||||
return
|
||||
}
|
||||
lastAttempt = now
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
|
||||
@@ -32,7 +32,7 @@ fun Trackpad(
|
||||
onMove: (dx: Int, dy: Int) -> Unit,
|
||||
onClick: (button: Int) -> Unit,
|
||||
onScroll: (dy: Int) -> Unit,
|
||||
onTwoFingerHold: () -> Unit,
|
||||
onThreeFingerHold: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fingers by remember { mutableIntStateOf(0) }
|
||||
@@ -53,7 +53,7 @@ fun Trackpad(
|
||||
val t0 = System.currentTimeMillis()
|
||||
var maxPtrs = 1
|
||||
var holdFired = false
|
||||
var twoStart = 0L
|
||||
var threeStart = 0L
|
||||
var scrollAcc = 0f
|
||||
fingers = 1
|
||||
|
||||
@@ -64,19 +64,24 @@ fun Trackpad(
|
||||
fingers = active.size
|
||||
|
||||
when {
|
||||
active.size >= 2 -> {
|
||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
||||
// Three fingers = hold for menu; two = scroll. Kept
|
||||
// on separate counts so a long two-finger scroll can
|
||||
// never fire the menu mid-gesture.
|
||||
active.size >= 3 -> {
|
||||
if (threeStart == 0L) threeStart = System.currentTimeMillis()
|
||||
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
|
||||
holdFired = true
|
||||
onTwoFingerHold()
|
||||
onThreeFingerHold()
|
||||
}
|
||||
if (!holdFired) {
|
||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||
scrollAcc += dy
|
||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||
scrollAcc = 0f
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
active.size == 2 -> {
|
||||
threeStart = 0L
|
||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||
scrollAcc += dy
|
||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||
scrollAcc = 0f
|
||||
}
|
||||
ev.changes.forEach { it.consume() }
|
||||
}
|
||||
@@ -99,7 +104,11 @@ fun Trackpad(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (fingers >= 2) "hold for menu" else "",
|
||||
text = when {
|
||||
fingers >= 3 -> "hold for menu"
|
||||
fingers == 2 -> "scroll"
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = muted.copy(alpha = 0.4f),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
|
||||
/**
|
||||
* Native replacement for the web wallet's scan pane — same visual design as
|
||||
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||
*
|
||||
* Decoded text is handed back to the page ([onDecoded]) which does all the
|
||||
* detection/spend logic; the page in turn streams status lines (animated-QR
|
||||
* progress, "not recognised" errors) back in via [status] and closes the
|
||||
* modal through the JS bridge once it accepts a code.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletQrScannerModal(
|
||||
visible: Boolean,
|
||||
status: Pair<String, Boolean>?, // message from the web page + isError
|
||||
onDecoded: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
// Local error from a failed image upload; a fresh web status replaces it.
|
||||
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||
val noQrMessage = stringResource(R.string.no_qr_in_image)
|
||||
val imagePicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val decoded = decodeQrFromUri(context, uri)
|
||||
if (decoded != null) {
|
||||
uploadError = null
|
||||
onDecoded(decoded)
|
||||
} else {
|
||||
uploadError = noQrMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
uploadError = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF212151C))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {}, // swallow — only the scrim dismisses
|
||||
)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// Header — mirrors the web modal's title row
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_send),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Square camera preview with the orange viewfinder
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
// Throttle repeat frames: a static QR decodes ~20x/s but
|
||||
// the page only needs one; animated QRs still stream
|
||||
// because each frame's text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
CameraQrPreview(onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
})
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(0.62f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Status strip — same slot the web modal uses for hints/errors
|
||||
val message = uploadError ?: status?.first
|
||||
val isError = uploadError != null || status?.second == true
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(12.dp)
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.scan_wallet_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.upload_qr_image),
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
var sample = 1
|
||||
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
|
||||
while (maxDim / (sample * 2) >= 1600) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
||||
?: return null
|
||||
val pixels = IntArray(bmp.width * bmp.height)
|
||||
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
||||
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
||||
val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||
} catch (_: NotFoundException) {
|
||||
// Light-on-dark QRs (dark-themed wallets) decode inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.archipelago.app.ui.navigation
|
||||
|
||||
import android.net.VpnService
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -16,7 +19,10 @@ import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
@@ -27,6 +33,8 @@ object Routes {
|
||||
const val SERVER_CONNECT = "server_connect"
|
||||
const val WEB_VIEW = "web_view"
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
const val MESH_PARTY = "mesh_party"
|
||||
const val FLARE = "flare"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -46,6 +54,34 @@ fun AppNavHost(
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
|
||||
|
||||
// Mesh tunnel: Android's VPN consent dialog is the single unavoidable
|
||||
// interaction — it can only be launched from an Activity, so pairing
|
||||
// paths raise FipsManager.consentNeeded and it is handled here, once.
|
||||
val consentNeeded by FipsManager.consentNeeded.collectAsState()
|
||||
val vpnConsentLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
FipsManager.consentHandled()
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
FipsManager.startService(context)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(consentNeeded) {
|
||||
if (!consentNeeded) return@LaunchedEffect
|
||||
val consentIntent = VpnService.prepare(context)
|
||||
if (consentIntent == null) {
|
||||
FipsManager.consentHandled()
|
||||
FipsManager.startService(context)
|
||||
} else {
|
||||
vpnConsentLauncher.launch(consentIntent)
|
||||
}
|
||||
}
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
@@ -58,6 +94,7 @@ fun AppNavHost(
|
||||
// Pairing implies the app is installed and in use — skip the intro.
|
||||
prefs.markIntroSeen()
|
||||
val merged = prefs.upsertServer(result.server)
|
||||
FipsManager.registerNode(context, result.fips, merged.displayName())
|
||||
if (merged.password.isNotBlank()) {
|
||||
// Demo flow: password came with the link — connect in one step.
|
||||
prefs.setActiveServer(merged)
|
||||
@@ -89,6 +126,9 @@ fun AppNavHost(
|
||||
) {
|
||||
composable(Routes.INTRO) {
|
||||
IntroScreen(
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
onContinue = {
|
||||
scope.launch {
|
||||
prefs.markIntroSeen()
|
||||
@@ -125,6 +165,7 @@ fun AppNavHost(
|
||||
WebViewScreen(
|
||||
serverUrl = server.toUrl(),
|
||||
serverPassword = server.password,
|
||||
meshFallbackUrl = server.toMeshUrl(),
|
||||
onDisconnect = {
|
||||
scope.launch {
|
||||
prefs.clearActiveServer()
|
||||
@@ -145,6 +186,22 @@ fun AppNavHost(
|
||||
onBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MESH_PARTY) {
|
||||
PartyScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenChat = { navController.navigate(Routes.FLARE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.FLARE) {
|
||||
FlareScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.Image
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import com.archipelago.app.fips.FlareClient
|
||||
import com.archipelago.app.fips.FlareMessage
|
||||
import com.archipelago.app.fips.FlareStore
|
||||
import com.archipelago.app.fips.PartyPeer
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
|
||||
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
|
||||
|
||||
/**
|
||||
* Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
|
||||
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
|
||||
* via a public anchor (5G) or a direct hotspot link is invisible up here —
|
||||
* which is the entire point.
|
||||
*/
|
||||
@Composable
|
||||
fun FlareScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { FipsPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
|
||||
var myName by remember { mutableStateOf("Phone") }
|
||||
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
||||
var selectedNpub by remember { mutableStateOf<String?>(null) }
|
||||
val allMessages by FlareStore.messages.collectAsState()
|
||||
var draft by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
identity = FipsManager.ensureIdentity(prefs)
|
||||
myName = prefs.partyName()
|
||||
}
|
||||
LaunchedEffect(peers) {
|
||||
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
|
||||
selectedNpub = peers.firstOrNull()?.npub
|
||||
}
|
||||
}
|
||||
|
||||
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
|
||||
fun sendText() {
|
||||
val target = peer ?: return
|
||||
val me = identity ?: return
|
||||
val text = draft.trim()
|
||||
if (text.isEmpty()) return
|
||||
draft = ""
|
||||
val msg = FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = target.npub,
|
||||
fromMe = true,
|
||||
name = myName,
|
||||
text = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
status = FlareMessage.Status.SENDING,
|
||||
)
|
||||
FlareStore.add(msg)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val ok = FlareClient.sendText(target, me.npub, myName, text)
|
||||
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPhoto(uri: Uri) {
|
||||
val target = peer ?: return
|
||||
val me = identity ?: return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val jpeg = compressPhoto(context, uri) ?: return@launch
|
||||
// Local copy so our own bubble renders the sent photo.
|
||||
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
|
||||
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
|
||||
val msg = FlareMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
peerNpub = target.npub,
|
||||
fromMe = true,
|
||||
name = myName,
|
||||
photoPath = local.absolutePath,
|
||||
ts = System.currentTimeMillis(),
|
||||
status = FlareMessage.Status.SENDING,
|
||||
)
|
||||
FlareStore.add(msg)
|
||||
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
|
||||
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
val photoPicker = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri -> uri?.let { sendPhoto(it) } }
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceDark)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
|
||||
peer?.let {
|
||||
Text(
|
||||
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(48.dp))
|
||||
}
|
||||
|
||||
// Peer tabs when chatting with more than one phone
|
||||
if (peers.size > 1) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
peers.forEach { p ->
|
||||
val active = p.npub == selectedNpub
|
||||
Text(
|
||||
p.name,
|
||||
color = if (active) BitcoinOrange else TextMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.border(
|
||||
1.dp,
|
||||
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
|
||||
RoundedCornerShape(10.dp),
|
||||
)
|
||||
.clickable { selectedNpub = p.npub }
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (peer == null) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(messages, key = { it.id }) { msg ->
|
||||
MessageBubble(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Composer
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(BubbleTheirs)
|
||||
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
|
||||
.clickable { photoPicker.launch("image/*") },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { sendText() }),
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { sendText() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("➤", color = BitcoinOrange, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBubble(msg: FlareMessage) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
|
||||
.border(
|
||||
1.dp,
|
||||
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
|
||||
RoundedCornerShape(16.dp),
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (msg.photoPath.isNotBlank()) {
|
||||
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||
bmp?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "Beamed photo",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (msg.text.isNotBlank()) {
|
||||
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
|
||||
}
|
||||
Text(
|
||||
when (msg.status) {
|
||||
FlareMessage.Status.SENDING -> "sending…"
|
||||
FlareMessage.Status.SENT -> "sent · E2E via mesh"
|
||||
FlareMessage.Status.FAILED -> "failed — tap to retry later"
|
||||
FlareMessage.Status.RECEIVED -> msg.name
|
||||
},
|
||||
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
||||
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, bounds)
|
||||
}
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
|
||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val bitmap = context.contentResolver.openInputStream(uri)?.use {
|
||||
BitmapFactory.decodeStream(it, null, opts)
|
||||
} ?: return@withContext null
|
||||
val out = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
|
||||
bitmap.recycle()
|
||||
out.toByteArray()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun IntroScreen(onContinue: () -> Unit) {
|
||||
fun IntroScreen(
|
||||
onContinue: () -> Unit,
|
||||
// Mesh Party works with no node at all (phone↔phone) — offered right on
|
||||
// the first screen so a friend who just got the app can join a party.
|
||||
onMeshParty: () -> Unit = {},
|
||||
) {
|
||||
val logoAlpha = remember { Animatable(0f) }
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.mesh_party),
|
||||
onClick = onMeshParty,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import com.archipelago.app.fips.FlareClient
|
||||
import com.archipelago.app.fips.PartyPeer
|
||||
import com.archipelago.app.fips.PartyQr
|
||||
import com.archipelago.app.ui.components.CameraQrPreview
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val CardBg = Color.White.copy(alpha = 0.05f)
|
||||
private val CardBorder = Color.White.copy(alpha = 0.08f)
|
||||
|
||||
/** Public companion download (vps2 demo host — serves the same APK as the
|
||||
* nodes' QR link). Rendered as the "Share this app" QR. */
|
||||
private const val APP_DOWNLOAD_URL =
|
||||
"http://146.59.87.168:2100/packages/archipelago-companion.apk"
|
||||
|
||||
/**
|
||||
* Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the
|
||||
* two embedded mesh nodes link up: through anchors when there's internet,
|
||||
* directly over any shared WiFi/hotspot when there isn't.
|
||||
*/
|
||||
@Composable
|
||||
fun PartyScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { FipsPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var localIp by remember { mutableStateOf<String?>(null) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
var showShareQr by remember { mutableStateOf(false) }
|
||||
var scanHint by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Camera permission — fresh installs (and reinstalls: uninstall wipes
|
||||
// grants) land here with no CAMERA grant, and the raw preview just showed
|
||||
// black. Ask the moment the scanner opens.
|
||||
var hasCamera by remember {
|
||||
mutableStateOf(
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||
) { hasCamera = it }
|
||||
LaunchedEffect(showScanner) {
|
||||
if (showScanner && !hasCamera) {
|
||||
cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
|
||||
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
identity = FipsManager.ensureIdentity(prefs)
|
||||
name = prefs.partyName()
|
||||
// The hotspot/WiFi address can change while this screen is open
|
||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
||||
while (true) {
|
||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
|
||||
val qrPayload = identity?.let { id ->
|
||||
PartyQr.build(
|
||||
npub = id.npub,
|
||||
ula = id.address,
|
||||
name = name.ifBlank { "Phone" },
|
||||
ip = if (listenOn) localIp else null,
|
||||
port = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
}
|
||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
showScanner -> showScanner = false
|
||||
showShareQr -> showShareQr = false
|
||||
else -> onBack()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
|
||||
Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
|
||||
Spacer(Modifier.size(56.dp))
|
||||
}
|
||||
|
||||
// My QR card
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(CardBg)
|
||||
.border(1.dp, CardBorder, RoundedCornerShape(20.dp))
|
||||
.padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
qrBitmap?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "My mesh party QR",
|
||||
modifier = Modifier.size(220.dp),
|
||||
)
|
||||
}
|
||||
} ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
|
||||
|
||||
identity?.let {
|
||||
Text(
|
||||
it.npub.take(16) + "…" + it.npub.takeLast(6),
|
||||
color = TextMuted,
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it.take(24)
|
||||
scope.launch { prefs.setPartyName(name) }
|
||||
},
|
||||
placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
// Direct-link toggle (hotspot mode)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.padding(end = 12.dp)) {
|
||||
Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
when {
|
||||
listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
|
||||
listenOn -> "Waiting for a WiFi/hotspot address…"
|
||||
else -> "Off — mesh routes via anchors only"
|
||||
},
|
||||
color = if (listenOn) BitcoinOrange else TextMuted,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = listenOn,
|
||||
onCheckedChange = { on ->
|
||||
scope.launch {
|
||||
prefs.setPartyListen(on)
|
||||
FipsManager.requestMeshRestart(context)
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedTrackColor = BitcoinOrange,
|
||||
checkedThumbColor = Color.White,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlassButton(
|
||||
text = "Scan a Phone",
|
||||
onClick = { showScanner = true },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
|
||||
if (peers.isNotEmpty()) {
|
||||
Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
|
||||
peers.forEach { peer ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(CardBg)
|
||||
.border(1.dp, CardBorder, RoundedCornerShape(14.dp))
|
||||
.clickable { onOpenChat() }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(Modifier.padding(end = 8.dp)) {
|
||||
Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
peer.npub.take(14) + "…" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
|
||||
color = TextMuted,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"✕",
|
||||
color = TextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.clickable {
|
||||
scope.launch {
|
||||
prefs.removePartyPeer(peer.npub)
|
||||
FipsManager.requestMeshRestart(context)
|
||||
}
|
||||
}.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
GlassButton(
|
||||
text = "Open Flare Chat",
|
||||
onClick = onOpenChat,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
"Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Hand the app itself to a friend: shares this install's own APK
|
||||
// through the system sheet (Quick Share/Bluetooth), so a nearby
|
||||
// phone gets the companion with zero internet — the whole party
|
||||
// premise.
|
||||
GlassButton(
|
||||
text = "Share this app",
|
||||
onClick = { showShareQr = true },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// "Share this app" — a QR of the public download link, so the other
|
||||
// phone scans it with its normal camera and installs over any
|
||||
// internet. (The vps2 demo host serves the same APK the nodes do.)
|
||||
AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f))
|
||||
.clickable { showShareQr = false },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||
dlQr?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "Companion download QR",
|
||||
modifier = Modifier.size(240.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
"Scan with any camera to download\nthe Archipelago Companion",
|
||||
color = TextPrimary,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"…or send the APK file directly",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Party QR scanner overlay
|
||||
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||
if (!hasCamera) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Camera access is needed to scan a party QR.",
|
||||
color = TextPrimary,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = "Grant camera access",
|
||||
onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
} else CameraQrPreview(onDecoded = { text ->
|
||||
val peer = PartyQr.parse(text)
|
||||
when {
|
||||
peer == null -> scanHint = "Not a mesh party QR"
|
||||
peer.npub == identity?.npub -> scanHint = "That's your own QR"
|
||||
else -> {
|
||||
showScanner = false
|
||||
scanHint = null
|
||||
scope.launch {
|
||||
prefs.upsertPartyPeer(peer)
|
||||
// Pick up the direct-dial PeerConfig (and the
|
||||
// listener, if ours is on) immediately.
|
||||
FipsManager.requestMeshRestart(context)
|
||||
// Pairing must be MUTUAL: announce ourselves so
|
||||
// the scanned phone gets us as a peer + a chat
|
||||
// entry without scanning back. Retried while
|
||||
// the fresh link/session comes up.
|
||||
val me = identity
|
||||
if (me != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
for (attempt in 0 until 6) {
|
||||
val ok = FlareClient.sendHello(
|
||||
peer = peer,
|
||||
myNpub = me.npub,
|
||||
myName = name.ifBlank { "Phone" },
|
||||
myUla = me.address,
|
||||
myIp = if (listenOn) localIp else null,
|
||||
myPort = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
if (ok) break
|
||||
delay(3_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
onOpenChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
Text(
|
||||
"Close",
|
||||
color = TextPrimary,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.statusBarsPadding()
|
||||
.clickable { showScanner = false }
|
||||
.padding(20.dp),
|
||||
)
|
||||
scanHint?.let {
|
||||
Text(
|
||||
it,
|
||||
color = BitcoinOrange,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan hints fade so the camera feels live again.
|
||||
LaunchedEffect(scanHint) {
|
||||
if (scanHint != null) {
|
||||
delay(2500)
|
||||
scanHint = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a QR payload as a bitmap (dark modules on white). */
|
||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
val matrix = QRCodeWriter().encode(
|
||||
payload,
|
||||
BarcodeFormat.QR_CODE,
|
||||
size,
|
||||
size,
|
||||
mapOf(EncodeHintType.MARGIN to 1),
|
||||
)
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
for (x in 0 until size) {
|
||||
pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
|
||||
}
|
||||
}
|
||||
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Share this install's own APK via the system share sheet — a nearby friend
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth). */
|
||||
private fun shareCompanionApk(context: android.content.Context) {
|
||||
try {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
src.copyTo(out, overwrite = true)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
android.content.Intent.createChooser(send, "Share Archipelago Companion")
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// No share targets / copy failed — nothing sensible to do here.
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.network.ConnectionState
|
||||
import com.archipelago.app.network.InputWebSocket
|
||||
import com.archipelago.app.ui.components.NESController
|
||||
@@ -53,7 +54,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -171,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||
onClick = { ws.sendClick(it) },
|
||||
onScroll = { ws.sendScroll(it) },
|
||||
onTwoFingerHold = { showModal = true },
|
||||
onThreeFingerHold = { showModal = true },
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
@@ -249,6 +250,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
},
|
||||
onBackToWebView = { showModal = false; onBack() },
|
||||
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
|
||||
@@ -256,10 +258,11 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
QrScannerOverlay(
|
||||
visible = showQrScanner,
|
||||
onDismiss = { showQrScanner = false },
|
||||
onServerScanned = { server ->
|
||||
onServerScanned = { scan ->
|
||||
showQrScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(server)
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,8 +71,11 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -83,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
@@ -169,10 +173,35 @@ fun ServerConnectScreen(
|
||||
errorMessage = null
|
||||
|
||||
scope.launch {
|
||||
val result = testConnection(server)
|
||||
var reachable = testConnection(server)
|
||||
|
||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||
// node. The scanned IP was only ever a dial hint; the node's real
|
||||
// identity is its npub and its ULA is reachable from anywhere over
|
||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||
if (!reachable && server.meshIp.isNotBlank()) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
val meshServer = server.copy(
|
||||
address = server.meshIp,
|
||||
useHttps = false,
|
||||
port = "",
|
||||
)
|
||||
// Mesh discovery + first session can take 15s+ through the
|
||||
// public tree (HANDOFF-2026-07-23 node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at
|
||||
// the same time — so probe patiently inside a 60s budget with
|
||||
// per-attempt timeouts wide enough to ride out TCP
|
||||
// retransmit backoff. The VPN service pre-warms the session
|
||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
||||
if (!reachable) delay(3000)
|
||||
}
|
||||
}
|
||||
isConnecting = false
|
||||
|
||||
if (result) {
|
||||
if (reachable) {
|
||||
prefs.setActiveServer(server)
|
||||
onConnected(server.toUrl())
|
||||
} else {
|
||||
@@ -190,12 +219,14 @@ fun ServerConnectScreen(
|
||||
}
|
||||
|
||||
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
|
||||
// (payload carried a password — the demo flow) or land on the password
|
||||
// prompt with everything else filled in (real nodes never embed one).
|
||||
fun onQrScanned(scanned: ServerEntry) {
|
||||
// (payload carried a credential — demo password or a real node's device
|
||||
// token) or land on the password prompt with everything else filled in.
|
||||
// Mesh info (when present) is registered so the FIPS tunnel comes up too.
|
||||
fun onQrScanned(scan: PairResult.Success) {
|
||||
showScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scanned)
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(context, scan.fips, merged.displayName())
|
||||
prefill(merged)
|
||||
if (merged.password.isNotBlank()) {
|
||||
connect(merged)
|
||||
@@ -580,6 +611,14 @@ fun ServerConnectScreen(
|
||||
onDismiss = { showScanner = false },
|
||||
onServerScanned = { onQrScanned(it) },
|
||||
)
|
||||
|
||||
// Full-screen branded loader while the first connect runs — most
|
||||
// visibly right after a pairing-QR scan, when the mesh may still be
|
||||
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||
// The small inline spinner stays for context; this owns the screen.
|
||||
if (isConnecting) {
|
||||
MeshLoadingScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,8 +686,10 @@ private fun sanitizeAddress(input: String): String {
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
|
||||
private suspend fun testConnection(server: ServerEntry): Boolean {
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
||||
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val url = URL("${server.toUrl()}/rpc/v1")
|
||||
@@ -668,8 +709,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
|
||||
}
|
||||
|
||||
connection.requestMethod = "POST"
|
||||
connection.connectTimeout = 5000
|
||||
connection.readTimeout = 5000
|
||||
connection.connectTimeout = timeoutMs
|
||||
connection.readTimeout = timeoutMs
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.doOutput = true
|
||||
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
|
||||
|
||||
@@ -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
|
||||
@@ -29,8 +35,10 @@ 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.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -47,28 +55,46 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import android.webkit.ValueCallback
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
@@ -98,6 +124,54 @@ private fun isSameHost(url: String, base: String): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving
|
||||
* the kiosk and coming back reattaches the LIVE page — no reload, no
|
||||
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
|
||||
private object KioskWebView {
|
||||
var instance: WebView? = null
|
||||
var url: String? = null
|
||||
|
||||
// Live-composition delegates for the JS bridges (see the factory) — the
|
||||
// registered interface objects call through these, so reattaching the
|
||||
// retained view re-points them instead of leaving stale closures.
|
||||
var onRouteOutbound: (String) -> Unit = {}
|
||||
var onOpenInApp: (String) -> Unit = {}
|
||||
var onQrOpen: () -> Unit = {}
|
||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||
var onQrClose: () -> Unit = {}
|
||||
|
||||
fun drop() {
|
||||
instance?.let {
|
||||
(it.parent as? ViewGroup)?.removeView(it)
|
||||
it.destroy()
|
||||
}
|
||||
instance = null
|
||||
url = null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
|
||||
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
||||
val u = android.net.Uri.parse(base)
|
||||
val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
|
||||
java.net.Socket().use {
|
||||
it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
|
||||
true
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||
* (patient — a cold session may still be establishing), else LAN anyway so
|
||||
* the existing error/fallback path handles it. */
|
||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
||||
lanUrl
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
* These are tuned for SPA performance and parity with the mobile browser;
|
||||
* none of them alter how a page renders visually. */
|
||||
@@ -140,17 +214,105 @@ fun WebViewScreen(
|
||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||
// demo flow from docs/companion-pairing-qr.md.
|
||||
serverPassword: String = "",
|
||||
// Node's FIPS mesh URL (http://[fd…]). When the primary address fails on
|
||||
// a main-frame load — typically the phone left the LAN — retry there
|
||||
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||
meshFallbackUrl: String? = null,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||
// loader; later navigations keep just the slim top progress bar.
|
||||
var firstLoadDone by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isLoading }.first { !it }
|
||||
firstLoadDone = true
|
||||
}
|
||||
var loadProgress by remember { mutableIntStateOf(0) }
|
||||
var triedMeshFallback by remember { mutableStateOf(false) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an
|
||||
// unreachable LAN IP burns a minute+ in connect retries before
|
||||
// onReceivedError fires the mesh fallback — the "stuck connecting"
|
||||
// stall. A raw TCP probe answers in milliseconds at home and fails in
|
||||
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
|
||||
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
|
||||
var raceNonce by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
|
||||
// A retained live session exists — reattach instantly: no race, no
|
||||
// reload, no re-login (remote ⇄ dashboard round trip).
|
||||
if (KioskWebView.instance != null && KioskWebView.url == serverUrl) {
|
||||
isLoading = false
|
||||
startUrl = serverUrl
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
|
||||
// Starting on the mesh: don't bounce back to it on error (it IS it).
|
||||
if (picked != serverUrl) triedMeshFallback = true
|
||||
startUrl = picked
|
||||
}
|
||||
|
||||
// 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.
|
||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
|
||||
// the ULA while app links may carry the LAN IP (and vice versa) —
|
||||
// comparing against one host bounced same-node apps (Pine, Home
|
||||
// Assistant, BTCPay) out to the phone's external browser.
|
||||
fun isSameNode(url: String): Boolean =
|
||||
isSameHost(url, serverUrl) ||
|
||||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
|
||||
|
||||
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
|
||||
// bridge; status lines stream back from the page while it's up.
|
||||
var walletScannerVisible by remember { mutableStateOf(false) }
|
||||
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
|
||||
|
||||
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||
// it while DataStore is still loading).
|
||||
val prefs = remember { ServerPreferences(webViewContext) }
|
||||
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||
// ~2 minutes after the kiosk first finishes loading, once the user has
|
||||
// settled in.
|
||||
var gestureHintReady by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { isLoading }.first { !it }
|
||||
delay(120_000)
|
||||
gestureHintReady = true
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// <input type="file"> support — without a chooser implementation the
|
||||
// WebView silently ignores file inputs (broke the wallet's upload path).
|
||||
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
|
||||
val fileChooserLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
pendingFileChooser?.onReceiveValue(
|
||||
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
||||
)
|
||||
pendingFileChooser = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
@@ -199,9 +361,16 @@ fun WebViewScreen(
|
||||
GlassButton(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
// Re-race LAN vs mesh — the network we're on may have
|
||||
// changed since the last pick. Drop the retained view:
|
||||
// an errored session must genuinely reload.
|
||||
KioskWebView.drop()
|
||||
webView = null
|
||||
hasError = false
|
||||
isLoading = true
|
||||
webView?.loadUrl(serverUrl)
|
||||
triedMeshFallback = false
|
||||
startUrl = null
|
||||
raceNonce++
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
@@ -210,17 +379,33 @@ fun WebViewScreen(
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.disconnect),
|
||||
onClick = onDisconnect,
|
||||
onClick = {
|
||||
KioskWebView.drop()
|
||||
onDisconnect()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
} else if (startUrl == null) {
|
||||
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
|
||||
// far cheaper than letting Chromium retry a dead LAN IP.
|
||||
MeshLoadingScreen()
|
||||
} else {
|
||||
// Edge-to-edge WebView — background bleeds behind status bar.
|
||||
// Safe area values injected as CSS env() polyfill on each page load.
|
||||
val initialUrl = startUrl ?: serverUrl
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
// Reattach the retained kiosk WebView (remote ⇄ dashboard
|
||||
// must not reload the node UI). Everything configured
|
||||
// below is idempotent, and re-running it rebinds clients,
|
||||
// bridges and listeners to THIS composition's state —
|
||||
// stale closures from the previous visit are replaced.
|
||||
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
||||
val reused = KioskWebView.instance
|
||||
(reused ?: WebView(context)).apply {
|
||||
(parent as? ViewGroup)?.removeView(this)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -250,32 +435,76 @@ fun WebViewScreen(
|
||||
// kiosk couldn't iframe — keep the user inside the app)
|
||||
// - different host → the phone's real browser
|
||||
fun routeOutbound(url: String) {
|
||||
if (isSameHost(url, serverUrl)) {
|
||||
if (isSameNode(url)) {
|
||||
inAppUrl = url
|
||||
} else {
|
||||
openExternalUrl(context, url)
|
||||
}
|
||||
}
|
||||
|
||||
// Bridge callbacks are DELEGATED through the holder:
|
||||
// the interface objects registered on a retained
|
||||
// WebView survive recomposition, and re-registering
|
||||
// them doesn't reliably swap the JS-visible object
|
||||
// without a reload — with direct closures, a
|
||||
// remote ⇄ dashboard round trip left the bridges
|
||||
// writing to a dead composition's state ("apps don't
|
||||
// launch"). These assignments re-point the live
|
||||
// interface objects at THIS composition every attach.
|
||||
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
|
||||
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
|
||||
KioskWebView.onQrOpen = {
|
||||
walletScannerStatus = null
|
||||
walletScannerVisible = true
|
||||
}
|
||||
KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err }
|
||||
KioskWebView.onQrClose = { walletScannerVisible = false }
|
||||
|
||||
// JS bridge. The web UI calls:
|
||||
// window.ArchipelagoNative.openExternal(url) — host-routed
|
||||
// window.ArchipelagoNative.openInApp(url) — force in-app
|
||||
// Falls back to window.open in a plain mobile browser.
|
||||
addJavascriptInterface(
|
||||
if (reused == null) addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { routeOutbound(url) }
|
||||
webViewRef.post { KioskWebView.onRouteOutbound(url) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
webViewRef.post { inAppUrl = url }
|
||||
webViewRef.post { KioskWebView.onOpenInApp(url) }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
|
||||
// Wallet QR bridge. The web scan modal calls:
|
||||
// window.ArchipelagoQr.open() — show the native scanner
|
||||
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
||||
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||
// Decodes flow back through window.__archyQrResult(text);
|
||||
// a user cancel calls window.__archyQrCancelled().
|
||||
if (reused == null) addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun open() {
|
||||
webViewRef.post { KioskWebView.onQrOpen() }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun setStatus(message: String, isError: Boolean) {
|
||||
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun close() {
|
||||
webViewRef.post { KioskWebView.onQrClose() }
|
||||
}
|
||||
},
|
||||
"ArchipelagoQr",
|
||||
)
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
isLoading = true
|
||||
@@ -316,8 +545,13 @@ fun WebViewScreen(
|
||||
)
|
||||
|
||||
// Auto-login with the stored password (QR pairing /
|
||||
// saved server) — only on our own server's pages.
|
||||
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
|
||||
// saved server) — only on our own server's pages
|
||||
// (LAN origin or the mesh address).
|
||||
val onOwnServer = url != null && (
|
||||
url.startsWith(serverUrl) ||
|
||||
(meshFallbackUrl != null && url.startsWith(meshFallbackUrl))
|
||||
)
|
||||
if (serverPassword.isNotBlank() && onOwnServer) {
|
||||
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
|
||||
}
|
||||
}
|
||||
@@ -328,6 +562,15 @@ fun WebViewScreen(
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
if (request?.isForMainFrame == true) {
|
||||
val mesh = meshFallbackUrl
|
||||
if (mesh != null && !triedMeshFallback &&
|
||||
request.url?.toString()?.startsWith(mesh) != true
|
||||
) {
|
||||
triedMeshFallback = true
|
||||
isLoading = true
|
||||
view?.loadUrl(mesh)
|
||||
return
|
||||
}
|
||||
hasError = true
|
||||
isLoading = false
|
||||
}
|
||||
@@ -346,7 +589,7 @@ fun WebViewScreen(
|
||||
error: android.net.http.SslError?,
|
||||
) {
|
||||
val u = error?.url
|
||||
if (u != null && isSameHost(u, serverUrl)) {
|
||||
if (u != null && isSameNode(u)) {
|
||||
handler?.proceed()
|
||||
} else {
|
||||
handler?.cancel()
|
||||
@@ -360,6 +603,8 @@ fun WebViewScreen(
|
||||
val url = request?.url?.toString() ?: return false
|
||||
// Keep kiosk navigation (same origin incl. port) in place
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Mesh address is the same server too
|
||||
if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) return false
|
||||
// Same node (other port) → in-app; external → browser
|
||||
routeOutbound(url)
|
||||
return true
|
||||
@@ -371,6 +616,46 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
view: WebView?,
|
||||
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean {
|
||||
pendingFileChooser?.onReceiveValue(null)
|
||||
pendingFileChooser = filePathCallback
|
||||
val intent = fileChooserParams?.createIntent()
|
||||
if (intent == null) {
|
||||
pendingFileChooser = null
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
fileChooserLauncher.launch(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
pendingFileChooser = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet QR scanner: grant the page camera access.
|
||||
// Only video capture is granted — anything else the
|
||||
// page asks for is denied as before.
|
||||
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.
|
||||
@@ -407,22 +692,24 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Two-finger hold (500ms) → navigate to remote input
|
||||
var twoFingerStart = 0L
|
||||
var twoFingerFired = false
|
||||
// Three-finger hold (500ms) → navigate to remote input.
|
||||
// Three fingers, not two: two-finger scroll/pinch on the
|
||||
// page collided with the old two-finger hold.
|
||||
var threeFingerStart = 0L
|
||||
var threeFingerFired = false
|
||||
setOnTouchListener { _, event ->
|
||||
val pointerCount = event.pointerCount
|
||||
when (event.actionMasked) {
|
||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
if (pointerCount >= 2) {
|
||||
twoFingerStart = System.currentTimeMillis()
|
||||
twoFingerFired = false
|
||||
if (pointerCount >= 3) {
|
||||
threeFingerStart = System.currentTimeMillis()
|
||||
threeFingerFired = false
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
||||
twoFingerFired = true
|
||||
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||
threeFingerFired = true
|
||||
onRemoteInput()
|
||||
}
|
||||
}
|
||||
@@ -430,8 +717,8 @@ fun WebViewScreen(
|
||||
android.view.MotionEvent.ACTION_UP,
|
||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
if (event.pointerCount <= 2) {
|
||||
twoFingerStart = 0L
|
||||
if (event.pointerCount <= 3) {
|
||||
threeFingerStart = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,7 +726,19 @@ fun WebViewScreen(
|
||||
}
|
||||
|
||||
webView = this
|
||||
loadUrl(serverUrl)
|
||||
if (reused == null) {
|
||||
KioskWebView.instance = this
|
||||
KioskWebView.url = serverUrl
|
||||
loadUrl(initialUrl)
|
||||
} else {
|
||||
// Reattached views keep stale measurements until an
|
||||
// input event — that was the top/bottom UI being
|
||||
// wrong until a tap. Force a fresh pass.
|
||||
post {
|
||||
requestLayout()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -458,15 +757,81 @@ fun WebViewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Branded first-load screen while the mesh session comes up.
|
||||
AnimatedVisibility(
|
||||
visible = isLoading && !firstLoadDone,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(SurfaceBlack),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") }
|
||||
withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") }
|
||||
},
|
||||
fontSize = 40.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 4.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"connecting to your archipelago",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 1.sp,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
|
||||
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||
inAppUrl?.let { target ->
|
||||
InAppBrowser(
|
||||
url = target,
|
||||
serverUrl = serverUrl,
|
||||
meshUrl = meshFallbackUrl,
|
||||
onClose = { inAppUrl = null },
|
||||
)
|
||||
}
|
||||
|
||||
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
|
||||
WalletQrScannerModal(
|
||||
visible = walletScannerVisible,
|
||||
status = walletScannerStatus,
|
||||
onDecoded = { text ->
|
||||
webView?.evaluateJavascript(
|
||||
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
|
||||
null,
|
||||
)
|
||||
},
|
||||
onDismiss = {
|
||||
walletScannerVisible = false
|
||||
webView?.evaluateJavascript(
|
||||
"window.__archyQrCancelled && window.__archyQrCancelled()",
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// First-launch teaching overlay for the three-finger hold — armed
|
||||
// ~2 minutes after login so it never fights the splash/first look.
|
||||
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
||||
!isLoading && inAppUrl == null
|
||||
) {
|
||||
GestureHintOverlay(
|
||||
onDismiss = {
|
||||
gestureHintDismissed = true
|
||||
scope.launch { prefs.markGestureHintSeen() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,9 +870,14 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||
private fun InAppBrowser(
|
||||
url: String,
|
||||
serverUrl: String,
|
||||
meshUrl: String? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||
fun isSameNode(u: String): Boolean =
|
||||
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
|
||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
@@ -516,6 +886,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
|
||||
@@ -565,6 +947,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() {
|
||||
@@ -593,7 +991,7 @@ private fun InAppBrowser(
|
||||
error: android.net.http.SslError?,
|
||||
) {
|
||||
val u = error?.url
|
||||
if (u != null && isSameHost(u, serverUrl)) {
|
||||
if (u != null && isSameNode(u)) {
|
||||
handler?.proceed()
|
||||
} else {
|
||||
handler?.cancel()
|
||||
@@ -607,7 +1005,7 @@ private fun InAppBrowser(
|
||||
val u = request?.url?.toString() ?: return false
|
||||
// Stay in the overlay for same-node navigation;
|
||||
// hand genuinely external links to the real browser.
|
||||
if (isSameHost(u, serverUrl)) return false
|
||||
if (isSameNode(u)) return false
|
||||
openExternalUrl(ctx, u)
|
||||
return true
|
||||
}
|
||||
@@ -741,7 +1139,16 @@ private fun buildAutoLoginScript(password: String): String {
|
||||
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
setter.call(el, pw);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', 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()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
|
||||
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
|
||||
<string name="get_started">Get Started</string>
|
||||
<string name="mesh_party">Mesh Party</string>
|
||||
<string name="use_https">Use HTTPS</string>
|
||||
<string name="port_label">Port (optional)</string>
|
||||
<string name="saved_servers">Saved Servers</string>
|
||||
@@ -41,4 +42,11 @@
|
||||
<string name="edit_server_title">Edit Server</string>
|
||||
<string name="save_changes">Save Changes</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="gesture_hint_title">Hold with three fingers</string>
|
||||
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
|
||||
<string name="gesture_hint_got_it">Got it</string>
|
||||
<string name="scan_to_send">Scan to send</string>
|
||||
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||
<string name="upload_qr_image">Upload image</string>
|
||||
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
|
||||
<paths>
|
||||
<cache-path name="share" path="share/" />
|
||||
</paths>
|
||||
Generated
+1690
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# Embedded FIPS mesh node for the Archipelago companion app.
|
||||
#
|
||||
# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task
|
||||
# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so.
|
||||
# Also builds on the host so `cargo test` covers the non-JNI logic.
|
||||
#
|
||||
# `fips` is pinned to the fips-native fork rev that this integration was
|
||||
# developed against — the fork carries Android support upstream lacks
|
||||
# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths).
|
||||
# Override with a local checkout when hacking on fips itself:
|
||||
# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"'
|
||||
[package]
|
||||
name = "archy-fips-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "archy_fips_core"
|
||||
# rlib for host tests; cdylib for the Android shared library.
|
||||
crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
||||
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
|
||||
anyhow = "1.0"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys).
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
|
||||
tracing = "0.1"
|
||||
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
|
||||
libc = "0.2"
|
||||
|
||||
# The JNI surface only exists on Android; host builds skip it and drive the
|
||||
# mesh module directly (tests).
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21"
|
||||
# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`.
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
paranoid-android = "0.2"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,129 @@
|
||||
//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings,
|
||||
//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as
|
||||
//! `{"error": "…"}` so Kotlin never sees a raw exception from native code.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::sys::{jboolean, jint, jstring};
|
||||
use jni::JNIEnv;
|
||||
|
||||
use crate::mesh;
|
||||
|
||||
static LOG_INIT: Once = Once::new();
|
||||
|
||||
fn init_logging() {
|
||||
LOG_INIT.call_once(|| {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new("info"))
|
||||
.with(paranoid_android::layer("archy-fips"))
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
|
||||
fn jstr(env: &mut JNIEnv, s: &JString) -> String {
|
||||
env.get_string(s).map(|s| s.into()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn out(env: &JNIEnv, s: String) -> jstring {
|
||||
env.new_string(s)
|
||||
.map(|s| s.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn err_json(e: impl std::fmt::Display) -> String {
|
||||
serde_json::json!({ "error": e.to_string() }).to_string()
|
||||
}
|
||||
|
||||
fn identity_json(info: &mesh::IdentityInfo) -> String {
|
||||
serde_json::json!({
|
||||
"secret": info.secret_hex,
|
||||
"npub": info.npub,
|
||||
"address": info.address,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun generateIdentity(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let json = match mesh::generate_identity() {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun deriveIdentity(secret: String): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let json = match mesh::derive_identity(&secret) {
|
||||
Ok(info) => identity_json(&info),
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
|
||||
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
|
||||
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
||||
mut env: JNIEnv,
|
||||
_class: JClass,
|
||||
secret: JString,
|
||||
peers_json: JString,
|
||||
tun_fd: jint,
|
||||
listen_port: jint,
|
||||
) -> jstring {
|
||||
init_logging();
|
||||
let secret = jstr(&mut env, &secret);
|
||||
let peers = jstr(&mut env, &peers_json);
|
||||
let listen_port = u16::try_from(listen_port).unwrap_or(0);
|
||||
let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
|
||||
Ok((npub, address)) => {
|
||||
serde_json::json!({ "npub": npub, "address": address }).to_string()
|
||||
}
|
||||
Err(e) => err_json(e),
|
||||
};
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun stop()`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) {
|
||||
mesh::stop();
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun isRunning(): Boolean`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning(
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jboolean {
|
||||
mesh::is_running() as jboolean
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun statusJson(): String`
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson(
|
||||
env: JNIEnv,
|
||||
_class: JClass,
|
||||
) -> jstring {
|
||||
out(&env, mesh::status_json())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Embedded FIPS mesh node for the Archipelago companion app.
|
||||
//!
|
||||
//! The phone runs a real, leaf-only FIPS node in-process: Android's
|
||||
//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic
|
||||
//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`].
|
||||
//! Peering is outbound-only — the pairing QR carries the node's npub and
|
||||
//! transport endpoints, and FIPS nodes accept inbound peers without prior
|
||||
//! registration, so no server-side enrollment step exists.
|
||||
//!
|
||||
//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and
|
||||
//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern:
|
||||
//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`.
|
||||
|
||||
pub mod mesh;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod jni_glue;
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Mesh lifecycle: identity, config assembly, and the node task.
|
||||
//!
|
||||
//! Host-buildable (no JNI) so the config/identity logic is unit-testable;
|
||||
//! only [`start`] needs a real TUN fd and therefore only runs on-device.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use fips::config::{PeerConfig, TransportInstances, UdpConfig};
|
||||
use fips::{Config, Identity, Node};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// How long `start` waits for the node to come up (TUN attach + transports).
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long `stop` waits for the node task to drain.
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
struct MeshHandle {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
shutdown: Arc<Notify>,
|
||||
running: Arc<AtomicBool>,
|
||||
npub: String,
|
||||
address: String,
|
||||
}
|
||||
|
||||
static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IdentityInfo {
|
||||
pub secret_hex: String,
|
||||
pub npub: String,
|
||||
/// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress.
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh mesh identity from the OS CSPRNG.
|
||||
pub fn generate_identity() -> Result<IdentityInfo> {
|
||||
// ~1 in 2^128 chance a candidate is off the curve; loop regardless.
|
||||
loop {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::getrandom(&mut bytes).context("OS RNG")?;
|
||||
if let Ok(id) = Identity::from_secret_bytes(&bytes) {
|
||||
return Ok(IdentityInfo {
|
||||
secret_hex: hex::encode(bytes),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive npub + ULA from a stored secret.
|
||||
pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
|
||||
let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?;
|
||||
Ok(IdentityInfo {
|
||||
secret_hex: secret.to_string(),
|
||||
npub: id.npub(),
|
||||
address: id.address().to_ipv6().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the phone-side node config: leaf-only (never routes third-party
|
||||
/// traffic — battery), no DNS responder, TUN enabled but attached to the
|
||||
/// VpnService fd rather than created.
|
||||
///
|
||||
/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
|
||||
/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
|
||||
/// local link (party mode); leaf_only still guarantees we never carry
|
||||
/// third-party transit even while accepting an inbound link.
|
||||
pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config {
|
||||
let mut cfg = Config::default();
|
||||
cfg.node.identity.nsec = Some(secret.to_string());
|
||||
cfg.node.identity.persistent = false;
|
||||
cfg.node.leaf_only = true;
|
||||
cfg.tun.enabled = true;
|
||||
cfg.tun.mtu = Some(1280);
|
||||
cfg.dns.enabled = false;
|
||||
cfg.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
bind_addr: Some(format!("0.0.0.0:{listen_port}")),
|
||||
..Default::default()
|
||||
});
|
||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
||||
cfg.peers = peers;
|
||||
cfg
|
||||
}
|
||||
|
||||
/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`:
|
||||
/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]`
|
||||
pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
|
||||
serde_json::from_str(peers_json).context("peers JSON")
|
||||
}
|
||||
|
||||
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
|
||||
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
||||
/// Any previously running node is stopped first.
|
||||
pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
|
||||
stop();
|
||||
|
||||
// Android hands the VpnService TUN fd over in non-blocking mode on some
|
||||
// OS builds. The fips TUN reader is a dedicated blocking-read thread that
|
||||
// treats EAGAIN as fatal — the loop died at startup ("TUN read error …
|
||||
// Try again (os error 11)" on-device), so sessions came up but no packet
|
||||
// ever entered the mesh. Force the fd into the blocking mode the reader
|
||||
// is designed for.
|
||||
unsafe {
|
||||
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
|
||||
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
|
||||
libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
let peers = parse_peers(peers_json)?;
|
||||
let config = build_config(secret, peers, listen_port);
|
||||
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
|
||||
let npub = node.npub();
|
||||
let address = node.identity().address().to_ipv6().to_string();
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.thread_name("archy-fips")
|
||||
.build()
|
||||
.context("tokio runtime")?;
|
||||
|
||||
let shutdown = Arc::new(Notify::new());
|
||||
let running = Arc::new(AtomicBool::new(false));
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>();
|
||||
|
||||
let task = {
|
||||
let shutdown = shutdown.clone();
|
||||
let running = running.clone();
|
||||
runtime.spawn(async move {
|
||||
match node.start_with_tun_fd(tun_fd).await {
|
||||
Ok(()) => {
|
||||
running.store(true, Ordering::SeqCst);
|
||||
let _ = started_tx.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = started_tx.send(Err(anyhow!("node start: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("mesh rx loop error: {e}");
|
||||
}
|
||||
}
|
||||
_ = shutdown.notified() => {}
|
||||
}
|
||||
if let Err(e) = node.stop().await {
|
||||
tracing::warn!("mesh stop: {e}");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
})
|
||||
};
|
||||
|
||||
let started = runtime
|
||||
.block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await })
|
||||
.map_err(|_| anyhow!("node start timed out"))?
|
||||
.map_err(|_| anyhow!("node task died during start"))?;
|
||||
if let Err(e) = started {
|
||||
runtime.shutdown_background();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
*MESH.lock().unwrap() = Some(MeshHandle {
|
||||
runtime,
|
||||
task: Some(task),
|
||||
shutdown,
|
||||
running,
|
||||
npub: npub.clone(),
|
||||
address: address.clone(),
|
||||
});
|
||||
Ok((npub, address))
|
||||
}
|
||||
|
||||
/// Stop the mesh node if running. Idempotent.
|
||||
pub fn stop() {
|
||||
let Some(mut handle) = MESH.lock().unwrap().take() else {
|
||||
return;
|
||||
};
|
||||
handle.shutdown.notify_waiters();
|
||||
if let Some(task) = handle.task.take() {
|
||||
let _ = handle
|
||||
.runtime
|
||||
.block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await });
|
||||
}
|
||||
handle.runtime.shutdown_background();
|
||||
}
|
||||
|
||||
pub fn is_running() -> bool {
|
||||
MESH.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|h| h.running.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `{running, npub, address}` for the Kotlin status surface.
|
||||
pub fn status_json() -> String {
|
||||
let guard = MESH.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(h) => serde_json::json!({
|
||||
"running": h.running.load(Ordering::SeqCst),
|
||||
"npub": h.npub,
|
||||
"address": h.address,
|
||||
})
|
||||
.to_string(),
|
||||
None => serde_json::json!({ "running": false }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_roundtrip() {
|
||||
let a = generate_identity().unwrap();
|
||||
let b = derive_identity(&a.secret_hex).unwrap();
|
||||
assert_eq!(a.npub, b.npub);
|
||||
assert_eq!(a.address, b.address);
|
||||
// ULA in fd::/8
|
||||
assert!(a.address.starts_with("fd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peers_json_parses_into_peer_config() {
|
||||
let peers = parse_peers(
|
||||
r#"[{
|
||||
"npub": "npub1abc",
|
||||
"alias": "My Archipelago",
|
||||
"addresses": [
|
||||
{"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10},
|
||||
{"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20}
|
||||
]
|
||||
}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(peers[0].addresses.len(), 2);
|
||||
assert!(peers[0].is_auto_connect());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_is_leaf_only_with_tun() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![], 0);
|
||||
assert!(cfg.node.leaf_only);
|
||||
assert!(cfg.tun.enabled);
|
||||
assert_eq!(cfg.tun.mtu(), 1280);
|
||||
assert!(!cfg.dns.enabled);
|
||||
assert!(!cfg.transports.udp.is_empty());
|
||||
assert!(!cfg.transports.tcp.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listen_port_sets_fixed_udp_bind() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![], 2121);
|
||||
// Party mode keeps leaf_only — accepting a link is not routing transit.
|
||||
assert!(cfg.node.leaf_only);
|
||||
let TransportInstances::Single(udp) = &cfg.transports.udp else {
|
||||
panic!("expected single UDP transport");
|
||||
};
|
||||
assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
|
||||
}
|
||||
}
|
||||
+104
@@ -1,5 +1,109 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.112-alpha (2026-07-22)
|
||||
|
||||
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
|
||||
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
|
||||
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
|
||||
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
|
||||
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
|
||||
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
|
||||
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
|
||||
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
|
||||
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
|
||||
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
|
||||
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
|
||||
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
|
||||
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
|
||||
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
|
||||
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
|
||||
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
|
||||
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
|
||||
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
|
||||
|
||||
## v1.7.111-alpha (2026-07-22)
|
||||
|
||||
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
|
||||
- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.)
|
||||
- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers.
|
||||
- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too.
|
||||
- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed.
|
||||
- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones.
|
||||
- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data.
|
||||
- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node.
|
||||
- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing.
|
||||
- On the phone home screen, the wallet card moved up to sit right under My Apps.
|
||||
- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably.
|
||||
|
||||
## v1.7.110-alpha (2026-07-21)
|
||||
|
||||
- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,13 +9,22 @@ app:
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
|
||||
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
|
||||
# container is actually running (Knots, Core, or any future distro archy
|
||||
# ships), so the gateway is never pinned to one node's container name.
|
||||
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
|
||||
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
|
||||
custom_args:
|
||||
- >-
|
||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||
else
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||
fi
|
||||
derived_env:
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||
|
||||
@@ -21,6 +21,11 @@ app:
|
||||
template: fedimint://{{HOST_MDNS}}:8173
|
||||
- key: FM_API_URL
|
||||
template: ws://{{HOST_MDNS}}:8174
|
||||
# Resolves to whichever bitcoin container is running (Knots/Core/future
|
||||
# distro) instead of a hardcoded name — the guardian works on any node
|
||||
# regardless of which Bitcoin software it runs.
|
||||
- key: FM_BITCOIND_URL
|
||||
template: "http://{{BITCOIN_HOST}}:8332"
|
||||
secret_env:
|
||||
- key: FM_BITCOIND_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
@@ -62,7 +67,8 @@ app:
|
||||
|
||||
environment:
|
||||
- FM_DATA_DIR=/data
|
||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
|
||||
# hardcoded name — do not re-add it here.
|
||||
- FM_BITCOIND_USERNAME=archipelago
|
||||
- FM_BITCOIN_NETWORK=bitcoin
|
||||
- FM_BIND_P2P=0.0.0.0:8173
|
||||
|
||||
@@ -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,78 @@
|
||||
app:
|
||||
id: pine-whisper
|
||||
name: Pine Whisper (STT)
|
||||
# App revision 3.4.2 = upstream wyoming-whisper 3.4.1 image + tuned args
|
||||
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
|
||||
# pick up the args change; the pre-release form "3.4.1-1" would compare
|
||||
# LOWER than 3.4.1 under semver and never roll out.
|
||||
version: "3.4.2"
|
||||
description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist.
|
||||
category: home
|
||||
|
||||
# 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).
|
||||
# --beam-size 1: the image default is 5 on x86 (1 on ARM). Benchmarked on
|
||||
# framework-pt (i5-1135G7, base-int8): beam 1 transcribes the same text
|
||||
# ~45% faster — the standard low-latency setting for short voice commands
|
||||
# (HA's own whisper add-on defaults to 1).
|
||||
custom_args: ["--model", "base-int8", "--language", "en", "--beam-size", "1"]
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
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
|
||||
Generated
+2
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.101-alpha"
|
||||
version = "1.7.111-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -145,6 +145,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial2-tokio",
|
||||
"sha2 0.10.9",
|
||||
"socket2 0.5.10",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.101-alpha"
|
||||
version = "1.7.111-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
@@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
||||
[dependencies]
|
||||
# Core dependencies
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with
|
||||
# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt).
|
||||
socket2 = "0.5"
|
||||
libc = "0.2" # process-group signalling for the supervised reticulum daemon
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -188,7 +188,7 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
// Not a paid item — no invoice to issue.
|
||||
return Ok(build_response(
|
||||
@@ -198,6 +198,13 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
};
|
||||
if !content_server::method_accepted(&item.access, "lightning") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
|
||||
));
|
||||
}
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
@@ -315,7 +322,18 @@ impl ApiHandler {
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&i.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -207,7 +207,11 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"invalid backup id"}"#),
|
||||
));
|
||||
}
|
||||
let file = self.config.data_dir.join("backups").join(format!("{id}.bak"));
|
||||
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)
|
||||
@@ -576,6 +580,26 @@ impl ApiHandler {
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// Pine node status — public tier (version/uptime/height/sync/peer
|
||||
// counts) is unauthenticated like /bitcoin-status; Lightning
|
||||
// balances + latest mesh message additionally require the bearer
|
||||
// token the pine/HA seeder minted (or a valid session).
|
||||
(Method::GET, "/api/pine/status") => {
|
||||
let bearer = headers
|
||||
.get(hyper::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
let authorized = self.rpc_handler.pine_status_token_ok(bearer).await
|
||||
|| self.is_authenticated(&headers).await;
|
||||
let body = self.rpc_handler.pine_status_json(authorized).await;
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
|
||||
@@ -9,6 +9,23 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
|
||||
// Companion device-token login: minted via auth.createDeviceToken and
|
||||
// carried by the pairing QR. Verified here so it shares the login rate
|
||||
// limiter with password attempts.
|
||||
if let Some(token) = params.get("token").and_then(|v| v.as_str()) {
|
||||
return match crate::device_tokens::verify(&self.config.data_dir, token).await {
|
||||
Some(device) => {
|
||||
tracing::info!("[onboarding] device-token login ({device})");
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("[onboarding] device-token login failed");
|
||||
Err(anyhow::anyhow!("Invalid device token"))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let password = params
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -32,6 +49,15 @@ impl RpcHandler {
|
||||
|
||||
let valid = self.auth_manager.verify_password(password).await?;
|
||||
if !valid {
|
||||
// The companion app sends its device token through the password
|
||||
// field (it reuses the whole password auto-login path, including
|
||||
// the WebView form). Accept a valid token here so that path works.
|
||||
if let Some(device) =
|
||||
crate::device_tokens::verify(&self.config.data_dir, password).await
|
||||
{
|
||||
tracing::info!("[onboarding] device-token login via password field ({device})");
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
tracing::warn!("[onboarding] login failed — wrong password");
|
||||
return Err(anyhow::anyhow!("Password Incorrect"));
|
||||
}
|
||||
@@ -73,6 +99,56 @@ impl RpcHandler {
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Mint a device token for the companion pairing QR. Session-gated by the
|
||||
/// dispatcher (not in UNAUTHENTICATED_METHODS), so only a logged-in web UI
|
||||
/// can mint one. The plaintext token is returned exactly once.
|
||||
pub(super) async fn handle_auth_create_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("companion")
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name.len() > 64 {
|
||||
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
|
||||
}
|
||||
// The default name was a single shared slot: every pairing popup
|
||||
// replaced the previous phone's token, silently logging out the
|
||||
// first phone the moment a second one paired. Default-named mints
|
||||
// get a unique suffix so each device keeps its own credential;
|
||||
// explicitly named devices keep replace-in-place semantics.
|
||||
if name == "companion" {
|
||||
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
|
||||
}
|
||||
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
||||
Ok(serde_json::json!({ "name": name, "token": token }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_list_device_tokens(&self) -> Result<serde_json::Value> {
|
||||
let tokens = crate::device_tokens::list(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!(tokens
|
||||
.iter()
|
||||
.map(|t| serde_json::json!({ "name": t.name, "created": t.created }))
|
||||
.collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_revoke_device_token(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing name"))?;
|
||||
let removed = crate::device_tokens::remove(&self.config.data_dir, name).await?;
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
|
||||
tracing::info!("[onboarding] logout");
|
||||
Ok(serde_json::Value::Null)
|
||||
@@ -147,6 +223,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.
|
||||
|
||||
@@ -179,7 +179,24 @@ impl RpcHandler {
|
||||
if price == 0 {
|
||||
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
||||
}
|
||||
AccessControl::Paid { price_sats: price }
|
||||
// Optional list of payment methods the sharer accepts.
|
||||
// Absent/empty = all methods (backward compatible).
|
||||
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
|
||||
let accepted: Vec<String> = params
|
||||
.get("accepted_methods")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m.as_str())
|
||||
.filter(|m| KNOWN_METHODS.contains(m))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
AccessControl::Paid {
|
||||
price_sats: price,
|
||||
accepted,
|
||||
}
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
||||
};
|
||||
@@ -412,6 +429,55 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||
}
|
||||
|
||||
// NEVER pay twice for content we already own (2026-07-22: a file
|
||||
// shared twice produced two catalog ids for the same bytes and the
|
||||
// buyer paid both). Guard BEFORE any ecash is minted, matching both
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/')
|
||||
== f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) = crate::content_owned::read_owned(
|
||||
&self.config.data_dir,
|
||||
&o.onion,
|
||||
&o.content_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(serde_json::json!({
|
||||
"owned": true,
|
||||
"already_owned": true,
|
||||
"filename": o.filename,
|
||||
"mime_type": mime,
|
||||
"size_bytes": bytes.len(),
|
||||
"paid_sats": 0,
|
||||
"data_base64":
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
}));
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||
// verify_payment_token accepts either, so a node whose balance lives in
|
||||
@@ -590,6 +656,54 @@ impl RpcHandler {
|
||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||
}
|
||||
|
||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||
// Photos for images/video, Music for audio, Documents otherwise —
|
||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||
// from the purchase cache; this makes the file ALSO show up where
|
||||
// files live, on every device, without relying on a browser
|
||||
// download. Best-effort: never fail a paid download over it.
|
||||
{
|
||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||
"Photos"
|
||||
} else if mime_type.starts_with("audio/") {
|
||||
"Music"
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let base = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download")
|
||||
.to_string();
|
||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||
} else {
|
||||
// Don't clobber an existing file of the same name: "x.jpg"
|
||||
// → "x (2).jpg" etc.
|
||||
let mut target = dir.join(&base);
|
||||
let (stem, ext) = match base.rsplit_once('.') {
|
||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||
_ => (base.clone(), String::new()),
|
||||
};
|
||||
let mut n = 2;
|
||||
while target.exists() {
|
||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!(
|
||||
"paid download: filed into {}",
|
||||
target.display()
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ impl RpcHandler {
|
||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||
"auth.isOnboardingComplete" => self.handle_auth_is_onboarding_complete().await,
|
||||
"auth.resetOnboarding" => self.handle_auth_reset_onboarding(params).await,
|
||||
"auth.createDeviceToken" => self.handle_auth_create_device_token(params).await,
|
||||
"auth.listDeviceTokens" => self.handle_auth_list_device_tokens().await,
|
||||
"auth.revokeDeviceToken" => self.handle_auth_revoke_device_token(params).await,
|
||||
|
||||
// Seed management (BIP-39 mnemonic)
|
||||
"seed.generate" => self.handle_seed_generate().await,
|
||||
@@ -257,6 +260,7 @@ impl RpcHandler {
|
||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
@@ -383,6 +387,7 @@ impl RpcHandler {
|
||||
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
@@ -456,6 +461,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,
|
||||
@@ -484,6 +491,7 @@ impl RpcHandler {
|
||||
|
||||
// FIPS mesh transport
|
||||
"fips.status" => self.handle_fips_status().await,
|
||||
"fips.pair-info" => self.handle_fips_pair_info().await,
|
||||
"fips.check-update" => self.handle_fips_check_update().await,
|
||||
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||
"fips.install" => self.handle_fips_install().await,
|
||||
|
||||
@@ -118,6 +118,31 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
|
||||
/// with sufficient balance. Returns the notes token for the recipient
|
||||
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
|
||||
/// split from Cashu 2026-07-22). The heavy lifting already existed in
|
||||
/// `fedimint_client::spend_from_any`; it was simply never exposed.
|
||||
pub(super) async fn handle_wallet_fedimint_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
|
||||
let (token, federation_id) =
|
||||
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
|
||||
.await?;
|
||||
Ok(serde_json::json!({
|
||||
"token": token,
|
||||
"federation_id": federation_id,
|
||||
"amount_sats": amount_sats,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||
|
||||
@@ -16,6 +16,53 @@ impl RpcHandler {
|
||||
Ok(serde_json::to_value(status)?)
|
||||
}
|
||||
|
||||
/// Everything the companion app needs to join this node's mesh, embedded
|
||||
/// in the pairing QR by the web UI: the daemon's npub (identity to dial),
|
||||
/// the fips0 ULA (where the UI is reachable once the phone is meshed),
|
||||
/// and the transport ports on this host. The QR builder supplies the
|
||||
/// host/IP itself — it knows which origin the browser reached the node on.
|
||||
pub(super) async fn handle_fips_pair_info(&self) -> Result<serde_json::Value> {
|
||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||
let npub = crate::identity::fips_npub(&identity_dir).await?.ok_or_else(|| {
|
||||
anyhow::anyhow!("FIPS identity not provisioned yet — complete onboarding first")
|
||||
})?;
|
||||
let ula = fips::iface::fips0_ula().map(|ip| ip.to_string());
|
||||
// The node's seed anchors ride along so the phone can rendezvous
|
||||
// through the same public mesh points when the node's LAN endpoint
|
||||
// isn't directly dialable (phone away from home, node behind NAT).
|
||||
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Pairing must always carry a public rendezvous point: without one the
|
||||
// phone is IP-bound to the LAN host it scanned and goes dark the
|
||||
// moment it leaves that network. This is a pairing hint only — the
|
||||
// node's own anchor file is not modified, so an operator's removal of
|
||||
// the default anchors still sticks for the node itself.
|
||||
if !anchor_list
|
||||
.iter()
|
||||
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
|
||||
{
|
||||
anchor_list.push(fips::anchors::archy_anchor());
|
||||
}
|
||||
let anchors = anchor_list
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"npub": a.npub,
|
||||
"addr": a.address,
|
||||
"transport": a.transport,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"npub": npub,
|
||||
"ula": ula,
|
||||
"udp_port": fips::PUBLISHED_UDP_PORT,
|
||||
"tcp_port": fips::DEFAULT_TCP_PORT,
|
||||
"anchors": anchors,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
|
||||
let check = fips::update::check().await?;
|
||||
Ok(serde_json::to_value(check)?)
|
||||
|
||||
@@ -86,7 +86,7 @@ impl RpcHandler {
|
||||
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||
.unwrap_or_default();
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.config.nostr_relays.clone();
|
||||
let relays = self.handshake_relays().await;
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
@@ -106,6 +106,17 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// The relay set every handshake operation uses: the user-managed relay
|
||||
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
|
||||
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
|
||||
/// hardcoded config relays (one of which is defunct) and ignored user
|
||||
/// relay edits entirely — so a sender publishing where the receiver
|
||||
/// never read was a routine, silent way for peer requests to vanish.
|
||||
pub(super) async fn handshake_relays(&self) -> Vec<String> {
|
||||
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Discover discoverable nodes via Nostr presence events.
|
||||
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||
@@ -113,9 +124,10 @@ impl RpcHandler {
|
||||
// to query relays as long as the user is actively browsing — they're
|
||||
// an anonymous observer of presence events, not publishing anything.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let nodes = nostr_handshake::discover_nodes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -161,7 +173,7 @@ impl RpcHandler {
|
||||
our_version,
|
||||
our_name,
|
||||
message,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
@@ -191,6 +203,40 @@ impl RpcHandler {
|
||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||
///
|
||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
|
||||
/// ONLY when a user opened Federation and pressed the Poll button — a
|
||||
/// peer request sat on the relay until the target's operator happened to
|
||||
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
|
||||
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
|
||||
/// and nudges the websocket revision when anything new lands so open UIs
|
||||
/// refresh immediately.
|
||||
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
|
||||
match self.handle_handshake_poll().await {
|
||||
Ok(res) => {
|
||||
let new = res
|
||||
.get("new_requests")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
let applied = res
|
||||
.get("applied_invites")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if new > 0 || applied > 0 {
|
||||
tracing::info!(
|
||||
new_requests = new,
|
||||
applied_invites = applied,
|
||||
"handshake poll: inbound peer activity"
|
||||
);
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||
// touch the relays. The poll endpoint is a hard no-op until they
|
||||
@@ -207,9 +253,10 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let relays = self.handshake_relays().await;
|
||||
let handshakes = nostr_handshake::poll_handshakes(
|
||||
&identity_dir,
|
||||
&self.config.nostr_relays,
|
||||
&relays,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,21 @@ use tracing::info;
|
||||
|
||||
use super::LND_REST_BASE_URL;
|
||||
|
||||
/// LND rejects RPCs with "server is still in the process of starting" for a
|
||||
/// short window after wallet unlock (p2p/graph subsystems still loading).
|
||||
/// Transient by design — match it so callers retry and, if it persists,
|
||||
/// surface a calm notice instead of a scary failure. The exact phrase below
|
||||
/// is what the frontend keys its softer (non-red) styling on.
|
||||
fn lnd_still_starting(msg: &str) -> bool {
|
||||
let m = msg.to_ascii_lowercase();
|
||||
m.contains("in the process of starting") || m.contains("server is still starting")
|
||||
}
|
||||
|
||||
/// User-facing text for the still-starting state. Deliberately calm: this is
|
||||
/// a "wait a moment", not an error.
|
||||
const LND_STARTING_MSG: &str = "Your Lightning node is still finishing its startup — this \
|
||||
usually takes a minute or two after the node comes online. Please try again shortly.";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChannelInfo {
|
||||
chan_id: String,
|
||||
@@ -251,25 +266,45 @@ impl RpcHandler {
|
||||
"perm": false,
|
||||
"timeout": "30"
|
||||
});
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// Right after wallet unlock, LND's RPC answers while its p2p
|
||||
// server is still spinning up, and every connect attempt gets
|
||||
// "server is still in the process of starting". That's a
|
||||
// transient state, not a failure — it clears in seconds — so
|
||||
// retry quietly for ~30s before surfacing a calm, non-scary
|
||||
// notice (the frontend styles LND_STARTING_MSG as info, not red).
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
let connect_resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&connect_body)
|
||||
.timeout(std::time::Duration::from_secs(35))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
|
||||
if !connect_resp.status().is_success() {
|
||||
if connect_resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
let body: serde_json::Value = connect_resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// LND returns an error if we already have this peer — that is fine
|
||||
if !msg.contains("already connected") {
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
if msg.contains("already connected") {
|
||||
break;
|
||||
}
|
||||
if lnd_still_starting(msg) && attempt < 5 {
|
||||
attempt += 1;
|
||||
info!(attempt, "LND still starting — retrying peer connect in 6s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
continue;
|
||||
}
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to connect to peer: {}", msg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +340,9 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
if lnd_still_starting(msg) {
|
||||
return Err(anyhow::anyhow!("{LND_STARTING_MSG}"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,172 @@ pub(crate) async fn read_lnd_admin_macaroon() -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Real-time wallet push (user req 2026-07-22): the UI must reflect an
|
||||
/// incoming on-chain transaction the moment the node sees it, not on the
|
||||
/// next poll. Streams LND's `/v1/transactions/subscribe` — it fires on
|
||||
/// 0-conf mempool arrival AND again on each confirmation — and nudges the
|
||||
/// shared data-model revision on every event; /ws/db pushes that to every
|
||||
/// connected client and the frontend refetches wallet balance/transactions.
|
||||
/// Reconnects forever with capped backoff: LND restarting, wallet locked, or
|
||||
/// LND not installed yet all just mean "try again shortly".
|
||||
pub(crate) fn spawn_lnd_tx_watcher(state_manager: std::sync::Arc<crate::state::StateManager>) {
|
||||
tokio::spawn(async move {
|
||||
let mut delay = std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
match stream_lnd_transactions(&state_manager).await {
|
||||
// Stream ended cleanly (LND shutdown) — resume fast.
|
||||
Ok(()) => delay = std::time::Duration::from_secs(5),
|
||||
Err(e) => {
|
||||
tracing::debug!("lnd tx watcher: {e:#} — retrying in {delay:?}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(std::time::Duration::from_secs(120));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> {
|
||||
let macaroon_hex = hex::encode(read_lnd_admin_macaroon().await?);
|
||||
// Dedicated client: the shared lnd_client() carries a 15s total timeout,
|
||||
// which would kill this deliberately long-lived stream.
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create streaming HTTP client")?;
|
||||
let mut resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/subscribe"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("subscribe request failed")?;
|
||||
anyhow::ensure!(
|
||||
resp.status().is_success(),
|
||||
"transactions/subscribe returned {}",
|
||||
resp.status()
|
||||
);
|
||||
tracing::info!("lnd tx watcher: streaming wallet transaction events");
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any streamed event = wallet activity. Revision bump is the push
|
||||
// contract (same pattern as the mesh-peer bridge in server.rs) — the
|
||||
// clients refetch, so we don't need to parse the event body.
|
||||
let (data, _) = sm.get_snapshot().await;
|
||||
sm.update_data(data).await;
|
||||
tracing::debug!(
|
||||
bytes = chunk.len(),
|
||||
"lnd tx watcher: wallet tx event — nudged ws clients"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
|
||||
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||
/// with channels that need a peer) persists. A restart reliably clears it
|
||||
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||
/// operator.
|
||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||
tokio::spawn(async move {
|
||||
let mut bad_minutes: u32 = 0;
|
||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||
continue;
|
||||
};
|
||||
let macaroon_hex = hex::encode(bytes);
|
||||
let Ok(client) = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(resp) = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
else {
|
||||
bad_minutes = 0; // down/locked — not the wedge signature
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
};
|
||||
let synced = info
|
||||
.get("synced_to_chain")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let channels = info
|
||||
.get("num_active_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_inactive_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
+ info
|
||||
.get("num_pending_channels")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let wedged = !synced || (channels > 0 && peers == 0);
|
||||
if !wedged {
|
||||
bad_minutes = 0;
|
||||
continue;
|
||||
}
|
||||
bad_minutes += 1;
|
||||
if bad_minutes < 15 {
|
||||
continue;
|
||||
}
|
||||
if last_restart
|
||||
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
synced_to_chain = synced,
|
||||
num_peers = peers,
|
||||
channels,
|
||||
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||
);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "lnd"])
|
||||
.output()
|
||||
.await;
|
||||
match out {
|
||||
Ok(o) if o.status.success() => {
|
||||
tracing::info!("LND watchdog restart complete");
|
||||
}
|
||||
Ok(o) => tracing::warn!(
|
||||
"LND watchdog restart failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||
}
|
||||
last_restart = Some(tokio::time::Instant::now());
|
||||
bad_minutes = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Helper: create an authenticated LND REST client.
|
||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||
|
||||
@@ -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"))
|
||||
@@ -55,6 +62,14 @@ impl RpcHandler {
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
// Invoices are short-lived; retrying the same one can never
|
||||
// succeed, so tell the user the way out instead of just the fact.
|
||||
if msg.contains("invoice expired") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||
msg.trim_start_matches("invoice expired. ")
|
||||
));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,34 @@ 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);
|
||||
}
|
||||
}
|
||||
// Hot-swap "keep as is": false = never write config to the radio
|
||||
// (region/channel/PHY/advert name) — use it exactly as flashed.
|
||||
if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) {
|
||||
config.manage_radio = manage;
|
||||
}
|
||||
// Firmware pin: probe only the named firmware on the port ("auto"/""
|
||||
// clears the pin and restores strict-probe auto-detect).
|
||||
if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) {
|
||||
|
||||
@@ -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
|
||||
@@ -48,6 +52,9 @@ impl RpcHandler {
|
||||
.map(|k| k.to_string().to_lowercase())
|
||||
.into(),
|
||||
);
|
||||
// Hot-swap "keep as is" state: false = archipelago never writes
|
||||
// config to the radio (see MeshConfig::manage_radio).
|
||||
obj.insert("manage_radio".into(), config.manage_radio.into());
|
||||
// USB identity per detected port so the setup modal can show the
|
||||
// actual board (product string on native-USB boards, vid:pid as
|
||||
// the fallback for bridge chips).
|
||||
@@ -74,6 +81,35 @@ impl RpcHandler {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// mesh.probe-device — Identify the firmware on a detected serial port and
|
||||
/// read its current configuration WITHOUT provisioning it. Powers the
|
||||
/// hot-swap "device detected" modal's current-details view. The path must
|
||||
/// be one of the currently detected candidate ports (no arbitrary device
|
||||
/// paths), and probing the live session's own port is refused.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_probe_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
let service = self.mesh_service.read().await;
|
||||
let probe = match service.as_ref() {
|
||||
Some(svc) => svc.probe_device(&path).await?,
|
||||
// No mesh service yet (radio never enabled) — probe directly.
|
||||
None => mesh::listener::probe_device(&path).await?,
|
||||
};
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
|
||||
@@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Mempool requires",
|
||||
"Container",
|
||||
"Image",
|
||||
// Wallet-actionable errors: masking "Insufficient balance: need 80
|
||||
// sats, have 0 sats" behind "Operation failed. Check server logs."
|
||||
// sent the operator to journalctl for a message that was written for
|
||||
// them in the first place (ecash send, 2026-07-22).
|
||||
"Insufficient balance",
|
||||
"Insufficient funds",
|
||||
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||
// Valid until …", "no route", …) — the user can act on every one of
|
||||
// them, and masking sent the operator to journalctl (invoice-expired
|
||||
// send, 2026-07-23).
|
||||
"Payment failed",
|
||||
"Invalid payment request",
|
||||
"Missing 'payment_request'",
|
||||
"Your Lightning node is still finishing",
|
||||
"Bitcoin address",
|
||||
"No router",
|
||||
"No OpenWrt",
|
||||
@@ -151,6 +165,25 @@ mod sanitize_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_payment_errors_pass_through() {
|
||||
// LND's payment-failure reasons are written for the payer — masking
|
||||
// "invoice expired" as "Check server logs" left a user retrying a
|
||||
// dead invoice (framework-pt, 2026-07-23).
|
||||
for msg in [
|
||||
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
|
||||
"Payment failed: unable to find a path to destination",
|
||||
"Invalid payment request: must be a Lightning invoice (lnbc...)",
|
||||
"Missing 'payment_request' parameter",
|
||||
] {
|
||||
assert_ne!(
|
||||
sanitize_error_message(msg),
|
||||
"Operation failed. Check server logs for details.",
|
||||
"masked: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_errors_stay_generic() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -15,7 +15,7 @@ mod fips;
|
||||
mod handshake;
|
||||
mod identity;
|
||||
mod interfaces;
|
||||
pub(in crate::api) mod lnd;
|
||||
pub(crate) mod lnd;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod middleware;
|
||||
@@ -26,7 +26,9 @@ mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
pub(crate) use package::wyoming_satellite_keeper;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
mod router;
|
||||
mod security;
|
||||
@@ -438,7 +440,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,
|
||||
@@ -525,9 +533,31 @@ impl RpcHandler {
|
||||
self.login_rate_limiter.record_failure(client_ip).await;
|
||||
}
|
||||
|
||||
// On successful login, check if 2FA is required
|
||||
// On successful login, check if 2FA is required. Device-token logins
|
||||
// (companion pairing QR) skip the TOTP challenge like remember-me does:
|
||||
// the token was minted from an already-authenticated session, and there
|
||||
// is no password with which to decrypt the TOTP secret anyway.
|
||||
if method == "auth.login" && rpc_resp.error.is_none() {
|
||||
let totp_enabled = self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str());
|
||||
let is_token_login = login_params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
|| match password {
|
||||
// Companion device tokens also arrive through the password
|
||||
// field (see handle_auth_login) — those logins get a full
|
||||
// session too; there's no password to decrypt TOTP with.
|
||||
Some(pw) => crate::device_tokens::verify(&self.config.data_dir, pw)
|
||||
.await
|
||||
.is_some(),
|
||||
None => false,
|
||||
};
|
||||
let totp_enabled = !is_token_login
|
||||
&& self.auth_manager.is_totp_enabled().await.unwrap_or(false);
|
||||
if totp_enabled {
|
||||
let password = login_params
|
||||
.as_ref()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -206,19 +206,24 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !deps.has_bitcoin {
|
||||
missing.push("Bitcoin Knots");
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push("ElectrumX");
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
missing.join(" and ")
|
||||
))
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"Mempool requires Bitcoin Knots to be running. \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
// ElectrumX is deliberately NOT a hard gate for mempool: mempool-api
|
||||
// reconnects to electrum on its own (verified live — it retries
|
||||
// ECONNREFUSED in a loop and comes good when electrum starts
|
||||
// listening), and an ElectrumX mid-resync can be legitimately down
|
||||
// for DAYS. The hard gate here blocked repeated mempool installs on
|
||||
// .116 (2026-07-22) while electrumx was compacting. Block/fee views
|
||||
// work from bitcoind immediately; address lookups light up when
|
||||
// ElectrumX finishes.
|
||||
"mempool" | "mempool-web" if !deps.has_electrumx => {
|
||||
info!(
|
||||
"Mempool installing while ElectrumX is not running — \
|
||||
mempool-api reconnects automatically once ElectrumX is up"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
"fedimint" if !deps.has_bitcoin => {
|
||||
info!("Fedimint installing without local Bitcoin node — configure remote Bitcoin RPC in Fedimint guardian setup");
|
||||
@@ -298,9 +303,10 @@ fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep>
|
||||
if !deps.has_bitcoin {
|
||||
missing.push(BITCOIN);
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push(ELECTRUM);
|
||||
}
|
||||
// ElectrumX intentionally not required — see check_install_deps:
|
||||
// mempool-api reconnects when electrum comes up, and a resyncing
|
||||
// ElectrumX can be down for days (the 3-minute bounded wait here
|
||||
// would still fail the install for no benefit).
|
||||
}
|
||||
// fedimint deliberately absent: check_install_deps allows it without
|
||||
// a local Bitcoin node (remote RPC configured in guardian setup).
|
||||
@@ -394,9 +400,9 @@ where
|
||||
// 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) || installing.iter().any(|i| i == c)
|
||||
})
|
||||
!dep.containers
|
||||
.iter()
|
||||
.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) {
|
||||
@@ -595,6 +601,10 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
| "nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
| "pine"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -626,6 +636,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -1106,7 +1117,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
|
||||
async fn mempool_waits_on_bitcoin_only() {
|
||||
// ElectrumX is deliberately NOT gated (2026-07-22): a resyncing
|
||||
// ElectrumX can be down for days and mempool-api reconnects on
|
||||
// its own, so the wait only covers Bitcoin — even when electrumx
|
||||
// is installed and stopped.
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let (labels, sink) = label_sink();
|
||||
let probe_calls = Arc::clone(&calls);
|
||||
@@ -1114,8 +1129,8 @@ mod tests {
|
||||
"mempool",
|
||||
move || {
|
||||
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
|
||||
// Bitcoin comes up on probe 2, electrumx on probe 3.
|
||||
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
|
||||
// Bitcoin comes up on probe 2; electrumx never does.
|
||||
async move { Ok(probe(n >= 1, false, &["bitcoin-knots", "electrumx"])) }
|
||||
},
|
||||
sink,
|
||||
36,
|
||||
@@ -1126,22 +1141,19 @@ mod tests {
|
||||
let labels = labels.lock().unwrap();
|
||||
assert_eq!(
|
||||
labels.as_slice(),
|
||||
&[
|
||||
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
|
||||
"Waiting for ElectrumX to start…".to_string(),
|
||||
]
|
||||
&["Waiting for Bitcoin to start…".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
|
||||
// Bitcoin is installed (waiting could help) but ElectrumX is not
|
||||
// installed at all — waiting can never satisfy the gate, so fail
|
||||
// fast with the canonical message.
|
||||
async fn mempool_fails_fast_when_bitcoin_is_not_installed() {
|
||||
// Bitcoin isn't installed at all — waiting can never satisfy the
|
||||
// gate, so fail fast with the canonical message. (A missing
|
||||
// ElectrumX no longer blocks anything.)
|
||||
let (labels, sink) = label_sink();
|
||||
let err = wait_for_install_deps(
|
||||
"mempool",
|
||||
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
|
||||
|| async { Ok(probe(false, false, &["electrumx"])) },
|
||||
sink,
|
||||
36,
|
||||
Duration::ZERO,
|
||||
|
||||
@@ -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
|
||||
@@ -1140,8 +1143,7 @@ impl RpcHandler {
|
||||
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;
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1561,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];
|
||||
|
||||
@@ -3,6 +3,8 @@ mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod pine_ha;
|
||||
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod set_config;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,11 @@ use tracing::info;
|
||||
|
||||
use super::install::{install_log, patch_indeedhub_nostr_provider};
|
||||
|
||||
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
// 45s, not 10s: under heavy disk load (e.g. an ElectrumX resync/compaction
|
||||
// hammering the same SSD) a plain `podman ps` was observed taking >10s on
|
||||
// .116 (2026-07-22), failing a mempool install at the probe step for no real
|
||||
// reason. Podman being slow is not podman being dead.
|
||||
const PODMAN_STACK_PROBE_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const PODMAN_STACK_LOG_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PODMAN_STACK_PULL_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const PODMAN_STACK_REMOVE_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
@@ -700,9 +704,7 @@ 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_progress(stack_name, total, total).await;
|
||||
handler
|
||||
.set_install_phase(stack_name, InstallPhase::PostInstall)
|
||||
.await;
|
||||
@@ -746,6 +748,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),
|
||||
@@ -1909,6 +1920,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("", ""));
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,17 @@ impl RpcHandler {
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "archipelago".to_string());
|
||||
// LAN IPv4 rides along for the companion pairing QR: when the
|
||||
// operator's browser reaches this node over Tailscale/VPN or
|
||||
// localhost, that origin is useless to a phone on the LAN — the QR
|
||||
// must advertise an address the phone can actually dial
|
||||
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
|
||||
// companion could never connect).
|
||||
let lan_ip = crate::host_ip::primary_host_ipv4().await;
|
||||
Ok(serde_json::json!({
|
||||
"hostname": hostname,
|
||||
"mdns_hostname": format!("{hostname}.local"),
|
||||
"lan_ip": lan_ip,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -402,6 +410,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 +727,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());
|
||||
|
||||
|
||||
@@ -763,7 +763,9 @@ mod tests {
|
||||
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();
|
||||
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");
|
||||
@@ -784,7 +786,9 @@ mod tests {
|
||||
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();
|
||||
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");
|
||||
|
||||
@@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||
|
||||
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
|
||||
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
|
||||
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
|
||||
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
|
||||
// The router daemon handles routing + the ELD re-modeset nudge; this heal
|
||||
// installs the packages, group membership, script and unit on deployed nodes.
|
||||
const AUDIO_ROUTER: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
|
||||
const AUDIO_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
|
||||
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
|
||||
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
|
||||
|
||||
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
|
||||
// splice-from-configs + self-heal pattern as the audio router.
|
||||
const GAMEPAD_KEYS: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
|
||||
const GAMEPAD_SERVICE: &str =
|
||||
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
|
||||
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
|
||||
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
|
||||
|
||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||
// write the identical file at build time (image-recipe/_archived/
|
||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||
@@ -80,6 +102,13 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the Pine node-status proxy.
|
||||
/// `/api/pine/status` serves the Pine launcher page's live status card and
|
||||
/// the seeded Home Assistant REST sensors (the sensitive tier is gated by a
|
||||
/// bearer token at the backend, so nginx just forwards). Kept in sync with
|
||||
/// the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PINE_STATUS_BLOCK: &str = "\n # Pine node status — live node facts for the Pine launcher page and the\n # seeded Home Assistant sensors. Sensitive fields are token-gated at the\n # backend; nginx only forwards.\n location /api/pine/status {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Authorization $http_authorization;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 10s;\n proxy_read_timeout 15s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
@@ -137,6 +166,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 +478,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
|
||||
@@ -718,6 +816,109 @@ pub async fn ensure_kiosk_hardened() {
|
||||
}
|
||||
}
|
||||
|
||||
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
|
||||
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
|
||||
/// under the lingering user manager — no logind seat, so no udev ACLs on
|
||||
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
|
||||
/// nudge) installed and current. No-op on nodes without the kiosk — audio
|
||||
/// only matters where media plays on an attached display.
|
||||
pub async fn ensure_audio_stack() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return; // no kiosk → no display audio to route
|
||||
}
|
||||
|
||||
// Package install runs via systemd-run (host_sudo), outside the service
|
||||
// sandbox — /usr and the dpkg database are read-only in our namespace.
|
||||
if fs::metadata("/usr/bin/pactl").await.is_err() {
|
||||
info!("audio: PipeWire stack missing — installing packages");
|
||||
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
|
||||
match host_sudo(&[
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-qq",
|
||||
"--no-install-recommends",
|
||||
"pipewire",
|
||||
"pipewire-pulse",
|
||||
"pipewire-alsa",
|
||||
"wireplumber",
|
||||
"alsa-utils",
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
|
||||
Ok(s) => {
|
||||
warn!("audio: package install exited with {} — will retry next start", s);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("audio: package install failed: {:#} — will retry next start", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
|
||||
|
||||
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("audio: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
// First install on this node — bring it up now and on every boot.
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
|
||||
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
|
||||
} else if script_changed || unit_changed {
|
||||
// Content update: restart only if it's running — never re-enable a
|
||||
// unit an operator deliberately disabled.
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
|
||||
info!("audio: router updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
|
||||
/// daemon (controller works inside every app iframe on the TV) installed and
|
||||
/// current. Same gating as audio: no kiosk → no display input to bridge.
|
||||
pub async fn ensure_gamepad_keys() {
|
||||
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
|
||||
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed {
|
||||
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
|
||||
}
|
||||
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if script_changed || unit_changed {
|
||||
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
|
||||
info!("gamepad: bridge installed and enabled (TV controller input)");
|
||||
} else if script_changed || unit_changed {
|
||||
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
|
||||
info!("gamepad: bridge updated");
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||
@@ -785,22 +986,46 @@ 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/")
|
||||
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
|
||||
// Companion mesh access: phones reach this node over FIPS at its fips0
|
||||
// ULA (http://[fdxx:…]). Configs shipped before 2026-07-23 listened on
|
||||
// IPv4 only, so the ULA could never connect — nothing answered [::]:80.
|
||||
let missing_v6_http = content.contains("listen 80 default_server;")
|
||||
&& !content.contains("listen [::]:80");
|
||||
let missing_v6_https = content.contains("listen 443 ssl default_server;")
|
||||
&& !content.contains("listen [::]:443");
|
||||
if !missing_app_catalog
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !missing_pine_status
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
&& !missing_v6_http
|
||||
&& !missing_v6_https
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut patched = content.clone();
|
||||
|
||||
if missing_v6_http {
|
||||
patched = patched.replace(
|
||||
"listen 80 default_server;",
|
||||
"listen 80 default_server;\n listen [::]:80 default_server;",
|
||||
);
|
||||
}
|
||||
if missing_v6_https {
|
||||
patched = patched.replace(
|
||||
"listen 443 ssl default_server;",
|
||||
"listen 443 ssl default_server;\n listen [::]:443 ssl default_server;",
|
||||
);
|
||||
}
|
||||
|
||||
if has_lnd_dup_cors {
|
||||
// Drop the redundant nginx-side CORS headers so the backend's single
|
||||
// validated Access-Control-Allow-Origin is the only one returned.
|
||||
@@ -838,6 +1063,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
if missing_pine_status {
|
||||
// Same anchoring as the LND proxy: prepend to every server block that
|
||||
// proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PINE_STATUS_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/pine/status patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
|
||||
@@ -108,17 +108,33 @@ impl BootReconciler {
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let interval = self.interval;
|
||||
Some(tokio::spawn(async move {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
let installed = orchestrator.manifest_ids().await;
|
||||
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
||||
{
|
||||
let failures =
|
||||
crate::container::companion::reconcile(&installed).await;
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
error = %err,
|
||||
"companion reconcile failed"
|
||||
);
|
||||
}
|
||||
time::sleep(interval).await;
|
||||
// A failed repair can involve registry pulls and full
|
||||
// image builds; retrying every 30s hammered unreachable
|
||||
// registries ~174×/image/day on an offline node
|
||||
// (archy-x250-dev log sweep, 2026-07-22). Back off
|
||||
// exponentially while rounds keep failing — 30s doubling
|
||||
// to a 1h cap — and reset the moment a round is clean.
|
||||
failure_rounds = if failures.is_empty() {
|
||||
0
|
||||
} else {
|
||||
failure_rounds.saturating_add(1)
|
||||
};
|
||||
let backoff = interval
|
||||
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
|
||||
.min(Duration::from_secs(3600));
|
||||
time::sleep(backoff).await;
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
|
||||
@@ -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")
|
||||
@@ -3062,8 +3094,9 @@ impl ProdContainerOrchestrator {
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
||||
// both Bitcoin node variants are reachable on archy-net by name.
|
||||
// The known Bitcoin node containers, preferred in order. Any archy
|
||||
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
let names = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
@@ -3073,12 +3106,23 @@ impl ProdContainerOrchestrator {
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default();
|
||||
names
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|name| BITCOIN_NAMES.contains(name))
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
||||
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||
// Prefer a known name in priority order…
|
||||
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
||||
return hit.to_string();
|
||||
}
|
||||
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
||||
// future Bitcoin distribution archy ships works without editing this
|
||||
// list (user req 2026-07-22). Excludes companions/sidecars like
|
||||
// `bitcoin-ui` and `archy-*`.
|
||||
if let Some(other) = running.iter().find(|n| {
|
||||
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
||||
&& !n.ends_with("-ui")
|
||||
&& !n.starts_with("archy-")
|
||||
}) {
|
||||
return other.to_string();
|
||||
}
|
||||
"bitcoin-knots".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3215,10 +3259,10 @@ impl ProdContainerOrchestrator {
|
||||
let mut env = manifest.app.environment.clone();
|
||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||
|
||||
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
|
||||
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
|
||||
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
|
||||
}
|
||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
|
||||
// derived-env and broke Core nodes.
|
||||
|
||||
let provider = FileSecretsProvider {
|
||||
root: self.secrets_dir.clone(),
|
||||
|
||||
@@ -284,7 +284,7 @@ impl QuadletUnit {
|
||||
let _ = writeln!(s, "PodmanArgs=--cpus={cpus}");
|
||||
}
|
||||
if let Some(h) = &self.health {
|
||||
let _ = writeln!(s, "HealthCmd={}", h.cmd);
|
||||
let _ = writeln!(s, "HealthCmd={}", h.cmd.replace('%', "%%"));
|
||||
let _ = writeln!(s, "HealthInterval={}", h.interval);
|
||||
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
|
||||
let _ = writeln!(s, "HealthRetries={}", h.retries);
|
||||
@@ -298,7 +298,7 @@ impl QuadletUnit {
|
||||
// images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper
|
||||
// into `bitcoind sh -lc ...` and crash-looped). Emitting
|
||||
// Entrypoint= makes the unit independent of the image's entrypoint.
|
||||
let _ = writeln!(s, "Entrypoint={first}");
|
||||
let _ = writeln!(s, "Entrypoint={}", first.replace('%', "%%"));
|
||||
let mut parts: Vec<String> = rest.to_vec();
|
||||
parts.extend(self.command.iter().cloned());
|
||||
if !parts.is_empty() {
|
||||
@@ -335,11 +335,16 @@ impl QuadletUnit {
|
||||
/// the minimum quoting needed so quadlet's parser sees one element per
|
||||
/// item: anything containing whitespace, quotes, or shell metacharacters
|
||||
/// gets wrapped in double quotes with embedded `"` and `\` escaped.
|
||||
///
|
||||
/// `%` is escaped to `%%`: quadlet copies Exec= content into the generated
|
||||
/// service's ExecStart, where systemd expands `%` specifiers at load time —
|
||||
/// a bare `%s` in a manifest script silently became `/bin/bash` (the user's
|
||||
/// shell) and corrupted bitcoind's generated rpc.conf.
|
||||
fn shell_join(parts: &[String]) -> String {
|
||||
parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let p = p.replace(['\r', '\n'], " ");
|
||||
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
|
||||
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
|
||||
let escaped = p
|
||||
.replace('\\', "\\\\")
|
||||
@@ -355,7 +360,8 @@ fn shell_join(parts: &[String]) -> String {
|
||||
}
|
||||
|
||||
fn quote_environment(env: &str) -> String {
|
||||
let env = env.replace(['\r', '\n'], " ");
|
||||
// `%` → `%%` for the same systemd-specifier reason as shell_join.
|
||||
let env = env.replace(['\r', '\n'], " ").replace('%', "%%");
|
||||
if env.is_empty()
|
||||
|| env
|
||||
.chars()
|
||||
@@ -1122,6 +1128,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_is_escaped_for_systemd_specifiers() {
|
||||
// Regression: a bare `%s` in an Exec= line is a systemd specifier
|
||||
// (user's shell = /bin/bash) once quadlet copies it into the
|
||||
// generated ExecStart — bitcoind's rpc.conf came out as
|
||||
// `rpcuser=/bin/bash` and every RPC consumer got 401s.
|
||||
assert_eq!(
|
||||
shell_join(&["printf 'rpcuser=%s' \"$U\"".to_string()]),
|
||||
"\"printf 'rpcuser=%%s' \\\"$$U\\\"\""
|
||||
);
|
||||
assert_eq!(shell_join(&["--fmt=%h:%p".to_string()]), "--fmt=%%h:%%p");
|
||||
assert_eq!(quote_environment("FMT=%m"), "FMT=%%m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_policy_emits_correct_systemd_string() {
|
||||
assert_eq!(RestartPolicy::Always.as_systemd(), "always");
|
||||
|
||||
@@ -51,9 +51,25 @@ pub enum AccessControl {
|
||||
PeersOnly,
|
||||
Paid {
|
||||
price_sats: u64,
|
||||
/// Payment methods the sharer accepts: "lightning", "onchain",
|
||||
/// "ecash", "fedimint". Empty = everything — which is also what
|
||||
/// catalogs written before this field deserialize to.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
accepted: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Does the sharer accept this payment method for the item? Empty list =
|
||||
/// all methods (pre-field catalogs and "no preference").
|
||||
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
|
||||
match access {
|
||||
AccessControl::Paid { accepted, .. } => {
|
||||
accepted.is_empty() || accepted.iter().any(|m| m == method)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ContentCatalog {
|
||||
pub items: Vec<ContentItem>,
|
||||
@@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
|
||||
}
|
||||
|
||||
/// Add a content item to the catalog.
|
||||
///
|
||||
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
||||
/// every call, so id-only dedup let the same file be shared twice as two
|
||||
/// separately-priced entries — and a buyer paid twice for one file
|
||||
/// (2026-07-22). Same filename → update the existing entry in place and
|
||||
/// keep its id, so existing buyers' owned records stay valid.
|
||||
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
|
||||
let mut catalog = load_catalog(data_dir).await?;
|
||||
if catalog.items.iter().any(|i| i.id == item.id) {
|
||||
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
|
||||
}
|
||||
catalog.items.push(item);
|
||||
let norm = |f: &str| f.trim_start_matches('/').to_string();
|
||||
if let Some(existing) = catalog
|
||||
.items
|
||||
.iter_mut()
|
||||
.find(|i| norm(&i.filename) == norm(&item.filename))
|
||||
{
|
||||
let keep_id = existing.id.clone();
|
||||
*existing = item;
|
||||
existing.id = keep_id;
|
||||
} else {
|
||||
catalog.items.push(item);
|
||||
}
|
||||
save_catalog(data_dir, &catalog).await?;
|
||||
Ok(catalog)
|
||||
}
|
||||
@@ -252,20 +285,26 @@ pub async fn serve_content(
|
||||
|
||||
// Check access control
|
||||
match &item.access {
|
||||
AccessControl::Paid { price_sats } => {
|
||||
AccessControl::Paid { price_sats, .. } => {
|
||||
// Two ways to satisfy payment:
|
||||
// (a) a valid ecash token (the local-wallet fast path), or
|
||||
// (b) a Lightning-invoice payment hash this node issued and has
|
||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||
// Each path only counts when the sharer accepts that method.
|
||||
let mut authorized = false;
|
||||
if let Some(token) = payment_token {
|
||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
||||
if (method_accepted(&item.access, "ecash")
|
||||
|| method_accepted(&item.access, "fedimint"))
|
||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
if !authorized {
|
||||
if let Some(hash) = invoice_hash {
|
||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
||||
if method_accepted(&item.access, "lightning")
|
||||
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||
{
|
||||
authorized = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -381,11 +403,28 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||
|
||||
for (i, record) in containers.iter().enumerate() {
|
||||
// Skip containers that are already up — `podman start` on a running
|
||||
// container produces the noisy benign conmon "Failed to create
|
||||
// container" + cgroup Permission-denied journal pair (fleet log
|
||||
// sweep 2026-07-22).
|
||||
if container_state(&record.name).await.as_deref() == Some("running") {
|
||||
report.recovered += 1;
|
||||
continue;
|
||||
}
|
||||
info!(
|
||||
"Recovering container: {} (image: {})",
|
||||
record.name, record.image
|
||||
);
|
||||
|
||||
// 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 +466,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 +492,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 +774,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 +839,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",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -861,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
async fn start_existing_container(container: &str) -> bool {
|
||||
info!("Recovering stack container: {}", container);
|
||||
let timeout = match container {
|
||||
"immich_server" | "netbird-server" => Duration::from_secs(120),
|
||||
_ => Duration::from_secs(90),
|
||||
};
|
||||
if container_state(container).await.as_deref() == Some("initialized") {
|
||||
cleanup_container_runtime_state(container).await;
|
||||
match container_state(container).await.as_deref() {
|
||||
// Already up — `podman start` on a running container makes conmon
|
||||
// try to join the live cgroup and fail with a scary "Failed to
|
||||
// create container: exit status 1" + cgroup.procs Permission denied
|
||||
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
|
||||
// these per day per node, all benign. Skip instead.
|
||||
Some("running") => return true,
|
||||
Some("initialized") => cleanup_container_runtime_state(container).await,
|
||||
_ => {}
|
||||
}
|
||||
info!("Recovering stack container: {}", container);
|
||||
match podman_output(&["start", container], timeout).await {
|
||||
Ok(output) if output.status.success() => {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Companion device tokens — long-lived bearer credentials minted from an
|
||||
//! authenticated session, so the pairing QR can log a phone in without
|
||||
//! carrying the admin password (which the browser never has anyway).
|
||||
//!
|
||||
//! Only the SHA-256 of each token is persisted (`device-tokens.json` in the
|
||||
//! data dir); the plaintext is returned exactly once at mint time and rides
|
||||
//! the QR as the `tok` param. Verification goes through `auth.login`'s
|
||||
//! `token` param and is covered by the same login rate limiter as passwords.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
const TOKENS_FILE: &str = "device-tokens.json";
|
||||
|
||||
/// Cap on stored tokens; re-pairing the same device name replaces its entry,
|
||||
/// so this only limits the number of *distinct* device names.
|
||||
const MAX_TOKENS: usize = 32;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceToken {
|
||||
pub name: String,
|
||||
/// Hex SHA-256 of the plaintext token.
|
||||
pub hash: String,
|
||||
/// Unix seconds at mint time.
|
||||
pub created: u64,
|
||||
}
|
||||
|
||||
fn tokens_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join(TOKENS_FILE)
|
||||
}
|
||||
|
||||
async fn load(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
match fs::read(tokens_path(data_dir)).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save(data_dir: &Path, tokens: &[DeviceToken]) -> Result<()> {
|
||||
let bytes = serde_json::to_vec_pretty(tokens)?;
|
||||
fs::write(tokens_path(data_dir), bytes)
|
||||
.await
|
||||
.context("write device-tokens.json")
|
||||
}
|
||||
|
||||
fn hash_hex(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
/// Mint a new token for `name`. An existing token with the same name is
|
||||
/// replaced, so re-showing the pairing QR never piles up stale entries.
|
||||
/// Returns the plaintext token — the only time it ever exists outside the QR.
|
||||
pub async fn create(data_dir: &Path, name: &str) -> Result<String> {
|
||||
let token_bytes: [u8; 32] = rand::random();
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let mut tokens = load(data_dir).await;
|
||||
tokens.retain(|t| t.name != name);
|
||||
if tokens.len() >= MAX_TOKENS {
|
||||
tokens.remove(0);
|
||||
}
|
||||
tokens.push(DeviceToken {
|
||||
name: name.to_string(),
|
||||
hash: hash_hex(&token),
|
||||
created: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
});
|
||||
save(data_dir, &tokens).await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verify a candidate token. Returns the device name it was minted for.
|
||||
pub async fn verify(data_dir: &Path, candidate: &str) -> Option<String> {
|
||||
let candidate_hash = hash_hex(candidate);
|
||||
load(data_dir)
|
||||
.await
|
||||
.iter()
|
||||
.find(|t| ct_eq(t.hash.as_bytes(), candidate_hash.as_bytes()))
|
||||
.map(|t| t.name.clone())
|
||||
}
|
||||
|
||||
/// List stored tokens (hashes only — plaintexts are unrecoverable).
|
||||
pub async fn list(data_dir: &Path) -> Vec<DeviceToken> {
|
||||
load(data_dir).await
|
||||
}
|
||||
|
||||
/// Remove the token minted for `name`. Returns whether one existed.
|
||||
pub async fn remove(data_dir: &Path, name: &str) -> Result<bool> {
|
||||
let mut tokens = load(data_dir).await;
|
||||
let before = tokens.len();
|
||||
tokens.retain(|t| t.name != name);
|
||||
let removed = tokens.len() != before;
|
||||
if removed {
|
||||
save(data_dir, &tokens).await?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_verify_replace_remove() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token = create(dir.path(), "phone").await.unwrap();
|
||||
assert_eq!(verify(dir.path(), &token).await.as_deref(), Some("phone"));
|
||||
assert!(verify(dir.path(), "not-a-token").await.is_none());
|
||||
|
||||
// Re-minting the same name invalidates the old token.
|
||||
let token2 = create(dir.path(), "phone").await.unwrap();
|
||||
assert!(verify(dir.path(), &token).await.is_none());
|
||||
assert_eq!(verify(dir.path(), &token2).await.as_deref(), Some("phone"));
|
||||
assert_eq!(list(dir.path()).await.len(), 1);
|
||||
|
||||
assert!(remove(dir.path(), "phone").await.unwrap());
|
||||
assert!(verify(dir.path(), &token2).await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Public FIPS-network anchors from join.fips.network (adverts published as
|
||||
/// Nostr kind-37195 `fips-overlay-v1` events, refreshed hourly). Of the eight
|
||||
/// live test anchors (2026-07-22), these two are the dual-transport ones —
|
||||
/// they answer on TCP as well as UDP, and TCP is what traverses restrictive
|
||||
/// networks. The remaining six are UDP-only; add them per-node via
|
||||
/// `fips.add-seed-anchor` if more rendezvous diversity is wanted.
|
||||
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
|
||||
vec![
|
||||
SeedAnchor {
|
||||
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u"
|
||||
.to_string(),
|
||||
address: "23.182.128.74:443".to_string(),
|
||||
transport: "tcp".to_string(),
|
||||
label: "FIPS network anchor (join.fips.network)".to_string(),
|
||||
},
|
||||
SeedAnchor {
|
||||
npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
|
||||
.to_string(),
|
||||
address: "217.77.8.91:443".to_string(),
|
||||
transport: "tcp".to_string(),
|
||||
label: "FIPS network anchor (join.fips.network)".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The default anchor set carried implicitly by `load()` on nodes that have
|
||||
/// never edited their anchor list, so every node dials them without operator
|
||||
/// action. Multiple anchors so one unreachable rendezvous host can't strand a
|
||||
/// 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> {
|
||||
let mut anchors = vec![archy_anchor(), default_public_anchor()];
|
||||
anchors.extend(fips_network_anchors());
|
||||
anchors
|
||||
}
|
||||
|
||||
/// One seed-anchor entry. `address` must be directly dialable (IP or
|
||||
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
|
||||
/// "tor", "ethernet" (the values upstream `fipsctl connect` accepts).
|
||||
@@ -94,7 +151,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 +325,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(), DEFAULT_PUBLIC_ANCHOR_NPUB)
|
||||
.await
|
||||
.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();
|
||||
let mut list = Vec::new();
|
||||
for anchor in default_public_anchors() {
|
||||
list = remove(dir.path(), &anchor.npub).await.unwrap();
|
||||
}
|
||||
assert!(list.is_empty());
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.is_empty(), "default must stay removed once edited");
|
||||
assert!(got.is_empty(), "defaults must stay removed once edited");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,54 +10,153 @@
|
||||
//! whitelists `install` into `/etc/fips/`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
use super::{
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
|
||||
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, PUBLISHED_UDP_PORT,
|
||||
};
|
||||
|
||||
/// 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:{PUBLISHED_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/`.
|
||||
@@ -194,7 +293,7 @@ mod tests {
|
||||
fn test_rendered_yaml_matches_upstream_schema() {
|
||||
let yaml = render_config_yaml();
|
||||
assert!(yaml.contains("persistent: true"));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", PUBLISHED_UDP_PORT)));
|
||||
assert!(yaml.contains(&format!("0.0.0.0:{}", DEFAULT_TCP_PORT)));
|
||||
assert!(yaml.contains("udp:"));
|
||||
assert!(yaml.contains("tcp:"));
|
||||
@@ -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:2121
|
||||
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();
|
||||
|
||||
@@ -119,6 +119,15 @@ pub const UPSTREAM_REPO: &str = "jmcorgan/fips";
|
||||
/// Default UDP port the daemon listens on.
|
||||
pub const DEFAULT_UDP_PORT: u16 = 8668;
|
||||
|
||||
/// UDP port archipelago actually publishes/binds for the daemon. The
|
||||
/// container publishes `2121:2121/udp` (see `package/config.rs`) and every
|
||||
/// peer roster in the fleet dials `<host>:2121`, but the rendered daemon
|
||||
/// config used to bind upstream's 8668 — leaving inbound UDP dead on
|
||||
/// bridged-network installs and everything silently riding TCP 8443. The
|
||||
/// bind now uses this port so the published mapping, the fleet rosters,
|
||||
/// and the pairing QR all agree.
|
||||
pub const PUBLISHED_UDP_PORT: u16 = 2121;
|
||||
|
||||
/// Default TCP port the daemon listens on. Used as a fallback when a
|
||||
/// peer can't be reached over UDP — common on networks that block UDP
|
||||
/// (corporate/guest wifi) and the path the public fips.v0l.io anchor
|
||||
|
||||
@@ -34,6 +34,7 @@ mod bitcoin_rpc;
|
||||
mod bitcoin_status;
|
||||
mod blobs;
|
||||
mod bootstrap;
|
||||
mod mesh_ports;
|
||||
mod ceremony;
|
||||
mod config;
|
||||
mod constants;
|
||||
@@ -45,6 +46,7 @@ mod content_server;
|
||||
mod crash_recovery;
|
||||
mod credentials;
|
||||
mod data_model;
|
||||
mod device_tokens;
|
||||
mod disk_monitor;
|
||||
mod electrs_status;
|
||||
mod federation;
|
||||
@@ -98,6 +100,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();
|
||||
|
||||
@@ -351,6 +387,23 @@ async fn main() -> Result<()> {
|
||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||
|
||||
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||
tokio::spawn(bootstrap::ensure_audio_stack());
|
||||
|
||||
// TV input: gamepad→keyboard bridge so controllers work inside every app
|
||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||
|
||||
// Mesh access: mirror IPv4-published app ports onto [::] so direct-port
|
||||
// app URLs (http://[<fips0 ULA>]:<port>) work from the companion.
|
||||
tokio::spawn(mesh_ports::run_mesh_port_mirror());
|
||||
|
||||
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
|
||||
// DHCP renumbering strands them — HA never re-resolves on its own.
|
||||
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
||||
|
||||
// Spawn periodic container snapshot (for crash recovery)
|
||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||
|
||||
|
||||
@@ -15,13 +15,15 @@ mod frames;
|
||||
mod node_cmd;
|
||||
mod session;
|
||||
|
||||
pub(crate) use session::{probe_device, DeviceProbe};
|
||||
|
||||
use super::types::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// How often to broadcast our identity advertisement (seconds).
|
||||
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
|
||||
@@ -52,6 +54,22 @@ const RX_STALL_TIMEOUT: Duration = Duration::from_secs(1800);
|
||||
/// Maximum stored messages (circular buffer).
|
||||
const MAX_MESSAGES: usize = 100;
|
||||
|
||||
/// On-disk message-history file under `data_dir/` (written by
|
||||
/// `spawn_message_persister`, restored by `load_persisted_messages`).
|
||||
const MESSAGES_FILE: &str = "mesh-messages.json";
|
||||
|
||||
/// Serialized form of the persisted history. `send_seqs` rides along because
|
||||
/// the per-target outbound sequence counters must survive restarts too:
|
||||
/// receivers dedup on (sender_pubkey, sender_seq), so a node that reboots and
|
||||
/// starts counting from 1 again has its first messages silently dropped by
|
||||
/// every peer that already saw those sequence numbers.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedMessages {
|
||||
messages: Vec<MeshMessage>,
|
||||
#[serde(default)]
|
||||
send_seqs: HashMap<u32, u64>,
|
||||
}
|
||||
|
||||
/// Check if two ISO8601 timestamps are within N seconds of each other.
|
||||
fn within_seconds_iso(ts1: &str, ts2: &str, secs: i64) -> bool {
|
||||
use chrono::DateTime;
|
||||
@@ -402,6 +420,90 @@ impl MeshState {
|
||||
let count = self.peers.read().await.len();
|
||||
self.status.write().await.peer_count = count;
|
||||
}
|
||||
|
||||
/// Restore the persisted message history + outbound sequence counters.
|
||||
/// Called once at service startup, before the listener spawns. Missing
|
||||
/// file (fresh node) is normal; a corrupt file is logged and skipped
|
||||
/// rather than blocking mesh startup.
|
||||
pub async fn load_persisted_messages(&self) {
|
||||
let path = self.data_dir.join(MESSAGES_FILE);
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(e) => {
|
||||
warn!("mesh: reading {} failed: {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let persisted: PersistedMessages = match serde_json::from_slice(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("mesh: parsing {} failed (skipping restore): {e}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = persisted.messages.len();
|
||||
let max_id = persisted.messages.iter().map(|m| m.id).max().unwrap_or(0);
|
||||
*self.messages.write().await = persisted.messages.into();
|
||||
if !persisted.send_seqs.is_empty() {
|
||||
*self.next_send_seq.write().await = persisted.send_seqs;
|
||||
}
|
||||
{
|
||||
let mut id = self.next_message_id.write().await;
|
||||
if *id <= max_id {
|
||||
*id = max_id + 1;
|
||||
}
|
||||
}
|
||||
info!("mesh: restored {count} persisted messages (next id {})", max_id + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the message history on a low-rate timer instead of hooking every
|
||||
/// mutation path (store, delivered/transport/encrypted stamps, edits, deletes,
|
||||
/// read-receipt prunes — and whatever gets added next). Serializing ≤100
|
||||
/// messages every few seconds is trivial; the write is skipped when nothing
|
||||
/// changed, and at most the last few seconds of history are lost on a hard
|
||||
/// kill — versus the entire history, which is what a restart cost before this
|
||||
/// existed. Atomic write-then-rename, 0600 (DM plaintext lives in this file).
|
||||
pub fn spawn_message_persister(state: Arc<MeshState>) {
|
||||
tokio::spawn(async move {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = state.data_dir.join(MESSAGES_FILE);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
let mut last_written: Option<Vec<u8>> = None;
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(5));
|
||||
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let snapshot = PersistedMessages {
|
||||
messages: state.messages.read().await.iter().cloned().collect(),
|
||||
send_seqs: state.next_send_seq.read().await.clone(),
|
||||
};
|
||||
let json = match serde_json::to_vec_pretty(&snapshot) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
warn!("mesh: serializing message history failed: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if last_written.as_deref() == Some(json.as_slice()) {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&tmp, &json).await {
|
||||
warn!("mesh: writing {} failed: {e}", tmp.display());
|
||||
continue;
|
||||
}
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
if let Err(e) = tokio::fs::set_permissions(&tmp, perms).await {
|
||||
warn!("mesh: chmod {} failed: {e}", tmp.display());
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
|
||||
warn!("mesh: renaming {} -> {} failed: {e}", tmp.display(), path.display());
|
||||
continue;
|
||||
}
|
||||
last_written = Some(json);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn the background mesh listener task.
|
||||
@@ -421,9 +523,11 @@ 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>,
|
||||
manage_radio: bool,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
@@ -456,9 +560,11 @@ 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(),
|
||||
manage_radio,
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
@@ -498,11 +604,17 @@ pub fn spawn_mesh_listener(
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to disconnected
|
||||
// Update status to disconnected. device_type/firmware_version are
|
||||
// reset too — they were previously left holding the LAST radio's
|
||||
// identity, so after a hot-swap the UI showed the old firmware
|
||||
// ("meshcore, disconnected") while a different stick sat in the
|
||||
// port. Unknown-until-next-successful-connect is the honest state.
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = false;
|
||||
status.device_path = None;
|
||||
status.device_type = super::types::DeviceType::Unknown;
|
||||
status.firmware_version = None;
|
||||
}
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
|
||||
|
||||
|
||||
@@ -340,6 +340,102 @@ async fn auto_detect_and_open(
|
||||
)
|
||||
}
|
||||
|
||||
/// What the hot-swap probe learned about a just-plugged radio, without
|
||||
/// provisioning anything. Serialized straight into the `mesh.probe-device`
|
||||
/// RPC response for the device setup modal.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DeviceProbe {
|
||||
pub path: String,
|
||||
/// "reticulum" | "meshcore" | "meshtastic"
|
||||
pub kind: String,
|
||||
pub firmware_version: Option<String>,
|
||||
pub node_id: Option<u32>,
|
||||
pub advert_name: Option<String>,
|
||||
/// Meshtastic-only: current radio config, read via `want_config`.
|
||||
pub region: Option<String>,
|
||||
pub modem_preset: Option<String>,
|
||||
pub primary_channel: Option<String>,
|
||||
pub secondary_channel: Option<String>,
|
||||
pub max_contacts: Option<u16>,
|
||||
}
|
||||
|
||||
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
|
||||
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
|
||||
/// for the same RNode-wedging reason) and read what's currently configured on
|
||||
/// it. The port is released when this returns, so the listener's reconnect
|
||||
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
|
||||
/// DETECT probe — no daemon spawn just to identify a stick.
|
||||
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||
// The listener's reconnect loop may hold this port for ~10s of every
|
||||
// backoff cycle, and Linux happily double-opens a tty — two concurrent
|
||||
// handshakes corrupt each other into silence (observed on framework-pt:
|
||||
// every firmware "failed" while the listener was mid-cycle). Retry across
|
||||
// the listener's idle gaps instead of failing on first collision.
|
||||
let mut last_err = None;
|
||||
for attempt in 0..3u32 {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(Duration::from_secs(7)).await;
|
||||
}
|
||||
match probe_device_once(path).await {
|
||||
Ok(p) => return Ok(p),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("probe failed")))
|
||||
}
|
||||
|
||||
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
||||
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
kind: "reticulum".to_string(),
|
||||
firmware_version: Some("RNode (KISS)".to_string()),
|
||||
node_id: None,
|
||||
advert_name: None,
|
||||
region: None,
|
||||
modem_preset: None,
|
||||
primary_channel: None,
|
||||
secondary_channel: None,
|
||||
max_contacts: None,
|
||||
});
|
||||
}
|
||||
if let Ok(mut dev) = MeshcoreDevice::open(path).await {
|
||||
if let Ok(info) = dev.initialize().await {
|
||||
let queried = dev.query_device_info().await;
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
kind: "meshcore".to_string(),
|
||||
firmware_version: queried.as_ref().map(|(v, _)| v.clone()),
|
||||
node_id: Some(info.node_id),
|
||||
advert_name: dev.advert_name(),
|
||||
region: None,
|
||||
modem_preset: None,
|
||||
primary_channel: None,
|
||||
secondary_channel: None,
|
||||
max_contacts: queried.map(|(_, m)| m).or(Some(info.max_contacts)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Ok(mut dev) = MeshtasticDevice::open(path).await {
|
||||
if let Ok(info) = dev.initialize().await {
|
||||
let cfg = dev.probed_config();
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
kind: "meshtastic".to_string(),
|
||||
firmware_version: Some(info.firmware_version.clone()),
|
||||
node_id: Some(info.node_id),
|
||||
advert_name: dev.advert_name(),
|
||||
region: cfg.region,
|
||||
modem_preset: cfg.modem_preset,
|
||||
primary_channel: cfg.primary_channel,
|
||||
secondary_channel: cfg.secondary_channel,
|
||||
max_contacts: Some(info.max_contacts),
|
||||
});
|
||||
}
|
||||
}
|
||||
anyhow::bail!("no supported mesh radio firmware answered on {path}")
|
||||
}
|
||||
|
||||
async fn open_preferred_path(
|
||||
path: &str,
|
||||
data_dir: &Path,
|
||||
@@ -836,6 +932,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,9 +951,11 @@ 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>,
|
||||
manage_radio: bool,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> Result<()> {
|
||||
@@ -918,8 +1020,15 @@ pub(super) async fn run_mesh_session(
|
||||
// re-handshake the freshly-rebooted radio (and then set its name on the
|
||||
// reconnect, where the region already matches and no reboot occurs).
|
||||
use std::sync::atomic::Ordering;
|
||||
if !manage_radio {
|
||||
// Keep-as-is mode (hot-swap "keep as is" decision): the operator told
|
||||
// us to use whatever is flashed/configured on this radio. Skip every
|
||||
// config write below — region, channel, PHY params, advert name — and
|
||||
// run with the device's own settings.
|
||||
info!("manage_radio=false — leaving the radio's own configuration untouched");
|
||||
}
|
||||
let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
if manage_radio && region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match device.ensure_lora_region(lora_region).await {
|
||||
Ok(true) => {
|
||||
REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -938,7 +1047,7 @@ pub(super) async fn run_mesh_session(
|
||||
Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||
Err(e) => warn!("Failed to provision LoRa region: {}", e),
|
||||
}
|
||||
} else if lora_region.is_some() {
|
||||
} else if manage_radio && lora_region.is_some() {
|
||||
warn!(
|
||||
region = lora_region.unwrap_or(""),
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
@@ -953,7 +1062,7 @@ pub(super) async fn run_mesh_session(
|
||||
// the radio). Without a matching channel two same-region radios still can't
|
||||
// decode each other's traffic. Same retry-cap + restart-on-change pattern.
|
||||
let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||
if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
if manage_radio && channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||
match device.ensure_channel(channel_name).await {
|
||||
Ok(true) => {
|
||||
CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -969,7 +1078,7 @@ pub(super) async fn run_mesh_session(
|
||||
Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||
Err(e) => warn!("Failed to provision mesh channel: {}", e),
|
||||
}
|
||||
} else if channel_name.is_some() {
|
||||
} else if manage_radio && channel_name.is_some() {
|
||||
warn!(
|
||||
channel = channel_name.unwrap_or(""),
|
||||
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||
@@ -978,24 +1087,86 @@ 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 (true, Some(params), MeshRadioDevice::Meshcore(dev)) =
|
||||
(manage_radio, lora_radio_params, &mut device)
|
||||
{
|
||||
let marker_path = data_dir.join("meshcore-radio-params.json");
|
||||
let applied: Option<crate::mesh::LoraRadioParams> = tokio::fs::read(&marker_path)
|
||||
.await
|
||||
.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 {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||
name.chars().take(20).collect::<String>()
|
||||
} else {
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
format!("Archy-{}", short_did)
|
||||
};
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
} else {
|
||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||
// its own radio echo from the peer list. Without this, the status
|
||||
// still carries whatever pre-set name the firmware reported and the
|
||||
// self-filter never matches.
|
||||
let mut status = state.status.write().await;
|
||||
status.self_advert_name = Some(advert_name.clone());
|
||||
// falling back to the DID fragment if no name is configured. Skipped in
|
||||
// keep-as-is mode — the radio keeps the name it came with (already
|
||||
// reflected in status from the connect handshake).
|
||||
if manage_radio {
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||
name.chars().take(20).collect::<String>()
|
||||
} else {
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
format!("Archy-{}", short_did)
|
||||
};
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
} else {
|
||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||
// its own radio echo from the peer list. Without this, the status
|
||||
// still carries whatever pre-set name the firmware reported and the
|
||||
// self-filter never matches.
|
||||
let mut status = state.status.write().await;
|
||||
status.self_advert_name = Some(advert_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast our advertisement so other nodes can discover us
|
||||
|
||||
@@ -167,7 +167,40 @@ pub struct MeshtasticDevice {
|
||||
pending_reinit: bool,
|
||||
}
|
||||
|
||||
/// Read-only snapshot of the radio config learned during `initialize`'s
|
||||
/// `want_config` stream — surfaced by the hot-swap probe so the setup modal
|
||||
/// can show what's currently on a just-plugged radio.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MeshtasticProbedConfig {
|
||||
pub region: Option<String>,
|
||||
pub modem_preset: Option<String>,
|
||||
pub primary_channel: Option<String>,
|
||||
pub secondary_channel: Option<String>,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
/// See [`MeshtasticProbedConfig`]. Everything here was learned via reads
|
||||
/// (`want_config`) — safe for a probe that must not modify the device.
|
||||
pub fn probed_config(&self) -> MeshtasticProbedConfig {
|
||||
MeshtasticProbedConfig {
|
||||
region: self
|
||||
.current_region
|
||||
.and_then(region_code_to_name)
|
||||
.map(str::to_string),
|
||||
modem_preset: self
|
||||
.current_modem_preset
|
||||
.and_then(modem_preset_name)
|
||||
.map(str::to_string),
|
||||
primary_channel: self
|
||||
.current_primary_channel
|
||||
.as_ref()
|
||||
.map(|(name, _)| if name.is_empty() { "(default public)".to_string() } else { name.clone() }),
|
||||
secondary_channel: self
|
||||
.current_secondary_channel
|
||||
.as_ref()
|
||||
.map(|(name, _)| name.clone()),
|
||||
}
|
||||
}
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
@@ -1321,6 +1354,53 @@ fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
|
||||
/// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g.
|
||||
/// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive.
|
||||
/// Returns `None` for an unrecognised name so we never write a bogus region.
|
||||
/// Inverse of `region_name_to_code` — for showing a probed radio's current
|
||||
/// region to the user (hot-swap setup modal).
|
||||
pub(crate) fn region_code_to_name(code: u32) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
0 => "UNSET",
|
||||
1 => "US",
|
||||
2 => "EU_433",
|
||||
3 => "EU_868",
|
||||
4 => "CN",
|
||||
5 => "JP",
|
||||
6 => "ANZ",
|
||||
7 => "KR",
|
||||
8 => "TW",
|
||||
9 => "RU",
|
||||
10 => "IN",
|
||||
11 => "NZ_865",
|
||||
12 => "TH",
|
||||
13 => "LORA_24",
|
||||
14 => "UA_433",
|
||||
15 => "UA_868",
|
||||
16 => "MY_433",
|
||||
17 => "MY_919",
|
||||
18 => "SG_923",
|
||||
19 => "PH_433",
|
||||
20 => "PH_868",
|
||||
21 => "PH_915",
|
||||
22 => "ANZ_433",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Meshtastic `Config.LoRaConfig.ModemPreset` enum names, for display.
|
||||
pub(crate) fn modem_preset_name(code: u32) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
0 => "LONG_FAST",
|
||||
1 => "LONG_SLOW",
|
||||
2 => "VERY_LONG_SLOW",
|
||||
3 => "MEDIUM_SLOW",
|
||||
4 => "MEDIUM_FAST",
|
||||
5 => "SHORT_SLOW",
|
||||
6 => "SHORT_FAST",
|
||||
7 => "LONG_MODERATE",
|
||||
8 => "SHORT_TURBO",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn region_name_to_code(name: &str) -> Option<u32> {
|
||||
Some(match name.trim().to_uppercase().as_str() {
|
||||
"UNSET" => 0,
|
||||
|
||||
@@ -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,
|
||||
@@ -399,6 +420,12 @@ pub struct MeshConfig {
|
||||
/// serial-only/auto-detect behavior unchanged.
|
||||
#[serde(default)]
|
||||
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
|
||||
/// Whether archipelago manages the radio's configuration (region, shared
|
||||
/// channel, PHY params, advert name) on connect. `true` (default) is
|
||||
/// today's behavior. `false` = the hot-swap modal's "keep as is": use the
|
||||
/// radio exactly as flashed — no config writes at all.
|
||||
#[serde(default = "default_true")]
|
||||
pub manage_radio: bool,
|
||||
}
|
||||
|
||||
/// True when `name` is a LoRa region the Meshtastic driver can program
|
||||
@@ -422,6 +449,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,
|
||||
@@ -436,6 +464,7 @@ impl Default for MeshConfig {
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
device_kind: None,
|
||||
reticulum_tcp: None,
|
||||
manage_radio: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,6 +698,13 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the message history + outbound send-sequence counters, then
|
||||
// keep them persisted. Until 2026-07-22 both were RAM-only: every
|
||||
// restart wiped the chat history, and the reset sequence counters made
|
||||
// peers drop the first post-reboot messages as (pubkey, seq) replays.
|
||||
state.load_persisted_messages().await;
|
||||
listener::spawn_message_persister(Arc::clone(&state));
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
@@ -722,9 +758,11 @@ 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(),
|
||||
self.config.manage_radio,
|
||||
shutdown_rx,
|
||||
cmd_rx,
|
||||
);
|
||||
@@ -981,6 +1019,21 @@ impl MeshService {
|
||||
self.state.peers.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
||||
/// it — powers the hot-swap "device detected" modal's current-details
|
||||
/// view. Refuses to probe the port the live session currently occupies
|
||||
/// (the probe would steal the serial port from under the session); a
|
||||
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||
/// with the reconnect loop (whichever loses just retries).
|
||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
||||
let status = self.state.status.read().await;
|
||||
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||
}
|
||||
drop(status);
|
||||
listener::probe_device(path).await
|
||||
}
|
||||
|
||||
/// Get message history.
|
||||
pub async fn messages(&self, limit: Option<usize>) -> Vec<MeshMessage> {
|
||||
let messages = self.state.messages.read().await;
|
||||
|
||||
@@ -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];
|
||||
@@ -335,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
|
||||
|
||||
// ─── Response parsers ───────────────────────────────────────────────────
|
||||
|
||||
/// Decode a device/contact name from raw frame bytes, defensively.
|
||||
///
|
||||
/// Name fields sit at firmware-version-dependent offsets; when an offset
|
||||
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
|
||||
/// hand the UI replacement-character soup ("�\u{618}…"). Strict rules: valid
|
||||
/// UTF-8 up to the first NUL, no control characters, at least one
|
||||
/// non-whitespace char — anything else gets the caller's readable fallback.
|
||||
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
match std::str::from_utf8(&bytes[..end]) {
|
||||
Ok(s) => {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
|
||||
s.to_string()
|
||||
} else {
|
||||
fallback.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||
/// Returns firmware version string and device capabilities.
|
||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||
@@ -366,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
|
||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
|
||||
// Name follows after fixed fields — find it by scanning for printable ASCII
|
||||
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||
// differs would put binary here — decode defensively so the settings
|
||||
// panel never shows byte soup.
|
||||
let name_start = 4;
|
||||
let name = if data.len() > name_start {
|
||||
let name_end = data[name_start..]
|
||||
.iter()
|
||||
.position(|&b| b == 0)
|
||||
.map(|p| name_start + p)
|
||||
.unwrap_or(data.len());
|
||||
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
|
||||
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -435,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
||||
// name at data[99..131] (32 bytes)
|
||||
let name_start = 99.min(data.len());
|
||||
let name_end = (name_start + 32).min(data.len());
|
||||
let short_id = format!("{}...", &public_key_hex[..8]);
|
||||
let advert_name = if data.len() > name_start {
|
||||
String::from_utf8_lossy(&data[name_start..name_end])
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
decode_mesh_name(&data[name_start..name_end], &short_id)
|
||||
} else {
|
||||
format!("{}...", &public_key_hex[..8])
|
||||
short_id
|
||||
};
|
||||
|
||||
// last_advert at data[131..135]
|
||||
@@ -730,6 +766,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]
|
||||
|
||||
@@ -1083,7 +1083,7 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
|
||||
/// Send the verified RNode KISS detect sequence and look for `DETECT_RESP`.
|
||||
/// Bytes confirmed against the canonical Reticulum source — see the module
|
||||
/// doc comment and `docs/RETICULUM-TRANSPORT-PROGRESS.md`.
|
||||
async fn probe_rnode(path: &str) -> Result<()> {
|
||||
pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
||||
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
|
||||
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
|
||||
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
||||
|
||||
@@ -149,6 +149,35 @@ impl MeshcoreDevice {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The advert name learned from SELF_INFO during `initialize`.
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.advert_name.clone()
|
||||
}
|
||||
|
||||
/// Read firmware version + contact capacity via CMD_DEVICE_QUERY (0x16).
|
||||
/// Read-only — used by the hot-swap probe to show what's on a
|
||||
/// just-plugged radio. Tolerates interleaved push frames and firmware
|
||||
/// that doesn't answer the query (returns None rather than erroring).
|
||||
pub async fn query_device_info(&mut self) -> Option<(String, u16)> {
|
||||
if self
|
||||
.send_raw(&protocol::build_device_query())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for _ in 0..5 {
|
||||
match self.recv_frame_timeout(Duration::from_secs(2)).await {
|
||||
Ok(f) if f.code == protocol::RESP_DEVICE_INFO => {
|
||||
return protocol::parse_device_info(&f.data).ok();
|
||||
}
|
||||
Ok(_) => continue, // push notification — keep waiting
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Set the advertised name on the mesh network.
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.send_raw(&protocol::build_set_advert_name(name))
|
||||
@@ -164,6 +193,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?;
|
||||
@@ -520,6 +572,12 @@ pub struct DetectedDeviceInfo {
|
||||
pub pid: Option<String>,
|
||||
pub product: Option<String>,
|
||||
pub manufacturer: Option<String>,
|
||||
/// Unix epoch seconds of the /dev node's creation — udev recreates the
|
||||
/// node on every plug, so this changes on each replug. The UI keys its
|
||||
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
|
||||
/// unplug/replug faster than a status poll) invalidates old dismissals
|
||||
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
|
||||
pub plugged_at: Option<u64>,
|
||||
}
|
||||
|
||||
/// Like `detect_serial_devices`, but with USB metadata per port.
|
||||
@@ -527,12 +585,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
||||
let mut out = Vec::new();
|
||||
for path in detect_serial_devices().await {
|
||||
let usb = usb_info_for_tty(&path).await;
|
||||
let plugged_at = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
out.push(DetectedDeviceInfo {
|
||||
path,
|
||||
vid: usb.0,
|
||||
pid: usb.1,
|
||||
product: usb.2,
|
||||
manufacturer: usb.3,
|
||||
plugged_at,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user