Compare commits

..
Author SHA1 Message Date
archipelagoandClaude Fable 5 df88d6d00a chore: signed manifest for v1.7.112-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:04:16 -04:00
archipelago 0dfc3a7cfb Merge remote-tracking branch 'gitea-ai/main'
Demo images / Build & push demo images (push) Successful in 2m51s
2026-07-24 06:35:29 -04:00
archipelagoandClaude Fable 5 b163d30a0e feat(wallet): send success screen — burst animation + copyable payment hash / txid
A settled payment now takes over the modal: animated check with radiating
rings (emerald + one Archipelago-orange), the amount large, method label,
then copyable payment hash (lightning) / transaction ID (on-chain) and a
settling note, with Send-another / Done. Replaces the old alert-line at the
bottom of the form. Reduced-motion honored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 06:34:56 -04:00
lfg2025 85b25dd803 Merge pull request 'fix(companion): touch-shield under the in-app control bar — 0.5.11' (#121) from fix/inapp-bar-touch-shield into main
Demo images / Build & push demo images (push) Successful in 2m54s
2026-07-24 10:32:06 +00:00
DorianandClaude Fable 5 e3db5558e4 fix(companion): touch-shield under the in-app control bar — 0.5.11
The gesture-nav strip below the in-app browser's control bar was painted
black by inset padding, but painted-only areas don't consume touches in
Compose — stray taps fell straight THROUGH the overlay into the kiosk's
tab bar behind it (accidentally opening the AIUI chat tab). The strip is
now an explicit Box: solid black AND click-swallowing. Bottom inset
moved off the Column padding so the shield can own that region.

Served APK: 0.5.11 (vc31).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:31:55 +01:00
lfg2025 91c51c4d97 Merge pull request 'perf(ui): app-launch-speed doctrine for data panels — parallel RPCs + instant last-known paint' (#120) from perf/data-panels into main
Demo images / Build & push demo images (push) Successful in 2m51s
2026-07-24 10:22:53 +00:00
DorianandClaude Fable 5 4343949005 perf(ui): app-launch-speed doctrine for data panels — parallel RPCs + instant last-known paint
Why apps feel instant over the mesh and data panels didn't: an app
launch is ONE streamed HTTP fetch on a warm session; the panels were
built from SERIALIZED RPC round-trips, each costing a full mesh RTT.

- Wallet card (Home): the 7 balance/history RPCs fired one-by-one
  (seconds by construction) — now all 7 in parallel, and the card paints
  a persisted last-known snapshot (balances + recent transactions)
  BEFORE any network round-trip. Refresh replaces it silently.
- Web5 connected nodes: listPeers + federationListNodes fetched
  together instead of stacked.
- Peer files: the cosmetic peer-name lookup no longer blocks the
  catalog/owned fetches it never depended on.

Rules going forward: never serialize independent RPCs; never show a
spinner where last-known data exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:22:46 +01:00
archipelago 5771f318bd chore: release v1.7.112-alpha 2026-07-24 05:36:45 -04:00
archipelagoandClaude Fable 5 408c605001 test: LightningChannels mounts with Pinia — panel setup now uses the tx-explorer store
Demo images / Build & push demo images (push) Successful in 3m5s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:42:52 -04:00
archipelagoandClaude Fable 5 0cd2164d24 style: cargo fmt across today's touched modules
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:35:12 -04:00
archipelago 5227406341 Merge remote-tracking branch 'gitea-ai/main' 2026-07-24 04:29:16 -04:00
archipelagoandClaude Fable 5 d924c59c6c style: cargo fmt on mesh_ports
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 04:29:15 -04:00
lfg2025 0fa2a866f5 Merge pull request 'perf(apps): launch never waits on the credentials RPC — 1.2s budget + memo' (#119) from fix/apps-launch-latency into main
Demo images / Build & push demo images (push) Successful in 2m52s
2026-07-24 08:14:47 +00:00
DorianandClaude Fable 5 9fcb68816b perf(apps): launch never waits on the credentials RPC — 1.2s budget + memo
Apps-tab launches awaited package.credentials with a 5s timeout before
opening anything (home-card launches skip the gate — that's why they
always felt instant). The lookup now races a 1.2s budget: past it, the
app launches with the static fallback config while the RPC finishes
into a per-app memo, making every subsequent launch instant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:14:40 +01:00
lfg2025 9a66f22138 Merge pull request 'fix(wallet+companion): pre-demo blockers — Send modal TDZ crash, cold-start inset collapse, loader branding — 0.5.10' (#118) from fix/safe-area-insets into main
Demo images / Build & push demo images (push) Successful in 2m47s
2026-07-24 08:05:30 +00:00
DorianandClaude Fable 5 504944fd08 fix(wallet+companion): pre-demo blockers — Send modal TDZ crash, cold-start inset collapse, loader branding — 0.5.10
- SendBitcoinModal crashed AT MOUNT on device ('Cannot access R before
  initialization'): the invoice-first block's watch() evaluates its
  computed source at setup, which read effectiveMethod while still in
  its temporal dead zone (declared 20 lines later). Send button did
  nothing and the failure poisoned sibling modal data. Block moved below
  effectiveMethod, with a comment so it can't drift back.
- Cold start injected 0px safe-area vars (onPageFinished can beat window
  attachment; rootWindowInsets null) — UI lost top/bottom margins and
  the tab bar sat in the gesture zone eating taps. Injection extracted
  to injectSafeAreaVars(), re-fired from an OnApplyWindowInsetsListener
  when real insets arrive, on page finish, and on reattach.
- MeshLoadingScreen wears the proper brand: pixel 'a' in the black
  circle-container + F*CK IPS MESH wordmark.

Served APK: 0.5.10 (vc30). Reproduced+diagnosed live on-device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 09:03:43 +01:00
archipelagoandClaude Fable 5 8af2ca4ac2 docs: complete the v1.7.112-alpha changelog — audio/TV-input/companion-mesh/paid-sharing batch + What's New re-sync
Demo images / Build & push demo images (push) Successful in 2m45s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:18:37 -04:00
lfg2025 f2b6028ca2 Merge pull request 'feat(companion): night-test polish sweep — 0.5.9' (#117) from feat/final-polish into main
Demo images / Build & push demo images (push) Successful in 2m45s
2026-07-24 01:04:13 +00:00
DorianandClaude Fable 5 e679b4e886 feat(companion): night-test polish sweep — 0.5.9
Everything from tonight's remote field testing:

- Back-to-dashboard fixed: JS bridges on the retained WebView delegated
  through live-composition callbacks (stale closures made apps refuse to
  launch after remote ⇄ dashboard), and reattach forces a layout pass
  (top/bottom UI was wrong until a tap).
- F*CK IPs MESH branded loader: full-screen on relaunch (startup race)
  and during the first connect after scanning a node QR.
- Party 'Share this app' is now a QR of the public vps2 download link
  (scan with any camera → install over any internet); direct APK
  file-share kept as a secondary action.
- Mesh party pairing is MUTUAL: scanner announces itself to the scanned
  phone's Flare /hello — both sides get the peer, a chat entry and a
  '👋 joined the party' message; scanner auto-opens the chat.
- Party scanner asks for CAMERA permission (fresh installs/reinstalls
  landed on a black preview).
- Node: device tokens mint unique companion-<id> names — pairing a
  second phone no longer revokes the first phone's login (the source of
  'reconnecting a lot' and the second phone's failure).

Served APK: 0.5.9 (vc29).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 02:03:47 +01:00
35 changed files with 882 additions and 245 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 28
versionName = "0.5.8"
versionCode = 31
versionName = "0.5.11"
vectorDrawables {
useSupportLibrary = true
@@ -115,6 +115,15 @@ class ArchyVpnService : VpnService() {
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)
}
}
}
}
@@ -72,6 +72,12 @@ object FlareServer {
@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()
@@ -145,6 +151,12 @@ object FlareServer {
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
@@ -166,6 +178,38 @@ object FlareServer {
}
}
/** 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)
@@ -296,6 +340,32 @@ object FlareClient {
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()
@@ -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)
}
}
}
@@ -52,6 +52,7 @@ 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
@@ -70,6 +71,11 @@ 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,
@@ -88,7 +94,27 @@ fun PartyScreen(
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())
@@ -114,7 +140,13 @@ fun PartyScreen(
}
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
BackHandler { if (showScanner) showScanner = false else onBack() }
BackHandler {
when {
showScanner -> showScanner = false
showShareQr -> showShareQr = false
else -> onBack()
}
}
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
Column(
@@ -287,16 +319,81 @@ fun PartyScreen(
// premise.
GlassButton(
text = "Share this app",
onClick = { shareCompanionApk(context) },
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)) {
CameraQrPreview(onDecoded = { text ->
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"
@@ -309,6 +406,27 @@ fun PartyScreen(
// 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()
}
}
@@ -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,26 @@ 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
@@ -66,6 +72,7 @@ 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
@@ -82,6 +89,7 @@ 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
@@ -130,6 +138,15 @@ 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)
@@ -140,6 +157,34 @@ private object KioskWebView {
}
}
/** 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)
@@ -379,9 +424,7 @@ fun WebViewScreen(
} 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.
@@ -421,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
@@ -434,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",
@@ -459,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",
@@ -492,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
@@ -707,6 +746,16 @@ fun WebViewScreen(
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)
}
}
}
},
@@ -886,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()) {
@@ -1075,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 = {},
),
)
}
}
+16 -1
View File
@@ -1,7 +1,22 @@
# Changelog
## v1.7.112-alpha (2026-07-22)
## 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.
+1 -1
View File
@@ -95,7 +95,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.111-alpha"
version = "1.7.112-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.111-alpha"
version = "1.7.112-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+3 -1
View File
@@ -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"}"#,
),
));
}
+9 -1
View File
@@ -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 }))
}
+5 -12
View File
@@ -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()
+5 -3
View File
@@ -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
+2 -2
View File
@@ -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 {
+38 -10
View File
@@ -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,
+2 -4
View File
@@ -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(),
+1 -1
View File
@@ -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;
+13 -3
View File
@@ -438,7 +438,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 +457,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 +504,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);
+7 -4
View File
@@ -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()
+4 -4
View File
@@ -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 {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.111-alpha",
"version": "1.7.112-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.111-alpha",
"version": "1.7.112-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.111-alpha",
"version": "1.7.112-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
Binary file not shown.
+165 -40
View File
@@ -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>
@@ -125,16 +176,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">
@@ -171,9 +212,23 @@ const amount = ref<number>(0)
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 +248,6 @@ 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
})
// 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 +270,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.
@@ -315,14 +373,22 @@ function review() {
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 +411,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 +423,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,7 +443,7 @@ 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 }>({
@@ -388,10 +452,15 @@ async function send() {
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.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 +469,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>
+40 -26
View File
@@ -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
View File
@@ -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
+13 -9
View File
@@ -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
}
@@ -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')
@@ -366,9 +366,24 @@ init()
<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>
@@ -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
+24 -24
View File
@@ -1,36 +1,36 @@
{
"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."
"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."
],
"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.112-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.111-alpha",
"sha256": "9e50577470cb4c5dd67b4316a3efaaa87e7afac15a8b5c2e3841edc6c3ed6550",
"size_bytes": 50615920
"new_version": "1.7.112-alpha",
"sha256": "7cd7ab9dd9b433db929841328cbf553a056937f18814d0b7368b0a5c44dc2b9b",
"size_bytes": 51469560
},
{
"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.112-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago-frontend-1.7.112-alpha.tar.gz",
"name": "archipelago-frontend-1.7.112-alpha.tar.gz",
"new_version": "1.7.112-alpha",
"sha256": "02d916cadb54f76c19910376d6d42e820ee7b42fff7c1ea071bb5ec9b2bea3b8",
"size_bytes": 177952072
}
],
"release_date": "2026-07-22",
"signature": "1cc569a121f2cc5560840198115732155db4ee037ea98c4be8cf9ef8a7b227e26dc686a88a779f1e40daaa94b5cb44083b78147f45398a29188bf2834488eb03",
"release_date": "2026-07-24",
"signature": "ffe1d1576e63c68f27dc6924f6db4381f745f48e1e279a9b20351c1104cf6e9a8756596cef99e8b3436925361bbbc9f305c14b0632e7c2f095b817783bbfa60d",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.111-alpha"
"version": "1.7.112-alpha"
}
+24 -24
View File
@@ -1,36 +1,36 @@
{
"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."
"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."
],
"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.112-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.111-alpha",
"sha256": "9e50577470cb4c5dd67b4316a3efaaa87e7afac15a8b5c2e3841edc6c3ed6550",
"size_bytes": 50615920
"new_version": "1.7.112-alpha",
"sha256": "7cd7ab9dd9b433db929841328cbf553a056937f18814d0b7368b0a5c44dc2b9b",
"size_bytes": 51469560
},
{
"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.112-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.112-alpha/archipelago-frontend-1.7.112-alpha.tar.gz",
"name": "archipelago-frontend-1.7.112-alpha.tar.gz",
"new_version": "1.7.112-alpha",
"sha256": "02d916cadb54f76c19910376d6d42e820ee7b42fff7c1ea071bb5ec9b2bea3b8",
"size_bytes": 177952072
}
],
"release_date": "2026-07-22",
"signature": "1cc569a121f2cc5560840198115732155db4ee037ea98c4be8cf9ef8a7b227e26dc686a88a779f1e40daaa94b5cb44083b78147f45398a29188bf2834488eb03",
"release_date": "2026-07-24",
"signature": "ffe1d1576e63c68f27dc6924f6db4381f745f48e1e279a9b20351c1104cf6e9a8756596cef99e8b3436925361bbbc9f305c14b0632e7c2f095b817783bbfa60d",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.111-alpha"
"version": "1.7.112-alpha"
}