Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7208e1769 | ||
|
|
77ee39a3df | ||
|
|
02917cc22c | ||
|
|
325b9ea9c9 | ||
|
|
9739bbb3db | ||
|
|
4db1b85fc5 | ||
|
|
a78aa02890 | ||
|
|
0365cc0f9d | ||
|
|
0880824fb3 | ||
|
|
c0d34bd836 | ||
|
|
c8d0dda656 | ||
|
|
57c6a4d512 | ||
|
|
a32e40da31 | ||
|
|
449ed7c1f7 | ||
|
|
729cee573a | ||
|
|
5b7ebd85b6 | ||
|
|
c6f11f8ddb | ||
|
|
a26090e561 | ||
|
|
2609f60e6c | ||
|
|
c4c558954b | ||
|
|
d4ea4ed636 | ||
|
|
0fadbb1d0f | ||
|
|
0e5cd24e18 | ||
|
|
1d8e57d564 | ||
|
|
00b7e1798f | ||
|
|
bca1698682 | ||
|
|
0641ee85ef | ||
|
|
901cf5713a | ||
|
|
875da6c630 | ||
|
|
4589e88442 | ||
|
|
5862689949 | ||
|
|
3995ab5cf5 | ||
|
|
9d83ee3770 | ||
|
|
7e3d01f633 | ||
|
|
385c950f00 | ||
|
|
98e15b3af0 | ||
|
|
df88d6d00a | ||
|
|
0dfc3a7cfb | ||
|
|
b163d30a0e | ||
|
|
85b25dd803 | ||
|
|
e3db5558e4 | ||
|
|
91c51c4d97 | ||
|
|
4343949005 | ||
|
|
5771f318bd | ||
|
|
408c605001 | ||
|
|
0cd2164d24 | ||
|
|
5227406341 | ||
|
|
d924c59c6c | ||
|
|
0fa2a866f5 | ||
|
|
9fcb68816b | ||
|
|
9a66f22138 | ||
|
|
504944fd08 | ||
|
|
8af2ca4ac2 | ||
|
|
f2b6028ca2 | ||
|
|
e679b4e886 | ||
|
|
d045f0b499 | ||
|
|
1e20b79c3c | ||
|
|
1bcf6ccadb | ||
|
|
9f6294d169 | ||
|
|
a0f688b522 | ||
|
|
f0926ece94 |
@@ -0,0 +1,62 @@
|
||||
name: Build Archipelago release ISO (gated)
|
||||
|
||||
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
|
||||
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
|
||||
# the iso-builder runner is a live node — builds are deliberate events.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-iso:
|
||||
runs-on: iso-builder
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Sync source to workspace
|
||||
run: |
|
||||
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
|
||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||
|
||||
- name: Install ISO build dependencies
|
||||
run: |
|
||||
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
|
||||
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
|
||||
echo "ISO build deps already installed, skipping apt"
|
||||
else
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
debootstrap squashfs-tools xorriso \
|
||||
isolinux syslinux-common mtools \
|
||||
grub-efi-amd64-bin grub-pc-bin grub-common
|
||||
fi
|
||||
|
||||
- name: Build backend + frontend if stale
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR"
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
|
||||
cargo build --release --manifest-path core/Cargo.toml -p archipelago
|
||||
fi
|
||||
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
|
||||
(cd neode-ui && npm ci && npm run build)
|
||||
fi
|
||||
|
||||
- name: Gated ISO build (gates + build + smoke + qemu)
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
cd "$REPO_DIR"
|
||||
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||
bash scripts/build-iso-release.sh
|
||||
|
||||
- name: Report artifacts
|
||||
if: always()
|
||||
run: |
|
||||
REPO_DIR="$HOME/Projects/archy"
|
||||
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
|
||||
@@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
|
||||
**aborts** if any is missing.
|
||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
||||
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
|
||||
6. The first-launch companion modal and Android "Share this app" QR point at
|
||||
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
|
||||
repo artifact is built, mirror that exact APK to the VPS2-served path before
|
||||
calling the release done.
|
||||
|
||||
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
|
||||
skips the clean build and the signature enforcement and is exactly how a broken
|
||||
@@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
|
||||
|
||||
## Verify the published download after shipping
|
||||
|
||||
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
||||
what you built and signed:
|
||||
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
|
||||
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
|
||||
built and signed:
|
||||
|
||||
```bash
|
||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||
curl -sS -o /tmp/live.apk "$URL"
|
||||
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
||||
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
|
||||
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
|
||||
curl -sS -o /tmp/live-qr.apk "$QR_URL"
|
||||
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
|
||||
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||
```
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 26
|
||||
versionName = "0.5.6"
|
||||
versionCode = 38
|
||||
versionName = "0.5.18"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -23,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"
|
||||
|
||||
@@ -88,8 +88,18 @@ object ServerQrParser {
|
||||
|
||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||
val host = uri.getQueryParameter("fhost")?.trim()
|
||||
var host = uri.getQueryParameter("fhost")?.trim()
|
||||
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||
// A .fips fhost is a dead dial hint: Android's system DNS can't
|
||||
// resolve .fips, so every handshake send fails and first connect
|
||||
// falls back to slow anchor discovery. If the QR's `url` host is a
|
||||
// real address, dial that instead (QRs minted while the node UI was
|
||||
// browsed over the mesh carry npub….fips here).
|
||||
if (host.endsWith(".fips")) {
|
||||
val urlHost = uri.getQueryParameter("url")
|
||||
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
|
||||
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
|
||||
}
|
||||
return FipsPairInfo(
|
||||
npub = npub,
|
||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||
|
||||
@@ -51,13 +51,27 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
val prefs = FipsPreferences(this)
|
||||
val identity = FipsManager.ensureIdentity(prefs)
|
||||
val peersJson = prefs.peersJson()
|
||||
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()
|
||||
|
||||
@@ -93,12 +107,23 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
|
||||
val fd = pfd.detachFd()
|
||||
val result = FipsNative.start(identity.secret, peersJson, fd)
|
||||
Log.i(TAG, "mesh start: $result")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,18 +141,23 @@ class ArchyVpnService : VpnService() {
|
||||
warmerJob?.cancel()
|
||||
warmerJob = scope.launch {
|
||||
val prefs = ServerPreferences(this@ArchyVpnService)
|
||||
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
|
||||
var round = 0
|
||||
while (isActive && FipsNative.isRunning()) {
|
||||
val ulas = try {
|
||||
prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct()
|
||||
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()
|
||||
}
|
||||
for (ula in ulas) {
|
||||
}.distinct()
|
||||
for ((ula, port) in targets) {
|
||||
try {
|
||||
java.net.Socket().use { s ->
|
||||
s.connect(
|
||||
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80),
|
||||
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
|
||||
20_000,
|
||||
)
|
||||
}
|
||||
@@ -146,6 +176,7 @@ class ArchyVpnService : VpnService() {
|
||||
|
||||
private fun shutdown() {
|
||||
warmerJob?.cancel()
|
||||
FlareServer.stop()
|
||||
FipsNative.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
|
||||
@@ -21,6 +21,12 @@ object FipsManager {
|
||||
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
|
||||
}
|
||||
@@ -34,6 +40,7 @@ object FipsManager {
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
|
||||
@@ -64,6 +71,22 @@ object FipsManager {
|
||||
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)
|
||||
|
||||
@@ -21,7 +21,13 @@ object FipsNative {
|
||||
|
||||
external fun generateIdentity(): String
|
||||
external fun deriveIdentity(secret: String): String
|
||||
external fun start(secret: String, peersJson: String, tunFd: Int): 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
|
||||
|
||||
@@ -3,9 +3,12 @@ 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
|
||||
@@ -22,6 +25,26 @@ internal const val ARCHY_ANCHOR_NPUB =
|
||||
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
||||
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
||||
|
||||
/**
|
||||
* Public FIPS network anchors (join.fips.network — the dual-transport TCP
|
||||
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
|
||||
* fips_network_anchors()). Baked into every pairing at trailing priority so
|
||||
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
|
||||
* still joins the public tree and routes to the node through it.
|
||||
*/
|
||||
internal val PUBLIC_FIPS_ANCHORS = listOf(
|
||||
Triple(
|
||||
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
|
||||
"23.182.128.74:443",
|
||||
"tcp",
|
||||
),
|
||||
Triple(
|
||||
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
|
||||
"217.77.8.91:443",
|
||||
"tcp",
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||
* storage model as ServerPreferences (the server password lives there the
|
||||
@@ -34,6 +57,12 @@ class FipsPreferences(private val context: Context) {
|
||||
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()
|
||||
@@ -60,6 +89,157 @@ class FipsPreferences(private val context: Context) {
|
||||
|
||||
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", hostSafeAlias(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)
|
||||
}))
|
||||
})
|
||||
}
|
||||
// Backfill anchor peers for entries paired before newer QR/app
|
||||
// releases added them. `upsertNodePeer` persists these on re-scan, but
|
||||
// startup must also self-heal old DataStore state so updating the APK is
|
||||
// enough to get off-LAN redundancy.
|
||||
if (merged.length() > 0) {
|
||||
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
|
||||
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||
val (npub, addr, transport) = anchor
|
||||
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
|
||||
}
|
||||
}
|
||||
return merged.toString()
|
||||
}
|
||||
|
||||
private fun addAnchorIfMissing(
|
||||
peers: JSONArray,
|
||||
npub: String,
|
||||
alias: String,
|
||||
addr: String,
|
||||
transport: String,
|
||||
priority: Int,
|
||||
) {
|
||||
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
|
||||
peers.put(JSONObject().apply {
|
||||
put("npub", npub)
|
||||
put("alias", alias)
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("addr", addr)
|
||||
put("priority", priority)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -71,16 +251,19 @@ class FipsPreferences(private val context: Context) {
|
||||
val incoming = mutableListOf<JSONObject>()
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", info.npub)
|
||||
put("alias", alias.ifBlank { "Archipelago" })
|
||||
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
|
||||
val addresses = JSONArray()
|
||||
if (info.udpPort > 0) {
|
||||
// .fips hosts are unresolvable on Android (no system .fips DNS):
|
||||
// storing one gives the mesh a dial target that fails every
|
||||
// handshake and stalls first connect on anchor discovery.
|
||||
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${info.host}:${info.udpPort}")
|
||||
put("priority", 10)
|
||||
})
|
||||
}
|
||||
if (info.tcpPort > 0) {
|
||||
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
|
||||
addresses.put(JSONObject().apply {
|
||||
put("transport", "tcp")
|
||||
put("addr", "${info.host}:${info.tcpPort}")
|
||||
@@ -91,9 +274,10 @@ class FipsPreferences(private val context: Context) {
|
||||
}
|
||||
for (anchor in info.anchors) {
|
||||
if (anchor.npub == info.npub) continue
|
||||
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", anchor.npub)
|
||||
put("alias", "Mesh anchor")
|
||||
put("alias", "mesh-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", anchor.transport)
|
||||
put("addr", anchor.addr)
|
||||
@@ -108,7 +292,7 @@ class FipsPreferences(private val context: Context) {
|
||||
) {
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "Archipelago anchor")
|
||||
put("alias", "archipelago-anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
@@ -116,6 +300,21 @@ class FipsPreferences(private val context: Context) {
|
||||
}))
|
||||
}
|
||||
}
|
||||
// And the public FIPS network anchors at trailing priority, so one
|
||||
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
|
||||
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||
val (npub, addr, transport) = anchor
|
||||
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
|
||||
incoming += JSONObject().apply {
|
||||
put("npub", npub)
|
||||
put("alias", "fips-network-anchor-${i + 1}")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", transport)
|
||||
put("addr", addr)
|
||||
put("priority", 50 + i * 10)
|
||||
}))
|
||||
}
|
||||
}
|
||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||
context.fipsDataStore.edit { prefs ->
|
||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||
@@ -129,3 +328,14 @@ class FipsPreferences(private val context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
|
||||
* that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
|
||||
* silently drops the peer from name resolution. Slug it instead of losing it.
|
||||
*/
|
||||
internal fun hostSafeAlias(alias: String): String =
|
||||
alias.lowercase()
|
||||
.replace(Regex("[^a-z0-9.-]+"), "-")
|
||||
.trim('-', '.')
|
||||
.ifBlank { "archipelago" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.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.draw.clip
|
||||
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) {
|
||||
// The brand's circle-container logo (as on the connect screen /
|
||||
// web login): pixel-art "a" centered in a black disc.
|
||||
Box(
|
||||
Modifier
|
||||
.size(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color.Black)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f),
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PixelArtLogo(Modifier.size(64.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -20,7 +20,9 @@ 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
|
||||
@@ -31,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
|
||||
@@ -122,6 +126,9 @@ fun AppNavHost(
|
||||
) {
|
||||
composable(Routes.INTRO) {
|
||||
IntroScreen(
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
onContinue = {
|
||||
scope.launch {
|
||||
prefs.markIntroSeen()
|
||||
@@ -179,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.
|
||||
}
|
||||
}
|
||||
@@ -54,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()
|
||||
@@ -250,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
|
||||
|
||||
@@ -75,6 +75,7 @@ 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
|
||||
@@ -610,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,20 +23,28 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
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.windowInsetsBottomHeight
|
||||
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
|
||||
@@ -64,19 +72,27 @@ 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.Color
|
||||
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
|
||||
@@ -87,28 +103,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
try {
|
||||
@@ -137,6 +131,82 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/** Inject the safe-area CSS vars from the CURRENT window insets. Android
|
||||
* WebView doesn't populate env(safe-area-inset-*); worse, on a cold start
|
||||
* onPageFinished can run before the view is attached — rootWindowInsets is
|
||||
* null then, and injecting 0px collapsed the UI's top/bottom margins (and
|
||||
* put the tab bar inside the gesture zone, killing its taps). Called from
|
||||
* onPageFinished, from the window-insets listener (fires when real insets
|
||||
* arrive), and on reattach. */
|
||||
private fun injectSafeAreaVars(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return // listener re-fires when real
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var style = document.getElementById('archipelago-android-insets');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'archipelago-android-insets';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||
})();
|
||||
""".trimIndent(),
|
||||
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. */
|
||||
@@ -185,6 +255,13 @@ fun WebViewScreen(
|
||||
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) }
|
||||
@@ -198,6 +275,13 @@ fun WebViewScreen(
|
||||
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
|
||||
@@ -226,7 +310,7 @@ fun WebViewScreen(
|
||||
// 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) out to the phone's external browser.
|
||||
// Assistant, BTCPay) out to the phone's external browser.
|
||||
fun isSameNode(url: String): Boolean =
|
||||
isSameHost(url, serverUrl) ||
|
||||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
|
||||
@@ -313,7 +397,10 @@ fun WebViewScreen(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
// Re-race LAN vs mesh — the network we're on may have
|
||||
// changed since the last pick.
|
||||
// changed since the last pick. Drop the retained view:
|
||||
// an errored session must genuinely reload.
|
||||
KioskWebView.drop()
|
||||
webView = null
|
||||
hasError = false
|
||||
isLoading = true
|
||||
triedMeshFallback = false
|
||||
@@ -327,16 +414,17 @@ 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.
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
MeshLoadingScreen()
|
||||
} else {
|
||||
// Edge-to-edge WebView — background bleeds behind status bar.
|
||||
// Safe area values injected as CSS env() polyfill on each page load.
|
||||
@@ -344,7 +432,15 @@ fun WebViewScreen(
|
||||
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,
|
||||
@@ -368,6 +464,14 @@ fun WebViewScreen(
|
||||
|
||||
val webViewRef = this
|
||||
|
||||
// Re-inject the safe-area vars whenever REAL insets
|
||||
// arrive — on cold start onPageFinished often beats
|
||||
// window attachment and would otherwise bake in 0px.
|
||||
setOnApplyWindowInsetsListener { v, insets ->
|
||||
(v as? WebView)?.let { injectSafeAreaVars(it) }
|
||||
v.onApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
// Decide where an outbound URL goes:
|
||||
// - same host as the node → in-app WebView overlay
|
||||
// (this is the "open in browser" target for apps the
|
||||
@@ -381,20 +485,38 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
@@ -406,24 +528,21 @@ fun WebViewScreen(
|
||||
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||
// Decodes flow back through window.__archyQrResult(text);
|
||||
// a user cancel calls window.__archyQrCancelled().
|
||||
addJavascriptInterface(
|
||||
if (reused == null) addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun open() {
|
||||
webViewRef.post {
|
||||
walletScannerStatus = null
|
||||
walletScannerVisible = true
|
||||
}
|
||||
webViewRef.post { KioskWebView.onQrOpen() }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun setStatus(message: String, isError: Boolean) {
|
||||
webViewRef.post { walletScannerStatus = message to isError }
|
||||
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun close() {
|
||||
webViewRef.post { walletScannerVisible = false }
|
||||
webViewRef.post { KioskWebView.onQrClose() }
|
||||
}
|
||||
},
|
||||
"ArchipelagoQr",
|
||||
@@ -439,34 +558,7 @@ fun WebViewScreen(
|
||||
isLoading = false
|
||||
if (view == null) return
|
||||
|
||||
// Convert physical pixels → CSS pixels
|
||||
val density = view.resources.displayMetrics.density
|
||||
val satPx = view.rootWindowInsets
|
||||
?.getInsets(android.view.WindowInsets.Type.statusBars())
|
||||
?.top ?: 0
|
||||
val sabPx = view.rootWindowInsets
|
||||
?.getInsets(android.view.WindowInsets.Type.navigationBars())
|
||||
?.bottom ?: 0
|
||||
val sat = (satPx / density).toInt()
|
||||
val sab = (sabPx / density).toInt()
|
||||
|
||||
// Android WebView doesn't populate env(safe-area-inset-*).
|
||||
// Set CSS custom properties the web UI can use as fallback:
|
||||
// var(--safe-area-top, env(safe-area-inset-top, 0px))
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var style = document.getElementById('archipelago-android-insets');
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = 'archipelago-android-insets';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
injectSafeAreaVars(view)
|
||||
|
||||
// Auto-login with the stored password (QR pairing /
|
||||
// saved server) — only on our own server's pages
|
||||
@@ -650,7 +742,21 @@ fun WebViewScreen(
|
||||
}
|
||||
|
||||
webView = this
|
||||
loadUrl(initialUrl)
|
||||
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, and re-sync
|
||||
// the page's safe-area vars while we're at it.
|
||||
post {
|
||||
requestLayout()
|
||||
invalidate()
|
||||
injectSafeAreaVars(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -669,6 +775,39 @@ 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 ->
|
||||
@@ -796,7 +935,13 @@ private fun InAppBrowser(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
// Bottom inset handled by the touch-shield strip below the bar —
|
||||
// NOT by padding: a padded area only paints, it doesn't consume,
|
||||
// so taps in the gesture strip fell straight THROUGH this overlay
|
||||
// into the kiosk's tab bar behind it (accidental AIUI-tab hits).
|
||||
.windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
|
||||
),
|
||||
) {
|
||||
// WebView + loading overlay fill the area above the bottom control bar.
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
@@ -985,6 +1130,21 @@ private fun InAppBrowser(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Touch-shield over the gesture-nav strip: solid black AND consumes
|
||||
// taps — stray touches below the control bar landed on the kiosk's
|
||||
// tab bar behind this overlay (opening the AIUI chat by accident).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsBottomHeight(WindowInsets.navigationBars)
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
+1
-1
@@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
[[package]]
|
||||
name = "fips"
|
||||
version = "0.3.0-dev"
|
||||
source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6"
|
||||
source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f"
|
||||
dependencies = [
|
||||
"bech32",
|
||||
"chacha20poly1305",
|
||||
|
||||
@@ -23,7 +23,10 @@ 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"] }
|
||||
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
|
||||
# discovery re-fire on topology change (fresh 5G join: first route no longer
|
||||
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
|
||||
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
|
||||
anyhow = "1.0"
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
|
||||
@@ -76,8 +76,9 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
|
||||
out(&env, json)
|
||||
}
|
||||
|
||||
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
|
||||
/// 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,
|
||||
@@ -85,11 +86,13 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
||||
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 json = match mesh::start(&secret, &peers, tun_fd) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
|
||||
}
|
||||
|
||||
/// Build the phone-side node config: leaf-only (never routes third-party
|
||||
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
|
||||
/// TUN enabled but attached to the VpnService fd rather than created.
|
||||
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
|
||||
/// 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;
|
||||
@@ -74,13 +79,33 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
|
||||
cfg.tun.enabled = true;
|
||||
cfg.tun.mtu = Some(1280);
|
||||
cfg.dns.enabled = false;
|
||||
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
|
||||
cfg.transports.udp = TransportInstances::Single(UdpConfig {
|
||||
bind_addr: Some("0.0.0.0:0".to_string()),
|
||||
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());
|
||||
// Fast-connect profile — a phone opens the app and expects the node NOW.
|
||||
// Stock pacing is tuned for always-on routers: a failed discovery backs
|
||||
// off 30s, session resends gap out to 8-16s, dead links redial at
|
||||
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
|
||||
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
|
||||
// link/session is down, so steady-state traffic is unchanged.
|
||||
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
|
||||
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
|
||||
cfg.node.retry.max_retries = 30;
|
||||
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
|
||||
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
|
||||
cfg.node.rate_limit.handshake_max_resends = 10;
|
||||
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
|
||||
cfg.node.discovery.backoff_max_secs = 30;
|
||||
cfg.node.discovery.retry_interval_secs = 2;
|
||||
cfg.node.discovery.max_attempts = 3;
|
||||
// Lookups launched before the tree position settles are doomed; a 10s
|
||||
// completion timeout made each one cost 10s before the 1s retry could
|
||||
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
|
||||
// the resend-within-window above still gives each attempt two shots.
|
||||
cfg.node.discovery.timeout_secs = 5;
|
||||
cfg.peers = peers;
|
||||
cfg
|
||||
}
|
||||
@@ -94,7 +119,7 @@ pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
|
||||
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
|
||||
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
||||
/// Any previously running node is stopped first.
|
||||
pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
|
||||
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
|
||||
@@ -111,7 +136,7 @@ pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, Str
|
||||
}
|
||||
|
||||
let peers = parse_peers(peers_json)?;
|
||||
let config = build_config(secret, peers);
|
||||
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();
|
||||
@@ -247,7 +272,7 @@ mod tests {
|
||||
#[test]
|
||||
fn config_is_leaf_only_with_tun() {
|
||||
let id = generate_identity().unwrap();
|
||||
let cfg = build_config(&id.secret_hex, vec![]);
|
||||
let cfg = build_config(&id.secret_hex, vec![], 0);
|
||||
assert!(cfg.node.leaf_only);
|
||||
assert!(cfg.tun.enabled);
|
||||
assert_eq!(cfg.tun.mtu(), 1280);
|
||||
@@ -255,4 +280,16 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-1
@@ -1,7 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.112-alpha (2026-07-22)
|
||||
## v1.7.115-alpha (2026-07-26)
|
||||
|
||||
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
||||
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
|
||||
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
|
||||
|
||||
## v1.7.114-alpha (2026-07-26)
|
||||
|
||||
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
|
||||
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
|
||||
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
|
||||
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
|
||||
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
|
||||
|
||||
## v1.7.113-alpha (2026-07-25)
|
||||
|
||||
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
|
||||
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
|
||||
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
|
||||
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
|
||||
|
||||
## v1.7.112-alpha (2026-07-23)
|
||||
|
||||
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
|
||||
- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.
|
||||
- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.
|
||||
- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.
|
||||
- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.
|
||||
- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.
|
||||
- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.
|
||||
- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.
|
||||
- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.
|
||||
- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.
|
||||
- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.
|
||||
- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.
|
||||
- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.
|
||||
- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.
|
||||
- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.
|
||||
- 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.
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.111-alpha"
|
||||
version = "1.7.115-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.111-alpha"
|
||||
version = "1.7.115-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -202,7 +202,9 @@ impl ApiHandler {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept Lightning for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let name = params
|
||||
let mut name = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -116,6 +116,14 @@ impl RpcHandler {
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -443,8 +443,7 @@ impl RpcHandler {
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/')
|
||||
== f.trim_start_matches('/')
|
||||
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
@@ -454,12 +453,9 @@ impl RpcHandler {
|
||||
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
|
||||
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!({
|
||||
@@ -692,10 +688,7 @@ impl RpcHandler {
|
||||
n += 1;
|
||||
}
|
||||
match tokio::fs::write(&target, &bytes).await {
|
||||
Ok(()) => tracing::info!(
|
||||
"paid download: filed into {}",
|
||||
target.display()
|
||||
),
|
||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
||||
Err(e) => tracing::warn!(
|
||||
"paid download: filing into {} failed (non-fatal): {e}",
|
||||
target.display()
|
||||
|
||||
@@ -124,10 +124,12 @@ impl RpcHandler {
|
||||
}
|
||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
|
||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
|
||||
@@ -23,9 +23,11 @@ impl RpcHandler {
|
||||
/// 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 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
|
||||
|
||||
@@ -30,6 +30,8 @@ struct ChannelInfo {
|
||||
active: bool,
|
||||
status: String,
|
||||
channel_point: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
closing_txid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -58,6 +60,10 @@ struct LndChannel {
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndPendingChannelsResponse {
|
||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||
// Cooperative closes waiting for their closing tx to confirm
|
||||
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
|
||||
// Force closes serving out their timelock
|
||||
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -65,6 +71,18 @@ struct LndPendingOpenChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndWaitingCloseChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndForceClosingChannel {
|
||||
channel: Option<LndPendingChannel>,
|
||||
closing_txid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndPendingChannel {
|
||||
remote_node_pub: Option<String>,
|
||||
@@ -74,6 +92,52 @@ struct LndPendingChannel {
|
||||
channel_point: Option<String>,
|
||||
}
|
||||
|
||||
impl LndPendingChannel {
|
||||
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
|
||||
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
|
||||
capacity: parse(&self.capacity),
|
||||
local_balance: parse(&self.local_balance),
|
||||
remote_balance: parse(&self.remote_balance),
|
||||
active: false,
|
||||
status: status.into(),
|
||||
channel_point: self.channel_point.unwrap_or_default(),
|
||||
closing_txid: closing_txid.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct LndClosedChannelsResponse {
|
||||
channels: Option<Vec<LndClosedChannel>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LndClosedChannel {
|
||||
chan_id: Option<String>,
|
||||
remote_pubkey: Option<String>,
|
||||
capacity: Option<String>,
|
||||
settled_balance: Option<String>,
|
||||
close_type: Option<String>,
|
||||
closing_tx_hash: Option<String>,
|
||||
channel_point: Option<String>,
|
||||
close_height: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ClosedChannelInfo {
|
||||
chan_id: String,
|
||||
remote_pubkey: String,
|
||||
capacity: i64,
|
||||
settled_balance: i64,
|
||||
close_type: String,
|
||||
closing_tx_hash: String,
|
||||
channel_point: String,
|
||||
close_height: i64,
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
@@ -131,6 +195,7 @@ impl RpcHandler {
|
||||
"inactive".into()
|
||||
},
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
closing_txid: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -138,31 +203,20 @@ impl RpcHandler {
|
||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||
if let Some(ch) = pch.channel {
|
||||
let capacity: i64 = ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let local: i64 = ch
|
||||
.local_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let remote: i64 = ch
|
||||
.remote_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
pending_channels.push(ChannelInfo {
|
||||
chan_id: String::new(),
|
||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||
capacity,
|
||||
local_balance: local,
|
||||
remote_balance: remote,
|
||||
active: false,
|
||||
status: "pending_open".into(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
});
|
||||
pending_channels.push(ch.into_channel_info("pending_open", None));
|
||||
}
|
||||
}
|
||||
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
|
||||
if let Some(ch) = wch.channel {
|
||||
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
|
||||
}
|
||||
}
|
||||
for fch in pending_resp
|
||||
.pending_force_closing_channels
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Some(ch) = fch.channel {
|
||||
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +403,46 @@ impl RpcHandler {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp: LndClosedChannelsResponse = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse LND closed channels response")?;
|
||||
|
||||
let channels: Vec<ClosedChannelInfo> = resp
|
||||
.channels
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|ch| ClosedChannelInfo {
|
||||
chan_id: ch.chan_id.unwrap_or_default(),
|
||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||
capacity: ch
|
||||
.capacity
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
settled_balance: ch
|
||||
.settled_balance
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
close_type: ch.close_type.unwrap_or_default(),
|
||||
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
|
||||
channel_point: ch.channel_point.unwrap_or_default(),
|
||||
close_height: ch.close_height.unwrap_or(0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "channels": channels }))
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -389,27 +483,35 @@ impl RpcHandler {
|
||||
"Closing Lightning channel"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let (_, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// The close endpoint is server-streaming: LND holds the connection
|
||||
// open and emits updates until the closing tx CONFIRMS on-chain
|
||||
// (potentially hours). Reading the whole body hangs the RPC even
|
||||
// though the close already went through, and the shared lnd_client's
|
||||
// 15s total timeout would abort the stream mid-read. Use a dedicated
|
||||
// client and return as soon as the first streamed update arrives.
|
||||
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 url = format!(
|
||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||
parts[0], parts[1], force
|
||||
);
|
||||
|
||||
let resp = client
|
||||
let mut resp = client
|
||||
.delete(&url)
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to close channel")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse close channel response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -417,6 +519,56 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
// First streamed line is {"result":{"close_pending":…}} on success or
|
||||
// {"error":…} — the stream reports errors in-band after a 200.
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
|
||||
while let Some(chunk) = resp.chunk().await? {
|
||||
buf.extend_from_slice(&chunk);
|
||||
let line = match buf.iter().position(|&b| b == b'\n') {
|
||||
Some(pos) => &buf[..pos],
|
||||
None => &buf[..],
|
||||
};
|
||||
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
|
||||
return Ok::<_, anyhow::Error>(Some(v));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
})
|
||||
.await;
|
||||
|
||||
match first_update {
|
||||
Ok(Ok(Some(update))) => {
|
||||
if let Some(err) = update.get("error") {
|
||||
let msg = err
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||
}
|
||||
// txid arrives base64-encoded in internal byte order; flip it
|
||||
// into the display order explorers use.
|
||||
use base64::Engine as _;
|
||||
let closing_txid = update
|
||||
.pointer("/result/close_pending/txid")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(|mut bytes| {
|
||||
bytes.reverse();
|
||||
hex::encode(bytes)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
info!(channel_point, closing_txid, "Channel close initiated");
|
||||
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
|
||||
}
|
||||
Ok(Ok(None)) => Err(anyhow::anyhow!(
|
||||
"LND ended the close stream without an update — check the channel list"
|
||||
)),
|
||||
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
|
||||
// No update inside the window: the close is almost certainly still
|
||||
// negotiating with the peer — report initiated, the channel list
|
||||
// will show it under Closing.
|
||||
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,16 +124,41 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
amount = amount,
|
||||
send_all = send_all,
|
||||
target_conf = target_conf,
|
||||
sat_per_vbyte = sat_per_vbyte,
|
||||
"Sending on-chain Bitcoin"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let send_body = match amount {
|
||||
let mut send_body = match amount {
|
||||
Some(amount) => serde_json::json!({
|
||||
"addr": addr,
|
||||
"amount": amount.to_string(),
|
||||
@@ -143,6 +168,13 @@ impl RpcHandler {
|
||||
"send_all": true,
|
||||
}),
|
||||
};
|
||||
if let Some(tc) = target_conf {
|
||||
send_body["target_conf"] = serde_json::json!(tc);
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
// LND REST encodes uint64 as a JSON string
|
||||
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||
@@ -171,6 +203,82 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "txid": txid }))
|
||||
}
|
||||
|
||||
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
|
||||
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
|
||||
/// UI can show the cost of each preset before the user confirms.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let addr = params
|
||||
.get("addr")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
let amount = params
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
|
||||
.query(&[
|
||||
(format!("AddrToAmount[{addr}]"), amount.to_string()),
|
||||
("target_conf".to_string(), target_conf.to_string()),
|
||||
])
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to estimate fee")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse fee estimate response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
|
||||
}
|
||||
|
||||
// LND REST encodes int64 as JSON strings
|
||||
let fee_sat = body
|
||||
.get("fee_sat")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
let sat_per_vbyte = body
|
||||
.get("sat_per_vbyte")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"fee_sat": fee_sat,
|
||||
"sat_per_vbyte": sat_per_vbyte,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create a Lightning invoice.
|
||||
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
||||
///
|
||||
|
||||
@@ -556,8 +556,8 @@ impl RpcHandler {
|
||||
.is_some(),
|
||||
None => false,
|
||||
};
|
||||
let totp_enabled = !is_token_login
|
||||
&& self.auth_manager.is_totp_enabled().await.unwrap_or(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()
|
||||
|
||||
@@ -766,7 +766,11 @@ async fn find_satellite(port: u16) -> Option<String> {
|
||||
if self_ips.contains(&ip) {
|
||||
continue;
|
||||
}
|
||||
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
|
||||
set.spawn(async move {
|
||||
tcp_alive(&ip.to_string(), port, 500)
|
||||
.await
|
||||
.then(|| ip.to_string())
|
||||
});
|
||||
}
|
||||
while let Some(res) = set.join_next().await {
|
||||
if let Ok(Some(ip)) = res {
|
||||
|
||||
@@ -848,11 +848,17 @@ pub async fn ensure_audio_stack() {
|
||||
{
|
||||
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
|
||||
Ok(s) => {
|
||||
warn!("audio: package install exited with {} — will retry next start", 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);
|
||||
warn!(
|
||||
"audio: package install failed: {:#} — will retry next start",
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -878,12 +884,23 @@ pub async fn ensure_audio_stack() {
|
||||
}
|
||||
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;
|
||||
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;
|
||||
let _ = host_sudo(&[
|
||||
"systemctl",
|
||||
"try-restart",
|
||||
"archipelago-audio-router.service",
|
||||
])
|
||||
.await;
|
||||
info!("audio: router updated");
|
||||
}
|
||||
}
|
||||
@@ -911,10 +928,21 @@ pub async fn ensure_gamepad_keys() {
|
||||
}
|
||||
}
|
||||
if unit_was_missing {
|
||||
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
|
||||
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;
|
||||
let _ = host_sudo(&[
|
||||
"systemctl",
|
||||
"try-restart",
|
||||
"archipelago-gamepad-keys.service",
|
||||
])
|
||||
.await;
|
||||
info!("gamepad: bridge updated");
|
||||
}
|
||||
}
|
||||
@@ -994,10 +1022,10 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
// 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");
|
||||
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
|
||||
|
||||
@@ -111,8 +111,7 @@ impl BootReconciler {
|
||||
let mut failure_rounds: u32 = 0;
|
||||
loop {
|
||||
let installed = orchestrator.manifest_ids().await;
|
||||
let failures =
|
||||
crate::container::companion::reconcile(&installed).await;
|
||||
let failures = crate::container::companion::reconcile(&installed).await;
|
||||
for (companion, err) in &failures {
|
||||
tracing::warn!(
|
||||
companion = %companion,
|
||||
|
||||
@@ -248,10 +248,17 @@ impl QuadletUnit {
|
||||
} else {
|
||||
proto.as_str()
|
||||
};
|
||||
// Keep the rendered directive byte-identical for unbound
|
||||
// ports so existing units don't read as drifted.
|
||||
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
|
||||
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
|
||||
// (accepts, forwards nothing — "empty response" from the
|
||||
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
|
||||
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
|
||||
// which forwards to the v4 loopback listener that works.
|
||||
// NOTE: this changes the rendered directive for unbound
|
||||
// ports on purpose — the one-time drift/recreate at upgrade
|
||||
// is the deploy vehicle for the fix.
|
||||
if bind.is_empty() {
|
||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
|
||||
} else {
|
||||
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
|
||||
}
|
||||
@@ -1018,7 +1025,7 @@ mod tests {
|
||||
u.network = NetworkMode::Bridge("archy-net".into());
|
||||
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1167,8 +1174,8 @@ mod tests {
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(!s.contains("PublishPort=8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1200,8 +1207,8 @@ mod tests {
|
||||
..QuadletUnit::default()
|
||||
};
|
||||
let s = u.render();
|
||||
assert!(s.contains("PublishPort=8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
|
||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||
@@ -1317,7 +1324,7 @@ app:
|
||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||
let s = QuadletUnit::from_manifest(&m, "searxng").render();
|
||||
|
||||
assert!(s.contains("PublishPort=8888:8080/tcp"));
|
||||
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
|
||||
assert!(!s.contains("Network=host"));
|
||||
}
|
||||
|
||||
@@ -1746,7 +1753,7 @@ app:
|
||||
assert!(body.contains("Network=archy-net"));
|
||||
assert!(body.contains("NetworkAlias=lnd"));
|
||||
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
|
||||
assert!(body.contains("PublishPort=10009:10009/tcp"));
|
||||
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
|
||||
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
|
||||
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
|
||||
assert!(body.contains("PodmanArgs=--memory=1024m"));
|
||||
|
||||
@@ -90,15 +90,13 @@ pub fn archy_anchor() -> SeedAnchor {
|
||||
pub fn fips_network_anchors() -> Vec<SeedAnchor> {
|
||||
vec![
|
||||
SeedAnchor {
|
||||
npub: "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u"
|
||||
.to_string(),
|
||||
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(),
|
||||
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(),
|
||||
@@ -158,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
let anchors: Vec<SeedAnchor> =
|
||||
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
||||
Ok(anchors)
|
||||
Ok(repair_legacy_anchor_set(anchors))
|
||||
}
|
||||
|
||||
/// Add the newer redundant public TCP anchors to legacy files that only carry
|
||||
/// the Archipelago-operated vps2 anchor. A deliberately custom/private anchor
|
||||
/// list remains authoritative; this only repairs the exact stale shape shipped
|
||||
/// before the join.fips.network anchors became defaults.
|
||||
fn repair_legacy_anchor_set(mut anchors: Vec<SeedAnchor>) -> Vec<SeedAnchor> {
|
||||
let has_archy = anchors.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB);
|
||||
let has_fips_network = fips_network_anchors()
|
||||
.iter()
|
||||
.any(|default| anchors.iter().any(|a| a.npub == default.npub));
|
||||
if has_archy && !has_fips_network {
|
||||
for anchor in fips_network_anchors() {
|
||||
anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
anchors
|
||||
}
|
||||
|
||||
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||
@@ -340,6 +355,30 @@ mod tests {
|
||||
assert!(got.iter().all(|a| a.transport == "tcp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_repairs_legacy_archy_only_anchor_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save(dir.path(), &[archy_anchor()]).await.unwrap();
|
||||
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert!(got.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
|
||||
for anchor in fips_network_anchors() {
|
||||
assert!(got.iter().any(|a| a.npub == anchor.npub));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_keeps_private_anchor_file_authoritative() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let private = mk("npub1private");
|
||||
save(dir.path(), std::slice::from_ref(&private))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = load(dir.path()).await.unwrap();
|
||||
assert_eq!(got, vec![private]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||
// Editing the anchor list (here removing one default) makes the file
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
|
||||
//!
|
||||
//! Catalog app launch ports (the web UIs the companion opens by direct
|
||||
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
|
||||
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||
//! no listener, so allowing them is inert.
|
||||
|
||||
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||
2283,
|
||||
2342,
|
||||
3000,
|
||||
3001,
|
||||
3002,
|
||||
4080,
|
||||
5180,
|
||||
7778,
|
||||
8080,
|
||||
8081,
|
||||
8082,
|
||||
8083,
|
||||
8084,
|
||||
8085,
|
||||
8087,
|
||||
8088,
|
||||
8089,
|
||||
8090,
|
||||
8096,
|
||||
8123,
|
||||
8175,
|
||||
8176,
|
||||
8240,
|
||||
8334,
|
||||
8888,
|
||||
8999,
|
||||
9000,
|
||||
9100,
|
||||
10380,
|
||||
11434,
|
||||
18081,
|
||||
18083,
|
||||
23000,
|
||||
32838,
|
||||
50002,
|
||||
];
|
||||
@@ -48,6 +48,30 @@ pub struct FipsConfig {
|
||||
pub struct NodeSection {
|
||||
pub identity: IdentitySection,
|
||||
pub discovery: DiscoverySection,
|
||||
pub retry: RetrySection,
|
||||
pub rate_limit: RateLimitSection,
|
||||
}
|
||||
|
||||
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
|
||||
/// doubling to 300s) are tuned for stable always-on links; a node redialing
|
||||
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
|
||||
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RetrySection {
|
||||
pub base_interval_secs: u64,
|
||||
pub max_backoff_secs: u64,
|
||||
pub max_retries: u32,
|
||||
}
|
||||
|
||||
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
|
||||
/// gaps out to 8-16s between resends exactly when a route has just
|
||||
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
|
||||
/// window (phone measured session-after-route: 225ms).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct RateLimitSection {
|
||||
pub handshake_resend_interval_ms: u64,
|
||||
pub handshake_resend_backoff: f64,
|
||||
pub handshake_max_resends: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
@@ -60,6 +84,14 @@ pub struct IdentitySection {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct DiscoverySection {
|
||||
/// Lookup completion timeout. Lookups fired while the tree position is
|
||||
/// still settling are doomed; failing them fast (5s, not 10s) lets the
|
||||
/// 1s-backoff retry find the route the moment it exists.
|
||||
pub timeout_secs: u64,
|
||||
pub backoff_base_secs: u64,
|
||||
pub backoff_max_secs: u64,
|
||||
pub retry_interval_secs: u64,
|
||||
pub max_attempts: u8,
|
||||
pub lan: LanDiscoverySection,
|
||||
}
|
||||
|
||||
@@ -123,8 +155,23 @@ impl Default for FipsConfig {
|
||||
node: NodeSection {
|
||||
identity: IdentitySection { persistent: true },
|
||||
discovery: DiscoverySection {
|
||||
timeout_secs: 5,
|
||||
backoff_base_secs: 1,
|
||||
backoff_max_secs: 30,
|
||||
retry_interval_secs: 2,
|
||||
max_attempts: 3,
|
||||
lan: LanDiscoverySection { enabled: true },
|
||||
},
|
||||
retry: RetrySection {
|
||||
base_interval_secs: 1,
|
||||
max_backoff_secs: 30,
|
||||
max_retries: 30,
|
||||
},
|
||||
rate_limit: RateLimitSection {
|
||||
handshake_resend_interval_ms: 400,
|
||||
handshake_resend_backoff: 1.5,
|
||||
handshake_max_resends: 10,
|
||||
},
|
||||
},
|
||||
tun: TunSection {
|
||||
enabled: true,
|
||||
@@ -186,6 +233,65 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(&stage).await;
|
||||
install_result?;
|
||||
|
||||
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
|
||||
// out-of-band) default-denies inbound on fips0 — without an explicit
|
||||
// allowance the node's web UI is unreachable over the mesh (phones got
|
||||
// RST on :80 with a healthy session; root-caused 2026-07-26 on
|
||||
// framework-pt). Ship the allowance as a fips.d drop-in on every
|
||||
// install/upgrade so no node ever regresses to a UI-less mesh.
|
||||
sudo_install_dir("/etc/fips/fips.d").await?;
|
||||
let dropin = "# Written by archipelago on every daemon config install.\n\
|
||||
# Allows the web UI + peer API through the fips0\n\
|
||||
# default-deny inbound baseline (fips.nft).\n\
|
||||
tcp dport 80 accept\n\
|
||||
tcp dport 8443 accept\n";
|
||||
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&nft_stage, dropin)
|
||||
.await
|
||||
.context("Failed to stage web-ui nft drop-in")?;
|
||||
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
|
||||
let _ = tokio::fs::remove_file(&nft_stage).await;
|
||||
nft_install?;
|
||||
|
||||
// App launch ports: the companion opens catalog apps by direct port
|
||||
// over the mesh. Ports of apps that aren't installed have no listener,
|
||||
// so the allowance is inert until an app exists to answer.
|
||||
let port_list = super::app_ports::APP_LAUNCH_PORTS
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let app_dropin = format!(
|
||||
"# Written by archipelago on every daemon config install.\n\
|
||||
# Catalog app launch ports (web UIs) allowed through the fips0\n\
|
||||
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
|
||||
tcp dport {{ {port_list} }} accept\n"
|
||||
);
|
||||
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
|
||||
tokio::fs::write(&app_stage, app_dropin)
|
||||
.await
|
||||
.context("Failed to stage app-ports nft drop-in")?;
|
||||
let app_install =
|
||||
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
|
||||
let _ = tokio::fs::remove_file(&app_stage).await;
|
||||
app_install?;
|
||||
// Make the allowance live immediately; a no-op error when the
|
||||
// hardening baseline isn't installed on this node yet.
|
||||
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {
|
||||
match Command::new("sudo")
|
||||
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(out) if !out.status.success() => tracing::warn!(
|
||||
"nft reload after web-ui drop-in failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
),
|
||||
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||
@@ -318,8 +424,21 @@ node:
|
||||
identity:
|
||||
persistent: true
|
||||
discovery:
|
||||
timeout_secs: 5
|
||||
backoff_base_secs: 1
|
||||
backoff_max_secs: 30
|
||||
retry_interval_secs: 2
|
||||
max_attempts: 3
|
||||
lan:
|
||||
enabled: true
|
||||
retry:
|
||||
base_interval_secs: 1
|
||||
max_backoff_secs: 30
|
||||
max_retries: 30
|
||||
rate_limit:
|
||||
handshake_resend_interval_ms: 400
|
||||
handshake_resend_backoff: 1.5
|
||||
handshake_max_resends: 10
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod anchors;
|
||||
pub mod app_ports;
|
||||
pub mod config;
|
||||
pub mod dial;
|
||||
pub mod iface;
|
||||
|
||||
@@ -81,6 +81,7 @@ async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> {
|
||||
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
|
||||
/// on every backend startup once the key is on disk.
|
||||
pub async fn activate(unit: &str) -> Result<()> {
|
||||
kill_stale_daemons().await?;
|
||||
// Order matters: unmask before enable/start, otherwise enable fails
|
||||
// on a masked unit.
|
||||
sudo_systemctl("unmask", unit).await?;
|
||||
@@ -94,9 +95,42 @@ pub async fn stop(unit: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub async fn restart(unit: &str) -> Result<()> {
|
||||
kill_stale_daemons().await?;
|
||||
sudo_systemctl("restart", unit).await
|
||||
}
|
||||
|
||||
/// Kill orphaned `fips` processes not owned by either known systemd unit.
|
||||
///
|
||||
/// Field failure, 2026-07-24: a stale daemon survived outside systemd and kept
|
||||
/// `0.0.0.0:8443` bound. The supervised daemon then started UDP-only, so every
|
||||
/// TCP seed-anchor connect failed with "no operational transport" and phones
|
||||
/// on 5G could not discover the node. We keep the cleanup narrow: preserve the
|
||||
/// MainPID of both units and terminate only extra `pgrep -x fips` matches.
|
||||
pub async fn kill_stale_daemons() -> Result<()> {
|
||||
let script = format!(
|
||||
r#"keep="$(systemctl show -p MainPID --value {managed} 2>/dev/null; systemctl show -p MainPID --value {upstream} 2>/dev/null)"
|
||||
for pid in $(pgrep -x fips 2>/dev/null || true); do
|
||||
case " $keep " in
|
||||
*" $pid "*) ;;
|
||||
*) kill "$pid" 2>/dev/null || true ;;
|
||||
esac
|
||||
done
|
||||
"#,
|
||||
managed = super::SERVICE_UNIT,
|
||||
upstream = super::UPSTREAM_SERVICE_UNIT,
|
||||
);
|
||||
let out = Command::new("sudo")
|
||||
.args(["sh", "-c", &script])
|
||||
.output()
|
||||
.await
|
||||
.context("sudo stale fips cleanup failed to launch")?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
anyhow::bail!("stale fips cleanup failed: {}", stderr);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve which systemd unit is actually supervising the fips daemon
|
||||
/// on this host. Nodes installed from the archipelago ISO run
|
||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
||||
|
||||
@@ -34,7 +34,6 @@ mod bitcoin_rpc;
|
||||
mod bitcoin_status;
|
||||
mod blobs;
|
||||
mod bootstrap;
|
||||
mod mesh_ports;
|
||||
mod ceremony;
|
||||
mod config;
|
||||
mod constants;
|
||||
@@ -57,6 +56,7 @@ mod identity;
|
||||
mod identity_manager;
|
||||
mod marketplace;
|
||||
mod mesh;
|
||||
mod mesh_ports;
|
||||
mod monitoring;
|
||||
mod names;
|
||||
mod network;
|
||||
|
||||
@@ -87,6 +87,18 @@ const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
|
||||
/// Maximum reconnect delay (cap for exponential backoff).
|
||||
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Minimum time a session must run before we trust it enough to reset
|
||||
/// backoff to the minimum. Without this gate, a device that connects then
|
||||
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
|
||||
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
|
||||
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
|
||||
/// auto-reset-circuit boards alike), turning a device that's merely
|
||||
/// unstable into a self-sustaining boot loop that outlasts whatever
|
||||
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
|
||||
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
|
||||
/// flash left it in a marginal state.
|
||||
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Number of consecutive write failures before we consider the device dead
|
||||
/// and trigger a reconnection cycle.
|
||||
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
|
||||
@@ -438,7 +450,10 @@ impl MeshState {
|
||||
let persisted: PersistedMessages = match serde_json::from_slice(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("mesh: parsing {} failed (skipping restore): {e}", path.display());
|
||||
warn!(
|
||||
"mesh: parsing {} failed (skipping restore): {e}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -454,7 +469,10 @@ impl MeshState {
|
||||
*id = max_id + 1;
|
||||
}
|
||||
}
|
||||
info!("mesh: restored {count} persisted messages (next id {})", max_id + 1);
|
||||
info!(
|
||||
"mesh: restored {count} persisted messages (next id {})",
|
||||
max_id + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +516,11 @@ pub fn spawn_message_persister(state: Arc<MeshState>) {
|
||||
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());
|
||||
warn!(
|
||||
"mesh: renaming {} -> {} failed: {e}",
|
||||
tmp.display(),
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
last_written = Some(json);
|
||||
@@ -550,6 +572,7 @@ pub fn spawn_mesh_listener(
|
||||
return;
|
||||
}
|
||||
|
||||
let session_start = std::time::Instant::now();
|
||||
match session::run_mesh_session(
|
||||
&state,
|
||||
&data_dir,
|
||||
@@ -572,13 +595,14 @@ pub fn spawn_mesh_listener(
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Mesh session ended cleanly");
|
||||
// Session was established before ending — reset backoff
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
// Only trust a session that actually ran for a while —
|
||||
// see STABLE_SESSION_THRESHOLD's doc comment.
|
||||
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Check if session was ever connected (vs failed to open)
|
||||
let was_connected = state.status.read().await.device_connected;
|
||||
if was_connected {
|
||||
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
}
|
||||
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
|
||||
|
||||
@@ -269,10 +269,23 @@ async fn auto_detect_and_open(
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
skip_path: Option<&str>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
let mut paths = super::super::serial::detect_serial_devices().await;
|
||||
// When falling back from a just-failed preferred path, don't probe that
|
||||
// same device again in the same cycle — every open() toggles DTR/RTS,
|
||||
// which resets ESP32-family boards, and back-to-back re-probes are what
|
||||
// keeps a mid-boot board from ever finishing its boot.
|
||||
if let Some(skip) = skip_path {
|
||||
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
|
||||
let skip_canon = canon(skip);
|
||||
paths.retain(|p| canon(p) != skip_canon);
|
||||
}
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
anyhow::bail!(match skip_path {
|
||||
Some(skip) => format!("No serial devices found in /dev besides {skip}, which was already probed this cycle"),
|
||||
None => "No serial devices found in /dev".to_string(),
|
||||
});
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
@@ -359,6 +372,16 @@ pub struct DeviceProbe {
|
||||
pub max_contacts: Option<u16>,
|
||||
}
|
||||
|
||||
/// Serializes serial-port open sequences between the listener's session
|
||||
/// opens and the RPC probe (`mesh.probe-device`). Linux happily double-opens
|
||||
/// a tty, and two concurrent handshakes corrupt each other into silence —
|
||||
/// observed live on .116 (2026-07-26): the kiosk browser's hot-swap
|
||||
/// auto-probe collided with the listener's cycle on every backoff window, so
|
||||
/// neither ever succeeded, and each collision's open() DTR/RTS-reset the
|
||||
/// board again. The probe's retry-across-idle-gaps heuristic (5f01ec31)
|
||||
/// narrowed but could not close the race; this closes it.
|
||||
static PORT_OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// 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
|
||||
@@ -366,11 +389,10 @@ pub struct DeviceProbe {
|
||||
/// 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.
|
||||
// Retries kept even with PORT_OPEN_LOCK closing the double-open race:
|
||||
// the board may still be mid-boot from a previous open's DTR/RTS reset,
|
||||
// and a later attempt after a quiet gap can succeed where the first
|
||||
// couldn't.
|
||||
let mut last_err = None;
|
||||
for attempt in 0..3u32 {
|
||||
if attempt > 0 {
|
||||
@@ -385,6 +407,7 @@ pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||
}
|
||||
|
||||
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
||||
let _port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
||||
return Ok(DeviceProbe {
|
||||
path: path.to_string(),
|
||||
@@ -963,6 +986,11 @@ pub(super) async fn run_mesh_session(
|
||||
// set, otherwise try the preferred serial path, falling back to
|
||||
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
||||
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
||||
//
|
||||
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
||||
// can't interleave its own handshakes on the same tty (see the lock's
|
||||
// doc comment). Held only until the device is opened, then released.
|
||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
|
||||
} else if let Some(path) = preferred_path {
|
||||
@@ -986,6 +1014,7 @@ pub(super) async fn run_mesh_session(
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(path),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
@@ -996,9 +1025,11 @@ pub(super) async fn run_mesh_session(
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
drop(port_guard);
|
||||
|
||||
// Update status
|
||||
{
|
||||
|
||||
@@ -191,10 +191,13 @@ impl MeshtasticDevice {
|
||||
.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() }),
|
||||
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()
|
||||
@@ -214,11 +217,20 @@ impl MeshtasticDevice {
|
||||
path
|
||||
))?;
|
||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||
// boards reset on a DTR/RTS transition, so deassert both and settle
|
||||
// before the handshake below.
|
||||
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||
// settle before the handshake below. 300ms is nowhere near a real
|
||||
// firmware boot time (LoRa radio init alone can take longer) —
|
||||
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||
// board before it ever finished booting from the PREVIOUS attempt's
|
||||
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||
// root cause hiding behind what looked like a per-protocol failure.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
|
||||
@@ -38,6 +38,14 @@ use tokio::sync::watch;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
|
||||
/// How long `MeshService::stop()` waits for the listener task to notice its
|
||||
/// shutdown signal and exit gracefully before force-aborting it. See
|
||||
/// `stop()`'s doc comment for the real incident this guards against: without
|
||||
/// a hard abort fallback, a slow-to-notice listener could be left running
|
||||
/// forever, orphaned, racing a later independently-started listener on the
|
||||
/// same serial port.
|
||||
const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
||||
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
|
||||
@@ -967,8 +975,36 @@ impl MeshService {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
if let Some(handle) = self.listener_handle.take() {
|
||||
let _ = handle.await;
|
||||
if let Some(mut handle) = self.listener_handle.take() {
|
||||
// Bounded wait for graceful shutdown, with a hard abort as
|
||||
// fallback — confirmed live 2026-07-23: a caller-side timeout
|
||||
// wrapping stop() (mesh::flash's STOP_LISTENER_TIMEOUT) cancelled
|
||||
// this await when the listener was slow to notice its shutdown
|
||||
// signal (mid multi-candidate probe), but `.take()` above had
|
||||
// already cleared `listener_handle` to None — so MeshService
|
||||
// believed it was stopped while the task kept running, orphaned
|
||||
// (dropping a JoinHandle does not abort the task it points to).
|
||||
// A later start() then spawned a second, fully independent
|
||||
// listener session racing the orphaned one on the same serial
|
||||
// port — neither could ever get a clean response, so every
|
||||
// mesh.configure/probe against that device failed indefinitely
|
||||
// even though the device itself was fine.
|
||||
//
|
||||
// Awaiting `&mut handle` (not `handle` by value) is what makes
|
||||
// the fallback possible: the Future is polled through the
|
||||
// reference, so if the timeout fires, this task's own `handle`
|
||||
// binding is still ours to call `.abort()` on afterward —
|
||||
// unlike moving `handle` into the timeout future outright, which
|
||||
// would drop (and thus orphan) it on timeout with nothing left
|
||||
// to abort.
|
||||
if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut handle)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!("Mesh listener did not shut down gracefully in time — aborting it");
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
if let Some(handle) = self.deadman_handle.take() {
|
||||
handle.abort();
|
||||
@@ -1027,8 +1063,18 @@ impl MeshService {
|
||||
/// 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");
|
||||
if status.device_connected {
|
||||
if let Some(active) = status.device_path.as_deref() {
|
||||
// Compare canonical paths: /dev/mesh-radio is a symlink to the
|
||||
// ttyUSB*/ttyACM* node, and a probe through the alias would
|
||||
// still open the very tty the live session is holding.
|
||||
let canon = |p: &str| {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p))
|
||||
};
|
||||
if canon(active) == canon(path) {
|
||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(status);
|
||||
listener::probe_device(path).await
|
||||
|
||||
@@ -58,11 +58,20 @@ impl MeshcoreDevice {
|
||||
path
|
||||
))?;
|
||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||
// boards reset on a DTR/RTS transition, so deassert both and settle
|
||||
// before the handshake below.
|
||||
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||
// settle before the handshake below. 300ms is nowhere near a real
|
||||
// firmware boot time (LoRa radio init alone can take longer) —
|
||||
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||
// board before it ever finished booting from the PREVIOUS attempt's
|
||||
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||
// root cause hiding behind what looked like a per-protocol failure.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
|
||||
|
||||
@@ -547,14 +556,30 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
///
|
||||
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev
|
||||
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so
|
||||
/// without this one physical board shows up (and gets probed, and gets its
|
||||
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which
|
||||
/// keeps the stable /dev/mesh-radio name when the symlink exists.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
let canonical = tokio::fs::canonicalize(path)
|
||||
.await
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
if seen_canonical.contains(&canonical) {
|
||||
debug!(path = %path, canonical = %canonical.display(),
|
||||
"Skipping alias of an already-detected serial device");
|
||||
continue;
|
||||
}
|
||||
seen_canonical.push(canonical);
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +110,8 @@ async fn listening_ports(path: &str, addr_hex_len: usize) -> Result<HashSet<u16>
|
||||
/// A v6-only listener on [::]:port forwarding each connection to 127.0.0.1:port.
|
||||
fn spawn_forwarder(port: u16) -> Result<JoinHandle<()>> {
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))
|
||||
.context("create v6 socket")?;
|
||||
let socket =
|
||||
Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP)).context("create v6 socket")?;
|
||||
// v6only so we coexist with the app's own 0.0.0.0:<port> bind.
|
||||
socket.set_only_v6(true).context("set v6only")?;
|
||||
socket.set_reuse_address(true).ok();
|
||||
@@ -119,8 +119,8 @@ fn spawn_forwarder(port: u16) -> Result<JoinHandle<()>> {
|
||||
let addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0);
|
||||
socket.bind(&addr.into()).context("bind [::]")?;
|
||||
socket.listen(128).context("listen")?;
|
||||
let listener = tokio::net::TcpListener::from_std(socket.into())
|
||||
.context("register with tokio")?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(socket.into()).context("register with tokio")?;
|
||||
|
||||
Ok(tokio::spawn(async move {
|
||||
loop {
|
||||
|
||||
@@ -988,8 +988,54 @@ impl Server {
|
||||
main_addr,
|
||||
));
|
||||
|
||||
// The mesh is IPv6-only: a phone reaching the node over its fips0
|
||||
// ULA lands on port 80 over v6, where a 0.0.0.0 listener never
|
||||
// answers — the UI was structurally unreachable over the mesh
|
||||
// (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200,
|
||||
// v6:80 = refused). Mirror an IPv4-any main listener with a
|
||||
// V6ONLY [::] socket on the same port — v6-only so it coexists
|
||||
// with the v4 listener regardless of net.ipv6.bindv6only.
|
||||
let v4_any_port = match main_addr {
|
||||
SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()),
|
||||
_ => None,
|
||||
};
|
||||
let v6_task = if let Some(port) = v4_any_port {
|
||||
let v6_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
||||
port,
|
||||
);
|
||||
match bind_v6_only(v6_addr) {
|
||||
Ok(listener) => {
|
||||
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
||||
Some(tokio::spawn(accept_loop(
|
||||
self.api_handler.clone(),
|
||||
listener,
|
||||
active_connections.clone(),
|
||||
false, // same semantics as the main listener
|
||||
tx.subscribe(),
|
||||
v6_addr,
|
||||
)))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Peer listener: late-binding so we don't need an archipelago
|
||||
// restart when fips0 comes up after onboarding.
|
||||
// App UIs over the mesh: rootless podman's port forwarder binds
|
||||
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
|
||||
// nothing even with the firewall open (HA/FileBrowser/Gitea/
|
||||
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
|
||||
// themselves). Bridge each catalog launch port to its v4 loopback
|
||||
// listener with a V6ONLY relay — self-selecting: where the app
|
||||
// already answers on v6 the bind fails and the app wins.
|
||||
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||
|
||||
let peer_task = tokio::spawn(peer_late_bind_loop(
|
||||
self.api_handler.clone(),
|
||||
active_connections.clone(),
|
||||
@@ -1012,6 +1058,10 @@ impl Server {
|
||||
}
|
||||
|
||||
let _ = main_task.await;
|
||||
if let Some(t) = v6_task {
|
||||
let _ = t.await;
|
||||
}
|
||||
relay_task.abort();
|
||||
let _ = peer_task.await;
|
||||
|
||||
info!("Shutdown complete");
|
||||
@@ -1019,6 +1069,78 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the
|
||||
/// socket never claims the IPv4 side (which the main listener owns) —
|
||||
/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with
|
||||
/// EADDRINUSE.
|
||||
fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||
let socket = socket2::Socket::new(
|
||||
socket2::Domain::IPV6,
|
||||
socket2::Type::STREAM,
|
||||
Some(socket2::Protocol::TCP),
|
||||
)?;
|
||||
socket.set_only_v6(true)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
tokio::net::TcpListener::from_std(socket.into())
|
||||
}
|
||||
|
||||
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
|
||||
/// why). Rescans every 60s so ports of freshly installed apps get bridged
|
||||
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
|
||||
/// raw TCP to the same port on IPv4 loopback.
|
||||
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
use std::collections::HashSet;
|
||||
let mut bridged: HashSet<u16> = HashSet::new();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
|
||||
if bridged.contains(&port) {
|
||||
continue;
|
||||
}
|
||||
let addr = SocketAddr::new(
|
||||
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
|
||||
port,
|
||||
);
|
||||
// EADDRINUSE = the app itself (or a prior relay) already
|
||||
// answers on v6 — exactly when we must stay out of the way.
|
||||
let Ok(listener) = bind_v6_only(addr) else { continue };
|
||||
bridged.insert(port);
|
||||
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
|
||||
let mut rx = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
accepted = listener.accept() => {
|
||||
let Ok((mut inbound, _)) = accepted else { break };
|
||||
tokio::spawn(async move {
|
||||
let Ok(mut outbound) = tokio::net::TcpStream::connect(
|
||||
("127.0.0.1", port),
|
||||
)
|
||||
.await else { return };
|
||||
let _ = tokio::io::copy_bidirectional(
|
||||
&mut inbound,
|
||||
&mut outbound,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
_ = rx.changed() => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
|
||||
/// listener and run the normal accept loop. If the bind fails (port
|
||||
/// already taken, permissions), log and keep retrying. Returns on
|
||||
|
||||
@@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
||||
// Mark original proofs as spent
|
||||
wallet.mark_spent(&indices);
|
||||
|
||||
// Separate send proofs from change proofs
|
||||
let (send, change): (Vec<_>, Vec<_>) = swap_result
|
||||
.new_proofs
|
||||
.into_iter()
|
||||
.partition(|p| send_denoms.contains(&p.amount));
|
||||
// Separate send proofs from change proofs BY COUNT, not membership:
|
||||
// partition(contains) put EVERY proof whose denomination appeared in
|
||||
// send_denoms into the token — when change shared a denomination with
|
||||
// the send (worst case: spending from a proof worth exactly 2× the
|
||||
// amount, send [n] + change [n]), the change proofs rode along and the
|
||||
// receiver was credited double. Consume exactly one proof per needed
|
||||
// send denomination; everything else is change.
|
||||
let mut send_needed = send_denoms.clone();
|
||||
let mut send: Vec<Proof> = Vec::new();
|
||||
let mut change: Vec<Proof> = Vec::new();
|
||||
for p in swap_result.new_proofs {
|
||||
if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) {
|
||||
send_needed.swap_remove(pos);
|
||||
send.push(p);
|
||||
} else {
|
||||
change.push(p);
|
||||
}
|
||||
}
|
||||
if !send_needed.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Mint swap returned incomplete send denominations (missing {:?})",
|
||||
send_needed
|
||||
);
|
||||
}
|
||||
|
||||
// Add change proofs back to wallet
|
||||
if !change.is_empty() {
|
||||
|
||||
@@ -162,7 +162,8 @@ All endpoints use JSON-RPC over HTTP POST to `/rpc/v1`.
|
||||
| `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes |
|
||||
| `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes |
|
||||
| `lnd.newaddress` | — | `{ address: string }` | Yes |
|
||||
| `lnd.sendcoins` | `{ addr: string, amount: number }` | `{ txid: string }` | Yes |
|
||||
| `lnd.sendcoins` | `{ addr: string, amount?: number, send_all?: bool, target_conf?: number, sat_per_vbyte?: number }` | `{ txid: string }` | Yes |
|
||||
| `lnd.estimatefee` | `{ addr: string, amount: number, target_conf?: number }` | `{ fee_sat: number, sat_per_vbyte: number }` | Yes |
|
||||
| `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes |
|
||||
| `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes |
|
||||
| `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes |
|
||||
|
||||
@@ -83,7 +83,7 @@ QEMU_ARGS=(
|
||||
|
||||
# Display mode
|
||||
if [ "$NOGRAPHIC" = true ]; then
|
||||
QEMU_ARGS+=(-nographic -append "console=ttyS0")
|
||||
QEMU_ARGS+=(-display none)
|
||||
else
|
||||
QEMU_ARGS+=(-vga virtio -display default)
|
||||
fi
|
||||
|
||||
@@ -3453,12 +3453,26 @@ app.post('/rpc/v1', (req, res) => {
|
||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
||||
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
||||
{ chan_id: '', remote_pubkey: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4', capacity: 500000, local_balance: 500000, remote_balance: 0, active: false, status: 'pending_open', channel_point: randomHex(32) + ':0', peer_alias: 'ACINQ' },
|
||||
{ chan_id: '', remote_pubkey: '02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff', capacity: 800000, local_balance: 350000, remote_balance: 450000, active: false, status: 'closing', channel_point: randomHex(32) + ':0', closing_txid: randomHex(32), peer_alias: 'Bitrefill' },
|
||||
]
|
||||
return res.json({
|
||||
result: {
|
||||
channels,
|
||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
||||
// Totals sum open channels only, matching the real backend.
|
||||
total_outbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.local_balance, 0),
|
||||
total_inbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.remote_balance, 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case 'lnd.closedchannels': {
|
||||
return res.json({
|
||||
result: {
|
||||
channels: [
|
||||
{ chan_id: '840921088110001', remote_pubkey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', capacity: 1000000, settled_balance: 612000, close_type: 'COOPERATIVE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':0', close_height: 903112 },
|
||||
{ chan_id: '840921088110002', remote_pubkey: '0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f', capacity: 250000, settled_balance: 0, close_type: 'REMOTE_FORCE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':1', close_height: 897540 },
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3518,6 +3532,13 @@ app.post('/rpc/v1', (req, res) => {
|
||||
})
|
||||
}
|
||||
|
||||
case 'lnd.estimatefee': {
|
||||
// ~141 vB P2WPKH spend at a rate that scales with urgency
|
||||
const target = params?.target_conf || 6
|
||||
const rate = target <= 1 ? 22 : target <= 6 ? 8 : 2
|
||||
return res.json({ result: { fee_sat: rate * 141, sat_per_vbyte: rate } })
|
||||
}
|
||||
|
||||
case 'lnd.decodepayreq': {
|
||||
return res.json({
|
||||
result: {
|
||||
|
||||
Generated
+57
-3
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.111-alpha",
|
||||
"version": "1.7.115-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.111-alpha",
|
||||
"version": "1.7.115-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -149,6 +150,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1810,6 +1812,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1833,6 +1836,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2882,6 +2886,18 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3538,6 +3554,28 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
|
||||
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||
"version": "3.0.0-pre1",
|
||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||
@@ -3885,6 +3923,7 @@
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
@@ -3934,6 +3973,7 @@
|
||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -4434,6 +4474,7 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -4919,6 +4960,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -5937,6 +5979,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -8129,6 +8172,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -8178,6 +8222,7 @@
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -8280,7 +8325,8 @@
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
@@ -9036,6 +9082,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9697,6 +9744,7 @@
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -10952,6 +11000,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11207,6 +11256,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -11448,6 +11498,7 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -11610,6 +11661,7 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11623,6 +11675,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -11715,6 +11768,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.111-alpha",
|
||||
"version": "1.7.115-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -24,6 +24,7 @@
|
||||
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
Binary file not shown.
@@ -150,7 +150,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// exposes the release-server address.
|
||||
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
: 'http://146.59.87.168:2100/packages/archipelago-companion.apk'
|
||||
|
||||
// Deep-link scheme the companion app registers; carries the server entry the
|
||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||
@@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean {
|
||||
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
|
||||
* QR carried one of these on 2026-07-22 and the companion sat there
|
||||
* dialing an IP the phone had no route to)
|
||||
* - a .fips name or mesh ULA (operator browsing over the mesh — a scanned
|
||||
* QR carried npub….fips as fhost on 2026-07-24; Android's system DNS
|
||||
* can't resolve .fips, so the phone's direct dial died and first
|
||||
* connect crawled through anchor discovery instead of the LAN)
|
||||
*/
|
||||
async function resolveServerUrl(): Promise<string> {
|
||||
if (IS_DEMO) return DEMO_SERVER_URL
|
||||
const { hostname, origin } = window.location
|
||||
const phoneUnreachable =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
isTailnetIp(hostname) ||
|
||||
hostname.endsWith('.fips') ||
|
||||
hostname.includes(':') // IPv6 literal — the node's mesh ULA
|
||||
if (!phoneUnreachable) return origin
|
||||
try {
|
||||
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<!-- No Channels -->
|
||||
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
|
||||
<div v-else-if="channels.length === 0 && closedChannels.length === 0" key="empty" class="glass-card p-8 text-center">
|
||||
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
@@ -85,6 +85,23 @@
|
||||
|
||||
<!-- Channel List -->
|
||||
<div v-else key="channels" class="space-y-3">
|
||||
<!-- Status tabs -->
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
|
||||
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
|
||||
>{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@@ -96,7 +113,7 @@
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
v-for="ch in filteredChannels"
|
||||
:key="ch.chan_id || ch.channel_point"
|
||||
class="glass-card p-4"
|
||||
:class="{ 'bg-white/5': compact }"
|
||||
@@ -109,12 +126,14 @@
|
||||
'bg-green-400': channelStatus(ch) === 'active',
|
||||
'bg-yellow-400': channelStatus(ch) === 'pending_open',
|
||||
'bg-red-400': channelStatus(ch) === 'inactive',
|
||||
'bg-gray-400': channelStatus(ch) === 'closing',
|
||||
'bg-gray-500': channelStatus(ch) === 'force_closing',
|
||||
}"
|
||||
></span>
|
||||
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="channelStatus(ch) !== 'pending_open'"
|
||||
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
|
||||
@click="confirmClose(ch)"
|
||||
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
|
||||
>
|
||||
@@ -148,9 +167,10 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Funding tx -->
|
||||
<div v-if="fundingTxid(ch)" class="flex justify-end">
|
||||
<!-- Funding / closing tx -->
|
||||
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
|
||||
<button
|
||||
v-if="fundingTxid(ch)"
|
||||
@click="openInMempool(fundingTxid(ch))"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="fundingTxid(ch)"
|
||||
@@ -160,8 +180,66 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="ch.closing_txid"
|
||||
@click="openInMempool(ch.closing_txid!)"
|
||||
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
|
||||
:title="ch.closing_txid"
|
||||
>
|
||||
Closing tx in Mempool
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closed channel history (All + Closed tabs) -->
|
||||
<div
|
||||
v-for="ch in filteredClosed"
|
||||
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
|
||||
class="glass-card p-4 opacity-75"
|
||||
:class="{ 'bg-white/5': compact }"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-white/30"></span>
|
||||
<span class="text-white/60 text-sm font-medium">Closed</span>
|
||||
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
|
||||
</div>
|
||||
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
|
||||
{{ ch.remote_pubkey }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-between text-xs text-white/50 mb-2">
|
||||
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
|
||||
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ch.closing_tx_hash" class="flex justify-end">
|
||||
<button
|
||||
@click="openInMempool(ch.closing_tx_hash)"
|
||||
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
|
||||
:title="ch.closing_tx_hash"
|
||||
>
|
||||
Closing tx in Mempool
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-tab empty state -->
|
||||
<div
|
||||
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
|
||||
class="glass-card p-6 text-center"
|
||||
>
|
||||
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -299,7 +377,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
|
||||
@@ -314,6 +392,18 @@ interface Channel {
|
||||
active: boolean
|
||||
status?: string
|
||||
channel_point?: string
|
||||
closing_txid?: string
|
||||
}
|
||||
|
||||
interface ClosedChannel {
|
||||
chan_id?: string
|
||||
remote_pubkey: string
|
||||
capacity: number
|
||||
settled_balance: number
|
||||
close_type?: string
|
||||
closing_tx_hash?: string
|
||||
channel_point?: string
|
||||
close_height?: number
|
||||
}
|
||||
|
||||
/** Status with a fallback derived from `active` for backends that omit it */
|
||||
@@ -321,6 +411,48 @@ function channelStatus(ch: Channel): string {
|
||||
return ch.status ?? (ch.active ? 'active' : 'inactive')
|
||||
}
|
||||
|
||||
type ChannelTab = 'all' | 'active' | 'pending' | 'closed'
|
||||
const activeTab = ref<ChannelTab>('all')
|
||||
|
||||
/** pending_open, closing and force_closing all live on the Pending tab */
|
||||
function isPendingState(ch: Channel): boolean {
|
||||
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
|
||||
}
|
||||
|
||||
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
|
||||
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
|
||||
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
|
||||
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
|
||||
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
|
||||
])
|
||||
|
||||
const filteredChannels = computed((): Channel[] => {
|
||||
switch (activeTab.value) {
|
||||
case 'closed': return []
|
||||
case 'active': return channels.value.filter(ch => !isPendingState(ch))
|
||||
case 'pending': return channels.value.filter(isPendingState)
|
||||
default: return channels.value
|
||||
}
|
||||
})
|
||||
|
||||
const filteredClosed = computed((): ClosedChannel[] =>
|
||||
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
|
||||
)
|
||||
|
||||
const emptyTabMessage = computed((): string => {
|
||||
switch (activeTab.value) {
|
||||
case 'active': return 'No open channels.'
|
||||
case 'pending': return 'No pending or closing channels.'
|
||||
case 'closed': return 'No closed channels yet.'
|
||||
default: return 'No channels yet.'
|
||||
}
|
||||
})
|
||||
|
||||
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
|
||||
function closeTypeLabel(ch: ClosedChannel): string {
|
||||
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||
|
||||
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
@@ -333,6 +465,7 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const channels = ref<Channel[]>([])
|
||||
const closedChannels = ref<ClosedChannel[]>([])
|
||||
const summary = ref({ total_inbound: 0, total_outbound: 0 })
|
||||
|
||||
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
|
||||
@@ -419,6 +552,17 @@ async function loadChannels() {
|
||||
total_inbound: result.total_inbound || 0,
|
||||
total_outbound: result.total_outbound || 0,
|
||||
}
|
||||
// Closed history is a separate RPC — a failure here keeps the previous
|
||||
// list rather than blanking the main channel view.
|
||||
try {
|
||||
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
|
||||
method: 'lnd.closedchannels',
|
||||
timeout: 15000,
|
||||
})
|
||||
closedChannels.value = closed.channels || []
|
||||
} catch {
|
||||
/* keep previous closed list */
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load channels'
|
||||
if (!hadChannels) channels.value = []
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
@click="seedTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<template v-if="seedTab === 'words'">
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="hidden ? 'blur-md' : ''"
|
||||
@click="hidden = !hidden"
|
||||
>
|
||||
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">
|
||||
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
|
||||
Tap to {{ hidden ? 'reveal' : 'hide' }}.
|
||||
</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
|
||||
:class="hidden ? 'blur-md' : ''"
|
||||
@click="hidden = !hidden"
|
||||
>
|
||||
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
</div>
|
||||
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
|
||||
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
@click="qrFormat = f.key"
|
||||
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
<template v-if="aezeed">
|
||||
The code contains your seed words as plain text — treat it exactly like the words
|
||||
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
|
||||
phrase — it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
|
||||
not into hardware wallets like Passport.
|
||||
</template>
|
||||
<template v-else-if="qrFormat === 'seedqr'">
|
||||
SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import
|
||||
seeds by QR. Treat this code exactly like the words themselves.
|
||||
</template>
|
||||
<template v-else>
|
||||
Plain text words — for wallets that read the phrase as text. Treat this code exactly
|
||||
like the words themselves.
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
|
||||
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
|
||||
// Words are always the first view. For BIP39 seeds the QR defaults to the
|
||||
// SeedQR standard (4-digit wordlist indices — what Passport Prime, SeedSigner,
|
||||
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
|
||||
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
|
||||
// plain-text QR plus an explanation — SeedQR-encoding one would be dishonest.
|
||||
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
|
||||
|
||||
const seedTab = ref<'words' | 'qr'>('words')
|
||||
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
|
||||
const seedQrAvailable = ref(!props.aezeed)
|
||||
const hidden = ref(true)
|
||||
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
async function renderQr() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || props.words.length === 0) return
|
||||
try {
|
||||
let payload = props.words.join(' ')
|
||||
if (!props.aezeed && qrFormat.value === 'seedqr') {
|
||||
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||
const digits = await toSeedQrDigits(props.words)
|
||||
if (digits) {
|
||||
payload = digits
|
||||
} else {
|
||||
// Not a BIP39 phrase after all — only plain text is honest.
|
||||
seedQrAvailable.value = false
|
||||
qrFormat.value = 'text'
|
||||
return // the qrFormat watcher re-renders as text
|
||||
}
|
||||
}
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
|
||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||
}
|
||||
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
|
||||
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
|
||||
watch(() => props.words, () => {
|
||||
seedTab.value = 'words' // fresh reveal always shows words first
|
||||
hidden.value = true
|
||||
seedQrAvailable.value = !props.aezeed
|
||||
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
|
||||
})
|
||||
</script>
|
||||
@@ -1,7 +1,58 @@
|
||||
<template>
|
||||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||
<!-- ============ SUCCESS PANE — the payment's moment, not a footnote ============ -->
|
||||
<template v-if="successInfo">
|
||||
<div class="text-center py-4">
|
||||
<div class="send-success-burst mx-auto mb-6">
|
||||
<span class="burst-ring"></span>
|
||||
<span class="burst-ring burst-ring-2"></span>
|
||||
<span class="burst-ring burst-ring-3"></span>
|
||||
<div class="burst-core">
|
||||
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
|
||||
{{ successInfo.amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold tracking-widest text-white mb-1">SENT</div>
|
||||
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
|
||||
|
||||
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
|
||||
<div v-if="successInfo.hash">
|
||||
<p class="text-xs text-white/50 mb-1">Payment hash</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
|
||||
<button
|
||||
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
|
||||
@click="copyDetail(successInfo.hash)"
|
||||
>{{ copiedDetail === successInfo.hash ? 'Copied!' : 'Copy' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="successInfo.txid">
|
||||
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
|
||||
<button
|
||||
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
|
||||
@click="copyDetail(successInfo.txid)"
|
||||
>{{ copiedDetail === successInfo.txid ? 'Copied!' : 'Copy' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
|
||||
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||
<template v-if="confirming">
|
||||
<template v-else-if="confirming">
|
||||
<div class="mb-3 p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-white/50">Method</span>
|
||||
@@ -27,6 +78,10 @@
|
||||
</span>
|
||||
<span class="text-sm font-medium text-white/80">−{{ confirmAmount.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div v-if="effectiveMethod === 'onchain'" class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Network fee</span>
|
||||
<span class="text-sm font-medium text-white/80">{{ feeEstimateLabel }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-white/50">Balance after</span>
|
||||
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
|
||||
@@ -66,7 +121,19 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-white/60 text-sm">{{ amountLabel }}</label>
|
||||
<!-- sats/BTC entry toggle (on-chain only) -->
|
||||
<div v-if="sendMethod === 'onchain'" class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="u in (['sats', 'btc'] as const)"
|
||||
:key="u"
|
||||
@click="setAmountUnit(u)"
|
||||
class="px-2 py-0.5 rounded text-[11px] font-medium transition-colors"
|
||||
:class="amountUnit === u ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ u === 'btc' ? 'BTC' : 'sats' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||
<button
|
||||
v-if="sendMethod === 'onchain'"
|
||||
@@ -80,10 +147,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="amount"
|
||||
v-model.number="amountEntry"
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
|
||||
:min="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
|
||||
:step="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
|
||||
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : amountUnit === 'btc' && sendMethod === 'onchain' ? '0.001' : '1000'"
|
||||
:disabled="sendAll || pastedInvoiceAmount !== null"
|
||||
class="w-full input-glass disabled:opacity-50"
|
||||
/>
|
||||
@@ -96,6 +164,7 @@
|
||||
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
|
||||
Zero-amount invoice — enter how many sats to pay.
|
||||
</p>
|
||||
<p v-else-if="unitConversionHint" class="text-xs text-white/40 mt-1">{{ unitConversionHint }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||
@@ -114,6 +183,48 @@
|
||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Network fee (on-chain only) -->
|
||||
<div v-if="sendMethod === 'onchain'" class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">Network fee</label>
|
||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="preset in onchainFeePresets"
|
||||
:key="preset.key"
|
||||
@click="feePreset = preset.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ preset.label }}</button>
|
||||
</div>
|
||||
<p v-if="feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
|
||||
{{ onchainFeePresets.find(p => p.key === feePreset)?.hint }}
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-2 gap-3 mt-2">
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Target blocks</label>
|
||||
<input
|
||||
v-model.number="customConfTarget"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1008"
|
||||
placeholder="6"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
|
||||
<input
|
||||
v-model.number="customSatPerVbyte"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5000"
|
||||
placeholder="—"
|
||||
class="w-full input-glass"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
|
||||
<!-- QR so the recipient can scan the token straight off this screen
|
||||
@@ -125,16 +236,6 @@
|
||||
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="resultTxid" class="mb-3 alert-success">
|
||||
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
|
||||
</div>
|
||||
<div v-if="resultHash" class="mb-3 alert-success">
|
||||
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
||||
</div>
|
||||
<div v-if="resultArk" class="mb-3 alert-success">
|
||||
<p class="text-xs">{{ resultArk }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
@@ -167,13 +268,67 @@ const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
||||
// 'auto' remains in the type for the effectiveMethod logic but is no longer
|
||||
// offered as a tab (hidden per operator request 2026-07-22).
|
||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
|
||||
const amount = ref<number>(0)
|
||||
|
||||
// --- Amount entry with a sats/BTC unit toggle (on-chain). `amountEntry` is
|
||||
// --- what the user types in the chosen unit; `amount` stays the canonical
|
||||
// --- sats value the rest of the flow reads and writes.
|
||||
const amountUnit = ref<'sats' | 'btc'>('sats')
|
||||
const amountEntry = ref<number>(0)
|
||||
const amount = computed<number>({
|
||||
get: () =>
|
||||
amountUnit.value === 'btc'
|
||||
? Math.round((amountEntry.value || 0) * 100_000_000)
|
||||
: Math.floor(amountEntry.value || 0),
|
||||
set: (sats: number) => {
|
||||
amountEntry.value = amountUnit.value === 'btc' ? (sats || 0) / 100_000_000 : sats || 0
|
||||
},
|
||||
})
|
||||
|
||||
/** Switch entry unit, converting whatever is already typed. */
|
||||
function setAmountUnit(unit: 'sats' | 'btc') {
|
||||
if (unit === amountUnit.value) return
|
||||
const sats = amount.value
|
||||
amountUnit.value = unit
|
||||
amount.value = sats
|
||||
}
|
||||
|
||||
// Only the on-chain tab offers BTC entry — leaving it snaps back to sats so
|
||||
// the lightning/ecash flows (and their sats-only hints) stay consistent.
|
||||
watch(sendMethod, (m) => {
|
||||
if (m !== 'onchain' && amountUnit.value !== 'sats') setAmountUnit('sats')
|
||||
})
|
||||
|
||||
const amountLabel = computed(() =>
|
||||
sendMethod.value === 'onchain' && amountUnit.value === 'btc' ? 'Amount (BTC)' : t('sendBitcoin.amountSats')
|
||||
)
|
||||
|
||||
const unitConversionHint = computed(() => {
|
||||
if (sendMethod.value !== 'onchain' || !amountEntry.value) return ''
|
||||
return amountUnit.value === 'btc'
|
||||
? `= ${amount.value.toLocaleString()} sats`
|
||||
: `= ${(amount.value / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')} BTC`
|
||||
})
|
||||
|
||||
const dest = ref('')
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
const resultTxid = ref('')
|
||||
const resultHash = ref('')
|
||||
const resultArk = ref('')
|
||||
// Set on a completed send — flips the modal to the success pane.
|
||||
const successInfo = ref<{
|
||||
amount: number
|
||||
methodLabel: string
|
||||
hash?: string
|
||||
txid?: string
|
||||
note?: string
|
||||
} | null>(null)
|
||||
const copiedDetail = ref('')
|
||||
|
||||
function copyDetail(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
copiedDetail.value = text
|
||||
setTimeout(() => {
|
||||
if (copiedDetail.value === text) copiedDetail.value = ''
|
||||
}, 1500)
|
||||
}
|
||||
const ecashToken = ref('')
|
||||
|
||||
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
|
||||
@@ -193,22 +348,78 @@ function toggleSendAll() {
|
||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
||||
|
||||
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
|
||||
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
|
||||
// leave it editable. Clearing/leaving lightning unlocks again.
|
||||
const pastedInvoiceAmount = computed<number | null>(() => {
|
||||
if (effectiveMethod.value !== 'lightning') return null
|
||||
const d = dest.value.trim()
|
||||
if (!d) return null
|
||||
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
|
||||
})
|
||||
watch(pastedInvoiceAmount, (fixed, prev) => {
|
||||
if (fixed !== null) amount.value = fixed
|
||||
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
|
||||
// keep the previous invoice's sats — make the user type the new amount.
|
||||
else if (prev !== null) amount.value = 0
|
||||
// --- On-chain network fee: presets map to LND confirmation targets; custom
|
||||
// --- takes a block target or an explicit sat/vB rate (rate wins).
|
||||
type OnchainFeePreset = 'fast' | 'standard' | 'slow' | 'custom'
|
||||
|
||||
const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||
{ key: 'fast', label: 'Fast', hint: 'Targets the next block (~10 minutes)', confTarget: 1 },
|
||||
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
|
||||
{ key: 'slow', label: 'Slow', hint: 'Confirms within ~144 blocks (about a day)', confTarget: 144 },
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
const feePreset = ref<OnchainFeePreset>('standard')
|
||||
const customConfTarget = ref<number | null>(null)
|
||||
const customSatPerVbyte = ref<number | null>(null)
|
||||
// Resolved at review time so confirm + send use the same params.
|
||||
const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>({})
|
||||
|
||||
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (feePreset.value !== 'custom') {
|
||||
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
|
||||
}
|
||||
const rate = customSatPerVbyte.value
|
||||
const conf = customConfTarget.value
|
||||
if (rate != null && rate !== 0) {
|
||||
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
|
||||
return { sat_per_vbyte: Math.floor(rate) }
|
||||
}
|
||||
if (conf != null && conf !== 0) {
|
||||
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
|
||||
return { target_conf: Math.floor(conf) }
|
||||
}
|
||||
error.value = 'Custom fee requires target blocks or sats per vByte'
|
||||
return null
|
||||
}
|
||||
|
||||
// Fee estimate for the confirm pane (best-effort — LND's own estimator).
|
||||
const feeEstimate = ref<{ fee_sat: number; sat_per_vbyte: number } | null>(null)
|
||||
const feeEstimateLoading = ref(false)
|
||||
|
||||
const feeEstimateLabel = computed(() => {
|
||||
if (feeEstimate.value) {
|
||||
return `~${feeEstimate.value.fee_sat.toLocaleString()} sats · ${feeEstimate.value.sat_per_vbyte} sat/vB`
|
||||
}
|
||||
if (resolvedFeeParams.value.sat_per_vbyte) {
|
||||
return `${resolvedFeeParams.value.sat_per_vbyte} sat/vB (custom)`
|
||||
}
|
||||
if (feeEstimateLoading.value) return '…'
|
||||
return isSweep.value ? 'deducted from swept amount' : 'estimated at broadcast'
|
||||
})
|
||||
|
||||
async function loadFeeEstimate() {
|
||||
feeEstimate.value = null
|
||||
// Explicit sat/vB shows as-is; sweeps have no fixed amount to estimate on.
|
||||
if (resolvedFeeParams.value.sat_per_vbyte || isSweep.value) return
|
||||
const addr = dest.value.trim()
|
||||
const amt = confirmAmount.value
|
||||
if (!addr || amt < 546) return
|
||||
feeEstimateLoading.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
|
||||
method: 'lnd.estimatefee',
|
||||
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
|
||||
timeout: 10000,
|
||||
})
|
||||
if (res.fee_sat > 0) feeEstimate.value = res
|
||||
} catch {
|
||||
/* estimate is a preview — the label falls back to prose */
|
||||
} finally {
|
||||
feeEstimateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Clipboard read needs a secure context (or the companion bridge); hide the
|
||||
// button where it can't work — the textarea still accepts a manual paste.
|
||||
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
|
||||
@@ -231,6 +442,25 @@ const effectiveMethod = computed(() => {
|
||||
return 'lightning'
|
||||
})
|
||||
|
||||
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
|
||||
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
|
||||
// leave it editable. Clearing/leaving lightning unlocks again.
|
||||
// MUST come after effectiveMethod: watch() evaluates its source getter at
|
||||
// setup, and reading a const still in its temporal dead zone crashed the
|
||||
// whole modal at mount ("Cannot access 'R' before initialization").
|
||||
const pastedInvoiceAmount = computed<number | null>(() => {
|
||||
if (effectiveMethod.value !== 'lightning') return null
|
||||
const d = dest.value.trim()
|
||||
if (!d) return null
|
||||
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
|
||||
})
|
||||
watch(pastedInvoiceAmount, (fixed, prev) => {
|
||||
if (fixed !== null) amount.value = fixed
|
||||
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
|
||||
// keep the previous invoice's sats — make the user type the new amount.
|
||||
else if (prev !== null) amount.value = 0
|
||||
})
|
||||
|
||||
// --- Second-step confirmation (parity with the scan flow): review shows the
|
||||
// --- balance reduction before anything is sent or any token is minted.
|
||||
|
||||
@@ -309,20 +539,37 @@ function review() {
|
||||
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
|
||||
error.value = t('sendBitcoin.amountSats'); return
|
||||
}
|
||||
if (method === 'onchain') {
|
||||
const fee = onchainFeeParams()
|
||||
if (!fee) return
|
||||
resolvedFeeParams.value = fee
|
||||
void loadFeeEstimate()
|
||||
} else {
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
}
|
||||
void loadConfirmBalance()
|
||||
confirming.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
error.value = ''
|
||||
resultTxid.value = ''
|
||||
resultHash.value = ''
|
||||
resultArk.value = ''
|
||||
ecashToken.value = ''
|
||||
confirming.value = false
|
||||
successInfo.value = null
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** Reset the form for a fresh payment straight from the success screen. */
|
||||
function sendAnother() {
|
||||
successInfo.value = null
|
||||
confirming.value = false
|
||||
dest.value = ''
|
||||
amount.value = 0
|
||||
sendAll.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(() => {})
|
||||
}
|
||||
@@ -345,11 +592,9 @@ async function send() {
|
||||
processing.value = true
|
||||
error.value = ''
|
||||
ecashToken.value = ''
|
||||
resultTxid.value = ''
|
||||
resultHash.value = ''
|
||||
resultArk.value = ''
|
||||
|
||||
const method = effectiveMethod.value
|
||||
const paidAmount = confirmAmount.value
|
||||
try {
|
||||
if (method === 'ark') {
|
||||
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
||||
@@ -359,7 +604,7 @@ async function send() {
|
||||
// Ark sends can wait on round participation.
|
||||
timeout: 130000,
|
||||
})
|
||||
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
|
||||
successInfo.value = { amount: paidAmount, methodLabel: 'Sent via Ark', note: 'The transfer settles with the next Ark round.' }
|
||||
} else if (method === 'ecash') {
|
||||
const res = await rpcClient.call<{ token: string }>({
|
||||
method: 'wallet.ecash-send',
|
||||
@@ -379,19 +624,24 @@ async function send() {
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: dest.value.trim() },
|
||||
})
|
||||
resultHash.value = res.payment_hash
|
||||
successInfo.value = { amount: paidAmount, methodLabel: 'Paid over Lightning', hash: res.payment_hash }
|
||||
} else {
|
||||
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: isSweep.value
|
||||
? { addr: dest.value.trim(), send_all: true }
|
||||
: { addr: dest.value.trim(), amount: amount.value },
|
||||
? { addr: dest.value.trim(), send_all: true, ...resolvedFeeParams.value }
|
||||
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
|
||||
})
|
||||
resultTxid.value = res.txid
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
methodLabel: isSweep.value ? 'Swept on-chain' : 'Sent on-chain',
|
||||
txid: res.txid,
|
||||
note: 'On-chain payments confirm over the next blocks.',
|
||||
}
|
||||
}
|
||||
emit('sent')
|
||||
// Back to the form pane so the success/token panes are visible.
|
||||
// Success pane (or the token pane for ecash mints) takes over the modal.
|
||||
confirming.value = false
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
||||
@@ -400,3 +650,59 @@ async function send() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Success burst — pop-in check inside radiating rings, wallet palette
|
||||
(emerald for the settled payment, one Archipelago-orange ring). */
|
||||
.send-success-burst {
|
||||
position: relative;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
.burst-core {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
|
||||
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
|
||||
}
|
||||
.burst-check {
|
||||
stroke-dasharray: 32;
|
||||
stroke-dashoffset: 32;
|
||||
animation: burst-draw 0.45s ease-out 0.25s forwards;
|
||||
}
|
||||
.burst-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(16, 185, 129, 0.45);
|
||||
animation: burst-ripple 1.8s ease-out infinite;
|
||||
}
|
||||
.burst-ring-2 {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
.burst-ring-3 {
|
||||
animation-delay: 0.9s;
|
||||
border-color: rgba(249, 115, 22, 0.35);
|
||||
}
|
||||
@keyframes burst-pop {
|
||||
from { transform: scale(0.3); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes burst-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes burst-ripple {
|
||||
0% { transform: scale(0.7); opacity: 0.9; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.burst-core, .burst-check, .burst-ring { animation: none; }
|
||||
.burst-check { stroke-dashoffset: 0; }
|
||||
.burst-ring { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -415,6 +415,7 @@
|
||||
"totalEarned": "Total Earned",
|
||||
"monthlyAvg": "Monthly Avg",
|
||||
"ecashBalance": "Ecash Balance",
|
||||
"totalBitcoin": "Total Bitcoin",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
|
||||
@@ -413,6 +413,7 @@
|
||||
"totalEarned": "Total ganado",
|
||||
"monthlyAvg": "Promedio mensual",
|
||||
"ecashBalance": "Saldo Ecash",
|
||||
"totalBitcoin": "Bitcoin total",
|
||||
"onChain": "On-chain",
|
||||
"lightning": "Lightning",
|
||||
"ecash": "Ecash",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime,
|
||||
* SeedSigner, Keystone, Nunchuk, Sparrow, …): each BIP39 word becomes its
|
||||
* zero-padded 4-digit wordlist index (0000–2047), concatenated into one
|
||||
* digit stream and rendered as a numeric-mode QR. A 24-word seed is 96
|
||||
* digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr
|
||||
*
|
||||
* Only valid for real BIP39 mnemonics — LND's aezeed shares the wordlist but
|
||||
* is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it.
|
||||
*/
|
||||
export async function toSeedQrDigits(words: string[]): Promise<string | null> {
|
||||
if (words.length === 0) return null
|
||||
const { wordlist } = await import('@scure/bip39/wordlists/english.js')
|
||||
const digits: string[] = []
|
||||
for (const raw of words) {
|
||||
const idx = wordlist.indexOf(raw.trim().toLowerCase())
|
||||
if (idx < 0) return null // not a BIP39 word — caller falls back to text
|
||||
digits.push(idx.toString().padStart(4, '0'))
|
||||
}
|
||||
return digits.join('')
|
||||
}
|
||||
@@ -685,9 +685,9 @@ onBeforeUnmount(() => {
|
||||
min-height: var(--app-session-mobile-bar-height, 84px);
|
||||
padding: 10px 16px;
|
||||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
/* Solid black, not translucent: the app iframe's theme colour bled
|
||||
through the bar and its safe-area strip on phones. */
|
||||
background: #000;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
+40
-26
@@ -646,37 +646,51 @@ function launchAppNow(id: string) {
|
||||
useAppLauncherStore().openSession(id)
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await rpcClient.call<AppCredentialsResponse>({
|
||||
// Per-app credentials memo: the pre-launch RPC could hold an Apps-tab launch
|
||||
// hostage for its full 5s timeout over the mesh (home-card launches skip this
|
||||
// gate entirely, which is why they always felt instant). First launch waits at
|
||||
// most LAUNCH_CRED_BUDGET_MS; the RPC keeps running in the background and its
|
||||
// answer is memoized, so every later launch of that app resolves instantly.
|
||||
const LAUNCH_CRED_BUDGET_MS = 1200
|
||||
const credentialsCache = new Map<string, AppCredentialsResponse | null>()
|
||||
|
||||
function fetchCredentials(id: string): Promise<AppCredentialsResponse | null> {
|
||||
return rpcClient
|
||||
.call<AppCredentialsResponse>({
|
||||
method: 'package.credentials',
|
||||
params: { app_id: id },
|
||||
timeout: 5000,
|
||||
})
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
const credentials = resolveAppCredentials(id, null)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
.then((r) => {
|
||||
credentialsCache.set(id, r)
|
||||
return r
|
||||
})
|
||||
.catch(() => {
|
||||
credentialsCache.set(id, null)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
|
||||
const result = credentialsCache.has(id)
|
||||
? credentialsCache.get(id) ?? null
|
||||
: await Promise.race([
|
||||
fetchCredentials(id),
|
||||
// Budget exceeded → launch with the static fallback config; the
|
||||
// in-flight RPC still lands in the cache for next time.
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), LAUNCH_CRED_BUDGET_MS)),
|
||||
])
|
||||
const credentials = resolveAppCredentials(id, result)
|
||||
if (!credentials) return false
|
||||
credentialModal.value = {
|
||||
show: true,
|
||||
appId: id,
|
||||
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
|
||||
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
|
||||
credentials: credentials.credentials,
|
||||
copied: '',
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function closeCredentialModal() {
|
||||
|
||||
+71
-16
@@ -522,8 +522,10 @@ const cloudStorageDisplay = computed(() => cloudStorageUsed.value !== null ? for
|
||||
const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? String(cloudFolderCount.value) : '...')
|
||||
|
||||
onMounted(async () => {
|
||||
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
|
||||
// Paint last-known wallet figures BEFORE any network round-trip.
|
||||
hydrateWalletSnapshot()
|
||||
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
|
||||
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
|
||||
// Poll wallet balances/transactions like Web5.vue does — without this a
|
||||
// pending on-chain receive (or a fresh instant payment) only shows up
|
||||
// after a manual wallet action or a remount.
|
||||
@@ -583,25 +585,78 @@ function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
|
||||
}
|
||||
}
|
||||
|
||||
// Last-known wallet snapshot, hydrated before ANY network round-trip so the
|
||||
// card paints real figures instantly (app-launch-speed doctrine: over the
|
||||
// mesh every serialized RPC costs a full RTT — never make the user watch it).
|
||||
const WALLET_SNAPSHOT_KEY = 'archy-wallet-snapshot-v1'
|
||||
|
||||
function hydrateWalletSnapshot() {
|
||||
try {
|
||||
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
|
||||
if (!raw) return
|
||||
const s = JSON.parse(raw)
|
||||
walletOnchain.value = s.onchain ?? 0
|
||||
walletLightning.value = s.lightning ?? 0
|
||||
walletEcash.value = s.ecash ?? 0
|
||||
walletFedimint.value = s.fedimint ?? 0
|
||||
walletArk.value = s.ark ?? 0
|
||||
walletConnected.value = s.connected === true
|
||||
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
|
||||
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
|
||||
}
|
||||
|
||||
function persistWalletSnapshot() {
|
||||
try {
|
||||
localStorage.setItem(WALLET_SNAPSHOT_KEY, JSON.stringify({
|
||||
onchain: walletOnchain.value,
|
||||
lightning: walletLightning.value,
|
||||
ecash: walletEcash.value,
|
||||
fedimint: walletFedimint.value,
|
||||
ark: walletArk.value,
|
||||
connected: walletConnected.value,
|
||||
// Enough for the Transactions modal's first paint; refresh replaces it.
|
||||
transactions: walletTransactions.value.slice(0, 50),
|
||||
}))
|
||||
} catch { /* storage full — snapshot is best-effort */ }
|
||||
}
|
||||
|
||||
async function loadWeb5Status() {
|
||||
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
||||
// there is a balance"). On failure keep the last-known value — the refs start
|
||||
// at 0, so only the very first load before any success shows 0.
|
||||
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
// from the persisted snapshot, so 0 only ever shows on a genuinely fresh node.
|
||||
//
|
||||
// All seven calls are independent — fire them TOGETHER. Serialized, this
|
||||
// block cost 7 × (mesh RTT + backend time); parallel it costs one slowest
|
||||
// call, which is what makes the card feel like an app launch.
|
||||
const balances = Promise.allSettled([
|
||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
|
||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true })
|
||||
.catch(() => { walletConnected.value = false }),
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 })
|
||||
.then(res => { walletEcash.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||
rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 })
|
||||
.then(res => { walletFedimint.value = res.balance_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||
rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 })
|
||||
.then(res => { walletArk.value = res.spendable_sats ?? 0 }).catch(() => { /* keep last-known */ }),
|
||||
])
|
||||
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
|
||||
// already unifies both) — previously only LND transactions were fetched
|
||||
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
|
||||
// appeared in the Transactions modal even though the balance included it.
|
||||
let lndTxs: WalletTransaction[] = []
|
||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const })) } catch { /* keep last-known transactions */ }
|
||||
let lightningTxs: WalletTransaction[] = []
|
||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 }); lightningTxs = res.transactions || [] } catch { /* keep last-known transactions */ }
|
||||
let ecashTxs: WalletTransaction[] = []
|
||||
try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ }
|
||||
walletTransactions.value = [...lndTxs, ...lightningTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp)
|
||||
// already unifies both) so Cashu/Fedimint receives appear in the modal.
|
||||
const histories = Promise.allSettled([
|
||||
rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 })
|
||||
.then(res => (res.transactions || []).map(tx => ({ ...tx, kind: 'onchain' as const }))).catch(() => [] as WalletTransaction[]),
|
||||
rpcClient.call<{ transactions: WalletTransaction[] }>({ method: 'lnd.lightning-history', timeout: 5000 })
|
||||
.then(res => res.transactions || []).catch(() => [] as WalletTransaction[]),
|
||||
rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 })
|
||||
.then(res => (res.transactions || []).map(ecashToWalletTransaction)).catch(() => [] as WalletTransaction[]),
|
||||
]).then((results) => {
|
||||
const merged = results.flatMap(r => (r.status === 'fulfilled' ? r.value : []))
|
||||
// Keep last-known list when every history call failed this round.
|
||||
if (merged.length > 0 || results.some(r => r.status === 'fulfilled')) {
|
||||
walletTransactions.value = merged.sort((a, b) => b.time_stamp - a.time_stamp)
|
||||
}
|
||||
})
|
||||
await Promise.allSettled([balances, histories])
|
||||
persistWalletSnapshot()
|
||||
}
|
||||
|
||||
// System stats
|
||||
|
||||
@@ -44,7 +44,19 @@
|
||||
|
||||
<!-- Word Grid -->
|
||||
<div v-if="words.length > 0" class="w-full max-w-[600px]">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
||||
<!-- Words / QR tabs — words first; QR for wallets that import by scan -->
|
||||
<div class="flex gap-1 mb-2 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
@click="seedTab = tab.key"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="seedTab === 'words'" class="grid grid-cols-2 sm:grid-cols-4 gap-1 sm:gap-1.5">
|
||||
<div
|
||||
v-for="(word, i) in words"
|
||||
:key="i"
|
||||
@@ -55,6 +67,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-2 py-2">
|
||||
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||
<div class="flex p-0.5 bg-white/5 rounded-md">
|
||||
<button
|
||||
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
@click="qrFormat = f.key"
|
||||
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
|
||||
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/50 text-center max-w-[420px]">
|
||||
{{ qrFormat === 'seedqr'
|
||||
? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.'
|
||||
: 'Plain text words — for wallets that read the phrase as text.' }}
|
||||
Treat this code exactly like the words themselves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="mt-3 bg-orange-500/10 border border-orange-500/20 rounded-lg px-3 py-2.5">
|
||||
<p class="text-xs sm:text-sm text-orange-300/90">
|
||||
@@ -99,6 +131,32 @@ import { playNavSound } from '@/composables/useNavSounds'
|
||||
const router = useRouter()
|
||||
const continueButton = ref<HTMLButtonElement | null>(null)
|
||||
const words = ref<string[]>([])
|
||||
|
||||
// Words / QR code view of the seed — words are the default first view.
|
||||
// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format
|
||||
// hardware wallets like Passport Prime / SeedSigner actually import; plain
|
||||
// text stays available for wallets that read the phrase as text.
|
||||
const seedTab = ref<'words' | 'qr'>('words')
|
||||
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
|
||||
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
async function renderSeedQr() {
|
||||
await nextTick()
|
||||
if (!seedQrCanvas.value || words.value.length === 0) return
|
||||
try {
|
||||
let payload = words.value.join(' ')
|
||||
if (qrFormat.value === 'seedqr') {
|
||||
const { toSeedQrDigits } = await import('@/utils/seedqr')
|
||||
const digits = await toSeedQrDigits(words.value)
|
||||
if (digits) payload = digits
|
||||
else qrFormat.value = 'text' // non-BIP39 word — only text is honest
|
||||
}
|
||||
const QRCode = await import('qrcode')
|
||||
await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 })
|
||||
} catch { /* QR is a convenience — the words remain authoritative */ }
|
||||
}
|
||||
watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() })
|
||||
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() })
|
||||
const confirmed = ref(false)
|
||||
const loading = ref(false)
|
||||
const waitingForServer = ref(false)
|
||||
|
||||
@@ -794,15 +794,19 @@ function goBack() {
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.peerId) {
|
||||
// Find the peer by onion address
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
const peers = result?.nodes ?? []
|
||||
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
|
||||
} catch {
|
||||
// Continue with just the onion address
|
||||
}
|
||||
await Promise.all([loadCatalog(), loadOwned()])
|
||||
// The peer-name lookup is cosmetic — the catalog only needs the onion we
|
||||
// already have. Serialized, it added a full mesh round-trip before the
|
||||
// files even started loading.
|
||||
await Promise.all([
|
||||
rpcClient.federationListNodes()
|
||||
.then((result) => {
|
||||
const peers = result?.nodes ?? []
|
||||
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
|
||||
})
|
||||
.catch(() => { /* continue with just the onion address */ }),
|
||||
loadCatalog(),
|
||||
loadOwned(),
|
||||
])
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
// Lightning seed (aezeed) backup card. Shown on the LND app detail page.
|
||||
// The backend captures the aezeed at wallet-init time; until the user
|
||||
@@ -166,20 +167,8 @@ async function confirmBackedUp() {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<SeedRevealPanel :words="revealedWords" aezeed />
|
||||
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" :disabled="acking" @click="confirmBackedUp" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia } from 'pinia'
|
||||
import LightningChannels from '@/components/LightningChannelsPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
@@ -44,7 +45,11 @@ describe('LightningChannels', () => {
|
||||
total_outbound: 60_000,
|
||||
})
|
||||
|
||||
const wrapper = mount(LightningChannels)
|
||||
// The panel's setup pulls a Pinia store via useTxExplorer — mount with a
|
||||
// fresh Pinia or setup throws before the first render.
|
||||
const wrapper = mount(LightningChannels, {
|
||||
global: { plugins: [createPinia()] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('peer-pubkey')
|
||||
|
||||
@@ -112,9 +112,18 @@
|
||||
</transition>
|
||||
|
||||
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center justify-between p-3 bg-white/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-orange-500" role="img" aria-label="On-chain" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
|
||||
</div>
|
||||
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
|
||||
@@ -232,6 +241,10 @@ defineEmits<{
|
||||
|
||||
const showIncomingTxPanel = ref(false)
|
||||
|
||||
const walletTotal = computed(() =>
|
||||
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
|
||||
)
|
||||
|
||||
function isOnchain(tx: WalletTransaction): boolean {
|
||||
return !tx.kind || tx.kind === 'onchain'
|
||||
}
|
||||
|
||||
@@ -362,13 +362,67 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.7.115-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.115-alpha</span>
|
||||
<span class="text-xs text-white/40">July 26, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.</p>
|
||||
<p>The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.</p>
|
||||
<p>Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.114-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.114-alpha</span>
|
||||
<span class="text-xs text-white/40">July 26, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.</p>
|
||||
<p>The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.</p>
|
||||
<p>Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.</p>
|
||||
<p>Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.</p>
|
||||
<p>Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.113-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.113-alpha</span>
|
||||
<span class="text-xs text-white/40">July 25, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.</p>
|
||||
<p>Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.</p>
|
||||
<p>The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.</p>
|
||||
<p>The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.112-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.112-alpha</span>
|
||||
<span class="text-xs text-white/40">July 22, 2026</span>
|
||||
<span class="text-xs text-white/40">July 23, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.</p>
|
||||
<p>Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too.</p>
|
||||
<p>The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects.</p>
|
||||
<p>Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app.</p>
|
||||
<p>Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone.</p>
|
||||
<p>The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address.</p>
|
||||
<p>Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard.</p>
|
||||
<p>Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture.</p>
|
||||
<p>Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen.</p>
|
||||
<p>Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases.</p>
|
||||
<p>Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode.</p>
|
||||
<p>A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes.</p>
|
||||
<p>Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them.</p>
|
||||
<p>If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it.</p>
|
||||
<p>Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -329,20 +330,7 @@ defineExpose({ loadBackups })
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
|
||||
:class="wordsHidden ? 'blur-md' : ''"
|
||||
@click="wordsHidden = !wordsHidden"
|
||||
>
|
||||
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
|
||||
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
|
||||
<span class="text-white font-mono">{{ w }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
|
||||
</div>
|
||||
<SeedRevealPanel :words="revealedWords" />
|
||||
<div class="flex gap-2 pt-4">
|
||||
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
|
||||
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
|
||||
|
||||
@@ -350,13 +350,17 @@ async function loadPeers() {
|
||||
const hadPeers = peers.value.length > 0 || observers.value.length > 0
|
||||
loadingPeers.value = true
|
||||
try {
|
||||
const res = await rpcClient.listPeers()
|
||||
// Independent RPCs — fetched together (serialized they stacked two full
|
||||
// mesh round-trips before anything rendered).
|
||||
const [res, fedSettled] = await Promise.all([
|
||||
rpcClient.listPeers(),
|
||||
rpcClient.federationListNodes().catch(() => null),
|
||||
])
|
||||
const peerList = res.peers || []
|
||||
const observerList: Peer[] = []
|
||||
|
||||
try {
|
||||
const fedRes = await rpcClient.federationListNodes()
|
||||
const fedNodes = fedRes.nodes || []
|
||||
const fedNodes = fedSettled?.nodes || []
|
||||
for (const n of fedNodes) {
|
||||
if (!n.onion || n.trust_level === 'untrusted') {
|
||||
continue
|
||||
|
||||
@@ -95,10 +95,21 @@
|
||||
<div v-if="walletError" class="alert-error mb-3">{{ walletError }}</div>
|
||||
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<!-- Total Balance -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<span class="text-white text-sm font-medium">{{ t('web5.totalBitcoin') }}</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-semibold">{{ (lndOnchainBalance + lndChannelBalance + ecashBalance).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
|
||||
<!-- On-chain Balance -->
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
<svg class="w-5 h-5 text-orange-500" role="img" aria-label="On-chain" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span class="text-white/80 text-sm">{{ t('web5.onChain') }}</span>
|
||||
</div>
|
||||
<span class="text-orange-500 text-sm font-medium">{{ lndOnchainBalance.toLocaleString() }} sats</span>
|
||||
|
||||
+17
-24
@@ -1,36 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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."
|
||||
"The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.",
|
||||
"The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.",
|
||||
"Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.111-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago",
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.111-alpha",
|
||||
"sha256": "9e50577470cb4c5dd67b4316a3efaaa87e7afac15a8b5c2e3841edc6c3ed6550",
|
||||
"size_bytes": 50615920
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "d4e2d7cb65bf7ac6461eb11642fb8ff9254be636c173a016ad1834d4073a7cee",
|
||||
"size_bytes": 51768592
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.111-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago-frontend-1.7.111-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.111-alpha.tar.gz",
|
||||
"new_version": "1.7.111-alpha",
|
||||
"sha256": "3ea73cd8e98312b3b6877e00434363fe575362e7b2b134e03a9004d4e42930c9",
|
||||
"size_bytes": 174650727
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "9c131c881016a4e451e2ea3688bc9114302adcb5a2b58e485f3273abcebf07bb",
|
||||
"size_bytes": 178060942
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-22",
|
||||
"signature": "1cc569a121f2cc5560840198115732155db4ee037ea98c4be8cf9ef8a7b227e26dc686a88a779f1e40daaa94b5cb44083b78147f45398a29188bf2834488eb03",
|
||||
"release_date": "2026-07-26",
|
||||
"signature": "80a76615a43e4b715b564143ffc7acf4e3897a921c0b6338c757963becfc6c979c118a7f32554c82ff32de283a4a0c0a566f27c98d4ffd7b6d93747a1f3ea103",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.111-alpha"
|
||||
"version": "1.7.115-alpha"
|
||||
}
|
||||
|
||||
+17
-24
@@ -1,36 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"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."
|
||||
"The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.",
|
||||
"The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.",
|
||||
"Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.111-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago",
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.111-alpha",
|
||||
"sha256": "9e50577470cb4c5dd67b4316a3efaaa87e7afac15a8b5c2e3841edc6c3ed6550",
|
||||
"size_bytes": 50615920
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "d4e2d7cb65bf7ac6461eb11642fb8ff9254be636c173a016ad1834d4073a7cee",
|
||||
"size_bytes": 51768592
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.111-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.111-alpha/archipelago-frontend-1.7.111-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.111-alpha.tar.gz",
|
||||
"new_version": "1.7.111-alpha",
|
||||
"sha256": "3ea73cd8e98312b3b6877e00434363fe575362e7b2b134e03a9004d4e42930c9",
|
||||
"size_bytes": 174650727
|
||||
"current_version": "1.7.115-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.115-alpha/archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.115-alpha.tar.gz",
|
||||
"new_version": "1.7.115-alpha",
|
||||
"sha256": "9c131c881016a4e451e2ea3688bc9114302adcb5a2b58e485f3273abcebf07bb",
|
||||
"size_bytes": 178060942
|
||||
}
|
||||
],
|
||||
"release_date": "2026-07-22",
|
||||
"signature": "1cc569a121f2cc5560840198115732155db4ee037ea98c4be8cf9ef8a7b227e26dc686a88a779f1e40daaa94b5cb44083b78147f45398a29188bf2834488eb03",
|
||||
"release_date": "2026-07-26",
|
||||
"signature": "80a76615a43e4b715b564143ffc7acf4e3897a921c0b6338c757963becfc6c979c118a7f32554c82ff32de283a4a0c0a566f27c98d4ffd7b6d93747a1f3ea103",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.111-alpha"
|
||||
"version": "1.7.115-alpha"
|
||||
}
|
||||
|
||||
Executable
+199
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gated ISO release build — the single command that turns a signed release
|
||||
# on `main` into a tested installer ISO.
|
||||
#
|
||||
# Stages (fail-fast, each logged with timing):
|
||||
# 0. preflight — Linux, clean tree on main, version parity across
|
||||
# Cargo.toml / package.json / releases/manifest.json /
|
||||
# CHANGELOG / git tag, manifest signature present
|
||||
# 1. gates — tests/release/run.sh (static + frontend + backend
|
||||
# slice), strict catalog drift, FULL cargo test suite
|
||||
# 2. artifacts — release binary embeds the version, frontend dist
|
||||
# matches, AIUI present (OTA-strip regression guard)
|
||||
# 3. build — image-recipe/build-debian-iso.sh (unbundled by default)
|
||||
# 4. smoke — scripts/iso-smoke-test.sh (mount-level, version-checked)
|
||||
# 5. qemu — headless boot test (skippable with --no-qemu)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-iso-release.sh [--skip-gates] [--no-qemu] [--bundled] [--rc N]
|
||||
#
|
||||
# The ISO is NOT signed here — run scripts/sign-iso-checksums.sh with the
|
||||
# offline RELEASE_MASTER_MNEMONIC afterwards (publisher only).
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
SKIP_GATES=0 NO_QEMU=0 UNBUNDLED=1 RC_OVERRIDE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-gates) SKIP_GATES=1 ;;
|
||||
--no-qemu) NO_QEMU=1 ;;
|
||||
--bundled) UNBUNDLED=0 ;;
|
||||
--rc) RC_OVERRIDE="${2:?--rc needs a number}"; shift ;;
|
||||
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
|
||||
|
||||
PASS=() FAIL=()
|
||||
stage() { # stage <name> <cmd...>
|
||||
local name="$1"; shift
|
||||
local t0=$SECONDS
|
||||
echo
|
||||
echo "═══ [$name] $*"
|
||||
if "$@"; then
|
||||
echo "═══ [$name] PASS ($((SECONDS - t0))s)"
|
||||
PASS+=("$name")
|
||||
else
|
||||
local rc=$?
|
||||
echo "═══ [$name] FAIL exit=$rc ($((SECONDS - t0))s)"
|
||||
FAIL+=("$name")
|
||||
summary 1
|
||||
fi
|
||||
}
|
||||
summary() {
|
||||
echo
|
||||
echo "──────── ISO release build summary ────────"
|
||||
printf 'PASS: %s\n' "${PASS[@]:-none}"
|
||||
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# ── Stage 0: preflight ───────────────────────────────────────────────
|
||||
preflight() {
|
||||
[ "$(uname -s)" = "Linux" ] || { echo "ISO builds run on Linux only"; return 1; }
|
||||
|
||||
local branch; branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||
[ "$branch" = "main" ] || { echo "must build from main (on: $branch)"; return 1; }
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "working tree is not clean — release ISOs build from committed state only:"
|
||||
git status --porcelain | head -20
|
||||
return 1
|
||||
fi
|
||||
|
||||
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
local ui_ver manifest_ver
|
||||
ui_ver="$(python3 -c 'import json;print(json.load(open("neode-ui/package.json"))["version"])')"
|
||||
manifest_ver="$(python3 -c 'import json;print(json.load(open("releases/manifest.json"))["version"])')"
|
||||
echo " Cargo.toml: $VERSION"
|
||||
echo " package.json: $ui_ver"
|
||||
echo " releases/manifest: $manifest_ver"
|
||||
[ "$VERSION" = "$ui_ver" ] || { echo "version mismatch Cargo vs package.json"; return 1; }
|
||||
[ "$VERSION" = "$manifest_ver" ] || { echo "version mismatch Cargo vs releases/manifest.json"; return 1; }
|
||||
|
||||
head -5 CHANGELOG.md | grep -qF "v$VERSION" \
|
||||
|| { echo "CHANGELOG.md top entry is not v$VERSION"; return 1; }
|
||||
|
||||
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null \
|
||||
|| { echo "tag v$VERSION does not exist — cut the release first (scripts/create-release.sh)"; return 1; }
|
||||
|
||||
# The ISO must only ever be cut from a ceremony-signed manifest.
|
||||
python3 - <<'EOF' || return 1
|
||||
import json, sys
|
||||
m = json.load(open("releases/manifest.json"))
|
||||
sig, by = m.get("signature"), m.get("signed_by", "")
|
||||
if not sig or not by.startswith("did:key:"):
|
||||
print("releases/manifest.json is UNSIGNED — run the signing ceremony first")
|
||||
sys.exit(1)
|
||||
print(f" manifest signed by {by[:32]}…")
|
||||
EOF
|
||||
|
||||
echo " version: $VERSION @ $(git rev-parse --short HEAD), tree clean, manifest signed"
|
||||
}
|
||||
stage "preflight" preflight
|
||||
VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
|
||||
|
||||
# ── Stage 1: gates ───────────────────────────────────────────────────
|
||||
if [ "$SKIP_GATES" = "0" ]; then
|
||||
stage "release-gate-harness" bash tests/release/run.sh
|
||||
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Full Rust suite — the release harness only runs a 6-module slice;
|
||||
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
|
||||
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
|
||||
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
|
||||
else
|
||||
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
||||
fi
|
||||
|
||||
# ── Stage 2: artifact verification ───────────────────────────────────
|
||||
verify_artifacts() {
|
||||
local bin="core/target/release/archipelago"
|
||||
[ -x "$bin" ] || { echo "missing release binary $bin — build it first"; return 1; }
|
||||
strings "$bin" | grep -qF "$VERSION" \
|
||||
|| { echo "release binary does not embed $VERSION — stale build"; return 1; }
|
||||
echo " backend binary embeds $VERSION ($(du -h "$bin" | cut -f1))"
|
||||
|
||||
[ -f web/dist/neode-ui/index.html ] || { echo "missing frontend dist"; return 1; }
|
||||
grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js \
|
||||
|| { echo "frontend dist does not contain $VERSION — stale build"; return 1; }
|
||||
echo " frontend dist contains $VERSION"
|
||||
|
||||
# AIUI must ride inside the dist BEFORE packaging or OTA upgrades
|
||||
# silently strip it from nodes in the field.
|
||||
[ -f web/dist/neode-ui/aiui/index.html ] \
|
||||
|| { echo "AIUI missing from web/dist/neode-ui/aiui — fold it in before building"; return 1; }
|
||||
echo " AIUI present in frontend dist"
|
||||
}
|
||||
stage "verify-artifacts" verify_artifacts
|
||||
|
||||
# ── Stage 3: build the ISO ───────────────────────────────────────────
|
||||
build_iso() {
|
||||
local env_args=(
|
||||
UNBUNDLED="$UNBUNDLED"
|
||||
BUILD_FROM_SOURCE=0
|
||||
DEV_SERVER=localhost
|
||||
ARCHIPELAGO_BIN="$REPO/core/target/release/archipelago"
|
||||
)
|
||||
[ -n "$RC_OVERRIDE" ] && env_args+=(RC="$RC_OVERRIDE")
|
||||
sudo -E env "${env_args[@]}" nice -n 5 bash image-recipe/build-debian-iso.sh
|
||||
}
|
||||
stage "build-iso" build_iso
|
||||
|
||||
find_iso() {
|
||||
ls -t "$REPO"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1
|
||||
}
|
||||
ISO="$(find_iso)"
|
||||
[ -n "$ISO" ] || { echo "FAIL: no ISO produced for $VERSION in image-recipe/results/"; FAIL+=("locate-iso"); summary 1; }
|
||||
|
||||
# ── Stage 4: mount-level smoke test ──────────────────────────────────
|
||||
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
|
||||
|
||||
# ── Stage 5: QEMU boot test (best-effort) ────────────────────────────
|
||||
# The ISO's kernel cmdline has no serial console, so the serial-log
|
||||
# sanity grep can miss a perfectly healthy boot. Run it, report it,
|
||||
# but don't fail an otherwise-green build on it.
|
||||
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
|
||||
echo
|
||||
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
|
||||
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
|
||||
echo "═══ [qemu-boot] PASS"
|
||||
PASS+=("qemu-boot")
|
||||
else
|
||||
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
|
||||
PASS+=("qemu-boot(inconclusive)")
|
||||
fi
|
||||
else
|
||||
echo; echo "═══ [qemu-boot] SKIPPED"
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────
|
||||
SHA_FILE="$ISO.sha256"
|
||||
[ -f "$SHA_FILE" ] || (cd "$(dirname "$ISO")" && sha256sum "$(basename "$ISO")" > "$SHA_FILE")
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " ISO RELEASE BUILD COMPLETE — v$VERSION"
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
|
||||
echo
|
||||
echo " Next steps (publisher, offline mnemonic required):"
|
||||
echo " 1. scripts/sign-iso-checksums.sh $ISO"
|
||||
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
|
||||
echo " v$VERSION Gitea release assets"
|
||||
summary 0
|
||||
@@ -142,7 +142,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
||||
# SIGPIPE-safe: use awk to read only the first line and exit,
|
||||
# then terminate the tar pipeline explicitly so `pipefail`+SIGPIPE
|
||||
# don't kill the whole `set -euo pipefail` script.
|
||||
root_mode=$(tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null | awk 'NR==1{print $1; exit}')
|
||||
root_mode=$({ tar tvzf "$FRONTEND_ARCHIVE" 2>/dev/null || true; } | awk 'NR==1{print $1; exit}')
|
||||
case "$root_mode" in
|
||||
drwxr-xr-x|drwxr-x*x*)
|
||||
echo " Tarball root perms OK: $root_mode"
|
||||
|
||||
@@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
|
||||
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
|
||||
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
|
||||
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
|
||||
"""
|
||||
distinct = sorted(set(list(ports.values()) + extra_ports))
|
||||
lines = [
|
||||
"//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
|
||||
"//!",
|
||||
"//! Catalog app launch ports (the web UIs the companion opens by direct",
|
||||
"//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
|
||||
"//! are reachable over the mesh; ports of apps that aren\'t installed have",
|
||||
"//! no listener, so allowing them is inert.",
|
||||
"",
|
||||
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
|
||||
]
|
||||
lines.extend(f" {port}," for port in distinct)
|
||||
lines.extend(["];", ""])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Keep in lockstep with APP_PORTS overrides in
|
||||
# neode-ui/src/views/appSession/appSessionConfig.ts.
|
||||
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
|
||||
|
||||
|
||||
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
catalog = json.load(fh)
|
||||
@@ -178,6 +204,11 @@ def main() -> int:
|
||||
default=[],
|
||||
help="Catalog JSON path to update. May be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rust-app-ports",
|
||||
default="core/archipelago/src/fips/app_ports.rs",
|
||||
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--app-session-config",
|
||||
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
|
||||
@@ -201,6 +232,20 @@ def main() -> int:
|
||||
print(f"{path}: updated")
|
||||
else:
|
||||
print(f"{path}: updated 0 fields")
|
||||
if args.rust_app_ports:
|
||||
ports = {
|
||||
app_id: port
|
||||
for app_id, app in manifests.items()
|
||||
if (port := manifest_launch_port(app))
|
||||
}
|
||||
rust_path = Path(args.rust_app_ports)
|
||||
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
|
||||
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
|
||||
if rust_old != rust_content:
|
||||
rust_path.write_text(rust_content, encoding="utf-8")
|
||||
print(f"{rust_path}: updated")
|
||||
else:
|
||||
print(f"{rust_path}: updated 0 fields")
|
||||
print(f"total_updated={total}")
|
||||
return 0
|
||||
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mount-level smoke test for an Archipelago installer ISO.
|
||||
#
|
||||
# Verifies boot plumbing (BIOS + UEFI + live-boot), the auto-installer
|
||||
# payload, and — the check that has bitten before — that the backend
|
||||
# binary inside the ISO actually embeds the version the filename claims.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/iso-smoke-test.sh <path-to-iso> [expected-version]
|
||||
#
|
||||
# expected-version defaults to core/archipelago/Cargo.toml. Needs sudo
|
||||
# (loop mount). Exits non-zero on the first hard failure; prints a
|
||||
# PASS/FAIL table either way.
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ISO="${1:-}"
|
||||
EXPECTED_VERSION="${2:-$(grep -m1 '^version' "$REPO/core/archipelago/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/')}"
|
||||
|
||||
if [ -z "$ISO" ] || [ ! -f "$ISO" ]; then
|
||||
echo "usage: $0 <path-to-iso> [expected-version]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAIL=0
|
||||
ok() { echo " OK: $*"; }
|
||||
bad() { echo " FAIL: $*"; FAIL=1; }
|
||||
warn() { echo " WARN: $*"; }
|
||||
|
||||
echo "ISO smoke test"
|
||||
echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
|
||||
echo " Version: $EXPECTED_VERSION (expected)"
|
||||
|
||||
# ── Filename ↔ version parity (gap: ISO version can silently drift) ──
|
||||
case "$(basename "$ISO")" in
|
||||
*"$EXPECTED_VERSION"*) ok "filename contains $EXPECTED_VERSION" ;;
|
||||
*) bad "filename does not contain expected version $EXPECTED_VERSION" ;;
|
||||
esac
|
||||
|
||||
MNT="$(mktemp -d)"
|
||||
INITRD_DIR=""
|
||||
cleanup() {
|
||||
sudo umount "$MNT" 2>/dev/null || true
|
||||
rmdir "$MNT" 2>/dev/null || true
|
||||
[ -n "$INITRD_DIR" ] && sudo rm -rf "$INITRD_DIR" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if ! sudo mount -o loop,ro "$ISO" "$MNT"; then
|
||||
echo " FAIL: could not loop-mount ISO" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Required boot + installer files ──────────────────────────────────
|
||||
for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
||||
isolinux/isolinux.bin isolinux/isolinux.cfg \
|
||||
boot/grub/grub.cfg EFI/BOOT/BOOTX64.EFI \
|
||||
archipelago/auto-install.sh archipelago/rootfs.tar; do
|
||||
if [ -e "$MNT/$f" ]; then
|
||||
ok "$f ($(sudo du -h "$MNT/$f" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
bad "missing $f"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── GRUB must boot the live system ───────────────────────────────────
|
||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
||||
ok "grub.cfg has boot=live"
|
||||
else
|
||||
bad "grub.cfg missing boot=live"
|
||||
fi
|
||||
|
||||
# ── initrd must contain live-boot scripts ────────────────────────────
|
||||
if command -v unmkinitramfs >/dev/null 2>&1; then
|
||||
INITRD_DIR="$(mktemp -d)"
|
||||
sudo unmkinitramfs "$MNT/live/initrd.img" "$INITRD_DIR" 2>/dev/null
|
||||
if [ -e "$INITRD_DIR/scripts/live" ] || [ -e "$INITRD_DIR/main/scripts/live" ]; then
|
||||
ok "initrd has live-boot scripts"
|
||||
else
|
||||
bad "initrd missing live-boot scripts"
|
||||
fi
|
||||
else
|
||||
warn "unmkinitramfs not installed — skipping initrd live-boot check"
|
||||
fi
|
||||
|
||||
# ── Backend binary inside the ISO embeds the expected version ────────
|
||||
# (the v1.4.0-binary-in-a-v1.5-ISO incident: a stale captured binary
|
||||
# shipped and the fleet rejected its fips.yaml on Activate)
|
||||
BIN_IN_ISO=""
|
||||
if [ -f "$MNT/archipelago/bin/archipelago" ]; then
|
||||
BIN_IN_ISO="$MNT/archipelago/bin/archipelago"
|
||||
if sudo strings "$BIN_IN_ISO" 2>/dev/null | grep -qF "$EXPECTED_VERSION"; then
|
||||
ok "payload backend binary embeds $EXPECTED_VERSION"
|
||||
else
|
||||
bad "payload backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
|
||||
fi
|
||||
else
|
||||
# Fall back to the copy inside rootfs.tar
|
||||
TMPBIN="$(mktemp -d)"
|
||||
if sudo tar -xf "$MNT/archipelago/rootfs.tar" -C "$TMPBIN" \
|
||||
usr/local/bin/archipelago 2>/dev/null; then
|
||||
if sudo strings "$TMPBIN/usr/local/bin/archipelago" | grep -qF "$EXPECTED_VERSION"; then
|
||||
ok "rootfs backend binary embeds $EXPECTED_VERSION"
|
||||
else
|
||||
bad "rootfs backend binary does NOT embed $EXPECTED_VERSION (stale binary)"
|
||||
fi
|
||||
else
|
||||
bad "no backend binary found at archipelago/bin/ or in rootfs.tar"
|
||||
fi
|
||||
sudo rm -rf "$TMPBIN"
|
||||
fi
|
||||
|
||||
# ── Frontend payload present ─────────────────────────────────────────
|
||||
if [ -f "$MNT/archipelago/web-ui/index.html" ]; then
|
||||
ok "frontend payload (archipelago/web-ui/index.html)"
|
||||
if [ -f "$MNT/archipelago/web-ui/aiui/index.html" ]; then
|
||||
ok "AIUI included in frontend payload"
|
||||
else
|
||||
warn "AIUI missing from archipelago/web-ui (verify rootfs copy before shipping)"
|
||||
fi
|
||||
else
|
||||
warn "no archipelago/web-ui payload on ISO (frontend may live in rootfs.tar only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
echo "ISO SMOKE TEST: FAILED"
|
||||
exit 1
|
||||
fi
|
||||
echo "ISO SMOKE TEST: PASSED"
|
||||
@@ -62,7 +62,7 @@ summary() {
|
||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||
stage "git-diff-check" git diff --check
|
||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
|
||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
# Every release must surface its CHANGELOG entry in the Settings "What's New"
|
||||
# modal. The modal hardcodes a block per version and has drifted behind before
|
||||
# (sat at v1.7.84 while the fleet shipped to v1.7.92). Fail if any CHANGELOG
|
||||
|
||||
Reference in New Issue
Block a user