Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 26
|
||||
versionName = "0.5.6"
|
||||
versionCode = 31
|
||||
versionName = "0.5.11"
|
||||
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +37,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 +69,126 @@ 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", peer.name.ifBlank { "Party phone" })
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", "udp")
|
||||
put("addr", "${peer.ip}:${peer.port}")
|
||||
put("priority", 5)
|
||||
}))
|
||||
})
|
||||
}
|
||||
// A party-only phone (never paired with a node) still needs a public
|
||||
// rendezvous to reach its peers ACROSS the internet — without it, two
|
||||
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
|
||||
// this anchor in; do the same when there are party peers.
|
||||
if (party.isNotEmpty() &&
|
||||
(0 until merged.length()).none {
|
||||
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
|
||||
}
|
||||
) {
|
||||
merged.put(JSONObject().apply {
|
||||
put("npub", ARCHY_ANCHOR_NPUB)
|
||||
put("alias", "Archipelago anchor")
|
||||
put("addresses", JSONArray().put(JSONObject().apply {
|
||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||
put("addr", ARCHY_ANCHOR_ADDR)
|
||||
put("priority", 40)
|
||||
}))
|
||||
})
|
||||
}
|
||||
return merged.toString()
|
||||
}
|
||||
|
||||
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val npub = o.optString("npub")
|
||||
val ula = o.optString("ula")
|
||||
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
|
||||
PartyPeer(
|
||||
npub = npub,
|
||||
ula = ula,
|
||||
name = o.optString("name").ifBlank { "Phone" },
|
||||
ip = o.optString("ip"),
|
||||
port = o.optInt("port"),
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun toJson(peers: List<PartyPeer>): String {
|
||||
val arr = JSONArray()
|
||||
for (p in peers) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("npub", p.npub)
|
||||
put("ula", p.ula)
|
||||
put("name", p.name)
|
||||
put("ip", p.ip)
|
||||
put("port", p.port)
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update the node peer plus its rendezvous anchors (each matched
|
||||
* by npub, so re-pairing updates addresses instead of duplicating).
|
||||
|
||||
@@ -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>
|
||||
@@ -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,9 +79,8 @@ 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).
|
||||
@@ -94,7 +98,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 +115,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 +251,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 +259,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"));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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.
|
||||
|
||||
Generated
+1
-1
@@ -95,7 +95,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.111-alpha"
|
||||
version = "1.7.112-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+2
-2
@@ -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,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.
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user