Compare commits
56 Commits
5799c37111
...
078f3b3619
| Author | SHA1 | Date | |
|---|---|---|---|
| 078f3b3619 | |||
| 4222a8507c | |||
| db2cafc657 | |||
|
|
e49af6ff40 | ||
| a865306488 | |||
|
|
979113c598 | ||
| 420f756947 | |||
|
|
35849c88c6 | ||
| 08927b88a8 | |||
|
|
0b7a1b84e4 | ||
|
|
bb4166954a | ||
|
|
ed2c14c88a | ||
| 42e98d6a86 | |||
| 3e5feebd49 | |||
|
|
4199c43cc9 | ||
|
|
54ddd043bd | ||
|
|
402183c0ae | ||
|
|
80875a2ed4 | ||
|
|
f72d4b92ac | ||
| 5d2c28da7b | |||
|
|
fbed32f95d | ||
|
|
d5fc3d01a4 | ||
|
|
87c324e36c | ||
|
|
15954aec14 | ||
|
|
1df075f7b1 | ||
|
|
ff4013bd3b | ||
|
|
37c8992d21 | ||
|
|
1ecb5c9aa3 | ||
|
|
5bae8273a2 | ||
|
|
265196bc2c | ||
|
|
9f1daa0f8c | ||
|
|
960e41e164 | ||
|
|
6502f13e29 | ||
|
|
c58f84c6e9 | ||
|
|
e59ea1da69 | ||
|
|
be6f06a573 | ||
|
|
8a450bb8f8 | ||
|
|
852ffc5b18 | ||
|
|
db6003aef6 | ||
|
|
b991c18e73 | ||
|
|
fb72e3e159 | ||
|
|
f339358109 | ||
|
|
df9b9905b6 | ||
|
|
e68fe071a7 | ||
|
|
04876f3bef | ||
|
|
05fc49820c | ||
|
|
cbb108a636 | ||
|
|
bfd8bc5b9a | ||
|
|
6347d16a2f | ||
|
|
b193e64ffb | ||
|
|
6a3b46b5a9 | ||
|
|
770e3386f4 | ||
|
|
8f4c32b93f | ||
|
|
30a4343b83 | ||
|
|
cad68eb941 | ||
|
|
197e351880 |
@ -18,7 +18,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'neode-ui/**'
|
- 'neode-ui/**'
|
||||||
- 'docker-compose.demo.yml'
|
- 'docker-compose.demo.yml'
|
||||||
- '.github/workflows/demo-images.yml'
|
- '.gitea/workflows/demo-images.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 20
|
versionCode = 23
|
||||||
versionName = "0.5.0"
|
versionName = "0.5.3"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -21,6 +21,10 @@ data class ServerEntry(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
|
||||||
val meshIp: String = "",
|
val meshIp: String = "",
|
||||||
|
/** Node's FIPS npub — the durable identity. When present it, not the
|
||||||
|
* address, is what identifies the entry: FIPS peers on npubs, IPs are
|
||||||
|
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
|
||||||
|
val npub: String = "",
|
||||||
) {
|
) {
|
||||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||||
fun displayName(): String = name.ifBlank { address }
|
fun displayName(): String = name.ifBlank { address }
|
||||||
@ -45,9 +49,15 @@ data class ServerEntry(
|
|||||||
fun toMeshUrl(): String? =
|
fun toMeshUrl(): String? =
|
||||||
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
|
||||||
|
|
||||||
// name/meshIp are trailing fields so entries saved before they existed
|
// name/meshIp/npub are trailing fields so entries saved before they
|
||||||
// (4/5 fields) still deserialize, defaulting to "".
|
// existed (4/5/6 fields) still deserialize, defaulting to "".
|
||||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
|
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
|
||||||
|
|
||||||
|
/** Same node as [other]? npub identity wins; address/port/scheme is the
|
||||||
|
* fallback for LAN-only entries that never advertised FIPS. */
|
||||||
|
fun sameNode(other: ServerEntry): Boolean =
|
||||||
|
(npub.isNotBlank() && npub == other.npub) ||
|
||||||
|
(address == other.address && port == other.port && useHttps == other.useHttps)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun deserialize(raw: String): ServerEntry? {
|
fun deserialize(raw: String): ServerEntry? {
|
||||||
@ -60,6 +70,7 @@ data class ServerEntry(
|
|||||||
password = parts.getOrElse(3) { "" },
|
password = parts.getOrElse(3) { "" },
|
||||||
name = parts.getOrElse(4) { "" },
|
name = parts.getOrElse(4) { "" },
|
||||||
meshIp = parts.getOrElse(5) { "" },
|
meshIp = parts.getOrElse(5) { "" },
|
||||||
|
npub = parts.getOrElse(6) { "" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,8 +84,10 @@ class ServerPreferences(private val context: Context) {
|
|||||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||||
private val activeNameKey = stringPreferencesKey("active_name")
|
private val activeNameKey = stringPreferencesKey("active_name")
|
||||||
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
|
||||||
|
private val activeNpubKey = stringPreferencesKey("active_npub")
|
||||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
|
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||||
|
|
||||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||||
val address = prefs[activeAddressKey] ?: return@map null
|
val address = prefs[activeAddressKey] ?: return@map null
|
||||||
@ -85,6 +98,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
password = prefs[activePasswordKey] ?: "",
|
password = prefs[activePasswordKey] ?: "",
|
||||||
name = prefs[activeNameKey] ?: "",
|
name = prefs[activeNameKey] ?: "",
|
||||||
meshIp = prefs[activeMeshIpKey] ?: "",
|
meshIp = prefs[activeMeshIpKey] ?: "",
|
||||||
|
npub = prefs[activeNpubKey] ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,6 +111,11 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] ?: false
|
prefs[introSeenKey] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||||
|
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||||
|
prefs[gestureHintSeenKey] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setActiveServer(server: ServerEntry) {
|
suspend fun setActiveServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[activeAddressKey] = server.address
|
prefs[activeAddressKey] = server.address
|
||||||
@ -105,6 +124,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[activePasswordKey] = server.password
|
prefs[activePasswordKey] = server.password
|
||||||
prefs[activeNameKey] = server.name
|
prefs[activeNameKey] = server.name
|
||||||
prefs[activeMeshIpKey] = server.meshIp
|
prefs[activeMeshIpKey] = server.meshIp
|
||||||
|
prefs[activeNpubKey] = server.npub
|
||||||
}
|
}
|
||||||
addSavedServer(server)
|
addSavedServer(server)
|
||||||
}
|
}
|
||||||
@ -117,6 +137,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs.remove(activePasswordKey)
|
prefs.remove(activePasswordKey)
|
||||||
prefs.remove(activeNameKey)
|
prefs.remove(activeNameKey)
|
||||||
prefs.remove(activeMeshIpKey)
|
prefs.remove(activeMeshIpKey)
|
||||||
|
prefs.remove(activeNpubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,65 +149,66 @@ class ServerPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a saved server in place. Matches the existing entry by connection
|
* Replace a saved server in place. Matches the existing entry by node
|
||||||
* identity (address/port/scheme) so edits that change the name or password —
|
* identity — npub first, address/port/scheme as the LAN-only fallback
|
||||||
* or that touch a legacy 4-field entry — still update the right record. If the
|
* (ServerEntry.sameNode) — so edits that change the name, password or even
|
||||||
* edited server is also the active one, the active record is kept in sync.
|
* every address still update the right record. An edit form that doesn't
|
||||||
|
* carry the npub keeps the stored one. If the edited server is also the
|
||||||
|
* active one, the active record is kept in sync.
|
||||||
*/
|
*/
|
||||||
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||||
|
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
val filtered = current.filterNot { raw ->
|
val filtered = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(original) == true
|
||||||
e != null &&
|
|
||||||
e.address == original.address &&
|
|
||||||
e.port == original.port &&
|
|
||||||
e.useHttps == original.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
prefs[savedServersKey] = filtered + updated.serialize()
|
prefs[savedServersKey] = filtered + toStore.serialize()
|
||||||
|
|
||||||
val isActive = prefs[activeAddressKey] == original.address &&
|
val activeNpub = prefs[activeNpubKey] ?: ""
|
||||||
|
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
|
||||||
|
(
|
||||||
|
prefs[activeAddressKey] == original.address &&
|
||||||
(prefs[activePortKey] ?: "") == original.port &&
|
(prefs[activePortKey] ?: "") == original.port &&
|
||||||
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||||
|
)
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
prefs[activeAddressKey] = updated.address
|
prefs[activeAddressKey] = toStore.address
|
||||||
prefs[activeHttpsKey] = updated.useHttps
|
prefs[activeHttpsKey] = toStore.useHttps
|
||||||
prefs[activePortKey] = updated.port
|
prefs[activePortKey] = toStore.port
|
||||||
prefs[activePasswordKey] = updated.password
|
prefs[activePasswordKey] = toStore.password
|
||||||
prefs[activeNameKey] = updated.name
|
prefs[activeNameKey] = toStore.name
|
||||||
prefs[activeMeshIpKey] = updated.meshIp
|
prefs[activeMeshIpKey] = toStore.meshIp
|
||||||
|
prefs[activeNpubKey] = toStore.npub
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a server, or update the entry with the same connection identity
|
* Add a server, or update the entry for the same node — npub first,
|
||||||
* (address/port/scheme) — used by QR pairing so re-scanning a node never
|
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
|
||||||
* duplicates it. A blank incoming password/name keeps the stored value
|
* used by QR pairing so re-scanning a node never duplicates it, even after
|
||||||
* (a real node's QR never carries the password). Returns the merged entry.
|
* the LAN renumbered and every address changed (npub-first contract in
|
||||||
|
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
|
||||||
|
* stored value (a real node's QR never carries the password). Returns the
|
||||||
|
* merged entry.
|
||||||
*/
|
*/
|
||||||
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
suspend fun upsertServer(server: ServerEntry): ServerEntry {
|
||||||
var merged = server
|
var merged = server
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
|
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
|
||||||
it.address == server.address &&
|
.firstOrNull { it.sameNode(server) }
|
||||||
it.port == server.port &&
|
|
||||||
it.useHttps == server.useHttps
|
|
||||||
}
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
merged = server.copy(
|
merged = server.copy(
|
||||||
password = server.password.ifBlank { existing.password },
|
password = server.password.ifBlank { existing.password },
|
||||||
name = server.name.ifBlank { existing.name },
|
name = server.name.ifBlank { existing.name },
|
||||||
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
meshIp = server.meshIp.ifBlank { existing.meshIp },
|
||||||
|
npub = server.npub.ifBlank { existing.npub },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val filtered = current.filterNot { raw ->
|
val filtered = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(merged) == true
|
||||||
e != null &&
|
|
||||||
e.address == server.address &&
|
|
||||||
e.port == server.port &&
|
|
||||||
e.useHttps == server.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
prefs[savedServersKey] = filtered + merged.serialize()
|
prefs[savedServersKey] = filtered + merged.serialize()
|
||||||
}
|
}
|
||||||
@ -196,15 +218,11 @@ class ServerPreferences(private val context: Context) {
|
|||||||
suspend fun removeSavedServer(server: ServerEntry) {
|
suspend fun removeSavedServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
// Match by connection identity (address/port/scheme) rather than the
|
// Match by node identity (npub, else address/port/scheme) rather
|
||||||
// exact serialized string, so a rename — or the legacy 4-field format
|
// than the exact serialized string, so a rename — or a legacy
|
||||||
// saved before names existed — still removes the right entry.
|
// short-format entry — still removes the right record.
|
||||||
prefs[savedServersKey] = current.filterNot { raw ->
|
prefs[savedServersKey] = current.filterNot { raw ->
|
||||||
val e = ServerEntry.deserialize(raw)
|
ServerEntry.deserialize(raw)?.sameNode(server) == true
|
||||||
e != null &&
|
|
||||||
e.address == server.address &&
|
|
||||||
e.port == server.port &&
|
|
||||||
e.useHttps == server.useHttps
|
|
||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,4 +232,10 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[introSeenKey] = true
|
prefs[introSeenKey] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun markGestureHintSeen() {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[gestureHintSeenKey] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,9 @@ object ServerQrParser {
|
|||||||
password = credential,
|
password = credential,
|
||||||
name = uri.getQueryParameter("name") ?: "",
|
name = uri.getQueryParameter("name") ?: "",
|
||||||
meshIp = fips?.ula ?: "",
|
meshIp = fips?.ula ?: "",
|
||||||
|
// npub is the durable identity — saved-server upserts match on
|
||||||
|
// it, so re-scanning after a LAN renumber updates in place.
|
||||||
|
npub = fips?.npub ?: "",
|
||||||
),
|
),
|
||||||
fips = fips,
|
fips = fips,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -61,6 +61,13 @@ class ArchyVpnService : VpnService() {
|
|||||||
.setMtu(1280)
|
.setMtu(1280)
|
||||||
.addAddress(identity.address, 128)
|
.addAddress(identity.address, 128)
|
||||||
.addRoute("fd00::", 8)
|
.addRoute("fd00::", 8)
|
||||||
|
// The TUN is IPv6-only. Android blocks every address family
|
||||||
|
// the VPN has no address for — without this, bringing the
|
||||||
|
// mesh up cut ALL of the phone's IPv4 internet.
|
||||||
|
.allowFamily(android.system.OsConstants.AF_INET)
|
||||||
|
// And let apps that bind their own network skip the TUN
|
||||||
|
// entirely — this is mesh reachability, not a privacy VPN.
|
||||||
|
.allowBypass()
|
||||||
.establish()
|
.establish()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "VPN establish failed", e)
|
Log.e(TAG, "VPN establish failed", e)
|
||||||
|
|||||||
@ -12,6 +12,16 @@ import androidx.datastore.preferences.preferencesDataStore
|
|||||||
|
|
||||||
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
|
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
|
||||||
|
|
||||||
|
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
|
||||||
|
// both paths — direct LAN p2p to the node AND a public rendezvous for
|
||||||
|
// away-from-home — even when the scanned node is old enough that its QR
|
||||||
|
// carries no fanchors. Keep in lockstep with
|
||||||
|
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
|
||||||
|
internal const val ARCHY_ANCHOR_NPUB =
|
||||||
|
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
|
||||||
|
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
||||||
|
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||||
* storage model as ServerPreferences (the server password lives there the
|
* storage model as ServerPreferences (the server password lives there the
|
||||||
@ -91,6 +101,21 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Guarantee the public anchor: without it, a QR from an older node
|
||||||
|
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
|
||||||
|
if (info.npub != ARCHY_ANCHOR_NPUB &&
|
||||||
|
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
|
||||||
|
) {
|
||||||
|
incoming += JSONObject().apply {
|
||||||
|
put("npub", ARCHY_ANCHOR_NPUB)
|
||||||
|
put("alias", "Archipelago anchor")
|
||||||
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
|
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||||
|
put("addr", ARCHY_ANCHOR_ADDR)
|
||||||
|
put("priority", 40)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||||
context.fipsDataStore.edit { prefs ->
|
context.fipsDataStore.edit { prefs ->
|
||||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import com.archipelago.app.ui.theme.neoRaised
|
|||||||
@Composable
|
@Composable
|
||||||
fun GamepadLayout(
|
fun GamepadLayout(
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onTwoFingerHold: () -> Unit,
|
onThreeFingerHold: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val surface = Neo.surface()
|
val surface = Neo.surface()
|
||||||
@ -54,9 +54,9 @@ fun GamepadLayout(
|
|||||||
do {
|
do {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val a = ev.changes.filter { !it.changedToUp() }
|
val a = ev.changes.filter { !it.changedToUp() }
|
||||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
|
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() }
|
||||||
if (a.size < 2) t = 0L
|
if (a.size < 3) t = 0L
|
||||||
} while (ev.changes.any { it.pressed })
|
} while (ev.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,147 @@
|
|||||||
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-launch teaching overlay for the three-finger hold gesture. Three
|
||||||
|
* fingertip dots pulse in a "press" rhythm with an expanding ring while a
|
||||||
|
* short caption explains what the gesture opens. Dismissed by tapping
|
||||||
|
* anywhere (or automatically after a few seconds) — shown once, ever.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GestureHintOverlay(onDismiss: () -> Unit) {
|
||||||
|
// Auto-dismiss so a user who taps nothing is never stuck behind the scrim.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(6500)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
val transition = rememberInfiniteTransition(label = "gesture-hint")
|
||||||
|
// Fingertips press down together…
|
||||||
|
val press by transition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 0.86f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse),
|
||||||
|
label = "press",
|
||||||
|
)
|
||||||
|
// …while a ring ripples outward on each press cycle.
|
||||||
|
val ripple by transition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)),
|
||||||
|
label = "ripple",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.72f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
// Hand: three fingertip dots in a natural arc + ripple ring.
|
||||||
|
Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(150.dp)
|
||||||
|
.scale(0.4f + ripple * 0.6f)
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f),
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
FingerDot(x = (-44).dp, y = 14.dp, scale = press)
|
||||||
|
FingerDot(x = 0.dp, y = (-12).dp, scale = press)
|
||||||
|
FingerDot(x = 44.dp, y = 8.dp, scale = press)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gesture_hint_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gesture_hint_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 48.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.12f))
|
||||||
|
.clickable(onClick = onDismiss)
|
||||||
|
.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.gesture_hint_got_it),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.offset(x = x, y = y)
|
||||||
|
.size(26.dp)
|
||||||
|
.scale(scale)
|
||||||
|
.background(Color.White.copy(alpha = 0.92f), CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -116,7 +116,7 @@ fun NESController(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.twoFingerHold(onMenu)
|
.threeFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -451,17 +451,17 @@ fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Two-finger hold gesture modifier */
|
/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */
|
||||||
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
|
||||||
awaitEachGesture {
|
awaitEachGesture {
|
||||||
awaitFirstDown(requireUnconsumed = false)
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
var t = 0L; var fired = false
|
var t = 0L; var fired = false
|
||||||
do {
|
do {
|
||||||
val ev = awaitPointerEvent()
|
val ev = awaitPointerEvent()
|
||||||
val a = ev.changes.filter { !it.changedToUp() }
|
val a = ev.changes.filter { !it.changedToUp() }
|
||||||
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
|
if (a.size >= 3 && t == 0L) t = System.currentTimeMillis()
|
||||||
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
|
||||||
if (a.size < 2) t = 0L
|
if (a.size < 3) t = 0L
|
||||||
} while (ev.changes.any { it.pressed })
|
} while (ev.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ fun NESPortraitController(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.twoFingerHold(onMenu)
|
.threeFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@ -87,7 +87,7 @@ fun NESPortraitController(
|
|||||||
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
onMove = { dx, dy -> onMouseMove(dx, dy) },
|
||||||
onClick = { onMouseClick(it) },
|
onClick = { onMouseClick(it) },
|
||||||
onScroll = { dy -> onMouseScroll(dy) },
|
onScroll = { dy -> onMouseScroll(dy) },
|
||||||
onTwoFingerHold = onMenu,
|
onThreeFingerHold = onMenu,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
|
|||||||
@ -217,13 +217,20 @@ fun QrScannerOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||||
val previewView = remember {
|
val previewView = remember {
|
||||||
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
|
PreviewView(context).apply {
|
||||||
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
|
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||||
|
// hole in the window, which black-flashes inside Compose fades and
|
||||||
|
// ignores rounded-corner clipping (wallet modal).
|
||||||
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@ -282,7 +289,19 @@ private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAna
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var lastAttempt = 0L
|
||||||
|
|
||||||
override fun analyze(image: ImageProxy) {
|
override fun analyze(image: ImageProxy) {
|
||||||
|
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||||
|
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||||
|
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||||
|
// frames skipped here are simply dropped, so decodes stay current.
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastAttempt < 140) {
|
||||||
|
image.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAttempt = now
|
||||||
try {
|
try {
|
||||||
val plane = image.planes[0]
|
val plane = image.planes[0]
|
||||||
val buffer = plane.buffer
|
val buffer = plane.buffer
|
||||||
|
|||||||
@ -32,7 +32,7 @@ fun Trackpad(
|
|||||||
onMove: (dx: Int, dy: Int) -> Unit,
|
onMove: (dx: Int, dy: Int) -> Unit,
|
||||||
onClick: (button: Int) -> Unit,
|
onClick: (button: Int) -> Unit,
|
||||||
onScroll: (dy: Int) -> Unit,
|
onScroll: (dy: Int) -> Unit,
|
||||||
onTwoFingerHold: () -> Unit,
|
onThreeFingerHold: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var fingers by remember { mutableIntStateOf(0) }
|
var fingers by remember { mutableIntStateOf(0) }
|
||||||
@ -53,7 +53,7 @@ fun Trackpad(
|
|||||||
val t0 = System.currentTimeMillis()
|
val t0 = System.currentTimeMillis()
|
||||||
var maxPtrs = 1
|
var maxPtrs = 1
|
||||||
var holdFired = false
|
var holdFired = false
|
||||||
var twoStart = 0L
|
var threeStart = 0L
|
||||||
var scrollAcc = 0f
|
var scrollAcc = 0f
|
||||||
fingers = 1
|
fingers = 1
|
||||||
|
|
||||||
@ -64,20 +64,25 @@ fun Trackpad(
|
|||||||
fingers = active.size
|
fingers = active.size
|
||||||
|
|
||||||
when {
|
when {
|
||||||
active.size >= 2 -> {
|
// Three fingers = hold for menu; two = scroll. Kept
|
||||||
if (twoStart == 0L) twoStart = System.currentTimeMillis()
|
// on separate counts so a long two-finger scroll can
|
||||||
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
|
// never fire the menu mid-gesture.
|
||||||
|
active.size >= 3 -> {
|
||||||
|
if (threeStart == 0L) threeStart = System.currentTimeMillis()
|
||||||
|
if (!holdFired && System.currentTimeMillis() - threeStart > 500) {
|
||||||
holdFired = true
|
holdFired = true
|
||||||
onTwoFingerHold()
|
onThreeFingerHold()
|
||||||
}
|
}
|
||||||
if (!holdFired) {
|
ev.changes.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
active.size == 2 -> {
|
||||||
|
threeStart = 0L
|
||||||
val dy = active.map { it.positionChange().y }.average().toFloat()
|
val dy = active.map { it.positionChange().y }.average().toFloat()
|
||||||
scrollAcc += dy
|
scrollAcc += dy
|
||||||
if (kotlin.math.abs(scrollAcc) > 12f) {
|
if (kotlin.math.abs(scrollAcc) > 12f) {
|
||||||
onScroll(if (scrollAcc > 0) 1 else -1)
|
onScroll(if (scrollAcc > 0) 1 else -1)
|
||||||
scrollAcc = 0f
|
scrollAcc = 0f
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ev.changes.forEach { it.consume() }
|
ev.changes.forEach { it.consume() }
|
||||||
}
|
}
|
||||||
active.size == 1 && maxPtrs == 1 -> {
|
active.size == 1 && maxPtrs == 1 -> {
|
||||||
@ -99,7 +104,11 @@ fun Trackpad(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (fingers >= 2) "hold for menu" else "",
|
text = when {
|
||||||
|
fingers >= 3 -> "hold for menu"
|
||||||
|
fingers == 2 -> "scroll"
|
||||||
|
else -> ""
|
||||||
|
},
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = muted.copy(alpha = 0.4f),
|
color = muted.copy(alpha = 0.4f),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -0,0 +1,294 @@
|
|||||||
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.ui.screens.GlassButton
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.BinaryBitmap
|
||||||
|
import com.google.zxing.DecodeHintType
|
||||||
|
import com.google.zxing.MultiFormatReader
|
||||||
|
import com.google.zxing.NotFoundException
|
||||||
|
import com.google.zxing.RGBLuminanceSource
|
||||||
|
import com.google.zxing.common.HybridBinarizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native replacement for the web wallet's scan pane — same visual design as
|
||||||
|
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||||
|
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||||
|
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||||
|
*
|
||||||
|
* Decoded text is handed back to the page ([onDecoded]) which does all the
|
||||||
|
* detection/spend logic; the page in turn streams status lines (animated-QR
|
||||||
|
* progress, "not recognised" errors) back in via [status] and closes the
|
||||||
|
* modal through the JS bridge once it accepts a code.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WalletQrScannerModal(
|
||||||
|
visible: Boolean,
|
||||||
|
status: Pair<String, Boolean>?, // message from the web page + isError
|
||||||
|
onDecoded: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var hasPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted -> hasPermission = granted }
|
||||||
|
|
||||||
|
// Local error from a failed image upload; a fresh web status replaces it.
|
||||||
|
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||||
|
val noQrMessage = stringResource(R.string.no_qr_in_image)
|
||||||
|
val imagePicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetContent()
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val decoded = decodeQrFromUri(context, uri)
|
||||||
|
if (decoded != null) {
|
||||||
|
uploadError = null
|
||||||
|
onDecoded(decoded)
|
||||||
|
} else {
|
||||||
|
uploadError = noQrMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(visible) {
|
||||||
|
if (visible) {
|
||||||
|
uploadError = null
|
||||||
|
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
hasPermission = granted
|
||||||
|
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
BackHandler { onDismiss() }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.widthIn(max = 420.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.background(Color(0xF212151C))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = {}, // swallow — only the scrim dismisses
|
||||||
|
)
|
||||||
|
.padding(24.dp),
|
||||||
|
) {
|
||||||
|
// Header — mirrors the web modal's title row
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scan_to_send),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
stringResource(R.string.close),
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Square camera preview with the orange viewfinder
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f))
|
||||||
|
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (hasPermission) {
|
||||||
|
// Throttle repeat frames: a static QR decodes ~20x/s but
|
||||||
|
// the page only needs one; animated QRs still stream
|
||||||
|
// because each frame's text differs.
|
||||||
|
var lastText by remember { mutableStateOf("") }
|
||||||
|
var lastSentAt by remember { mutableStateOf(0L) }
|
||||||
|
CameraQrPreview(onDecoded = { text ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (text != lastText || now - lastSentAt > 250) {
|
||||||
|
lastText = text
|
||||||
|
lastSentAt = now
|
||||||
|
onDecoded(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize(0.62f)
|
||||||
|
.border(
|
||||||
|
2.dp,
|
||||||
|
BitcoinOrange.copy(alpha = 0.85f),
|
||||||
|
RoundedCornerShape(16.dp),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.camera_permission_needed),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.grant_camera_access),
|
||||||
|
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Status strip — same slot the web modal uses for hints/errors
|
||||||
|
val message = uploadError ?: status?.first
|
||||||
|
val isError = uploadError != null || status?.second == true
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.White.copy(alpha = 0.05f))
|
||||||
|
.padding(12.dp)
|
||||||
|
.defaultMinSize(minHeight = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.scan_wallet_hint),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.upload_qr_image),
|
||||||
|
onClick = { imagePicker.launch("image/*") },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||||
|
private fun decodeQrFromUri(context: Context, uri: Uri): String? {
|
||||||
|
return try {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||||
|
var sample = 1
|
||||||
|
val maxDim = maxOf(bounds.outWidth, bounds.outHeight)
|
||||||
|
while (maxDim / (sample * 2) >= 1600) sample *= 2
|
||||||
|
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) }
|
||||||
|
?: return null
|
||||||
|
val pixels = IntArray(bmp.width * bmp.height)
|
||||||
|
bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height)
|
||||||
|
val source = RGBLuminanceSource(bmp.width, bmp.height, pixels)
|
||||||
|
val reader = MultiFormatReader().apply {
|
||||||
|
setHints(
|
||||||
|
mapOf(
|
||||||
|
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||||
|
DecodeHintType.TRY_HARDER to true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
|
||||||
|
} catch (_: NotFoundException) {
|
||||||
|
// Light-on-dark QRs (dark-themed wallets) decode inverted.
|
||||||
|
reader.reset()
|
||||||
|
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -172,7 +172,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onClick = { ws.sendClick(it) },
|
onClick = { ws.sendClick(it) },
|
||||||
onScroll = { ws.sendScroll(it) },
|
onScroll = { ws.sendScroll(it) },
|
||||||
onTwoFingerHold = { showModal = true },
|
onThreeFingerHold = { showModal = true },
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -85,6 +85,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
|||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
import com.archipelago.app.ui.theme.TextSecondary
|
import com.archipelago.app.ui.theme.TextSecondary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
@ -171,10 +172,31 @@ fun ServerConnectScreen(
|
|||||||
errorMessage = null
|
errorMessage = null
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val result = testConnection(server)
|
var reachable = testConnection(server)
|
||||||
|
|
||||||
|
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||||
|
// node. The scanned IP was only ever a dial hint; the node's real
|
||||||
|
// identity is its npub and its ULA is reachable from anywhere over
|
||||||
|
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||||
|
if (!reachable && server.meshIp.isNotBlank()) {
|
||||||
|
FipsManager.autoStartIfReady(context)
|
||||||
|
val meshServer = server.copy(
|
||||||
|
address = server.meshIp,
|
||||||
|
useHttps = false,
|
||||||
|
port = "",
|
||||||
|
)
|
||||||
|
// The tunnel + mesh route need a moment on cold start — and on
|
||||||
|
// a first-ever pairing the VPN consent dialog is on screen at
|
||||||
|
// the same time, so give the user time to tap it.
|
||||||
|
for (attempt in 0 until 6) {
|
||||||
|
if (attempt > 0) delay(2000)
|
||||||
|
reachable = testConnection(meshServer)
|
||||||
|
if (reachable) break
|
||||||
|
}
|
||||||
|
}
|
||||||
isConnecting = false
|
isConnecting = false
|
||||||
|
|
||||||
if (result) {
|
if (reachable) {
|
||||||
prefs.setActiveServer(server)
|
prefs.setActiveServer(server)
|
||||||
onConnected(server.toUrl())
|
onConnected(server.toUrl())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -53,11 +53,14 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -68,13 +71,21 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import android.webkit.ValueCallback
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
|
import com.archipelago.app.data.ServerPreferences
|
||||||
|
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||||
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||||
@ -176,6 +187,39 @@ fun WebViewScreen(
|
|||||||
// while this is shown, so closing it returns instantly with no reload.
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
|
||||||
|
// bridge; status lines stream back from the page while it's up.
|
||||||
|
var walletScannerVisible by remember { mutableStateOf(false) }
|
||||||
|
var walletScannerStatus by remember { mutableStateOf<Pair<String, Boolean>?>(null) }
|
||||||
|
|
||||||
|
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||||
|
// it while DataStore is still loading).
|
||||||
|
val prefs = remember { ServerPreferences(webViewContext) }
|
||||||
|
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||||
|
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||||
|
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||||
|
// ~2 minutes after the kiosk first finishes loading, once the user has
|
||||||
|
// settled in.
|
||||||
|
var gestureHintReady by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { isLoading }.first { !it }
|
||||||
|
delay(120_000)
|
||||||
|
gestureHintReady = true
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// <input type="file"> support — without a chooser implementation the
|
||||||
|
// WebView silently ignores file inputs (broke the wallet's upload path).
|
||||||
|
var pendingFileChooser by remember { mutableStateOf<ValueCallback<Array<android.net.Uri>>?>(null) }
|
||||||
|
val fileChooserLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
pendingFileChooser?.onReceiveValue(
|
||||||
|
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data),
|
||||||
|
)
|
||||||
|
pendingFileChooser = null
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||||
webView?.goBack()
|
webView?.goBack()
|
||||||
}
|
}
|
||||||
@ -301,6 +345,35 @@ fun WebViewScreen(
|
|||||||
"ArchipelagoNative",
|
"ArchipelagoNative",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Wallet QR bridge. The web scan modal calls:
|
||||||
|
// window.ArchipelagoQr.open() — show the native scanner
|
||||||
|
// window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines
|
||||||
|
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||||
|
// Decodes flow back through window.__archyQrResult(text);
|
||||||
|
// a user cancel calls window.__archyQrCancelled().
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun open() {
|
||||||
|
webViewRef.post {
|
||||||
|
walletScannerStatus = null
|
||||||
|
walletScannerVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun setStatus(message: String, isError: Boolean) {
|
||||||
|
webViewRef.post { walletScannerStatus = message to isError }
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun close() {
|
||||||
|
webViewRef.post { walletScannerVisible = false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ArchipelagoQr",
|
||||||
|
)
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
@ -412,6 +485,27 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onShowFileChooser(
|
||||||
|
view: WebView?,
|
||||||
|
filePathCallback: ValueCallback<Array<android.net.Uri>>?,
|
||||||
|
fileChooserParams: FileChooserParams?,
|
||||||
|
): Boolean {
|
||||||
|
pendingFileChooser?.onReceiveValue(null)
|
||||||
|
pendingFileChooser = filePathCallback
|
||||||
|
val intent = fileChooserParams?.createIntent()
|
||||||
|
if (intent == null) {
|
||||||
|
pendingFileChooser = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
fileChooserLauncher.launch(intent)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingFileChooser = null
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wallet QR scanner: grant the page camera access.
|
// Wallet QR scanner: grant the page camera access.
|
||||||
// Only video capture is granted — anything else the
|
// Only video capture is granted — anything else the
|
||||||
// page asks for is denied as before.
|
// page asks for is denied as before.
|
||||||
@ -467,22 +561,24 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-finger hold (500ms) → navigate to remote input
|
// Three-finger hold (500ms) → navigate to remote input.
|
||||||
var twoFingerStart = 0L
|
// Three fingers, not two: two-finger scroll/pinch on the
|
||||||
var twoFingerFired = false
|
// page collided with the old two-finger hold.
|
||||||
|
var threeFingerStart = 0L
|
||||||
|
var threeFingerFired = false
|
||||||
setOnTouchListener { _, event ->
|
setOnTouchListener { _, event ->
|
||||||
val pointerCount = event.pointerCount
|
val pointerCount = event.pointerCount
|
||||||
when (event.actionMasked) {
|
when (event.actionMasked) {
|
||||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
if (pointerCount >= 2) {
|
if (pointerCount >= 3) {
|
||||||
twoFingerStart = System.currentTimeMillis()
|
threeFingerStart = System.currentTimeMillis()
|
||||||
twoFingerFired = false
|
threeFingerFired = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
android.view.MotionEvent.ACTION_MOVE -> {
|
android.view.MotionEvent.ACTION_MOVE -> {
|
||||||
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
|
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||||
if (System.currentTimeMillis() - twoFingerStart > 500) {
|
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||||
twoFingerFired = true
|
threeFingerFired = true
|
||||||
onRemoteInput()
|
onRemoteInput()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -490,8 +586,8 @@ fun WebViewScreen(
|
|||||||
android.view.MotionEvent.ACTION_UP,
|
android.view.MotionEvent.ACTION_UP,
|
||||||
android.view.MotionEvent.ACTION_POINTER_UP,
|
android.view.MotionEvent.ACTION_POINTER_UP,
|
||||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||||
if (event.pointerCount <= 2) {
|
if (event.pointerCount <= 3) {
|
||||||
twoFingerStart = 0L
|
threeFingerStart = 0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -527,6 +623,38 @@ fun WebViewScreen(
|
|||||||
onClose = { inAppUrl = null },
|
onClose = { inAppUrl = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native wallet QR scanner, opened by the page via ArchipelagoQr.
|
||||||
|
WalletQrScannerModal(
|
||||||
|
visible = walletScannerVisible,
|
||||||
|
status = walletScannerStatus,
|
||||||
|
onDecoded = { text ->
|
||||||
|
webView?.evaluateJavascript(
|
||||||
|
"window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
walletScannerVisible = false
|
||||||
|
webView?.evaluateJavascript(
|
||||||
|
"window.__archyQrCancelled && window.__archyQrCancelled()",
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// First-launch teaching overlay for the three-finger hold — armed
|
||||||
|
// ~2 minutes after login so it never fights the splash/first look.
|
||||||
|
if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed &&
|
||||||
|
!isLoading && inAppUrl == null
|
||||||
|
) {
|
||||||
|
GestureHintOverlay(
|
||||||
|
onDismiss = {
|
||||||
|
gestureHintDismissed = true
|
||||||
|
scope.launch { prefs.markGestureHintSeen() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,4 +41,11 @@
|
|||||||
<string name="edit_server_title">Edit Server</string>
|
<string name="edit_server_title">Edit Server</string>
|
||||||
<string name="save_changes">Save Changes</string>
|
<string name="save_changes">Save Changes</string>
|
||||||
<string name="cancel">Cancel</string>
|
<string name="cancel">Cancel</string>
|
||||||
|
<string name="gesture_hint_title">Hold with three fingers</string>
|
||||||
|
<string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string>
|
||||||
|
<string name="gesture_hint_got_it">Got it</string>
|
||||||
|
<string name="scan_to_send">Scan to send</string>
|
||||||
|
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||||
|
<string name="upload_qr_image">Upload image</string>
|
||||||
|
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7.112-alpha (2026-07-22)
|
||||||
|
|
||||||
|
- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.
|
||||||
|
- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box.
|
||||||
|
- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.
|
||||||
|
- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.
|
||||||
|
- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.
|
||||||
|
- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.
|
||||||
|
- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.
|
||||||
|
- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.
|
||||||
|
- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.
|
||||||
|
- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.
|
||||||
|
- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.
|
||||||
|
- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.
|
||||||
|
- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.
|
||||||
|
- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.
|
||||||
|
- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."
|
||||||
|
- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.
|
||||||
|
- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.
|
||||||
|
- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).
|
||||||
|
|
||||||
## v1.7.111-alpha (2026-07-22)
|
## v1.7.111-alpha (2026-07-22)
|
||||||
|
|
||||||
- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive.
|
- 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.
|
||||||
|
|||||||
@ -9,13 +9,22 @@ app:
|
|||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
entrypoint: ["sh", "-lc"]
|
entrypoint: ["sh", "-lc"]
|
||||||
|
# The bitcoind host comes from $FM_BITCOIND_URL, filled by the
|
||||||
|
# {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin
|
||||||
|
# container is actually running (Knots, Core, or any future distro archy
|
||||||
|
# ships), so the gateway is never pinned to one node's container name.
|
||||||
|
# (Was hardcoded http://host.archipelago:8332 — the host gateway IP where
|
||||||
|
# bitcoind does not listen — which crash-looped the gateway, 2026-07-22.)
|
||||||
custom_args:
|
custom_args:
|
||||||
- >-
|
- >-
|
||||||
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||||
else
|
else
|
||||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||||
fi
|
fi
|
||||||
|
derived_env:
|
||||||
|
- key: FM_BITCOIND_URL
|
||||||
|
template: "http://{{BITCOIN_HOST}}:8332"
|
||||||
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||||
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||||
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||||
|
|||||||
@ -21,6 +21,11 @@ app:
|
|||||||
template: fedimint://{{HOST_MDNS}}:8173
|
template: fedimint://{{HOST_MDNS}}:8173
|
||||||
- key: FM_API_URL
|
- key: FM_API_URL
|
||||||
template: ws://{{HOST_MDNS}}:8174
|
template: ws://{{HOST_MDNS}}:8174
|
||||||
|
# Resolves to whichever bitcoin container is running (Knots/Core/future
|
||||||
|
# distro) instead of a hardcoded name — the guardian works on any node
|
||||||
|
# regardless of which Bitcoin software it runs.
|
||||||
|
- key: FM_BITCOIND_URL
|
||||||
|
template: "http://{{BITCOIN_HOST}}:8332"
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: FM_BITCOIND_PASSWORD
|
- key: FM_BITCOIND_PASSWORD
|
||||||
secret_file: bitcoin-rpc-password
|
secret_file: bitcoin-rpc-password
|
||||||
@ -62,7 +67,8 @@ app:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
- FM_DATA_DIR=/data
|
- FM_DATA_DIR=/data
|
||||||
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
# FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a
|
||||||
|
# hardcoded name — do not re-add it here.
|
||||||
- FM_BITCOIND_USERNAME=archipelago
|
- FM_BITCOIND_USERNAME=archipelago
|
||||||
- FM_BITCOIN_NETWORK=bitcoin
|
- FM_BITCOIN_NETWORK=bitcoin
|
||||||
- FM_BIND_P2P=0.0.0.0:8173
|
- FM_BIND_P2P=0.0.0.0:8173
|
||||||
|
|||||||
@ -188,7 +188,7 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let price_sats = match &item.access {
|
let price_sats = match &item.access {
|
||||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||||
_ => {
|
_ => {
|
||||||
// Not a paid item — no invoice to issue.
|
// Not a paid item — no invoice to issue.
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
@ -198,6 +198,13 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if !content_server::method_accepted(&item.access, "lightning") {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let memo = format!("Archipelago peer file {content_id}");
|
let memo = format!("Archipelago peer file {content_id}");
|
||||||
match self
|
match self
|
||||||
@ -315,7 +322,18 @@ impl ApiHandler {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||||
Some(i) => match &i.access {
|
Some(i) => match &i.access {
|
||||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||||
|
if !content_server::method_accepted(&i.access, "onchain") {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(
|
||||||
|
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*price_sats
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
|||||||
@ -179,7 +179,24 @@ impl RpcHandler {
|
|||||||
if price == 0 {
|
if price == 0 {
|
||||||
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
||||||
}
|
}
|
||||||
AccessControl::Paid { price_sats: price }
|
// Optional list of payment methods the sharer accepts.
|
||||||
|
// Absent/empty = all methods (backward compatible).
|
||||||
|
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
|
||||||
|
let accepted: Vec<String> = params
|
||||||
|
.get("accepted_methods")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|m| m.as_str())
|
||||||
|
.filter(|m| KNOWN_METHODS.contains(m))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
AccessControl::Paid {
|
||||||
|
price_sats: price,
|
||||||
|
accepted,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
||||||
};
|
};
|
||||||
@ -412,6 +429,55 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEVER pay twice for content we already own (2026-07-22: a file
|
||||||
|
// shared twice produced two catalog ids for the same bytes and the
|
||||||
|
// buyer paid both). Guard BEFORE any ecash is minted, matching both
|
||||||
|
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||||
|
// catches duplicate ids pointing at the same file on the same
|
||||||
|
// seller. The owned copy is served from the local cache instead.
|
||||||
|
{
|
||||||
|
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||||
|
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||||
|
let already = owned.iter().find(|o| {
|
||||||
|
o.onion == onion
|
||||||
|
&& (o.content_id == content_id
|
||||||
|
|| filename.is_some_and(|f| {
|
||||||
|
!f.is_empty()
|
||||||
|
&& o.filename.trim_start_matches('/')
|
||||||
|
== f.trim_start_matches('/')
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
if let Some(o) = already {
|
||||||
|
tracing::info!(
|
||||||
|
onion,
|
||||||
|
content_id,
|
||||||
|
owned_as = %o.content_id,
|
||||||
|
"paid download: already owned — serving cached copy, NOT paying again"
|
||||||
|
);
|
||||||
|
if let Some((mime, bytes)) = crate::content_owned::read_owned(
|
||||||
|
&self.config.data_dir,
|
||||||
|
&o.onion,
|
||||||
|
&o.content_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
use base64::Engine;
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"owned": true,
|
||||||
|
"already_owned": true,
|
||||||
|
"filename": o.filename,
|
||||||
|
"mime_type": mime,
|
||||||
|
"size_bytes": bytes.len(),
|
||||||
|
"paid_sats": 0,
|
||||||
|
"data_base64":
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Cache record exists but bytes are gone — fall through and
|
||||||
|
// repurchase rather than stranding the user.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||||
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||||
// verify_payment_token accepts either, so a node whose balance lives in
|
// verify_payment_token accepts either, so a node whose balance lives in
|
||||||
@ -590,6 +656,54 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-file the purchase into the user's Files area (2026-07-22):
|
||||||
|
// Photos for images/video, Music for audio, Documents otherwise —
|
||||||
|
// same buckets the Cloud view uses. The in-app viewer still plays
|
||||||
|
// from the purchase cache; this makes the file ALSO show up where
|
||||||
|
// files live, on every device, without relying on a browser
|
||||||
|
// download. Best-effort: never fail a paid download over it.
|
||||||
|
{
|
||||||
|
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
||||||
|
"Photos"
|
||||||
|
} else if mime_type.starts_with("audio/") {
|
||||||
|
"Music"
|
||||||
|
} else {
|
||||||
|
"Documents"
|
||||||
|
};
|
||||||
|
let base = std::path::Path::new(&filename)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("download")
|
||||||
|
.to_string();
|
||||||
|
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
||||||
|
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
||||||
|
} else {
|
||||||
|
// Don't clobber an existing file of the same name: "x.jpg"
|
||||||
|
// → "x (2).jpg" etc.
|
||||||
|
let mut target = dir.join(&base);
|
||||||
|
let (stem, ext) = match base.rsplit_once('.') {
|
||||||
|
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
||||||
|
_ => (base.clone(), String::new()),
|
||||||
|
};
|
||||||
|
let mut n = 2;
|
||||||
|
while target.exists() {
|
||||||
|
target = dir.join(format!("{stem} ({n}){ext}"));
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
match tokio::fs::write(&target, &bytes).await {
|
||||||
|
Ok(()) => tracing::info!(
|
||||||
|
"paid download: filed into {}",
|
||||||
|
target.display()
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"paid download: filing into {} failed (non-fatal): {e}",
|
||||||
|
target.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
|||||||
@ -260,6 +260,7 @@ impl RpcHandler {
|
|||||||
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
|
||||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||||
|
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
|
||||||
|
|
||||||
// Ark protocol (via barkd sidecar)
|
// Ark protocol (via barkd sidecar)
|
||||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||||
|
|||||||
@ -118,6 +118,31 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "removed": removed }))
|
Ok(serde_json::json!({ "removed": removed }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
|
||||||
|
/// with sufficient balance. Returns the notes token for the recipient
|
||||||
|
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
|
||||||
|
/// split from Cashu 2026-07-22). The heavy lifting already existed in
|
||||||
|
/// `fedimint_client::spend_from_any`; it was simply never exposed.
|
||||||
|
pub(super) async fn handle_wallet_fedimint_send(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let amount_sats = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("amount_sats"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||||
|
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
|
||||||
|
let (token, federation_id) =
|
||||||
|
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"token": token,
|
||||||
|
"federation_id": federation_id,
|
||||||
|
"amount_sats": amount_sats,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
/// `wallet.fedimint-balance` — total sats across all joined federations.
|
||||||
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
|
||||||
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
// Soft-fail to zero when clientd isn't installed/running, so the unified
|
||||||
|
|||||||
@ -30,9 +30,21 @@ impl RpcHandler {
|
|||||||
// The node's seed anchors ride along so the phone can rendezvous
|
// The node's seed anchors ride along so the phone can rendezvous
|
||||||
// through the same public mesh points when the node's LAN endpoint
|
// through the same public mesh points when the node's LAN endpoint
|
||||||
// isn't directly dialable (phone away from home, node behind NAT).
|
// isn't directly dialable (phone away from home, node behind NAT).
|
||||||
let anchors = fips::anchors::load(&self.config.data_dir)
|
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
// Pairing must always carry a public rendezvous point: without one the
|
||||||
|
// phone is IP-bound to the LAN host it scanned and goes dark the
|
||||||
|
// moment it leaves that network. This is a pairing hint only — the
|
||||||
|
// node's own anchor file is not modified, so an operator's removal of
|
||||||
|
// the default anchors still sticks for the node itself.
|
||||||
|
if !anchor_list
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
|
||||||
|
{
|
||||||
|
anchor_list.push(fips::anchors::archy_anchor());
|
||||||
|
}
|
||||||
|
let anchors = anchor_list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
|||||||
@ -86,7 +86,7 @@ impl RpcHandler {
|
|||||||
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let version = data.server_info.version.clone();
|
let version = data.server_info.version.clone();
|
||||||
let relays = self.config.nostr_relays.clone();
|
let relays = self.handshake_relays().await;
|
||||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = nostr_handshake::publish_presence(
|
if let Err(e) = nostr_handshake::publish_presence(
|
||||||
@ -106,6 +106,17 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "enabled": enabled }))
|
Ok(serde_json::json!({ "enabled": enabled }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The relay set every handshake operation uses: the user-managed relay
|
||||||
|
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
|
||||||
|
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
|
||||||
|
/// hardcoded config relays (one of which is defunct) and ignored user
|
||||||
|
/// relay edits entirely — so a sender publishing where the receiver
|
||||||
|
/// never read was a routine, silent way for peer requests to vanish.
|
||||||
|
pub(super) async fn handshake_relays(&self) -> Vec<String> {
|
||||||
|
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Discover discoverable nodes via Nostr presence events.
|
/// Discover discoverable nodes via Nostr presence events.
|
||||||
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||||
@ -113,9 +124,10 @@ impl RpcHandler {
|
|||||||
// to query relays as long as the user is actively browsing — they're
|
// to query relays as long as the user is actively browsing — they're
|
||||||
// an anonymous observer of presence events, not publishing anything.
|
// an anonymous observer of presence events, not publishing anything.
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let relays = self.handshake_relays().await;
|
||||||
let nodes = nostr_handshake::discover_nodes(
|
let nodes = nostr_handshake::discover_nodes(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
&self.config.nostr_relays,
|
&relays,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -161,7 +173,7 @@ impl RpcHandler {
|
|||||||
our_version,
|
our_version,
|
||||||
our_name,
|
our_name,
|
||||||
message,
|
message,
|
||||||
&self.config.nostr_relays,
|
&self.handshake_relays().await,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -191,6 +203,40 @@ impl RpcHandler {
|
|||||||
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||||
///
|
///
|
||||||
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||||
|
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
|
||||||
|
/// ONLY when a user opened Federation and pressed the Poll button — a
|
||||||
|
/// peer request sat on the relay until the target's operator happened to
|
||||||
|
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
|
||||||
|
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
|
||||||
|
/// and nudges the websocket revision when anything new lands so open UIs
|
||||||
|
/// refresh immediately.
|
||||||
|
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
|
||||||
|
match self.handle_handshake_poll().await {
|
||||||
|
Ok(res) => {
|
||||||
|
let new = res
|
||||||
|
.get("new_requests")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let applied = res
|
||||||
|
.get("applied_invites")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if new > 0 || applied > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
new_requests = new,
|
||||||
|
applied_invites = applied,
|
||||||
|
"handshake poll: inbound peer activity"
|
||||||
|
);
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||||
// Runtime gate: if the user hasn't enabled discoverability, don't
|
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||||
// touch the relays. The poll endpoint is a hard no-op until they
|
// touch the relays. The poll endpoint is a hard no-op until they
|
||||||
@ -207,9 +253,10 @@ impl RpcHandler {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let relays = self.handshake_relays().await;
|
||||||
let handshakes = nostr_handshake::poll_handshakes(
|
let handshakes = nostr_handshake::poll_handshakes(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
&self.config.nostr_relays,
|
&relays,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -120,6 +120,108 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
|
||||||
|
/// for 14 HOURS with its RPC answering but the server never finishing
|
||||||
|
/// startup — synced_to_chain=false, zero peers, every channel inactive —
|
||||||
|
/// and nothing noticed until a human tried to open a channel. The wedge
|
||||||
|
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
|
||||||
|
/// with channels that need a peer) persists. A restart reliably clears it
|
||||||
|
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
|
||||||
|
/// after 15 consecutive bad minutes we bounce the container ourselves, with
|
||||||
|
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
|
||||||
|
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||||
|
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||||
|
/// operator.
|
||||||
|
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut bad_minutes: u32 = 0;
|
||||||
|
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||||
|
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||||
|
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let macaroon_hex = hex::encode(bytes);
|
||||||
|
let Ok(client) = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(resp) = client
|
||||||
|
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
bad_minutes = 0; // down/locked — not the wedge signature
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||||
|
bad_minutes = 0;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let synced = info
|
||||||
|
.get("synced_to_chain")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let channels = info
|
||||||
|
.get("num_active_channels")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ info
|
||||||
|
.get("num_inactive_channels")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ info
|
||||||
|
.get("num_pending_channels")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let wedged = !synced || (channels > 0 && peers == 0);
|
||||||
|
if !wedged {
|
||||||
|
bad_minutes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bad_minutes += 1;
|
||||||
|
if bad_minutes < 15 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if last_restart
|
||||||
|
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
synced_to_chain = synced,
|
||||||
|
num_peers = peers,
|
||||||
|
channels,
|
||||||
|
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
|
||||||
|
);
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["restart", "lnd"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match out {
|
||||||
|
Ok(o) if o.status.success() => {
|
||||||
|
tracing::info!("LND watchdog restart complete");
|
||||||
|
}
|
||||||
|
Ok(o) => tracing::warn!(
|
||||||
|
"LND watchdog restart failed: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
|
||||||
|
}
|
||||||
|
last_restart = Some(tokio::time::Instant::now());
|
||||||
|
bad_minutes = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Helper: create an authenticated LND REST client.
|
/// Helper: create an authenticated LND REST client.
|
||||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||||
|
|||||||
@ -62,6 +62,14 @@ impl RpcHandler {
|
|||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Unknown error");
|
.unwrap_or("Unknown error");
|
||||||
|
// Invoices are short-lived; retrying the same one can never
|
||||||
|
// succeed, so tell the user the way out instead of just the fact.
|
||||||
|
if msg.contains("invoice expired") {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
|
||||||
|
msg.trim_start_matches("invoice expired. ")
|
||||||
|
));
|
||||||
|
}
|
||||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -101,12 +101,21 @@ impl RpcHandler {
|
|||||||
detected.iter().any(|d| d == &path),
|
detected.iter().any(|d| d == &path),
|
||||||
"{path} is not a detected mesh-radio candidate port"
|
"{path} is not a detected mesh-radio candidate port"
|
||||||
);
|
);
|
||||||
|
// Only hold the mesh_service lock long enough for the quick
|
||||||
|
// active-path guard check — NEVER across the actual probe, which
|
||||||
|
// can take 15-60s across its internal collision retries. Confirmed
|
||||||
|
// live 2026-07-23: holding this read lock for the full probe starved
|
||||||
|
// a concurrent firmware-flash job's MeshService::stop() (which needs
|
||||||
|
// the write lock) well past its own bounded timeout, surfacing as
|
||||||
|
// "Mesh listener did not release the serial port" even though
|
||||||
|
// stop() itself was fast.
|
||||||
|
{
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let probe = match service.as_ref() {
|
if let Some(svc) = service.as_ref() {
|
||||||
Some(svc) => svc.probe_device(&path).await?,
|
svc.ensure_probe_allowed(&path).await?;
|
||||||
// No mesh service yet (radio never enabled) — probe directly.
|
}
|
||||||
None => mesh::listener::probe_device(&path).await?,
|
}
|
||||||
};
|
let probe = mesh::listener::probe_device(&path).await?;
|
||||||
Ok(serde_json::to_value(probe)?)
|
Ok(serde_json::to_value(probe)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
|||||||
"Mempool requires",
|
"Mempool requires",
|
||||||
"Container",
|
"Container",
|
||||||
"Image",
|
"Image",
|
||||||
|
// Wallet-actionable errors: masking "Insufficient balance: need 80
|
||||||
|
// sats, have 0 sats" behind "Operation failed. Check server logs."
|
||||||
|
// sent the operator to journalctl for a message that was written for
|
||||||
|
// them in the first place (ecash send, 2026-07-22).
|
||||||
|
"Insufficient balance",
|
||||||
|
"Insufficient funds",
|
||||||
|
// Lightning payment failures carry LND's reason ("invoice expired.
|
||||||
|
// Valid until …", "no route", …) — the user can act on every one of
|
||||||
|
// them, and masking sent the operator to journalctl (invoice-expired
|
||||||
|
// send, 2026-07-23).
|
||||||
|
"Payment failed",
|
||||||
|
"Invalid payment request",
|
||||||
|
"Missing 'payment_request'",
|
||||||
|
"Your Lightning node is still finishing",
|
||||||
"Bitcoin address",
|
"Bitcoin address",
|
||||||
"No router",
|
"No router",
|
||||||
"No OpenWrt",
|
"No OpenWrt",
|
||||||
@ -151,6 +165,25 @@ mod sanitize_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lightning_payment_errors_pass_through() {
|
||||||
|
// LND's payment-failure reasons are written for the payer — masking
|
||||||
|
// "invoice expired" as "Check server logs" left a user retrying a
|
||||||
|
// dead invoice (framework-pt, 2026-07-23).
|
||||||
|
for msg in [
|
||||||
|
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
|
||||||
|
"Payment failed: unable to find a path to destination",
|
||||||
|
"Invalid payment request: must be a Lightning invoice (lnbc...)",
|
||||||
|
"Missing 'payment_request' parameter",
|
||||||
|
] {
|
||||||
|
assert_ne!(
|
||||||
|
sanitize_error_message(msg),
|
||||||
|
"Operation failed. Check server logs for details.",
|
||||||
|
"masked: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn internal_errors_stay_generic() {
|
fn internal_errors_stay_generic() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -26,6 +26,7 @@ mod node;
|
|||||||
mod nostr;
|
mod nostr;
|
||||||
mod openwrt;
|
mod openwrt;
|
||||||
mod package;
|
mod package;
|
||||||
|
pub(crate) use package::wyoming_satellite_keeper;
|
||||||
mod peers;
|
mod peers;
|
||||||
mod pine_status;
|
mod pine_status;
|
||||||
mod response;
|
mod response;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ mod dependencies;
|
|||||||
mod install;
|
mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod pine_ha;
|
mod pine_ha;
|
||||||
|
pub(crate) use pine_ha::wyoming_satellite_keeper;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod set_config;
|
mod set_config;
|
||||||
|
|||||||
@ -697,6 +697,189 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wyoming satellite keeper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
|
||||||
|
///
|
||||||
|
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
|
||||||
|
/// renumbering — or the whole node moving to a different network — strands
|
||||||
|
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
|
||||||
|
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
|
||||||
|
/// never re-resolves on its own. This keeper probes each satellite entry and,
|
||||||
|
/// when one stops answering, sweeps the node's local /24s for the same
|
||||||
|
/// Wyoming port and rewrites the entry to the address that answers.
|
||||||
|
pub(crate) async fn wyoming_satellite_keeper() {
|
||||||
|
loop {
|
||||||
|
if home_assistant_installed().await {
|
||||||
|
heal_wyoming_satellites().await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
|
||||||
|
matches!(
|
||||||
|
tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(ms),
|
||||||
|
tokio::net::TcpStream::connect((host, port)),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Ok(Ok(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
|
||||||
|
/// excluded — satellites live on real LANs.
|
||||||
|
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
|
||||||
|
let Ok(out) = tokio::process::Command::new("ip")
|
||||||
|
.args(["-4", "-o", "addr", "show", "scope", "global"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let cidr = l.split_whitespace().nth(3)?;
|
||||||
|
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
|
||||||
|
let o = ip.octets();
|
||||||
|
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
|
||||||
|
if o[0] == 100 && (64..128).contains(&o[1]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ip)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sweep the /24 of every local interface for something answering `port`.
|
||||||
|
/// First responder wins; the node's own addresses are skipped.
|
||||||
|
async fn find_satellite(port: u16) -> Option<String> {
|
||||||
|
let self_ips = local_ipv4_addresses().await;
|
||||||
|
for base in self_ips.iter().map(|ip| ip.octets()) {
|
||||||
|
let mut set = tokio::task::JoinSet::new();
|
||||||
|
for i in 1..255u8 {
|
||||||
|
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
|
||||||
|
if self_ips.contains(&ip) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return Some(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
|
||||||
|
/// port now answers. Stops HA before editing the store (HA flushes its own
|
||||||
|
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
|
||||||
|
/// again after.
|
||||||
|
async fn heal_wyoming_satellites() {
|
||||||
|
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
|
||||||
|
let Some(store) = read_store(&path).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(entries) = store
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("entries"))
|
||||||
|
.and_then(|e| e.as_array())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// (host, port, new_host) for every stranded satellite we can re-resolve.
|
||||||
|
let mut moves: Vec<(String, u16, String)> = Vec::new();
|
||||||
|
for e in entries {
|
||||||
|
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(host) = e
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("host"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Engines use host.containers.internal — only IP-pinned entries drift.
|
||||||
|
if host.parse::<std::net::Ipv4Addr>().is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let port = e
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("port"))
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
if port == 0 || tcp_alive(host, port, 1500).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(new_host) = find_satellite(port).await else {
|
||||||
|
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if new_host != host {
|
||||||
|
moves.push((host.to_string(), port, new_host));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moves.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop HA, re-read + rewrite the store, start HA.
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "homeassistant"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Some(mut store) = read_store(&path).await {
|
||||||
|
let mut changed = false;
|
||||||
|
if let Some(entries) = store
|
||||||
|
.get_mut("data")
|
||||||
|
.and_then(|d| d.get_mut("entries"))
|
||||||
|
.and_then(|e| e.as_array_mut())
|
||||||
|
{
|
||||||
|
for e in entries.iter_mut() {
|
||||||
|
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let host = e
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("host"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let port = e
|
||||||
|
.get("data")
|
||||||
|
.and_then(|d| d.get("port"))
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0) as u16;
|
||||||
|
if let Some((_, _, new_host)) =
|
||||||
|
moves.iter().find(|(h, p, _)| *h == host && *p == port)
|
||||||
|
{
|
||||||
|
if let Some(data) = e.get_mut("data") {
|
||||||
|
data["host"] = json!(new_host);
|
||||||
|
}
|
||||||
|
e["modified_at"] = json!(ha_now());
|
||||||
|
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
write_store(&path, &store).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["start", "homeassistant"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Presence probes + HA restart
|
// Presence probes + HA restart
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -139,9 +139,17 @@ impl RpcHandler {
|
|||||||
.await
|
.await
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.unwrap_or_else(|_| "archipelago".to_string());
|
.unwrap_or_else(|_| "archipelago".to_string());
|
||||||
|
// LAN IPv4 rides along for the companion pairing QR: when the
|
||||||
|
// operator's browser reaches this node over Tailscale/VPN or
|
||||||
|
// localhost, that origin is useless to a phone on the LAN — the QR
|
||||||
|
// must advertise an address the phone can actually dial
|
||||||
|
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
|
||||||
|
// companion could never connect).
|
||||||
|
let lan_ip = crate::host_ip::primary_host_ipv4().await;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"mdns_hostname": format!("{hostname}.local"),
|
"mdns_hostname": format!("{hostname}.local"),
|
||||||
|
"lan_ip": lan_ip,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,28 @@ const KIOSK_LAUNCHER: &str =
|
|||||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||||
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
|
||||||
|
|
||||||
|
// HDMI audio (kiosk nodes): ISOs built before 2026-07-23 shipped no audio
|
||||||
|
// stack at all even though the kiosk launcher expects PipeWire-Pulse, and the
|
||||||
|
// kiosk's boot-time modeset can race the i915→HDA audio-component bind so the
|
||||||
|
// HDMI ELD is lost and every HDMI profile stays unavailable (silent failure).
|
||||||
|
// The router daemon handles routing + the ELD re-modeset nudge; this heal
|
||||||
|
// installs the packages, group membership, script and unit on deployed nodes.
|
||||||
|
const AUDIO_ROUTER: &str =
|
||||||
|
include_str!("../../../image-recipe/configs/archipelago-audio-router.sh");
|
||||||
|
const AUDIO_SERVICE: &str =
|
||||||
|
include_str!("../../../image-recipe/configs/archipelago-audio-router.service");
|
||||||
|
const AUDIO_ROUTER_PATH: &str = "/usr/local/bin/archipelago-audio-router";
|
||||||
|
const AUDIO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-audio-router.service";
|
||||||
|
|
||||||
|
// Gamepad→keyboard bridge (TV input inside every app iframe) — same
|
||||||
|
// splice-from-configs + self-heal pattern as the audio router.
|
||||||
|
const GAMEPAD_KEYS: &str =
|
||||||
|
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.py");
|
||||||
|
const GAMEPAD_SERVICE: &str =
|
||||||
|
include_str!("../../../image-recipe/configs/archipelago-gamepad-keys.service");
|
||||||
|
const GAMEPAD_KEYS_PATH: &str = "/usr/local/bin/archipelago-gamepad-keys";
|
||||||
|
const GAMEPAD_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-gamepad-keys.service";
|
||||||
|
|
||||||
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
|
||||||
// write the identical file at build time (image-recipe/_archived/
|
// write the identical file at build time (image-recipe/_archived/
|
||||||
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
|
||||||
@ -794,6 +816,109 @@ pub async fn ensure_kiosk_hardened() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HDMI-audio self-heal for kiosk nodes: install the PipeWire stack (older
|
||||||
|
/// ISOs shipped none), put the archipelago user in `audio` (PipeWire runs
|
||||||
|
/// under the lingering user manager — no logind seat, so no udev ACLs on
|
||||||
|
/// /dev/snd), and keep the audio-router daemon (HDMI routing + ELD boot-race
|
||||||
|
/// nudge) installed and current. No-op on nodes without the kiosk — audio
|
||||||
|
/// only matters where media plays on an attached display.
|
||||||
|
pub async fn ensure_audio_stack() {
|
||||||
|
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||||
|
return; // no kiosk → no display audio to route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package install runs via systemd-run (host_sudo), outside the service
|
||||||
|
// sandbox — /usr and the dpkg database are read-only in our namespace.
|
||||||
|
if fs::metadata("/usr/bin/pactl").await.is_err() {
|
||||||
|
info!("audio: PipeWire stack missing — installing packages");
|
||||||
|
let _ = host_sudo(&["apt-get", "update", "-qq"]).await;
|
||||||
|
match host_sudo(&[
|
||||||
|
"apt-get",
|
||||||
|
"install",
|
||||||
|
"-y",
|
||||||
|
"-qq",
|
||||||
|
"--no-install-recommends",
|
||||||
|
"pipewire",
|
||||||
|
"pipewire-pulse",
|
||||||
|
"pipewire-alsa",
|
||||||
|
"wireplumber",
|
||||||
|
"alsa-utils",
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(s) if s.success() => info!("audio: PipeWire stack installed"),
|
||||||
|
Ok(s) => {
|
||||||
|
warn!("audio: package install exited with {} — will retry next start", s);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("audio: package install failed: {:#} — will retry next start", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = host_sudo(&["usermod", "-aG", "audio", "archipelago"]).await;
|
||||||
|
|
||||||
|
let unit_was_missing = fs::metadata(AUDIO_SERVICE_PATH).await.is_err();
|
||||||
|
let script_changed = write_root_if_needed(AUDIO_ROUTER_PATH, AUDIO_ROUTER)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if script_changed {
|
||||||
|
let _ = host_sudo(&["chmod", "+x", AUDIO_ROUTER_PATH]).await;
|
||||||
|
}
|
||||||
|
let unit_changed = write_root_if_needed(AUDIO_SERVICE_PATH, AUDIO_SERVICE)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if script_changed || unit_changed {
|
||||||
|
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||||
|
warn!("audio: daemon-reload failed: {:#}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unit_was_missing {
|
||||||
|
// First install on this node — bring it up now and on every boot.
|
||||||
|
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-audio-router.service"]).await;
|
||||||
|
info!("audio: router installed and enabled (HDMI routing + ELD heal)");
|
||||||
|
} else if script_changed || unit_changed {
|
||||||
|
// Content update: restart only if it's running — never re-enable a
|
||||||
|
// unit an operator deliberately disabled.
|
||||||
|
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-audio-router.service"]).await;
|
||||||
|
info!("audio: router updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gamepad→keyboard bridge self-heal for kiosk nodes: keeps the evdev→uinput
|
||||||
|
/// daemon (controller works inside every app iframe on the TV) installed and
|
||||||
|
/// current. Same gating as audio: no kiosk → no display input to bridge.
|
||||||
|
pub async fn ensure_gamepad_keys() {
|
||||||
|
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let unit_was_missing = fs::metadata(GAMEPAD_SERVICE_PATH).await.is_err();
|
||||||
|
let script_changed = write_root_if_needed(GAMEPAD_KEYS_PATH, GAMEPAD_KEYS)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if script_changed {
|
||||||
|
let _ = host_sudo(&["chmod", "+x", GAMEPAD_KEYS_PATH]).await;
|
||||||
|
}
|
||||||
|
let unit_changed = write_root_if_needed(GAMEPAD_SERVICE_PATH, GAMEPAD_SERVICE)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if script_changed || unit_changed {
|
||||||
|
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
||||||
|
warn!("gamepad bridge: daemon-reload failed: {:#}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unit_was_missing {
|
||||||
|
let _ = host_sudo(&["systemctl", "enable", "--now", "archipelago-gamepad-keys.service"]).await;
|
||||||
|
info!("gamepad: bridge installed and enabled (TV controller input)");
|
||||||
|
} else if script_changed || unit_changed {
|
||||||
|
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-gamepad-keys.service"]).await;
|
||||||
|
info!("gamepad: bridge updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
|
||||||
/// configs shipped individual per-endpoint `location` blocks, so missing
|
/// configs shipped individual per-endpoint `location` blocks, so missing
|
||||||
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
/// endpoints silently fell through to the SPA `index.html` and the frontend
|
||||||
|
|||||||
@ -108,17 +108,33 @@ impl BootReconciler {
|
|||||||
let orchestrator = self.orchestrator.clone();
|
let orchestrator = self.orchestrator.clone();
|
||||||
let interval = self.interval;
|
let interval = self.interval;
|
||||||
Some(tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
|
let mut failure_rounds: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
let installed = orchestrator.manifest_ids().await;
|
let installed = orchestrator.manifest_ids().await;
|
||||||
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
let failures =
|
||||||
{
|
crate::container::companion::reconcile(&installed).await;
|
||||||
|
for (companion, err) in &failures {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
companion = %companion,
|
companion = %companion,
|
||||||
error = %err,
|
error = %err,
|
||||||
"companion reconcile failed"
|
"companion reconcile failed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
time::sleep(interval).await;
|
// A failed repair can involve registry pulls and full
|
||||||
|
// image builds; retrying every 30s hammered unreachable
|
||||||
|
// registries ~174×/image/day on an offline node
|
||||||
|
// (archy-x250-dev log sweep, 2026-07-22). Back off
|
||||||
|
// exponentially while rounds keep failing — 30s doubling
|
||||||
|
// to a 1h cap — and reset the moment a round is clean.
|
||||||
|
failure_rounds = if failures.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
failure_rounds.saturating_add(1)
|
||||||
|
};
|
||||||
|
let backoff = interval
|
||||||
|
.saturating_mul(2u32.saturating_pow(failure_rounds.min(7)))
|
||||||
|
.min(Duration::from_secs(3600));
|
||||||
|
time::sleep(backoff).await;
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -3094,8 +3094,9 @@ impl ProdContainerOrchestrator {
|
|||||||
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
.unwrap_or_else(|| "bitcoin-knots".to_string());
|
||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
// Mirrors api::rpc::package::dependencies (the legacy install path);
|
// The known Bitcoin node containers, preferred in order. Any archy
|
||||||
// both Bitcoin node variants are reachable on archy-net by name.
|
// Bitcoin distribution runs as a container named `bitcoin-<distro>`
|
||||||
|
// (or bare `bitcoin`), all reachable on archy-net by name.
|
||||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||||
let names = tokio::process::Command::new("podman")
|
let names = tokio::process::Command::new("podman")
|
||||||
.args(["ps", "--format", "{{.Names}}"])
|
.args(["ps", "--format", "{{.Names}}"])
|
||||||
@ -3105,12 +3106,23 @@ impl ProdContainerOrchestrator {
|
|||||||
.filter(|o| o.status.success())
|
.filter(|o| o.status.success())
|
||||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
names
|
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
|
||||||
.lines()
|
// Prefer a known name in priority order…
|
||||||
.map(|l| l.trim())
|
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
|
||||||
.find(|name| BITCOIN_NAMES.contains(name))
|
return hit.to_string();
|
||||||
.map(|name| name.to_string())
|
}
|
||||||
.unwrap_or_else(|| "bitcoin-knots".to_string())
|
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
|
||||||
|
// future Bitcoin distribution archy ships works without editing this
|
||||||
|
// list (user req 2026-07-22). Excludes companions/sidecars like
|
||||||
|
// `bitcoin-ui` and `archy-*`.
|
||||||
|
if let Some(other) = running.iter().find(|n| {
|
||||||
|
(**n == "bitcoin" || n.starts_with("bitcoin-"))
|
||||||
|
&& !n.ends_with("-ui")
|
||||||
|
&& !n.starts_with("archy-")
|
||||||
|
}) {
|
||||||
|
return other.to_string();
|
||||||
|
}
|
||||||
|
"bitcoin-knots".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -3247,10 +3259,10 @@ impl ProdContainerOrchestrator {
|
|||||||
let mut env = manifest.app.environment.clone();
|
let mut env = manifest.app.environment.clone();
|
||||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||||
|
|
||||||
if manifest.app.id == "fedimint" || manifest.app.id == "fedimintd" {
|
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||||
env.retain(|entry| !entry.starts_with("FM_BITCOIND_URL="));
|
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||||
env.push("FM_BITCOIND_URL=http://bitcoin-knots:8332".to_string());
|
// `bitcoin-knots` override was removed 2026-07-22 — it defeated the
|
||||||
}
|
// derived-env and broke Core nodes.
|
||||||
|
|
||||||
let provider = FileSecretsProvider {
|
let provider = FileSecretsProvider {
|
||||||
root: self.secrets_dir.clone(),
|
root: self.secrets_dir.clone(),
|
||||||
|
|||||||
@ -51,9 +51,25 @@ pub enum AccessControl {
|
|||||||
PeersOnly,
|
PeersOnly,
|
||||||
Paid {
|
Paid {
|
||||||
price_sats: u64,
|
price_sats: u64,
|
||||||
|
/// Payment methods the sharer accepts: "lightning", "onchain",
|
||||||
|
/// "ecash", "fedimint". Empty = everything — which is also what
|
||||||
|
/// catalogs written before this field deserialize to.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
accepted: Vec<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does the sharer accept this payment method for the item? Empty list =
|
||||||
|
/// all methods (pre-field catalogs and "no preference").
|
||||||
|
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
|
||||||
|
match access {
|
||||||
|
AccessControl::Paid { accepted, .. } => {
|
||||||
|
accepted.is_empty() || accepted.iter().any(|m| m == method)
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct ContentCatalog {
|
pub struct ContentCatalog {
|
||||||
pub items: Vec<ContentItem>,
|
pub items: Vec<ContentItem>,
|
||||||
@ -126,12 +142,29 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a content item to the catalog.
|
/// Add a content item to the catalog.
|
||||||
|
///
|
||||||
|
/// Idempotent per FILE, not just per id: `content.add` mints a fresh UUID on
|
||||||
|
/// every call, so id-only dedup let the same file be shared twice as two
|
||||||
|
/// separately-priced entries — and a buyer paid twice for one file
|
||||||
|
/// (2026-07-22). Same filename → update the existing entry in place and
|
||||||
|
/// keep its id, so existing buyers' owned records stay valid.
|
||||||
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
|
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
|
||||||
let mut catalog = load_catalog(data_dir).await?;
|
let mut catalog = load_catalog(data_dir).await?;
|
||||||
if catalog.items.iter().any(|i| i.id == item.id) {
|
if catalog.items.iter().any(|i| i.id == item.id) {
|
||||||
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
|
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
|
||||||
}
|
}
|
||||||
|
let norm = |f: &str| f.trim_start_matches('/').to_string();
|
||||||
|
if let Some(existing) = catalog
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|i| norm(&i.filename) == norm(&item.filename))
|
||||||
|
{
|
||||||
|
let keep_id = existing.id.clone();
|
||||||
|
*existing = item;
|
||||||
|
existing.id = keep_id;
|
||||||
|
} else {
|
||||||
catalog.items.push(item);
|
catalog.items.push(item);
|
||||||
|
}
|
||||||
save_catalog(data_dir, &catalog).await?;
|
save_catalog(data_dir, &catalog).await?;
|
||||||
Ok(catalog)
|
Ok(catalog)
|
||||||
}
|
}
|
||||||
@ -252,20 +285,26 @@ pub async fn serve_content(
|
|||||||
|
|
||||||
// Check access control
|
// Check access control
|
||||||
match &item.access {
|
match &item.access {
|
||||||
AccessControl::Paid { price_sats } => {
|
AccessControl::Paid { price_sats, .. } => {
|
||||||
// Two ways to satisfy payment:
|
// Two ways to satisfy payment:
|
||||||
// (a) a valid ecash token (the local-wallet fast path), or
|
// (a) a valid ecash token (the local-wallet fast path), or
|
||||||
// (b) a Lightning-invoice payment hash this node issued and has
|
// (b) a Lightning-invoice payment hash this node issued and has
|
||||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||||
|
// Each path only counts when the sharer accepts that method.
|
||||||
let mut authorized = false;
|
let mut authorized = false;
|
||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
if (method_accepted(&item.access, "ecash")
|
||||||
|
|| method_accepted(&item.access, "fedimint"))
|
||||||
|
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||||
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !authorized {
|
if !authorized {
|
||||||
if let Some(hash) = invoice_hash {
|
if let Some(hash) = invoice_hash {
|
||||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
if method_accepted(&item.access, "lightning")
|
||||||
|
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||||
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -403,6 +403,14 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
|||||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||||
|
|
||||||
for (i, record) in containers.iter().enumerate() {
|
for (i, record) in containers.iter().enumerate() {
|
||||||
|
// Skip containers that are already up — `podman start` on a running
|
||||||
|
// container produces the noisy benign conmon "Failed to create
|
||||||
|
// container" + cgroup Permission-denied journal pair (fleet log
|
||||||
|
// sweep 2026-07-22).
|
||||||
|
if container_state(&record.name).await.as_deref() == Some("running") {
|
||||||
|
report.recovered += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
info!(
|
info!(
|
||||||
"Recovering container: {} (image: {})",
|
"Recovering container: {} (image: {})",
|
||||||
record.name, record.image
|
record.name, record.image
|
||||||
@ -936,14 +944,21 @@ async fn container_state(container: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start_existing_container(container: &str) -> bool {
|
async fn start_existing_container(container: &str) -> bool {
|
||||||
info!("Recovering stack container: {}", container);
|
|
||||||
let timeout = match container {
|
let timeout = match container {
|
||||||
"immich_server" | "netbird-server" => Duration::from_secs(120),
|
"immich_server" | "netbird-server" => Duration::from_secs(120),
|
||||||
_ => Duration::from_secs(90),
|
_ => Duration::from_secs(90),
|
||||||
};
|
};
|
||||||
if container_state(container).await.as_deref() == Some("initialized") {
|
match container_state(container).await.as_deref() {
|
||||||
cleanup_container_runtime_state(container).await;
|
// Already up — `podman start` on a running container makes conmon
|
||||||
|
// try to join the live cgroup and fail with a scary "Failed to
|
||||||
|
// create container: exit status 1" + cgroup.procs Permission denied
|
||||||
|
// pair in the journal. Fleet log sweep 2026-07-22 found hundreds of
|
||||||
|
// these per day per node, all benign. Skip instead.
|
||||||
|
Some("running") => return true,
|
||||||
|
Some("initialized") => cleanup_container_runtime_state(container).await,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
info!("Recovering stack container: {}", container);
|
||||||
match podman_output(&["start", container], timeout).await {
|
match podman_output(&["start", container], timeout).await {
|
||||||
Ok(output) if output.status.success() => {
|
Ok(output) if output.status.success() => {
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|||||||
@ -386,6 +386,19 @@ async fn main() -> Result<()> {
|
|||||||
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
|
||||||
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
tokio::spawn(bootstrap::ensure_kiosk_hardened());
|
||||||
|
|
||||||
|
// HDMI audio: install the PipeWire stack + audio-router daemon on kiosk
|
||||||
|
// nodes (older ISOs shipped no audio stack; the router also heals the
|
||||||
|
// boot-time ELD race that leaves HDMI silently unavailable).
|
||||||
|
tokio::spawn(bootstrap::ensure_audio_stack());
|
||||||
|
|
||||||
|
// TV input: gamepad→keyboard bridge so controllers work inside every app
|
||||||
|
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||||
|
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
||||||
|
|
||||||
|
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
|
||||||
|
// DHCP renumbering strands them — HA never re-resolves on its own.
|
||||||
|
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
||||||
|
|
||||||
// Spawn periodic container snapshot (for crash recovery)
|
// Spawn periodic container snapshot (for crash recovery)
|
||||||
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
|
||||||
|
|
||||||
|
|||||||
@ -547,6 +547,10 @@ pub fn spawn_mesh_listener(
|
|||||||
let mut shutdown = shutdown;
|
let mut shutdown = shutdown;
|
||||||
let mut cmd_rx = cmd_rx;
|
let mut cmd_rx = cmd_rx;
|
||||||
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
||||||
|
// Mutable so a successful auto-detect can pin the firmware kind for
|
||||||
|
// the rest of this listener's lifetime — see the pin-on-first-success
|
||||||
|
// block below for why.
|
||||||
|
let mut device_kind = device_kind;
|
||||||
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
||||||
// builds a fresh device struct (contacts/current_region/etc. all
|
// builds a fresh device struct (contacts/current_region/etc. all
|
||||||
// start empty), so per-device session state is naturally isolated
|
// start empty), so per-device session state is naturally isolated
|
||||||
@ -618,6 +622,45 @@ pub fn spawn_mesh_listener(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the firmware kind after the first successful auto-detect.
|
||||||
|
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
|
||||||
|
// after clearing a stale pin), EVERY reconnect re-runs the full
|
||||||
|
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
|
||||||
|
// candidate past the first does its own open() with the DTR/RTS
|
||||||
|
// reset both boards need, so a device correctly identified as
|
||||||
|
// Meshtastic still gets reset once for the failed Meshcore
|
||||||
|
// attempt before Meshtastic's own open() resets it again. That
|
||||||
|
// doubled the reset count on every single reconnect indefinitely,
|
||||||
|
// not just during initial detection. Once auto-detect has
|
||||||
|
// identified the device this listener is actually talking to,
|
||||||
|
// there's no reason to keep guessing on subsequent reconnects —
|
||||||
|
// pin it, both in this task's own loop (takes effect
|
||||||
|
// immediately) and on disk (survives a service restart). A
|
||||||
|
// genuine hot-swap to different firmware is still handled: the
|
||||||
|
// setup modal's `mesh.probe-device` always re-probes unpinned,
|
||||||
|
// and the flash flow already clears this pin on its own.
|
||||||
|
if device_kind.is_none() {
|
||||||
|
let detected = state.status.read().await.device_type;
|
||||||
|
if detected != super::types::DeviceType::Unknown {
|
||||||
|
device_kind = Some(detected);
|
||||||
|
match super::load_config(&data_dir).await {
|
||||||
|
Ok(mut cfg) if cfg.device_kind.is_none() => {
|
||||||
|
cfg.device_kind = Some(detected);
|
||||||
|
if let Err(e) = super::save_config(&data_dir, &cfg).await {
|
||||||
|
warn!("Failed to persist auto-detected device_kind: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
kind = %detected,
|
||||||
|
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update status to disconnected. device_type/firmware_version are
|
// Update status to disconnected. device_type/firmware_version are
|
||||||
// reset too — they were previously left holding the LAST radio's
|
// reset too — they were previously left holding the LAST radio's
|
||||||
// identity, so after a hot-swap the UI showed the old firmware
|
// identity, so after a hot-swap the UI showed the old firmware
|
||||||
|
|||||||
@ -1064,19 +1064,17 @@ impl MeshService {
|
|||||||
self.state.peers.read().await.values().cloned().collect()
|
self.state.peers.read().await.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
/// Refuse to probe the port the live session currently occupies (the
|
||||||
/// it — powers the hot-swap "device detected" modal's current-details
|
/// probe would steal the serial port from under the session); a
|
||||||
/// view. Refuses to probe the port the live session currently occupies
|
|
||||||
/// (the probe would steal the serial port from under the session); a
|
|
||||||
/// detected-but-not-connected port is fair game, accepting a benign race
|
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||||
/// with the reconnect loop (whichever loses just retries).
|
/// with the reconnect loop (whichever loses just retries). Split out from
|
||||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
/// the actual probe on purpose — see `probe_device`'s doc comment.
|
||||||
|
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
|
||||||
let status = self.state.status.read().await;
|
let status = self.state.status.read().await;
|
||||||
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
||||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||||
}
|
}
|
||||||
drop(status);
|
Ok(())
|
||||||
listener::probe_device(path).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get message history.
|
/// Get message history.
|
||||||
|
|||||||
@ -353,6 +353,28 @@ pub fn build_get_stats() -> Vec<u8> {
|
|||||||
|
|
||||||
// ─── Response parsers ───────────────────────────────────────────────────
|
// ─── Response parsers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decode a device/contact name from raw frame bytes, defensively.
|
||||||
|
///
|
||||||
|
/// Name fields sit at firmware-version-dependent offsets; when an offset
|
||||||
|
/// lands inside binary data (path/pubkey bytes), `from_utf8_lossy` used to
|
||||||
|
/// hand the UI replacement-character soup ("<22>\u{618}…"). Strict rules: valid
|
||||||
|
/// UTF-8 up to the first NUL, no control characters, at least one
|
||||||
|
/// non-whitespace char — anything else gets the caller's readable fallback.
|
||||||
|
fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||||
|
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||||
|
match std::str::from_utf8(&bytes[..end]) {
|
||||||
|
Ok(s) => {
|
||||||
|
let s = s.trim();
|
||||||
|
if !s.is_empty() && s.chars().all(|c| !c.is_control()) {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
fallback.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => fallback.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||||
/// Returns firmware version string and device capabilities.
|
/// Returns firmware version string and device capabilities.
|
||||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||||
@ -384,15 +406,12 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
|||||||
|
|
||||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||||
|
|
||||||
// Name follows after fixed fields — find it by scanning for printable ASCII
|
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||||
|
// differs would put binary here — decode defensively so the settings
|
||||||
|
// panel never shows byte soup.
|
||||||
let name_start = 4;
|
let name_start = 4;
|
||||||
let name = if data.len() > name_start {
|
let name = if data.len() > name_start {
|
||||||
let name_end = data[name_start..]
|
decode_mesh_name(&data[name_start..], &format!("node-{node_id:08x}"))
|
||||||
.iter()
|
|
||||||
.position(|&b| b == 0)
|
|
||||||
.map(|p| name_start + p)
|
|
||||||
.unwrap_or(data.len());
|
|
||||||
String::from_utf8_lossy(&data[name_start..name_end]).to_string()
|
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@ -453,12 +472,11 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
|||||||
// name at data[99..131] (32 bytes)
|
// name at data[99..131] (32 bytes)
|
||||||
let name_start = 99.min(data.len());
|
let name_start = 99.min(data.len());
|
||||||
let name_end = (name_start + 32).min(data.len());
|
let name_end = (name_start + 32).min(data.len());
|
||||||
|
let short_id = format!("{}...", &public_key_hex[..8]);
|
||||||
let advert_name = if data.len() > name_start {
|
let advert_name = if data.len() > name_start {
|
||||||
String::from_utf8_lossy(&data[name_start..name_end])
|
decode_mesh_name(&data[name_start..name_end], &short_id)
|
||||||
.trim_end_matches('\0')
|
|
||||||
.to_string()
|
|
||||||
} else {
|
} else {
|
||||||
format!("{}...", &public_key_hex[..8])
|
short_id
|
||||||
};
|
};
|
||||||
|
|
||||||
// last_advert at data[131..135]
|
// last_advert at data[131..135]
|
||||||
|
|||||||
@ -556,14 +556,38 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
|||||||
|
|
||||||
/// Scan for serial devices that could be Meshcore radios.
|
/// Scan for serial devices that could be Meshcore radios.
|
||||||
/// Returns paths to existing serial device files.
|
/// Returns paths to existing serial device files.
|
||||||
|
///
|
||||||
|
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
|
||||||
|
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
|
||||||
|
/// primary radio currently enumerates as, so both names always pointed at
|
||||||
|
/// the same candidate list entry and both passed this scan — confirmed live
|
||||||
|
/// 2026-07-23, this made an already-connected, working radio (connected via
|
||||||
|
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
|
||||||
|
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
|
||||||
|
/// hot-swap UI's active-session guard compares path strings, so it didn't
|
||||||
|
/// recognize the two aliases as the same port, showed the "device detected"
|
||||||
|
/// modal for a radio that was already set up, and probing it there opened
|
||||||
|
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
|
||||||
|
/// with — a continuous, UI-driven reset loop that only ran while that view
|
||||||
|
/// was open (matches the reported "stops when I leave, resumes when I come
|
||||||
|
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
|
||||||
|
/// dedup and is what's reported when both alias and target are present.
|
||||||
pub async fn detect_serial_devices() -> Vec<String> {
|
pub async fn detect_serial_devices() -> Vec<String> {
|
||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
|
let mut seen_real_paths = std::collections::HashSet::new();
|
||||||
for path in SERIAL_CANDIDATES {
|
for path in SERIAL_CANDIDATES {
|
||||||
if tokio::fs::metadata(path).await.is_ok() {
|
if tokio::fs::metadata(path).await.is_ok() {
|
||||||
if likely_non_mesh_serial_device(path) {
|
if likely_non_mesh_serial_device(path) {
|
||||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let real_path = tokio::fs::canonicalize(path)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||||
|
if !seen_real_paths.insert(real_path.clone()) {
|
||||||
|
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
devices.push(path.to_string());
|
devices.push(path.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -581,6 +605,12 @@ pub struct DetectedDeviceInfo {
|
|||||||
pub pid: Option<String>,
|
pub pid: Option<String>,
|
||||||
pub product: Option<String>,
|
pub product: Option<String>,
|
||||||
pub manufacturer: Option<String>,
|
pub manufacturer: Option<String>,
|
||||||
|
/// Unix epoch seconds of the /dev node's creation — udev recreates the
|
||||||
|
/// node on every plug, so this changes on each replug. The UI keys its
|
||||||
|
/// "Not Now" dismissals on (path, plugged_at): swapping a stick (or
|
||||||
|
/// unplug/replug faster than a status poll) invalidates old dismissals
|
||||||
|
/// and the setup modal fires again, per the hot-swap UX (2026-07-22).
|
||||||
|
pub plugged_at: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Like `detect_serial_devices`, but with USB metadata per port.
|
/// Like `detect_serial_devices`, but with USB metadata per port.
|
||||||
@ -588,12 +618,19 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for path in detect_serial_devices().await {
|
for path in detect_serial_devices().await {
|
||||||
let usb = usb_info_for_tty(&path).await;
|
let usb = usb_info_for_tty(&path).await;
|
||||||
|
let plugged_at = tokio::fs::metadata(&path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs());
|
||||||
out.push(DetectedDeviceInfo {
|
out.push(DetectedDeviceInfo {
|
||||||
path,
|
path,
|
||||||
vid: usb.0,
|
vid: usb.0,
|
||||||
pid: usb.1,
|
pid: usb.1,
|
||||||
product: usb.2,
|
product: usb.2,
|
||||||
manufacturer: usb.3,
|
manufacturer: usb.3,
|
||||||
|
plugged_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
@ -406,7 +406,10 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
|
|||||||
validate_onion(onion)?;
|
validate_onion(onion)?;
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health")
|
match crate::fips::dial::PeerRequest::new(fips_npub, onion, "/health")
|
||||||
.service(crate::settings::transport::PeerService::Messaging)
|
.service(crate::settings::transport::PeerService::Messaging)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
// 12s, not 30s: this is a liveness dot, not a data transfer. A Tor
|
||||||
|
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||||
|
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||||
|
.timeout(std::time::Duration::from_secs(12))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -307,9 +307,19 @@ async fn send_handshake_message(
|
|||||||
|
|
||||||
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
let builder = EventBuilder::new(Kind::EncryptedDirectMessage, encrypted)
|
||||||
.tag(Tag::public_key(recipient_pk));
|
.tag(Tag::public_key(recipient_pk));
|
||||||
let _ = client.send_event_builder(builder).await;
|
// Surface real delivery failures. Before 2026-07-22 this was `let _ =`,
|
||||||
|
// so a request no relay accepted still reported ok:true to the UI and
|
||||||
|
// the sender believed it was delivered.
|
||||||
|
let send = client.send_event_builder(builder).await;
|
||||||
client.disconnect().await;
|
client.disconnect().await;
|
||||||
Ok(())
|
match send {
|
||||||
|
Ok(output) if !output.success.is_empty() => Ok(()),
|
||||||
|
Ok(output) => anyhow::bail!(
|
||||||
|
"no relay accepted the handshake event (tried {}, all failed)",
|
||||||
|
output.failed.len()
|
||||||
|
),
|
||||||
|
Err(e) => anyhow::bail!("failed to publish handshake event: {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
|
/// Send a `PeerRequest` to a discovered node's nostr pubkey. We never
|
||||||
|
|||||||
@ -47,6 +47,21 @@ const DEFAULT_RELAYS: &[&str] = &[
|
|||||||
"wss://relay.current.fyi",
|
"wss://relay.current.fyi",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The union of the boot-config relay list and the user-managed (enabled)
|
||||||
|
/// relays — the set every nostr-facing operation should use, so senders and
|
||||||
|
/// receivers always overlap regardless of which list the operator edited.
|
||||||
|
pub async fn merged_relay_list(data_dir: &Path, config_relays: &[String]) -> Vec<String> {
|
||||||
|
let mut relays: Vec<String> = config_relays.to_vec();
|
||||||
|
if let Ok(store) = load_relays(data_dir).await {
|
||||||
|
for r in store.relays {
|
||||||
|
if r.enabled && !relays.contains(&r.url) {
|
||||||
|
relays.push(r.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
relays
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
|
pub async fn load_relays(data_dir: &Path) -> Result<RelayStore> {
|
||||||
let path = data_dir.join(RELAYS_FILE);
|
let path = data_dir.join(RELAYS_FILE);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
|
|||||||
@ -106,6 +106,9 @@ impl Server {
|
|||||||
// seconds of hitting the mempool (works whenever LND is up; retries
|
// seconds of hitting the mempool (works whenever LND is up; retries
|
||||||
// forever otherwise). User req 2026-07-22.
|
// forever otherwise). User req 2026-07-22.
|
||||||
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
|
crate::api::rpc::lnd::spawn_lnd_tx_watcher(state_manager.clone());
|
||||||
|
// LND wedge watchdog — self-heal the silent "RPC up, server never
|
||||||
|
// ready" state instead of waiting for a human (100%-uptime req).
|
||||||
|
crate::api::rpc::lnd::spawn_lnd_health_watchdog();
|
||||||
|
|
||||||
// Retry Tor address in background — Tor may not be ready at startup
|
// Retry Tor address in background — Tor may not be ready at startup
|
||||||
if data.server_info.tor_address.is_none() {
|
if data.server_info.tor_address.is_none() {
|
||||||
@ -209,9 +212,15 @@ impl Server {
|
|||||||
let did =
|
let did =
|
||||||
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||||
let version = data.server_info.version.clone();
|
let version = data.server_info.version.clone();
|
||||||
let relays = config.nostr_relays.clone();
|
// Merged relay set (config + user-managed) — publish presence
|
||||||
|
// where handshake peers actually read (2026-07-22 unification).
|
||||||
|
let data_dir_for_relays = config.data_dir.clone();
|
||||||
|
let config_relays = config.nostr_relays.clone();
|
||||||
let tor_proxy = config.nostr_tor_proxy.clone();
|
let tor_proxy = config.nostr_tor_proxy.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let relays =
|
||||||
|
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
|
||||||
|
.await;
|
||||||
if let Err(e) = nostr_handshake::publish_presence(
|
if let Err(e) = nostr_handshake::publish_presence(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
&did,
|
&did,
|
||||||
@ -253,6 +262,23 @@ impl Server {
|
|||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Background handshake poll: fetch inbound nostr peer requests every
|
||||||
|
// 5 minutes instead of only when a user presses the Federation Poll
|
||||||
|
// button (requests used to sit on relays unseen — 2026-07-22). The
|
||||||
|
// handler's own discoverability gate makes this a no-op until the
|
||||||
|
// user opts in.
|
||||||
|
{
|
||||||
|
let rpc = api_handler.rpc_handler().clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(300));
|
||||||
|
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
rpc.background_handshake_poll().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize mesh networking service (if config has enabled: true)
|
// Initialize mesh networking service (if config has enabled: true)
|
||||||
{
|
{
|
||||||
let data_dir = config.data_dir.clone();
|
let data_dir = config.data_dir.clone();
|
||||||
|
|||||||
@ -3,6 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<!-- Must agree with the embedding neode-ui shell (color-scheme: dark):
|
||||||
|
browsers only honor a transparent same-origin iframe canvas when
|
||||||
|
embedder and iframe use the SAME color-scheme — without this, the
|
||||||
|
shell's :root{color-scheme:dark} forces this frame opaque and the
|
||||||
|
background art behind the chat vanishes (2026-07-23). -->
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
|||||||
56
docs/HANDOFF-2026-07-23-companion-apk-deploy.md
Normal file
56
docs/HANDOFF-2026-07-23-companion-apk-deploy.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# HANDOFF — deploy companion APK 0.5.1 (vc21) to nodes
|
||||||
|
|
||||||
|
**For: the agent on archi-dev-box.** User-reported failure this evening:
|
||||||
|
pairing flow on Framework PT — downloaded the companion from the node's
|
||||||
|
QR, then the pairing scan didn't work. The APK the node serves predates
|
||||||
|
today's scanner fixes; the pipeline below gets the fixed build into the
|
||||||
|
user's hands.
|
||||||
|
|
||||||
|
## What changed on main today (all merged)
|
||||||
|
|
||||||
|
- **Pairing-scanner fix** (`QrScannerOverlay.kt`): ZXing decode attempts are
|
||||||
|
frame-gated (~7/s, was every frame — the CPU contention made the preview
|
||||||
|
stutter badly enough to never decode) and PreviewView uses TextureView (no
|
||||||
|
more black flash on open). This is the likely fix for "doesn't scan".
|
||||||
|
- Three-finger menu gesture (was two-finger, collided with scroll) + one-time
|
||||||
|
teaching overlay ~2 min after login.
|
||||||
|
- Native wallet QR scanner behind `window.ArchipelagoQr` + WebView file-chooser
|
||||||
|
support; web scan modal hands live scanning to it.
|
||||||
|
- npub-keyed saved servers (pairing contract item 1, PR #106).
|
||||||
|
- Served APK refreshed: `neode-ui/public/packages/archipelago-companion.apk`
|
||||||
|
is now **0.5.3 / versionCode 23**. On top of the 0.5.1 scanner fixes it
|
||||||
|
guarantees dual-path peering — the node's LAN endpoint (direct p2p, npub-
|
||||||
|
keyed dial hints) AND the Archipelago public anchor (vps2, baked into the
|
||||||
|
app so even an old node's QR can't leave the phone LAN-only) — and fixes
|
||||||
|
the two field failures from the user's 5G test (screenshots, 21:54):
|
||||||
|
- **Mesh VPN no longer kills the phone's internet** — the IPv6-only TUN
|
||||||
|
never called `allowFamily(AF_INET)`, so Android blocked all IPv4 while
|
||||||
|
the mesh was up. Now allowed (+ `allowBypass`).
|
||||||
|
- **Off-LAN connect works** — `connect()` no longer hard-fails when the
|
||||||
|
scanned LAN IP doesn't answer; it brings the mesh up and probes the
|
||||||
|
node's ULA (`meshIp`) with retries before reporting failure.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
1. Redeploy the web-ui bundle from current main to the active nodes —
|
||||||
|
web root `/opt/archipelago/web-ui/` (NOT a neode-ui/ subfolder), all
|
||||||
|
nodes the user pairs against, at minimum the one Framework PT scans.
|
||||||
|
2. Verify the served artifact really updated:
|
||||||
|
`curl -sI http://<node>/packages/archipelago-companion.apk` — size should
|
||||||
|
change (~27 MB build of 2026-07-23), or pull it and check
|
||||||
|
`aapt dump badging` shows `versionCode='21' versionName='0.5.1'`.
|
||||||
|
3. The demo stack gets its images from CI (run 100 pushed today with the new
|
||||||
|
web bundle) — confirm the Portainer stack re-pulled, or trigger its
|
||||||
|
redeploy, so the demo QR also serves vc21.
|
||||||
|
4. **Node side is half the 5G story**: away-from-home reachability needs the
|
||||||
|
NODE connected to the public anchor too. On Framework PT (and any test
|
||||||
|
node): deploy current main (node-side npub-first `fips.pair-info`), then
|
||||||
|
verify `sudo -n fipsctl show status` reports the anchor connected —
|
||||||
|
`fips.reconnect` RPC if not. A phone can dial the anchor perfectly and
|
||||||
|
still fail if the node never enrolled with it.
|
||||||
|
5. Re-test the user's exact flows with vc23 (updates any older install in
|
||||||
|
place): (a) pair ON the LAN, then switch the phone to 5G — the UI must
|
||||||
|
come up via the mesh ULA; (b) pair while ALREADY on 5G (never on the
|
||||||
|
node's LAN) — scan, VPN consent, and the connect must succeed through
|
||||||
|
the anchor. If either fails, capture `adb logcat` around the attempt and
|
||||||
|
`fipsctl show sessions` on the node, and report back.
|
||||||
@ -38,7 +38,7 @@ Query parameters:
|
|||||||
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
|
| `fip` | no | Node's `fips0` ULA (IPv6). Once the phone is meshed, the node's UI stays reachable at `http://[<fip>]` from anywhere — this is the remote-access address (replaces the old WireGuard 10.44.0.1 flow). |
|
||||||
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
|
| `fhost` | no | Host the phone's embedded FIPS dials (same host `url` resolved to). |
|
||||||
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
|
| `fudp` / `ftcp` | no | Mesh transport ports on `fhost` (currently 2121/udp and 8443/tcp). |
|
||||||
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors (the node's seed-anchor list, capped at 4). The phone peers with these too so it can route to the node via the public mesh when the direct endpoint is unreachable (away from home / NAT). |
|
| `fanchors` | no | Comma-joined `npub@host:port/transport` rendezvous anchors, capped at 4. **The FIRST entry is the paired node itself** (`fnpub` at its current LAN host — the addr is a dial *hint*, the npub is the identity). The remaining entries are the node's seed anchors, and the node guarantees the Archipelago public anchor (vps2, `146.59.87.168:8444/tcp`) is present even if the operator trimmed their own list — so the phone can always rendezvous through the public mesh when the LAN endpoint is unreachable (away from home / NAT). |
|
||||||
|
|
||||||
Examples the web UI actually emits:
|
Examples the web UI actually emits:
|
||||||
|
|
||||||
@ -87,6 +87,38 @@ Examples the web UI actually emits:
|
|||||||
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
|
remote access = the node's fips0 ULA (`fip`), which the WebView falls back to
|
||||||
automatically when the LAN address stops answering.
|
automatically when the LAN address stops answering.
|
||||||
|
|
||||||
|
## npub-first connectivity (2026-07-23 — REQUIRED app-side changes)
|
||||||
|
|
||||||
|
The QR used to be effectively IP-first: the app connected to `url`/`fhost` and
|
||||||
|
broke as soon as the LAN renumbered or the phone left home. FIPS peers on
|
||||||
|
**npubs**; IPs are only dial hints. The app must treat them that way:
|
||||||
|
|
||||||
|
1. **Identity = `fnpub`.** The saved server entry is keyed by the node's npub
|
||||||
|
(fall back to origin only when the QR has no FIPS params). Re-scanning the
|
||||||
|
same npub updates the entry even if every address changed.
|
||||||
|
2. **Peer with the node itself as the first anchor** (`fanchors[0]`): on LAN
|
||||||
|
this is direct p2p over FIPS (mDNS/known-endpoint dial via archy-fips-core
|
||||||
|
v0.4+, which discovers LAN peers without any IP pinning), so it keeps
|
||||||
|
working after DHCP renumbering.
|
||||||
|
3. **Peer with the public anchors too** (remaining `fanchors` entries — the
|
||||||
|
Archipelago vps2 anchor is always included by the node): away from LAN the
|
||||||
|
phone routes to the node's npub via the public mesh and reaches the UI at
|
||||||
|
`http://[<fip>]` (the fips0 ULA).
|
||||||
|
4. **Address selection order** for the WebView: LAN `url` when it answers →
|
||||||
|
ULA `fip` over the mesh otherwise. Never hard-fail because the scanned LAN
|
||||||
|
IP stopped existing.
|
||||||
|
|
||||||
|
(Node-side counterpart shipped 2026-07-23: `fips.pair-info` always includes
|
||||||
|
the vps2 public anchor, and the web UI prepends the node's self-anchor to
|
||||||
|
`fanchors`.)
|
||||||
|
|
||||||
|
App-side status: items 2/3 were already covered by `FipsPreferences.
|
||||||
|
upsertNodePeer` (npub-matched peers, self-anchor dedup) and item 4 by the
|
||||||
|
WebView's `meshFallbackUrl` retry. Item 1 shipped 2026-07-23: `ServerEntry`
|
||||||
|
carries `npub` (trailing serialization field, legacy entries still parse) and
|
||||||
|
`ServerPreferences` matches saved/active entries via `sameNode` — npub first,
|
||||||
|
address/port/scheme only as the LAN-only fallback.
|
||||||
|
|
||||||
## Testing checklist (app side)
|
## Testing checklist (app side)
|
||||||
|
|
||||||
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
|
||||||
|
|||||||
84
docs/tv-input-iframe-apps.md
Normal file
84
docs/tv-input-iframe-apps.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# TV input: keyboard/gamepad inside iframe apps — design
|
||||||
|
|
||||||
|
**Goal (2026-07-23, user requirement):** on a TV/kiosk node, keyboard and
|
||||||
|
gamepad control must work *inside iframe apps* (IndeeHub, Jellyfin, fedimint
|
||||||
|
UI, AIUI…), easily and globally — no per-app hacks.
|
||||||
|
|
||||||
|
## What already exists
|
||||||
|
|
||||||
|
- `neode-ui/src/composables/useControllerNav.ts` — a complete spatial-nav
|
||||||
|
system for the shell: reads gamepads (`navigator.getGamepads`), moves focus
|
||||||
|
between `data-controller-container` regions, plays nav sounds. It stops at
|
||||||
|
iframe boundaries: nothing is forwarded into frames.
|
||||||
|
- Keyboard focus DOES enter iframes natively (click/Tab into the frame), and a
|
||||||
|
focused iframe receives all keys — same- or cross-origin. The gap is
|
||||||
|
gamepad→app and deliberate focus handoff shell↔frame.
|
||||||
|
- Kiosk Chromium is our process (archipelago-kiosk-launcher), X11, and the ISO
|
||||||
|
already ships `xdotool`. Most app iframes are **cross-origin**
|
||||||
|
(`http://host:port`), so shell-side script injection is impossible for them;
|
||||||
|
only the nginx-proxied `/app/...` ones are same-origin.
|
||||||
|
|
||||||
|
## Recommended architecture — two layers, both global
|
||||||
|
|
||||||
|
### Layer 1 (OS, kiosk nodes): gamepad → virtual keyboard, kernel-level
|
||||||
|
|
||||||
|
A small host daemon (`archipelago-gamepad-keys`) on kiosk nodes:
|
||||||
|
|
||||||
|
- reads game controllers via evdev (`/dev/input/event*`, capability
|
||||||
|
BTN_GAMEPAD), hotplug-aware (udev monitor or 5s rescan — same pattern as the
|
||||||
|
audio router);
|
||||||
|
- emits a **uinput virtual keyboard**: D-pad/left-stick → arrow keys, A →
|
||||||
|
Enter, B → Escape, X → Space (play/pause), Y → `f` (fullscreen in most
|
||||||
|
players), shoulders → Tab / Shift+Tab, Start → Enter, Select → Escape;
|
||||||
|
- ships exactly like the audio router: `image-recipe/configs/` script + unit,
|
||||||
|
spliced into the ISO, `include_str!` self-heal in `bootstrap.rs`, gated on
|
||||||
|
the kiosk being installed. The `archipelago` user is in `input` group OR the
|
||||||
|
unit runs as root (uinput needs it anyway — run as root, it's ~100 lines of
|
||||||
|
evdev→uinput with no network).
|
||||||
|
|
||||||
|
Why this layer wins: the browser sees a real keyboard, so **every iframe —
|
||||||
|
any origin, any app — just works** the way it does for a physical keyboard
|
||||||
|
today. Video players, web games, AIUI: all of them already have keyboard
|
||||||
|
bindings. Zero app cooperation, zero web-platform security fights.
|
||||||
|
|
||||||
|
### Layer 2 (shell): deliberate focus handoff into/out of frames
|
||||||
|
|
||||||
|
Small extension to `useControllerNav`:
|
||||||
|
|
||||||
|
- When spatial nav selects an app-session container and the user presses
|
||||||
|
A/Enter: call `iframe.focus()` (works cross-origin) — keys (real or
|
||||||
|
virtual) now flow into the app.
|
||||||
|
- A dedicated **exit chord** the daemon maps from the gamepad (e.g. Home
|
||||||
|
button → F12 or a rarely-used key): the shell listens with a *capturing*
|
||||||
|
window listener; on seeing it, `iframe.blur()` + return focus to the shell
|
||||||
|
nav. Keyboard users get the same via a documented chord (e.g. long
|
||||||
|
Escape / Ctrl+Escape — plain Escape stays with the app, players use it).
|
||||||
|
- Same-origin frames (the `/app/...` proxied set) can additionally get the
|
||||||
|
full spatial-nav treatment by running the existing nav over
|
||||||
|
`iframe.contentDocument` — nice-to-have after the layers above land.
|
||||||
|
|
||||||
|
### Optional layer 3 (per-app polish): postMessage contract
|
||||||
|
|
||||||
|
For OUR app UIs only (AIUI, fedimint, launcher pages): a tiny
|
||||||
|
`archipelago:input` postMessage contract for semantic actions (back, home,
|
||||||
|
context-menu) where raw keys aren't expressive enough. Documented in the app
|
||||||
|
packaging docs; never required for an app to be usable.
|
||||||
|
|
||||||
|
## What NOT to do
|
||||||
|
|
||||||
|
- ❌ CDP (`--remote-debugging-port` + Input.dispatchKeyEvent): works but adds
|
||||||
|
a privileged debug port to the kiosk and a daemon↔browser coupling; the
|
||||||
|
uinput route gets the same result at kernel level with no attack surface.
|
||||||
|
- ❌ Per-app nav scripts injected into iframes: cross-origin makes this
|
||||||
|
impossible for most apps, and it's exactly the per-app hack the requirement
|
||||||
|
rules out.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. `archipelago-gamepad-keys` daemon (evdev→uinput, ~python3 stdlib or small
|
||||||
|
Rust bin) + unit + ISO splice + bootstrap self-heal. Test on Framework PT
|
||||||
|
with any USB/BT controller.
|
||||||
|
2. `useControllerNav`: A-button → `iframe.focus()` on the focused app session;
|
||||||
|
exit-chord capture listener to reclaim focus.
|
||||||
|
3. (Later) same-origin spatial nav inside `/app/...` frames; postMessage
|
||||||
|
contract for our own app UIs.
|
||||||
@ -387,6 +387,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
|||||||
xorg \
|
xorg \
|
||||||
xdotool \
|
xdotool \
|
||||||
chromium \
|
chromium \
|
||||||
|
pipewire \
|
||||||
|
pipewire-pulse \
|
||||||
|
pipewire-alsa \
|
||||||
|
wireplumber \
|
||||||
|
alsa-utils \
|
||||||
unclutter \
|
unclutter \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
fonts-noto-color-emoji \
|
fonts-noto-color-emoji \
|
||||||
@ -425,7 +430,10 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
|||||||
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
|
||||||
|
|
||||||
# Create archipelago user with password "archipelago"
|
# Create archipelago user with password "archipelago"
|
||||||
RUN useradd -m -s /bin/bash -G sudo,dialout archipelago && \
|
# audio group: PipeWire runs under the lingering user manager (no logind seat
|
||||||
|
# session), so udev's seat ACLs on /dev/snd never apply — group access is the
|
||||||
|
# only way the kiosk's audio can open the hardware.
|
||||||
|
RUN useradd -m -s /bin/bash -G sudo,dialout,audio archipelago && \
|
||||||
echo "archipelago:archipelago" | chpasswd && \
|
echo "archipelago:archipelago" | chpasswd && \
|
||||||
echo "root:archipelago" | chpasswd && \
|
echo "root:archipelago" | chpasswd && \
|
||||||
echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago
|
echo "archipelago ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/archipelago
|
||||||
@ -1162,6 +1170,20 @@ if [ "$BACKEND_CAPTURED" = "0" ] && [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Bundle the Reticulum RNode daemon alongside the backend. install-to-disk
|
||||||
|
# copies everything in archipelago/bin/ to /usr/local/bin, and the mesh
|
||||||
|
# listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios —
|
||||||
|
# a node imaged without it can never connect a Reticulum stick
|
||||||
|
# (framework-pt, 2026-07-22: silent connect failures until hand-copied).
|
||||||
|
RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}"
|
||||||
|
if [ -f "$RETICULUM_DAEMON" ]; then
|
||||||
|
cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon"
|
||||||
|
chmod +x "$ARCH_DIR/bin/archy-reticulum-daemon"
|
||||||
|
echo " ✅ Reticulum daemon bundled ($(du -h "$ARCH_DIR/bin/archy-reticulum-daemon" | cut -f1))"
|
||||||
|
else
|
||||||
|
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||||
echo " ⚠️ Could not capture from live server, building from source..."
|
echo " ⚠️ Could not capture from live server, building from source..."
|
||||||
@ -2867,6 +2889,62 @@ fi
|
|||||||
echo "KIOSKSVC"
|
echo "KIOSKSVC"
|
||||||
} >> "$ARCH_DIR/auto-install.sh"
|
} >> "$ARCH_DIR/auto-install.sh"
|
||||||
|
|
||||||
|
# Audio router: same splice-from-configs/ pattern as the kiosk (single source
|
||||||
|
# of truth, also embedded in the binary for bootstrap self-heal). Keeps HDMI
|
||||||
|
# audio working: profile/default-sink routing + the ELD boot-race re-modeset.
|
||||||
|
AUDIO_ROUTER_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.sh"
|
||||||
|
AUDIO_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-audio-router.service"
|
||||||
|
for _audio_src in "$AUDIO_ROUTER_SRC" "$AUDIO_SERVICE_SRC"; do
|
||||||
|
if [ ! -f "$_audio_src" ]; then
|
||||||
|
echo "ERROR: audio config file missing: $_audio_src" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if grep -qx 'AUDIOROUTER' "$AUDIO_ROUTER_SRC" || grep -qx 'AUDIOSVC' "$AUDIO_SERVICE_SRC"; then
|
||||||
|
echo "ERROR: audio config contains a reserved heredoc terminator line (AUDIOROUTER/AUDIOSVC)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Audio router — HDMI profile/default-sink follow + ELD boot-race heal."
|
||||||
|
echo "# Payloads spliced at ISO-build time from image-recipe/configs/ (source of truth)."
|
||||||
|
echo "cat > /mnt/target/usr/local/bin/archipelago-audio-router <<'AUDIOROUTER'"
|
||||||
|
cat "$AUDIO_ROUTER_SRC"
|
||||||
|
echo "AUDIOROUTER"
|
||||||
|
echo "chmod +x /mnt/target/usr/local/bin/archipelago-audio-router"
|
||||||
|
echo ""
|
||||||
|
echo "cat > /mnt/target/etc/systemd/system/archipelago-audio-router.service <<'AUDIOSVC'"
|
||||||
|
cat "$AUDIO_SERVICE_SRC"
|
||||||
|
echo "AUDIOSVC"
|
||||||
|
} >> "$ARCH_DIR/auto-install.sh"
|
||||||
|
|
||||||
|
# Gamepad->keyboard bridge: same pattern (configs/ single source of truth,
|
||||||
|
# also self-healed via the binary). TV input for every app iframe.
|
||||||
|
GAMEPAD_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.py"
|
||||||
|
GAMEPAD_SERVICE_SRC="$SCRIPT_DIR/../configs/archipelago-gamepad-keys.service"
|
||||||
|
for _pad_src in "$GAMEPAD_SRC" "$GAMEPAD_SERVICE_SRC"; do
|
||||||
|
if [ ! -f "$_pad_src" ]; then
|
||||||
|
echo "ERROR: gamepad config file missing: $_pad_src" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if grep -qx 'GAMEPADPY' "$GAMEPAD_SRC" || grep -qx 'GAMEPADSVC' "$GAMEPAD_SERVICE_SRC"; then
|
||||||
|
echo "ERROR: gamepad config contains a reserved heredoc terminator line (GAMEPADPY/GAMEPADSVC)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "# Gamepad->keyboard bridge — controller input inside every app iframe on the TV."
|
||||||
|
echo "cat > /mnt/target/usr/local/bin/archipelago-gamepad-keys <<'GAMEPADPY'"
|
||||||
|
cat "$GAMEPAD_SRC"
|
||||||
|
echo "GAMEPADPY"
|
||||||
|
echo "chmod +x /mnt/target/usr/local/bin/archipelago-gamepad-keys"
|
||||||
|
echo ""
|
||||||
|
echo "cat > /mnt/target/etc/systemd/system/archipelago-gamepad-keys.service <<'GAMEPADSVC'"
|
||||||
|
cat "$GAMEPAD_SERVICE_SRC"
|
||||||
|
echo "GAMEPADSVC"
|
||||||
|
} >> "$ARCH_DIR/auto-install.sh"
|
||||||
|
|
||||||
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
|
cat >> "$ARCH_DIR/auto-install.sh" <<'INSTALLER_SCRIPT'
|
||||||
|
|
||||||
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
# Toggle script: sudo archipelago-kiosk enable|disable|status
|
||||||
@ -3246,6 +3324,8 @@ chroot /mnt/target systemctl enable archipelago-first-boot-secrets.service 2>/de
|
|||||||
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
|
chroot /mnt/target systemctl enable archipelago-setup-tor.service 2>/dev/null || true
|
||||||
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
|
chroot /mnt/target systemctl enable archipelago-first-boot-containers.service 2>/dev/null || true
|
||||||
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
|
chroot /mnt/target systemctl enable archipelago-kiosk.service 2>/dev/null || true
|
||||||
|
chroot /mnt/target systemctl enable archipelago-audio-router.service 2>/dev/null || true
|
||||||
|
chroot /mnt/target systemctl enable archipelago-gamepad-keys.service 2>/dev/null || true
|
||||||
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
|
chroot /mnt/target systemctl enable nostr-vpn.service 2>/dev/null || true
|
||||||
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
|
# Enable claude-api-proxy (create symlink manually — chroot systemctl can fail)
|
||||||
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \
|
chroot /mnt/target systemctl enable claude-api-proxy.service 2>/dev/null || \
|
||||||
|
|||||||
20
image-recipe/configs/archipelago-audio-router.service
Normal file
20
image-recipe/configs/archipelago-audio-router.service
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal)
|
||||||
|
# Talks to the archipelago user's PipeWire (running under the lingering user
|
||||||
|
# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start
|
||||||
|
# after both are plausibly up. Missing/inactive units here are harmless.
|
||||||
|
After=user@1000.service archipelago-kiosk.service
|
||||||
|
Wants=user@1000.service
|
||||||
|
ConditionPathExists=/usr/local/bin/archipelago-audio-router
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=archipelago
|
||||||
|
ExecStart=/usr/local/bin/archipelago-audio-router
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
# Polls a few pactl calls every 5s — keep it invisible to the scheduler.
|
||||||
|
Nice=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
145
image-recipe/configs/archipelago-audio-router.sh
Normal file
145
image-recipe/configs/archipelago-audio-router.sh
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Archipelago audio router — keep audio flowing out of the display the user is
|
||||||
|
# actually looking at.
|
||||||
|
#
|
||||||
|
# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock
|
||||||
|
# profile priorities rank the laptop's analog output above HDMI, so a node
|
||||||
|
# driving a TV plays video sound out of its own tiny speakers (or nowhere).
|
||||||
|
# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening
|
||||||
|
# monitor; this daemon polls that via the card's profile availability and:
|
||||||
|
#
|
||||||
|
# - switches the card to the best *available* HDMI stereo profile (the
|
||||||
|
# "+input:analog-stereo" combined variant when offered, so the mic keeps
|
||||||
|
# working), and back to analog when HDMI is unplugged;
|
||||||
|
# - keeps the default sink pointed at the routed output and migrates any
|
||||||
|
# live streams so playback follows a hot-plug without a page reload;
|
||||||
|
# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns
|
||||||
|
# the real volume control; analog volume is left where the user set it).
|
||||||
|
#
|
||||||
|
# Runs as the archipelago user (systemd system unit with User=archipelago),
|
||||||
|
# talks only to the user-session PipeWire; polling is a few pactl calls every
|
||||||
|
# 5s — negligible. Surround profiles are deliberately ignored: stereo is the
|
||||||
|
# lowest-common-denominator every TV decodes.
|
||||||
|
|
||||||
|
# - re-modesets an external output once when it is connected but no ELD
|
||||||
|
# reports a monitor: the kiosk's boot-time Xorg modeset can beat the
|
||||||
|
# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI
|
||||||
|
# profile stays "available: no" forever (no sound, no error). One
|
||||||
|
# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV).
|
||||||
|
|
||||||
|
RUNTIME_DIR="/run/user/$(id -u)"
|
||||||
|
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}"
|
||||||
|
export DISPLAY="${DISPLAY:-:0}"
|
||||||
|
|
||||||
|
# The user manager socket-activates pipewire, but after a live package install
|
||||||
|
# (bootstrap self-heal) nothing has poked it yet — start best-effort.
|
||||||
|
systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true
|
||||||
|
systemctl --user start wireplumber.service 2>/dev/null || true
|
||||||
|
|
||||||
|
LAST_SINK=""
|
||||||
|
NUDGED_OUTPUTS=""
|
||||||
|
|
||||||
|
# Re-deliver a lost ELD by cycling the connected external output once. Guarded:
|
||||||
|
# only when X answers, only when NO ELD anywhere reports a monitor, and only
|
||||||
|
# once per connector while it stays connected (a display with no audio support
|
||||||
|
# never produces an ELD — without the flag we would blank it every pass).
|
||||||
|
eld_nudge_once() {
|
||||||
|
# Any ELD already valid → audio path is live; reset the nudge memory.
|
||||||
|
if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then
|
||||||
|
NUDGED_OUTPUTS=""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local xrandr_out conn name output mode
|
||||||
|
xrandr_out=$(xrandr --query 2>/dev/null) || return 0
|
||||||
|
|
||||||
|
for conn in /sys/class/drm/card*-*/status; do
|
||||||
|
[ -e "$conn" ] || continue
|
||||||
|
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
|
||||||
|
name=${conn%/status}; name=${name##*/card?-}
|
||||||
|
case "$name" in eDP*|LVDS*) continue ;; esac
|
||||||
|
case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac
|
||||||
|
|
||||||
|
# DRM connector names match the modesetting driver's output names.
|
||||||
|
output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}')
|
||||||
|
[ -n "$output" ] || continue
|
||||||
|
|
||||||
|
# Keep the mode the kiosk chose (it may have capped a 4K panel);
|
||||||
|
# --auto only as a fallback.
|
||||||
|
mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" '
|
||||||
|
$1 == out { active = 1; next }
|
||||||
|
active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } }
|
||||||
|
active && /^[^[:space:]]/ { active = 0 }')
|
||||||
|
|
||||||
|
NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name"
|
||||||
|
xrandr --output "$output" --off 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
if [ -n "$mode" ]; then
|
||||||
|
xrandr --output "$output" --mode "$mode" 2>/dev/null \
|
||||||
|
|| xrandr --output "$output" --auto 2>/dev/null || true
|
||||||
|
else
|
||||||
|
xrandr --output "$output" --auto 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
route_once() {
|
||||||
|
local cards_dump card active want sink default_sink
|
||||||
|
cards_dump=$(pactl list cards 2>/dev/null) || return 0
|
||||||
|
[ -n "$cards_dump" ] || return 0
|
||||||
|
|
||||||
|
# One line per card: "<card>\t<active>\t<wanted-profile>"
|
||||||
|
# wanted = highest-priority available output:hdmi-stereo* profile, else
|
||||||
|
# highest-priority available output:analog* profile.
|
||||||
|
while IFS=$'\t' read -r card active want; do
|
||||||
|
[ -n "$card" ] && [ -n "$want" ] || continue
|
||||||
|
if [ "$active" != "$want" ]; then
|
||||||
|
pactl set-card-profile "$card" "$want" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done < <(printf '%s\n' "$cards_dump" | awk '
|
||||||
|
function flush() {
|
||||||
|
if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog)
|
||||||
|
card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1
|
||||||
|
}
|
||||||
|
/^Card #/ { flush() }
|
||||||
|
/^\tName: / { card=$2 }
|
||||||
|
/^\t\toutput:/ {
|
||||||
|
line=$0; sub(/^\t\t/, "", line)
|
||||||
|
prof=line; sub(/: .*/, "", prof)
|
||||||
|
prio=0
|
||||||
|
if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0
|
||||||
|
avail = (line ~ /available: yes/ || line ~ /availability unknown/)
|
||||||
|
if (!avail) next
|
||||||
|
if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } }
|
||||||
|
else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } }
|
||||||
|
}
|
||||||
|
/^\tActive Profile: / { active=$3 }
|
||||||
|
END { flush() }
|
||||||
|
')
|
||||||
|
|
||||||
|
# Point the default sink at HDMI when one exists, else the first sink.
|
||||||
|
sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}')
|
||||||
|
[ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}')
|
||||||
|
[ -n "$sink" ] || return 0
|
||||||
|
|
||||||
|
default_sink=$(pactl get-default-sink 2>/dev/null)
|
||||||
|
if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then
|
||||||
|
pactl set-default-sink "$sink" 2>/dev/null || true
|
||||||
|
pactl set-sink-mute "$sink" 0 2>/dev/null || true
|
||||||
|
case "$sink" in
|
||||||
|
*hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;;
|
||||||
|
esac
|
||||||
|
# Migrate live streams so playing audio follows the hot-plug.
|
||||||
|
pactl list short sink-inputs 2>/dev/null | while read -r id _; do
|
||||||
|
pactl move-sink-input "$id" "$sink" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
LAST_SINK="$sink"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
eld_nudge_once
|
||||||
|
route_once
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
223
image-recipe/configs/archipelago-gamepad-keys.py
Normal file
223
image-recipe/configs/archipelago-gamepad-keys.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Archipelago gamepad→keyboard bridge (kiosk nodes).
|
||||||
|
|
||||||
|
Reads every attached game controller via evdev and mirrors it as a virtual
|
||||||
|
uinput KEYBOARD, so gamepad input works in every app — including cross-origin
|
||||||
|
iframes (IndeeHub, Jellyfin, …) where the web shell can never inject events.
|
||||||
|
The browser just sees arrow/Enter/Escape keys from a real-looking keyboard;
|
||||||
|
X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md.
|
||||||
|
|
||||||
|
Mapping (standard pad):
|
||||||
|
D-pad / left stick -> arrow keys
|
||||||
|
A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape
|
||||||
|
X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen)
|
||||||
|
LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab
|
||||||
|
Start -> Enter Select -> Escape
|
||||||
|
|
||||||
|
*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f — either way
|
||||||
|
one is play/pause and one is fullscreen, which is fine for a TV.
|
||||||
|
|
||||||
|
Pure stdlib (struct/fcntl/select) — no python3-evdev dependency on the node.
|
||||||
|
Runs as root (uinput + /dev/input need it); hotplug via 5s rescans.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ---- kernel constants ------------------------------------------------------
|
||||||
|
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
|
||||||
|
SYN_REPORT = 0
|
||||||
|
|
||||||
|
KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57
|
||||||
|
KEY_LEFTSHIFT, KEY_F = 42, 33
|
||||||
|
KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108
|
||||||
|
|
||||||
|
BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
|
||||||
|
BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B
|
||||||
|
BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223
|
||||||
|
|
||||||
|
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
|
||||||
|
|
||||||
|
EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range
|
||||||
|
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
|
||||||
|
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
|
||||||
|
|
||||||
|
INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value
|
||||||
|
|
||||||
|
BUTTON_MAP = {
|
||||||
|
BTN_SOUTH: (KEY_ENTER,),
|
||||||
|
BTN_EAST: (KEY_ESC,),
|
||||||
|
BTN_NORTH: (KEY_SPACE,),
|
||||||
|
BTN_WEST: (KEY_F,),
|
||||||
|
BTN_TL: (KEY_LEFTSHIFT, KEY_TAB),
|
||||||
|
BTN_TR: (KEY_TAB,),
|
||||||
|
BTN_START: (KEY_ENTER,),
|
||||||
|
BTN_SELECT: (KEY_ESC,),
|
||||||
|
BTN_DPAD_UP: (KEY_UP,),
|
||||||
|
BTN_DPAD_DOWN: (KEY_DOWN,),
|
||||||
|
BTN_DPAD_LEFT: (KEY_LEFT,),
|
||||||
|
BTN_DPAD_RIGHT: (KEY_RIGHT,),
|
||||||
|
}
|
||||||
|
EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys}
|
||||||
|
| {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT})
|
||||||
|
|
||||||
|
STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses"
|
||||||
|
|
||||||
|
|
||||||
|
def is_gamepad(fd) -> bool:
|
||||||
|
buf = bytearray(96)
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
def has(code):
|
||||||
|
return bool(buf[code // 8] & (1 << (code % 8)))
|
||||||
|
return has(BTN_SOUTH) or has(BTN_START)
|
||||||
|
|
||||||
|
|
||||||
|
class VirtualKeyboard:
|
||||||
|
def __init__(self):
|
||||||
|
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
|
||||||
|
for key in EMITTED_KEYS:
|
||||||
|
fcntl.ioctl(self.fd, UI_SET_KEYBIT, key)
|
||||||
|
# Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects
|
||||||
|
# + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel.
|
||||||
|
name = b"Archipelago Gamepad Keys"
|
||||||
|
setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0)
|
||||||
|
setup += b"\0" * (64 * 4 * 4)
|
||||||
|
os.write(self.fd, setup)
|
||||||
|
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||||||
|
|
||||||
|
def _emit(self, etype, code, value):
|
||||||
|
os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value))
|
||||||
|
|
||||||
|
def set_key(self, key, pressed):
|
||||||
|
self._emit(EV_KEY, key, 1 if pressed else 0)
|
||||||
|
self._emit(EV_SYN, SYN_REPORT, 0)
|
||||||
|
|
||||||
|
def chord(self, keys, pressed):
|
||||||
|
seq = keys if pressed else tuple(reversed(keys))
|
||||||
|
for k in seq:
|
||||||
|
self._emit(EV_KEY, k, 1 if pressed else 0)
|
||||||
|
self._emit(EV_SYN, SYN_REPORT, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class PadState:
|
||||||
|
"""Per-device axis state → synthetic arrow presses."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.axis_keys = {} # axis -> currently-pressed arrow key (or None)
|
||||||
|
self.abs_range = {} # axis -> (min, max) for sticks
|
||||||
|
|
||||||
|
def arrow_for(self, axis, value):
|
||||||
|
if axis in (ABS_HAT0X, ABS_HAT0Y):
|
||||||
|
if value < 0:
|
||||||
|
return KEY_LEFT if axis == ABS_HAT0X else KEY_UP
|
||||||
|
if value > 0:
|
||||||
|
return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN
|
||||||
|
return None
|
||||||
|
lo, hi = self.abs_range.get(axis, (-32768, 32767))
|
||||||
|
span = (hi - lo) or 1
|
||||||
|
norm = (2 * (value - lo) / span) - 1
|
||||||
|
if norm <= -STICK_THRESHOLD:
|
||||||
|
return KEY_LEFT if axis == ABS_X else KEY_UP
|
||||||
|
if norm >= STICK_THRESHOLD:
|
||||||
|
return KEY_RIGHT if axis == ABS_X else KEY_DOWN
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stick_range(fd, axis):
|
||||||
|
# EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res }
|
||||||
|
buf = bytearray(24)
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(fd, 0x80184540 + axis, buf)
|
||||||
|
_, lo, hi = struct.unpack("iii", bytes(buf[:12]))
|
||||||
|
if hi > lo:
|
||||||
|
return (lo, hi)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return (-32768, 32767)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.system("modprobe uinput 2>/dev/null")
|
||||||
|
kbd = VirtualKeyboard()
|
||||||
|
pads = {} # path -> (fd, PadState)
|
||||||
|
last_scan = 0.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - last_scan > 5:
|
||||||
|
last_scan = now
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir("/dev/input"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
names = []
|
||||||
|
for name in names:
|
||||||
|
if not name.startswith("event"):
|
||||||
|
continue
|
||||||
|
path = "/dev/input/" + name
|
||||||
|
if path in pads:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if is_gamepad(fd):
|
||||||
|
state = PadState()
|
||||||
|
for axis in (ABS_X, ABS_Y):
|
||||||
|
state.abs_range[axis] = stick_range(fd, axis)
|
||||||
|
pads[path] = (fd, state)
|
||||||
|
print(f"gamepad attached: {path}", flush=True)
|
||||||
|
else:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
if not pads:
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0)
|
||||||
|
gone = []
|
||||||
|
for path, (fd, state) in list(pads.items()):
|
||||||
|
if fd not in readable:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = os.read(fd, INPUT_EVENT.size * 64)
|
||||||
|
except OSError:
|
||||||
|
gone.append(path)
|
||||||
|
continue
|
||||||
|
for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
|
||||||
|
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
||||||
|
if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1):
|
||||||
|
keys = BUTTON_MAP[code]
|
||||||
|
if len(keys) == 1:
|
||||||
|
kbd.set_key(keys[0], value == 1)
|
||||||
|
else:
|
||||||
|
kbd.chord(keys, value == 1)
|
||||||
|
elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y):
|
||||||
|
want = state.arrow_for(code, value)
|
||||||
|
held = state.axis_keys.get(code)
|
||||||
|
if want != held:
|
||||||
|
if held is not None:
|
||||||
|
kbd.set_key(held, False)
|
||||||
|
if want is not None:
|
||||||
|
kbd.set_key(want, True)
|
||||||
|
state.axis_keys[code] = want
|
||||||
|
for path in gone:
|
||||||
|
fd, state = pads.pop(path)
|
||||||
|
for held in state.axis_keys.values():
|
||||||
|
if held is not None:
|
||||||
|
kbd.set_key(held, False)
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"gamepad detached: {path}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
16
image-recipe/configs/archipelago-gamepad-keys.service
Normal file
16
image-recipe/configs/archipelago-gamepad-keys.service
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Archipelago gamepad->keyboard bridge (TV/kiosk input in every app iframe)
|
||||||
|
# Only meaningful where a display UI runs; the kiosk unit is the marker.
|
||||||
|
ConditionPathExists=/etc/systemd/system/archipelago-kiosk.service
|
||||||
|
ConditionPathExists=/usr/local/bin/archipelago-gamepad-keys
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# Root: /dev/uinput device creation + raw /dev/input readers.
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/bin/archipelago-gamepad-keys
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
Nice=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@ -174,6 +174,9 @@ while true; do
|
|||||||
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
|
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
|
||||||
# falls back to raw ALSA "default", fails to connect, and produces no audio
|
# falls back to raw ALSA "default", fails to connect, and produces no audio
|
||||||
# at all with no visible error (--noerrdialogs suppresses it).
|
# at all with no visible error (--noerrdialogs suppresses it).
|
||||||
|
# OverlayScrollbar: thin auto-hiding scrollbars (the Chrome-on-a-remote-
|
||||||
|
# device look) instead of classic X11 scrollbar chrome — a kiosk TV showed
|
||||||
|
# a permanent fat scrollbar on scrollable views (Peers).
|
||||||
# Force a DARK color-scheme preference. The main UI hardcodes its dark
|
# Force a DARK color-scheme preference. The main UI hardcodes its dark
|
||||||
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
|
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
|
||||||
# and defaults to its LIGHT variant (white panels) when the browser reports
|
# and defaults to its LIGHT variant (white panels) when the browser reports
|
||||||
@ -191,6 +194,7 @@ while true; do
|
|||||||
--no-first-run \
|
--no-first-run \
|
||||||
--check-for-update-interval=31536000 \
|
--check-for-update-interval=31536000 \
|
||||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||||
|
--enable-features=OverlayScrollbar \
|
||||||
--disable-session-crashed-bubble \
|
--disable-session-crashed-bubble \
|
||||||
--disable-save-password-bubble \
|
--disable-save-password-bubble \
|
||||||
--disable-suggestions-service \
|
--disable-suggestions-service \
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<!-- Dark-only app: pin native controls dark before CSS loads. -->
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|||||||
Binary file not shown.
@ -89,19 +89,43 @@ class FileBrowserClient {
|
|||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Don't hammer app.filebrowser-token after a failed login — one attempt
|
||||||
|
* per cooldown window, so a broken/missing filebrowser doesn't turn every
|
||||||
|
* poll of the Files card into a fresh login + 401 pair (console spam). */
|
||||||
|
private _lastLoginFailure = 0
|
||||||
|
private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000
|
||||||
|
|
||||||
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
|
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
|
||||||
private async ensureAuth(): Promise<void> {
|
private async ensureAuth(): Promise<void> {
|
||||||
if (this._authenticated && this.getAuthCookie()) return
|
if (this._authenticated && this.getAuthCookie()) return
|
||||||
|
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
|
||||||
|
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||||
|
}
|
||||||
const ok = await this.login()
|
const ok = await this.login()
|
||||||
if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
if (!ok) {
|
||||||
|
this._lastLoginFailure = Date.now()
|
||||||
|
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fetch() with auth headers + ONE transparent re-login on 401. The JWT
|
||||||
|
* from app.filebrowser-token is short-lived; before this, an expired
|
||||||
|
* cookie kept `_authenticated` true and every Files-card poll 401'd
|
||||||
|
* forever (the console-spam bug, 2026-07-22). */
|
||||||
|
private async authedFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||||
|
await this.ensureAuth()
|
||||||
|
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
|
||||||
|
if (res.status === 401) {
|
||||||
|
this._authenticated = false
|
||||||
|
await this.ensureAuth()
|
||||||
|
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||||
await this.ensureAuth()
|
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
|
||||||
headers: this.headers(),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
|
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
|
||||||
// When File Browser isn't installed, nginx falls through to the SPA and
|
// When File Browser isn't installed, nginx falls through to the SPA and
|
||||||
// returns index.html (200, text/html); when it's down it returns 502.
|
// returns index.html (200, text/html); when it's down it returns 502.
|
||||||
@ -133,11 +157,8 @@ class FileBrowserClient {
|
|||||||
* For large files (video/audio), prefer streamUrl() instead.
|
* For large files (video/audio), prefer streamUrl() instead.
|
||||||
*/
|
*/
|
||||||
async fetchBlobUrl(path: string): Promise<string> {
|
async fetchBlobUrl(path: string): Promise<string> {
|
||||||
await this.ensureAuth()
|
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
|
||||||
headers: this.headers(),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
return URL.createObjectURL(blob)
|
return URL.createObjectURL(blob)
|
||||||
@ -171,17 +192,12 @@ class FileBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(dirPath: string, file: File): Promise<void> {
|
async upload(dirPath: string, file: File): Promise<void> {
|
||||||
await this.ensureAuth()
|
|
||||||
const sanitized = sanitizePath(dirPath)
|
const sanitized = sanitizePath(dirPath)
|
||||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||||
const encodedName = encodeURIComponent(file.name)
|
const encodedName = encodeURIComponent(file.name)
|
||||||
const res = await fetch(
|
const res = await this.authedFetch(
|
||||||
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
||||||
{
|
{ method: 'POST', body: file },
|
||||||
method: 'POST',
|
|
||||||
headers: this.headers(),
|
|
||||||
body: file,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
@ -190,35 +206,31 @@ class FileBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createFolder(parentPath: string, name: string): Promise<void> {
|
async createFolder(parentPath: string, name: string): Promise<void> {
|
||||||
await this.ensureAuth()
|
|
||||||
const sanitized = sanitizePath(parentPath)
|
const sanitized = sanitizePath(parentPath)
|
||||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.headers(),
|
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
|
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteItem(path: string): Promise<void> {
|
async deleteItem(path: string): Promise<void> {
|
||||||
await this.ensureAuth()
|
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: this.headers(),
|
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
|
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
|
||||||
if (!this._authenticated || !this.getAuthCookie()) {
|
let res: Response
|
||||||
const ok = await this.login()
|
try {
|
||||||
if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
res = await this.authedFetch(`${this.baseUrl}/api/resources/`)
|
||||||
|
} catch {
|
||||||
|
// Not installed / login cooling down — the Files card shows zeros.
|
||||||
|
return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||||
}
|
}
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources/`, {
|
|
||||||
headers: this.headers(),
|
|
||||||
})
|
|
||||||
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
|
||||||
const data: FileBrowserListResponse = await res.json()
|
const data: FileBrowserListResponse = await res.json()
|
||||||
const items = data.items || []
|
const items = data.items || []
|
||||||
@ -242,17 +254,11 @@ class FileBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
|
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
|
||||||
if (!this._authenticated || !this.getAuthCookie()) {
|
|
||||||
const ok = await this.login()
|
|
||||||
if (!ok) throw new Error('FileBrowser authentication failed')
|
|
||||||
}
|
|
||||||
if (!this.isTextFile(path)) {
|
if (!this.isTextFile(path)) {
|
||||||
throw new Error(`Cannot read binary file: ${path}`)
|
throw new Error(`Cannot read binary file: ${path}`)
|
||||||
}
|
}
|
||||||
const safePath = sanitizePath(path)
|
const safePath = sanitizePath(path)
|
||||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
|
||||||
headers: this.headers(),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
const size = blob.size
|
const size = blob.size
|
||||||
@ -266,12 +272,9 @@ class FileBrowserClient {
|
|||||||
const safePath = sanitizePath(oldPath)
|
const safePath = sanitizePath(oldPath)
|
||||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||||
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
||||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...this.headers(),
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
|
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
|
||||||
|
|||||||
@ -447,7 +447,8 @@ class RPCClient {
|
|||||||
return this.call({
|
return this.call({
|
||||||
method: 'node-check-peer',
|
method: 'node-check-peer',
|
||||||
params: { onion },
|
params: { onion },
|
||||||
timeout: 35000,
|
// Backend health-dial timeout is 12s — 15s here covers RPC overhead.
|
||||||
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -104,11 +104,11 @@ function handleClickOutside(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('pointerdown', handleClickOutside)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('pointerdown', handleClickOutside)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,16 @@
|
|||||||
@click.self="close"
|
@click.self="close"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
|
<!-- Column layout (2026-07-22 modal contract): title row and the
|
||||||
|
optional #header slot (tabs) stay pinned at the top, the #footer
|
||||||
|
slot (action buttons) stays pinned at the bottom, and ONLY the
|
||||||
|
default slot scrolls. Callers that previously made the whole
|
||||||
|
card scroll via contentClass keep working — the inner region
|
||||||
|
simply never lets the card overflow. -->
|
||||||
<div
|
<div
|
||||||
ref="modalRef"
|
ref="modalRef"
|
||||||
class="glass-card p-6 w-full relative z-10"
|
class="glass-card p-6 w-full relative z-10 flex flex-col"
|
||||||
:class="[maxWidth, contentClass]"
|
:class="[maxWidth, contentClass, defaultMaxH]"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@click.stop
|
@click.stop
|
||||||
@ -28,10 +34,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="$slots.header" class="shrink-0">
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||||
<slot />
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.footer" class="shrink-0 pt-4">
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@ -60,6 +73,13 @@ const emit = defineEmits<{
|
|||||||
const modalRef = ref<HTMLElement | null>(null)
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const zClass = computed(() => props.zIndex)
|
const zClass = computed(() => props.zIndex)
|
||||||
|
// The pinned-footer layout needs a height bound or tall content pushes the
|
||||||
|
// footer off-screen anyway. Callers that set their own max-h (e.g. the
|
||||||
|
// Transactions modal's visual-viewport calc on mobile) keep authority —
|
||||||
|
// adding a second max-h class would make the CSS winner order-dependent.
|
||||||
|
const defaultMaxH = computed(() =>
|
||||||
|
props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]'
|
||||||
|
)
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|||||||
@ -252,21 +252,39 @@ watch(visible, async (isVisible) => {
|
|||||||
})
|
})
|
||||||
}, { immediate: true, flush: 'post' })
|
}, { immediate: true, flush: 'post' })
|
||||||
|
|
||||||
|
/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */
|
||||||
|
function isTailnetIp(host: string): boolean {
|
||||||
|
const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
|
||||||
|
return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The server URL the companion app should connect to. The demo advertises its
|
* The server URL the companion app should connect to. The demo advertises its
|
||||||
* public https origin; a real node advertises whatever address the browser is
|
* public https origin; a real node advertises the browser's own origin ONLY
|
||||||
* already using — except on the kiosk, where that is localhost and useless to
|
* when a phone could plausibly reach it too. Two origins that a phone on the
|
||||||
* a phone, so fall back to the node's mDNS .local name.
|
* LAN can never dial get substituted with the node's real LAN address:
|
||||||
|
* - localhost/127.0.0.1 (the kiosk browses itself)
|
||||||
|
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
|
||||||
|
* QR carried one of these on 2026-07-22 and the companion sat there
|
||||||
|
* dialing an IP the phone had no route to)
|
||||||
*/
|
*/
|
||||||
async function resolveServerUrl(): Promise<string> {
|
async function resolveServerUrl(): Promise<string> {
|
||||||
if (IS_DEMO) return DEMO_SERVER_URL
|
if (IS_DEMO) return DEMO_SERVER_URL
|
||||||
const { hostname, origin } = window.location
|
const { hostname, origin } = window.location
|
||||||
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
|
const phoneUnreachable =
|
||||||
|
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
|
||||||
|
if (!phoneUnreachable) return origin
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
|
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
|
||||||
|
method: 'system.get-hostname',
|
||||||
|
})
|
||||||
|
// Prefer the LAN IP (phones resolve it without mDNS support — Android
|
||||||
|
// notoriously lacks .local); fall back to the mDNS name.
|
||||||
|
if (res?.lan_ip) return `http://${res.lan_ip}`
|
||||||
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
||||||
} catch {
|
} catch {
|
||||||
// RPC unavailable — fall through to the (localhost) origin
|
// RPC unavailable — fall through to the (unreachable) origin; the app
|
||||||
|
// still lets the user edit the address by hand.
|
||||||
}
|
}
|
||||||
return origin
|
return origin
|
||||||
}
|
}
|
||||||
@ -330,9 +348,24 @@ async function buildPairingUrl(): Promise<string> {
|
|||||||
if (info.udp_port) params.set('fudp', String(info.udp_port))
|
if (info.udp_port) params.set('fudp', String(info.udp_port))
|
||||||
if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
|
if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
|
||||||
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
|
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
|
||||||
// Cap keeps the QR at a camera-friendly density; the node lists its
|
// FIRST entry is the paired node ITSELF: the phone peers with it by
|
||||||
// most reachable anchors first.
|
// npub (the fhost addr is only a dial hint), so LAN contact is direct
|
||||||
const anchors = (info.anchors || []).slice(0, 4)
|
// p2p over FIPS and survives DHCP renumbering; the node's public
|
||||||
|
// anchors follow for reaching it away from home. Cap keeps the QR at a
|
||||||
|
// camera-friendly density; the node lists its most reachable anchors
|
||||||
|
// first.
|
||||||
|
const selfAnchor = info.tcp_port
|
||||||
|
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
|
||||||
|
: []
|
||||||
|
// HARD CAP at 2 anchors: every extra npub adds ~100 URL-encoded chars
|
||||||
|
// and pushed the QR past what phone cameras decode off a screen
|
||||||
|
// (3 anchors ≈ 600 chars ≈ QR v23 — observed unscannable 2026-07-23).
|
||||||
|
// Self + one public rendezvous is all the phone needs; prefer the
|
||||||
|
// Archipelago-operated vps2 anchor as the public one.
|
||||||
|
const others = (info.anchors || []).filter((a) => a.npub !== info.npub)
|
||||||
|
const publicAnchor =
|
||||||
|
others.find((a) => a.addr.startsWith('146.59.87.168')) ?? others[0]
|
||||||
|
const anchors = [...selfAnchor, ...(publicAnchor ? [publicAnchor] : [])].slice(0, 2)
|
||||||
if (anchors.length) {
|
if (anchors.length) {
|
||||||
params.set(
|
params.set(
|
||||||
'fanchors',
|
'fanchors',
|
||||||
@ -357,10 +390,13 @@ async function showPairScreen() {
|
|||||||
pairingUrl.value = await buildPairingUrl()
|
pairingUrl.value = await buildPairingUrl()
|
||||||
// Large source + a real quiet zone; this QR is scanned by the companion
|
// Large source + a real quiet zone; this QR is scanned by the companion
|
||||||
// app's camera, so give it every advantage (see download QR note above).
|
// app's camera, so give it every advantage (see download QR note above).
|
||||||
|
// EC level L: the payload is long (token + npub + anchors) and screen
|
||||||
|
// scans don't suffer the damage EC-M protects against — L drops the
|
||||||
|
// module count a full version tier, which is what makes it scannable.
|
||||||
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
|
||||||
width: 512,
|
width: 768,
|
||||||
margin: 3,
|
margin: 3,
|
||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'L',
|
||||||
color: {
|
color: {
|
||||||
dark: '#111111',
|
dark: '#111111',
|
||||||
light: '#ffffff',
|
light: '#ffffff',
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<!-- z-3600: this consent prompt can be triggered from INSIDE another
|
||||||
|
modal (e.g. Wallet Settings → Channels → funding tx), so it must sit
|
||||||
|
above the standard modal layer (3000) but below the app overlay (4000). -->
|
||||||
<BaseModal
|
<BaseModal
|
||||||
:show="!!explorer.pendingTx.value"
|
:show="!!explorer.pendingTx.value"
|
||||||
title="Open on an external explorer?"
|
title="Open on an external explorer?"
|
||||||
max-width="max-w-md"
|
max-width="max-w-md"
|
||||||
|
z-index="z-[3600]"
|
||||||
@close="explorer.cancelPending()"
|
@close="explorer.cancelPending()"
|
||||||
>
|
>
|
||||||
<!-- Same visual language as the uninstall keep-your-data warning: amber
|
<!-- Same visual language as the uninstall keep-your-data warning: amber
|
||||||
|
|||||||
@ -131,10 +131,10 @@ async function loadIdentities() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadIdentities()
|
loadIdentities()
|
||||||
document.addEventListener('click', onClickOutside)
|
document.addEventListener('pointerdown', onClickOutside)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', onClickOutside)
|
document.removeEventListener('pointerdown', onClickOutside)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -301,7 +301,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||||
|
|
||||||
defineProps<{ compact?: boolean }>()
|
defineProps<{ compact?: boolean }>()
|
||||||
|
|
||||||
@ -390,10 +390,14 @@ function fundingTxid(ch: Channel): string {
|
|||||||
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
|
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const txExplorer = useTxExplorer()
|
||||||
function openInMempool(txid: string) {
|
function openInMempool(txid: string) {
|
||||||
if (!txid) return
|
if (!txid) return
|
||||||
// Overlay the explorer above the current page — never navigate away.
|
// Same routing as every other tx link (missed in the first pass —
|
||||||
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
|
// 2026-07-22): local Mempool app when it's running, otherwise the saved
|
||||||
|
// external explorer, with the first-time consent modal setting it up and
|
||||||
|
// then opening the tx.
|
||||||
|
txExplorer.openTx(txid)
|
||||||
}
|
}
|
||||||
|
|
||||||
function capacityPercent(amount: number, capacity: number): number {
|
function capacityPercent(amount: number, capacity: number): number {
|
||||||
|
|||||||
@ -1,14 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||||
|
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||||
|
<template v-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>
|
||||||
|
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="dest.trim()" class="mb-1">
|
||||||
|
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
|
||||||
|
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share — sats leave your balance when it's redeemed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live balance impact -->
|
||||||
|
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
|
||||||
|
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '…' : confirmBalance.toLocaleString() + ' sats' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50 flex items-center gap-2">
|
||||||
|
Amount
|
||||||
|
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium text-white/80">−{{ confirmAmount.toLocaleString() }} sats</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">Balance after</span>
|
||||||
|
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
|
||||||
|
{{ confirmBalance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
|
||||||
|
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
|
||||||
|
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
|
||||||
|
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
|
{{ processing ? t('common.sending') : 'Confirm & Send' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
|
v-for="m in (['lightning', 'onchain', 'ecash', 'fedimint', 'ark'] as const)"
|
||||||
:key="m"
|
:key="m"
|
||||||
@click="sendMethod = m"
|
@click="sendMethod = m"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
|
||||||
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
|
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
@ -18,6 +67,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||||||
|
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||||
<button
|
<button
|
||||||
v-if="sendMethod === 'onchain'"
|
v-if="sendMethod === 'onchain'"
|
||||||
@click="toggleSendAll"
|
@click="toggleSendAll"
|
||||||
@ -33,24 +83,44 @@
|
|||||||
v-model.number="amount"
|
v-model.number="amount"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
:placeholder="sendAll ? '' : '1000'"
|
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
|
||||||
:disabled="sendAll"
|
:disabled="sendAll || pastedInvoiceAmount !== null"
|
||||||
class="w-full input-glass disabled:opacity-50"
|
class="w-full input-glass disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
|
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
|
||||||
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
|
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else-if="pastedInvoiceAmount !== null" class="text-xs text-white/50 mt-1">
|
||||||
|
This invoice fixes the amount — nothing to type, just review and send.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
|
||||||
|
Zero-amount invoice — enter how many sats to pay.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
|
||||||
<label class="text-white/60 text-sm block mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<label class="text-white/60 text-sm">
|
||||||
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
|
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
|
||||||
</label>
|
</label>
|
||||||
|
<button
|
||||||
|
v-if="canReadClipboard"
|
||||||
|
@click="pasteFromClipboard"
|
||||||
|
class="text-xs px-2 py-0.5 rounded border bg-white/5 border-white/15 text-white/60 hover:text-white/90 transition-colors"
|
||||||
|
>
|
||||||
|
{{ effectiveMethod === 'lightning' ? 'Paste invoice' : 'Paste' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||||
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
|
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
|
||||||
|
<!-- QR so the recipient can scan the token straight off this screen
|
||||||
|
(animated multi-frame not needed: qrcode handles these sizes). -->
|
||||||
|
<div class="flex justify-center my-2">
|
||||||
|
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
|
||||||
|
</div>
|
||||||
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
|
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
|
||||||
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@ -75,15 +145,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Scan
|
Scan
|
||||||
</button>
|
</button>
|
||||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
<button @click="review" :disabled="processing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ processing ? t('common.sending') : t('common.send') }}
|
{{ t('common.send') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
@ -93,7 +164,9 @@ const { t } = useI18n()
|
|||||||
const props = defineProps<{ show: boolean }>()
|
const props = defineProps<{ show: boolean }>()
|
||||||
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
|
||||||
|
|
||||||
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
|
// 'auto' remains in the type for the effectiveMethod logic but is no longer
|
||||||
|
// offered as a tab (hidden per operator request 2026-07-22).
|
||||||
|
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
|
||||||
const amount = ref<number>(0)
|
const amount = ref<number>(0)
|
||||||
const dest = ref('')
|
const dest = ref('')
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
@ -120,6 +193,35 @@ function toggleSendAll() {
|
|||||||
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
|
||||||
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
|
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
|
||||||
|
|
||||||
|
async function pasteFromClipboard() {
|
||||||
|
try {
|
||||||
|
const text = (await navigator.clipboard.readText()).trim()
|
||||||
|
if (text) dest.value = text
|
||||||
|
} catch {
|
||||||
|
// Permission denied — user can long-press/Ctrl+V into the field instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveMethod = computed(() => {
|
const effectiveMethod = computed(() => {
|
||||||
if (sendMethod.value !== 'auto') return sendMethod.value
|
if (sendMethod.value !== 'auto') return sendMethod.value
|
||||||
const amt = amount.value || 0
|
const amt = amount.value || 0
|
||||||
@ -129,12 +231,95 @@ const effectiveMethod = computed(() => {
|
|||||||
return 'lightning'
|
return 'lightning'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Second-step confirmation (parity with the scan flow): review shows the
|
||||||
|
// --- balance reduction before anything is sent or any token is minted.
|
||||||
|
|
||||||
|
const confirming = ref(false)
|
||||||
|
const confirmBalance = ref<number | null>(null)
|
||||||
|
const invoiceAmountSats = ref<number | null>(null)
|
||||||
|
|
||||||
|
const methodLabel = computed(() => ({
|
||||||
|
auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain',
|
||||||
|
ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark',
|
||||||
|
}[effectiveMethod.value]))
|
||||||
|
|
||||||
|
const methodColor = computed(() => ({
|
||||||
|
auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500',
|
||||||
|
ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400',
|
||||||
|
}[effectiveMethod.value]))
|
||||||
|
|
||||||
|
const destDisplay = computed(() => {
|
||||||
|
const d = dest.value.trim()
|
||||||
|
return d.length > 64 ? `${d.slice(0, 38)}…${d.slice(-18)}` : d
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
|
||||||
|
function parseBolt11AmountSats(invoice: string): number | null {
|
||||||
|
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
|
||||||
|
if (!m || !m[1]) return null
|
||||||
|
const value = Number(m[1])
|
||||||
|
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
|
||||||
|
return Math.round(value * mult * 1e8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invoice-fixed amount always wins over the typed one; a sweep is priced
|
||||||
|
// at the whole balance.
|
||||||
|
const confirmAmount = computed(() => {
|
||||||
|
if (isSweep.value) return confirmBalance.value ?? 0
|
||||||
|
if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value
|
||||||
|
return amount.value > 0 ? Math.floor(amount.value) : 0
|
||||||
|
})
|
||||||
|
const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value)
|
||||||
|
const insufficient = computed(() =>
|
||||||
|
confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadConfirmBalance() {
|
||||||
|
confirmBalance.value = null
|
||||||
|
try {
|
||||||
|
if (effectiveMethod.value === 'ecash') {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
|
||||||
|
confirmBalance.value = res.balance_sats ?? 0
|
||||||
|
} else if (effectiveMethod.value === 'fedimint') {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
|
||||||
|
confirmBalance.value = res.balance_sats ?? 0
|
||||||
|
} else {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
|
||||||
|
confirmBalance.value = effectiveMethod.value === 'onchain'
|
||||||
|
? (res.balance_sats ?? 0)
|
||||||
|
: (res.channel_balance_sats ?? 0)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
confirmBalance.value = null // balance preview is best-effort; send still guarded server-side
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function review() {
|
||||||
|
error.value = ''
|
||||||
|
const method = effectiveMethod.value
|
||||||
|
const d = dest.value.trim()
|
||||||
|
if (method === 'lightning') {
|
||||||
|
if (!d) { error.value = t('web5.pasteInvoice'); return }
|
||||||
|
invoiceAmountSats.value = parseBolt11AmountSats(d)
|
||||||
|
} else {
|
||||||
|
invoiceAmountSats.value = null
|
||||||
|
if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
||||||
|
if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||||
|
}
|
||||||
|
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
|
||||||
|
error.value = t('sendBitcoin.amountSats'); return
|
||||||
|
}
|
||||||
|
void loadConfirmBalance()
|
||||||
|
confirming.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
resultTxid.value = ''
|
resultTxid.value = ''
|
||||||
resultHash.value = ''
|
resultHash.value = ''
|
||||||
resultArk.value = ''
|
resultArk.value = ''
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
|
confirming.value = false
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,9 +327,21 @@ function copyText(text: string) {
|
|||||||
navigator.clipboard.writeText(text).catch(() => {})
|
navigator.clipboard.writeText(text).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
watch(ecashToken, async (token) => {
|
||||||
|
if (!token) return
|
||||||
|
await nextTick()
|
||||||
|
if (!tokenQrCanvas.value) return
|
||||||
|
try {
|
||||||
|
const QRCode = await import('qrcode')
|
||||||
|
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
|
||||||
|
} catch { /* QR is a convenience — the copyable text is authoritative */ }
|
||||||
|
})
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (processing.value) return
|
if (processing.value) return
|
||||||
if (!amount.value && !isSweep.value) return
|
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
|
||||||
|
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
@ -169,6 +366,13 @@ async function send() {
|
|||||||
params: { amount_sats: amount.value },
|
params: { amount_sats: amount.value },
|
||||||
})
|
})
|
||||||
ecashToken.value = res.token
|
ecashToken.value = res.token
|
||||||
|
} else if (method === 'fedimint') {
|
||||||
|
const res = await rpcClient.call<{ token: string }>({
|
||||||
|
method: 'wallet.fedimint-send',
|
||||||
|
params: { amount_sats: amount.value },
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
ecashToken.value = res.token
|
||||||
} else if (method === 'lightning') {
|
} else if (method === 'lightning') {
|
||||||
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
||||||
const res = await rpcClient.call<{ payment_hash: string }>({
|
const res = await rpcClient.call<{ payment_hash: string }>({
|
||||||
@ -187,6 +391,8 @@ async function send() {
|
|||||||
resultTxid.value = res.txid
|
resultTxid.value = res.txid
|
||||||
}
|
}
|
||||||
emit('sent')
|
emit('sent')
|
||||||
|
// Back to the form pane so the success/token panes are visible.
|
||||||
|
confirming.value = false
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -43,7 +43,24 @@
|
|||||||
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
||||||
<!-- ============ SCAN PANE ============ -->
|
<!-- ============ SCAN PANE ============ -->
|
||||||
<div v-if="pane === 'scan'" key="scan">
|
<div v-if="pane === 'scan'" key="scan">
|
||||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
<!-- Chooser interstitial — every open: the user picks live camera
|
||||||
|
or a photo upload; neither starts until chosen. -->
|
||||||
|
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
|
||||||
|
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||||
|
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||||
|
the native bridge still provides a live camera -->
|
||||||
|
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Scan with camera
|
||||||
|
</button>
|
||||||
|
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Upload / take a photo of the QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||||
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
||||||
source-less <video> flashes a native play glyph in Android WebViews -->
|
source-less <video> flashes a native play glyph in Android WebViews -->
|
||||||
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
||||||
@ -73,6 +90,11 @@
|
|||||||
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
Take photo of QR
|
Take photo of QR
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single always-mounted picker input, shared by the chooser and
|
||||||
|
the in-camera fallback button -->
|
||||||
<input
|
<input
|
||||||
ref="photoInput"
|
ref="photoInput"
|
||||||
type="file"
|
type="file"
|
||||||
@ -81,8 +103,6 @@
|
|||||||
class="hidden"
|
class="hidden"
|
||||||
@change="onPhotoPicked"
|
@change="onPhotoPicked"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
||||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||||
@ -253,6 +273,23 @@ type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
|||||||
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
||||||
type Pane = 'scan' | 'amount' | 'success'
|
type Pane = 'scan' | 'amount' | 'success'
|
||||||
|
|
||||||
|
// JS bridge the Android companion injects: when present, live scanning is
|
||||||
|
// delegated to a native camera modal (styled like this one) — the WebView's
|
||||||
|
// getUserMedia preview lags, and over plain http it doesn't exist at all.
|
||||||
|
// Decodes come back through the window.__archyQr* callbacks; status lines
|
||||||
|
// (animated-QR progress, errors) mirror out to the native modal's strip.
|
||||||
|
interface ArchipelagoQrBridge {
|
||||||
|
open(): void
|
||||||
|
setStatus(message: string, isError: boolean): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
interface NativeWindow extends Window {
|
||||||
|
ArchipelagoQr?: ArchipelagoQrBridge
|
||||||
|
__archyQrResult?: (text: string) => void
|
||||||
|
__archyQrCancelled?: () => void
|
||||||
|
}
|
||||||
|
const nativeWin = window as NativeWindow
|
||||||
|
|
||||||
const PRESETS = [21, 2100, 21000, 100000]
|
const PRESETS = [21, 2100, 21000, 100000]
|
||||||
|
|
||||||
const props = defineProps<{ show: boolean }>()
|
const props = defineProps<{ show: boolean }>()
|
||||||
@ -282,7 +319,9 @@ function goBack() {
|
|||||||
if (pane.value === 'amount') {
|
if (pane.value === 'amount') {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
goTo('scan', 'back')
|
goTo('scan', 'back')
|
||||||
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
|
// Only relight the camera when that's what the user chose; otherwise
|
||||||
|
// they land back on the scan/upload chooser.
|
||||||
|
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
|
||||||
} else if (pane.value === 'success') {
|
} else if (pane.value === 'success') {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@ -295,6 +334,31 @@ const isScanning = ref(false)
|
|||||||
// open) — the fallback buttons stay hidden until it resolves, so they don't
|
// open) — the fallback buttons stay hidden until it resolves, so they don't
|
||||||
// flash for a second on every open.
|
// flash for a second on every open.
|
||||||
const autoStarting = ref(false)
|
const autoStarting = ref(false)
|
||||||
|
|
||||||
|
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
|
||||||
|
// nothing starts until the user picks, and Back from a later pane returns to
|
||||||
|
// the live camera only if that's what they chose.
|
||||||
|
const scanChoice = ref<'unset' | 'camera'>('unset')
|
||||||
|
|
||||||
|
function chooseCamera() {
|
||||||
|
if (startNativeScan()) return
|
||||||
|
scanChoice.value = 'camera'
|
||||||
|
void nextTick(() => startScanning())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Native scanner (companion app) ---
|
||||||
|
const hasNativeQr = !!nativeWin.ArchipelagoQr
|
||||||
|
const nativeScanActive = ref(false)
|
||||||
|
|
||||||
|
function startNativeScan(): boolean {
|
||||||
|
const bridge = nativeWin.ArchipelagoQr
|
||||||
|
if (!bridge) return false
|
||||||
|
nativeWin.__archyQrResult = (text: string) => handleScanned(text)
|
||||||
|
nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false }
|
||||||
|
nativeScanActive.value = true
|
||||||
|
bridge.open()
|
||||||
|
return true
|
||||||
|
}
|
||||||
const scanStatus = ref('')
|
const scanStatus = ref('')
|
||||||
const scanStatusIsError = ref(false)
|
const scanStatusIsError = ref(false)
|
||||||
const cameraError = ref(false)
|
const cameraError = ref(false)
|
||||||
@ -344,8 +408,20 @@ function stopScanning() {
|
|||||||
qrScanner.value?.destroy()
|
qrScanner.value?.destroy()
|
||||||
qrScanner.value = null
|
qrScanner.value = null
|
||||||
isScanning.value = false
|
isScanning.value = false
|
||||||
|
if (nativeScanActive.value) {
|
||||||
|
nativeScanActive.value = false
|
||||||
|
nativeWin.ArchipelagoQr?.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror status lines onto the native modal while it's up — it covers the
|
||||||
|
// page, so this strip is the only feedback the user can see.
|
||||||
|
watch([scanStatus, scanStatusIsError], () => {
|
||||||
|
if (nativeScanActive.value) {
|
||||||
|
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function submitPaste() {
|
function submitPaste() {
|
||||||
const text = pasteInput.value.trim()
|
const text = pasteInput.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
@ -363,9 +439,10 @@ async function onPhotoPicked(e: Event) {
|
|||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
scanStatusIsError.value = false
|
||||||
|
scanStatus.value = 'Reading photo…'
|
||||||
try {
|
try {
|
||||||
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
handleScanned(await decodePhotoRobust(file))
|
||||||
handleScanned(result.data)
|
|
||||||
} catch {
|
} catch {
|
||||||
scanStatusIsError.value = true
|
scanStatusIsError.value = true
|
||||||
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
|
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
|
||||||
@ -374,6 +451,28 @@ async function onPhotoPicked(e: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Decode a QR photo with every engine we have. On the companion app the
|
||||||
|
* photo path IS the scan path (plain-http LAN = no secure context = no
|
||||||
|
* live camera), and Lightning invoices make DENSE codes that the wasm
|
||||||
|
* engine's single pass often misses (reported 2026-07-22: "camera not
|
||||||
|
* picking up the invoice"). Android WebView's native BarcodeDetector is
|
||||||
|
* far stronger on dense codes, so try it first; fall back to qr-scanner. */
|
||||||
|
async function decodePhotoRobust(file: File): Promise<string> {
|
||||||
|
try {
|
||||||
|
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).BarcodeDetector
|
||||||
|
if (Detector) {
|
||||||
|
const bmp = await createImageBitmap(file)
|
||||||
|
const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp)
|
||||||
|
const hit = codes.find(c => c.rawValue)
|
||||||
|
if (hit) return hit.rawValue
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Native detector unavailable/failed — wasm engine below.
|
||||||
|
}
|
||||||
|
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
// --- Detection (ported from k484's scanner) ---
|
// --- Detection (ported from k484's scanner) ---
|
||||||
const rail = ref<Rail>('lightning')
|
const rail = ref<Rail>('lightning')
|
||||||
const action = ref<Action>('pay-invoice')
|
const action = ref<Action>('pay-invoice')
|
||||||
@ -686,7 +785,8 @@ function close() {
|
|||||||
watch(() => props.show, (open) => {
|
watch(() => props.show, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
resetAll()
|
resetAll()
|
||||||
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
|
// No auto-start: the chooser interstitial owns the first move every time.
|
||||||
|
scanChoice.value = 'unset'
|
||||||
} else {
|
} else {
|
||||||
stopScanning()
|
stopScanning()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh]" @close="close">
|
||||||
<!-- Protocol tabs -->
|
<!-- Protocol tabs — pinned via the header slot; only the pane below
|
||||||
|
scrolls (2026-07-22 modal contract). -->
|
||||||
|
<template #header>
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@ -10,6 +12,7 @@
|
|||||||
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||||
>{{ tab.label }}</button>
|
>{{ tab.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ===================== Lightning Channels ===================== -->
|
<!-- ===================== Lightning Channels ===================== -->
|
||||||
<div v-show="activeTab === 'channels'">
|
<div v-show="activeTab === 'channels'">
|
||||||
@ -17,9 +20,6 @@
|
|||||||
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
|
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
|
||||||
</p>
|
</p>
|
||||||
<LightningChannelsPanel v-if="show" compact />
|
<LightningChannelsPanel v-if="show" compact />
|
||||||
<div class="flex gap-3 mt-4">
|
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Cashu Mints ===================== -->
|
<!-- ===================== Cashu Mints ===================== -->
|
||||||
@ -75,16 +75,6 @@
|
|||||||
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
|
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
|
||||||
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
|
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-4">
|
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
|
||||||
<button
|
|
||||||
@click="saveMints"
|
|
||||||
:disabled="savingMints || mints.length === 0"
|
|
||||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ savingMints ? 'Saving…' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -133,16 +123,6 @@
|
|||||||
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
|
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
|
||||||
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
|
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-4">
|
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
|
||||||
<button
|
|
||||||
@click="joinFederation"
|
|
||||||
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
|
|
||||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ joiningFed ? 'Joining…' : 'Join federation' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
|
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
|
||||||
Joining federations lands with the Fedimint client backend.
|
Joining federations lands with the Fedimint client backend.
|
||||||
@ -179,9 +159,6 @@
|
|||||||
Don't warn me each time before opening the external explorer
|
Don't warn me each time before opening the external explorer
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-6">
|
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===================== Ark ===================== -->
|
<!-- ===================== Ark ===================== -->
|
||||||
@ -269,16 +246,33 @@
|
|||||||
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
|
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
|
||||||
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
|
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-4">
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Pinned footer (2026-07-22 modal contract): Close always, plus the
|
||||||
|
active tab's primary action — the buttons never scroll away. -->
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-3">
|
||||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="activeTab === 'cashu'"
|
||||||
|
@click="saveMints"
|
||||||
|
:disabled="savingMints || mints.length === 0"
|
||||||
|
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>{{ savingMints ? 'Saving…' : 'Save' }}</button>
|
||||||
|
<button
|
||||||
|
v-else-if="activeTab === 'fedimint'"
|
||||||
|
@click="joinFederation"
|
||||||
|
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
|
||||||
|
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>{{ joiningFed ? 'Joining…' : 'Join federation' }}</button>
|
||||||
|
<button
|
||||||
|
v-else-if="activeTab === 'ark' && arkStatus?.available"
|
||||||
@click="saveArkConfig"
|
@click="saveArkConfig"
|
||||||
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
|
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
|
||||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
|
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
<button
|
<button
|
||||||
class="cloud-file-item group"
|
class="cloud-file-item group"
|
||||||
data-controller-container
|
data-controller-container
|
||||||
|
data-controller-primary
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -17,12 +17,25 @@
|
|||||||
</span>
|
</span>
|
||||||
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
|
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
|
||||||
|
class="lightbox-btn"
|
||||||
|
title="Picture-in-picture"
|
||||||
|
@click.stop="togglePip(videoEl)"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
|
||||||
|
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="lightbox-btn" @click="close">
|
<button class="lightbox-btn" @click="close">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Navigation arrows -->
|
<!-- Navigation arrows -->
|
||||||
<button
|
<button
|
||||||
@ -111,6 +124,7 @@
|
|||||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||||
import { getFileCategory } from '@/composables/useFileType'
|
import { getFileCategory } from '@/composables/useFileType'
|
||||||
|
import { pipSupported, togglePip } from '@/utils/pip'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: FileBrowserItem[]
|
items: FileBrowserItem[]
|
||||||
|
|||||||
@ -87,6 +87,47 @@
|
|||||||
/>
|
/>
|
||||||
<span class="share-price-unit">sats</span>
|
<span class="share-price-unit">sats</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Accepted payment methods (only for paid) — gated on what this
|
||||||
|
node can actually receive; unavailable rails are disabled with
|
||||||
|
an ⓘ that explains how to enable them. -->
|
||||||
|
<div v-if="accessType === 'paid'" class="mt-3">
|
||||||
|
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
|
||||||
|
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||||
|
<p class="text-sm text-white/90">{{ m.label }}</p>
|
||||||
|
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking…</span>
|
||||||
|
<button
|
||||||
|
v-else-if="!capability[m.key]"
|
||||||
|
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
|
||||||
|
title="Why is this unavailable?"
|
||||||
|
@click="adviceFor = m.key"
|
||||||
|
>i</button>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
:model-value="acceptedSet.has(m.key)"
|
||||||
|
:disabled="!capability[m.key]"
|
||||||
|
:aria-label="`Accept ${m.label}`"
|
||||||
|
@update:model-value="toggleMethod(m.key, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
|
||||||
|
Pick at least one payment method buyers can use.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advice modal for an unavailable payment method -->
|
||||||
|
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
|
||||||
|
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
|
||||||
|
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status messages -->
|
<!-- Status messages -->
|
||||||
@ -109,7 +150,7 @@
|
|||||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
||||||
<button
|
<button
|
||||||
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
||||||
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))"
|
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
|
||||||
@click="save"
|
@click="save"
|
||||||
>
|
>
|
||||||
{{ shared ? 'Share' : 'Stop Sharing' }}
|
{{ shared ? 'Share' : 'Stop Sharing' }}
|
||||||
@ -144,17 +185,113 @@ const saving = ref(false)
|
|||||||
const errorMsg = ref<string | null>(null)
|
const errorMsg = ref<string | null>(null)
|
||||||
const successMsg = ref<string | null>(null)
|
const successMsg = ref<string | null>(null)
|
||||||
|
|
||||||
|
// --- Accepted payment methods, gated on what this node can actually receive ---
|
||||||
|
|
||||||
|
const PAY_METHODS = [
|
||||||
|
{ key: 'lightning', label: 'Lightning' },
|
||||||
|
{ key: 'onchain', label: 'On-chain' },
|
||||||
|
{ key: 'ecash', label: 'Cashu ecash' },
|
||||||
|
{ key: 'fedimint', label: 'Fedimint' },
|
||||||
|
] as const
|
||||||
|
type PayMethod = (typeof PAY_METHODS)[number]['key']
|
||||||
|
|
||||||
|
// undefined = probe in flight; then true/false per method.
|
||||||
|
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
|
||||||
|
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
|
||||||
|
const acceptedSet = ref<Set<PayMethod>>(new Set())
|
||||||
|
const adviceFor = ref<PayMethod | null>(null)
|
||||||
|
// Only default-select capable methods when the item had no saved list.
|
||||||
|
let acceptedLoadedFromItem = false
|
||||||
|
|
||||||
|
function toggleMethod(key: PayMethod, on: boolean) {
|
||||||
|
const next = new Set(acceptedSet.value)
|
||||||
|
if (on) next.add(key)
|
||||||
|
else next.delete(key)
|
||||||
|
acceptedSet.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe the node's rails and build advice for the unavailable ones. */
|
||||||
|
async function probeCapabilities() {
|
||||||
|
// Lightning + on-chain both live on LND.
|
||||||
|
try {
|
||||||
|
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
|
||||||
|
method: 'lnd.getinfo', timeout: 8000,
|
||||||
|
})
|
||||||
|
capability.value.onchain = true
|
||||||
|
const channels = info?.num_active_channels ?? 0
|
||||||
|
capability.value.lightning = channels > 0
|
||||||
|
if (channels === 0) {
|
||||||
|
adviceLines.value.lightning = [
|
||||||
|
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
|
||||||
|
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
|
||||||
|
'Once the channel is active, come back and enable Lightning here.',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
capability.value.lightning = false
|
||||||
|
capability.value.onchain = false
|
||||||
|
adviceLines.value.lightning = [
|
||||||
|
'The Lightning (LND) app isn\'t running on this node.',
|
||||||
|
'Install/start Lightning from the App Store, let it sync, then open a channel.',
|
||||||
|
]
|
||||||
|
adviceLines.value.onchain = [
|
||||||
|
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
|
||||||
|
'Install/start Lightning from the App Store — no channel needed for on-chain.',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
|
||||||
|
capability.value.ecash = true
|
||||||
|
} catch {
|
||||||
|
capability.value.ecash = false
|
||||||
|
adviceLines.value.ecash = [
|
||||||
|
'The Cashu ecash wallet isn\'t set up on this node.',
|
||||||
|
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
|
||||||
|
capability.value.fedimint = true
|
||||||
|
} catch {
|
||||||
|
capability.value.fedimint = false
|
||||||
|
adviceLines.value.fedimint = [
|
||||||
|
'This node hasn\'t joined a Fedimint federation.',
|
||||||
|
'Install the Fedimint app and join (or create) a federation, then enable it here.',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// Defaults: everything the node can receive — unless the item already
|
||||||
|
// carried an explicit list. Never auto-enable an incapable rail.
|
||||||
|
if (!acceptedLoadedFromItem) {
|
||||||
|
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
|
||||||
|
} else {
|
||||||
|
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If we have an existing item, load its state
|
// If we have an existing item, load its state
|
||||||
|
|
||||||
|
/** Catalog entries store the slash-stripped path; props carry a leading
|
||||||
|
* slash (filepath) or just the basename (filename). Normalize both sides —
|
||||||
|
* the old exact compare never matched, so every re-share created a brand
|
||||||
|
* new priced entry and buyers could pay twice for one file (2026-07-22). */
|
||||||
|
function matchesThisFile(catalogFilename: string): boolean {
|
||||||
|
const strip = (v: string) => v.replace(/^\/+/, '')
|
||||||
|
return (
|
||||||
|
strip(catalogFilename) === strip(props.filepath || '') ||
|
||||||
|
strip(catalogFilename) === strip(props.filename || '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ items: Array<{
|
const res = await rpcClient.call<{ items: Array<{
|
||||||
id: string
|
id: string
|
||||||
filename: string
|
filename: string
|
||||||
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
|
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
|
||||||
availability: string | { allpeers?: unknown; nobody?: unknown }
|
availability: string | { allpeers?: unknown; nobody?: unknown }
|
||||||
}> }>({ method: 'content.list-mine' })
|
}> }>({ method: 'content.list-mine' })
|
||||||
const match = res.items.find(
|
const match = res.items.find(
|
||||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
(i) => matchesThisFile(i.filename)
|
||||||
)
|
)
|
||||||
if (match) {
|
if (match) {
|
||||||
shared.value = true
|
shared.value = true
|
||||||
@ -166,6 +303,14 @@ onMounted(async () => {
|
|||||||
if ('paid' in access && access.paid) {
|
if ('paid' in access && access.paid) {
|
||||||
accessType.value = 'paid'
|
accessType.value = 'paid'
|
||||||
priceSats.value = access.paid.price_sats || 100
|
priceSats.value = access.paid.price_sats || 100
|
||||||
|
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
|
||||||
|
acceptedLoadedFromItem = true
|
||||||
|
acceptedSet.value = new Set(
|
||||||
|
access.paid.accepted.filter((m): m is PayMethod =>
|
||||||
|
PAY_METHODS.some((p) => p.key === m),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if ('peersonly' in access) {
|
} else if ('peersonly' in access) {
|
||||||
accessType.value = 'peers_only'
|
accessType.value = 'peers_only'
|
||||||
}
|
}
|
||||||
@ -174,6 +319,7 @@ onMounted(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
||||||
}
|
}
|
||||||
|
void probeCapabilities()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@ -188,7 +334,7 @@ async function save() {
|
|||||||
method: 'content.list-mine',
|
method: 'content.list-mine',
|
||||||
})
|
})
|
||||||
const match = res.items.find(
|
const match = res.items.find(
|
||||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
(i) => matchesThisFile(i.filename)
|
||||||
)
|
)
|
||||||
if (match) {
|
if (match) {
|
||||||
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
|
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
|
||||||
@ -200,7 +346,7 @@ async function save() {
|
|||||||
method: 'content.list-mine',
|
method: 'content.list-mine',
|
||||||
})
|
})
|
||||||
let itemId = res.items.find(
|
let itemId = res.items.find(
|
||||||
(i) => i.filename === props.filename || i.filename === props.filepath
|
(i) => matchesThisFile(i.filename)
|
||||||
)?.id
|
)?.id
|
||||||
|
|
||||||
// Add if not in catalog
|
// Add if not in catalog
|
||||||
@ -227,6 +373,7 @@ async function save() {
|
|||||||
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
||||||
if (accessType.value === 'paid') {
|
if (accessType.value === 'paid') {
|
||||||
pricingParams.price_sats = priceSats.value
|
pricingParams.price_sats = priceSats.value
|
||||||
|
pricingParams.accepted_methods = [...acceptedSet.value]
|
||||||
}
|
}
|
||||||
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- INTEGRATION POINT (other developer, in progress): the web flasher
|
||||||
|
for all three firmwares (MeshCore / Meshtastic / RNode) belongs
|
||||||
|
HERE as a third action on this screen — e.g. "Flash different
|
||||||
|
firmware" — driven by the probe result above. Per the operator:
|
||||||
|
the flasher must live in this detection UI. -->
|
||||||
<div class="flex gap-2 mt-5">
|
<div class="flex gap-2 mt-5">
|
||||||
<button
|
<button
|
||||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||||
|
|||||||
@ -105,6 +105,14 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
|||||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Topmost open modal dialog, if any — it owns navigation while visible. */
|
||||||
|
function getOpenModal(): HTMLElement | null {
|
||||||
|
const dialogs = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
|
||||||
|
).filter(el => el.offsetParent !== null)
|
||||||
|
return dialogs[dialogs.length - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||||
if (!el) return false
|
if (!el) return false
|
||||||
const container = el.closest('[data-controller-container]')
|
const container = el.closest('[data-controller-container]')
|
||||||
@ -250,6 +258,42 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
const activeEl = document.activeElement as HTMLElement
|
const activeEl = document.activeElement as HTMLElement
|
||||||
|
|
||||||
|
// ── MODAL SCOPE ──────────────────────────────────────────
|
||||||
|
// An open dialog owns navigation: focus is pulled inside, arrows move
|
||||||
|
// spatially between its controls, Enter activates. Escape stays with the
|
||||||
|
// modal's own close handling. Standard mapping, no per-modal code.
|
||||||
|
const modal = getOpenModal()
|
||||||
|
if (modal) {
|
||||||
|
if (e.key === 'Escape') return
|
||||||
|
const focusables = getFocusableElements(modal)
|
||||||
|
if (!focusables.length) return
|
||||||
|
if (!activeEl || !modal.contains(activeEl)) {
|
||||||
|
e.preventDefault()
|
||||||
|
const first = focusables[0]
|
||||||
|
if (first) focusEl(first)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
|
// Typing keys stay with the field; Enter keeps the form-submit
|
||||||
|
// behavior below; only Up/Down leave the field spatially.
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
playNavSound('action')
|
||||||
|
activeEl.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
const dir =
|
||||||
|
e.key === 'ArrowDown' ? ('down' as const)
|
||||||
|
: e.key === 'ArrowUp' ? ('up' as const)
|
||||||
|
: e.key === 'ArrowLeft' ? ('left' as const)
|
||||||
|
: ('right' as const)
|
||||||
|
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
|
||||||
|
if (nearest) focusEl(nearest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
// ── TEXT INPUT HANDLING ──────────────────────────────────
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
if (
|
if (
|
||||||
@ -364,6 +408,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (isContainer(activeEl)) {
|
if (isContainer(activeEl)) {
|
||||||
|
// Container declares its own click as THE action (e.g. a media file
|
||||||
|
// card whose click plays it) — without this the a[href] fallback
|
||||||
|
// below hits the card's download link and gamepad-select downloads
|
||||||
|
// a song instead of playing it.
|
||||||
|
if (activeEl.hasAttribute('data-controller-primary')) {
|
||||||
|
playNavSound('action')
|
||||||
|
activeEl.click()
|
||||||
|
return
|
||||||
|
}
|
||||||
// Prioritised action: install button
|
// Prioritised action: install button
|
||||||
if (activeEl.hasAttribute('data-controller-install')) {
|
if (activeEl.hasAttribute('data-controller-install')) {
|
||||||
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export interface MeshStatus {
|
|||||||
pid?: string | null
|
pid?: string | null
|
||||||
product?: string | null
|
product?: string | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
|
/** Epoch seconds the /dev node appeared — changes on every replug. */
|
||||||
|
plugged_at?: number | null
|
||||||
}>
|
}>
|
||||||
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
|
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
|
||||||
manage_radio?: boolean
|
manage_radio?: boolean
|
||||||
@ -305,10 +307,20 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
// Dismissals are cleared the moment the port disappears (unplug), so the
|
// Dismissals are cleared the moment the port disappears (unplug), so the
|
||||||
// modal re-appears on EVERY plug-in — including swapping a different stick
|
// modal re-appears on EVERY plug-in — including swapping a different stick
|
||||||
// into the same /dev path — per the hot-swap UX (2026-07-22).
|
// into the same /dev path — per the hot-swap UX (2026-07-22).
|
||||||
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
|
// v2: dismissals key on (path → plugged_at). udev recreates the /dev node
|
||||||
const dismissedDetectedPaths = ref<Set<string>>(new Set(
|
// on every plug, so plugged_at changes on each replug — an old "Not Now"
|
||||||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
|
// can never suppress a NEWLY plugged stick, even when the swap happens
|
||||||
))
|
// faster than a status poll (which absence-pruning alone missed) or when
|
||||||
|
// the same /dev path is reused. v1 (a bare path set) is intentionally
|
||||||
|
// abandoned: stale one-time dismissals from before 2026-07-22 shouldn't
|
||||||
|
// suppress anything either.
|
||||||
|
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2'
|
||||||
|
const dismissedDetected = ref<Record<string, number>>(
|
||||||
|
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
|
||||||
|
)
|
||||||
|
function pluggedAt(s: MeshStatus, path: string): number {
|
||||||
|
return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0
|
||||||
|
}
|
||||||
// Consecutive polls each candidate port has been present-but-not-connected.
|
// Consecutive polls each candidate port has been present-but-not-connected.
|
||||||
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
||||||
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
||||||
@ -317,15 +329,15 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
const s = status.value
|
const s = status.value
|
||||||
if (!s) return []
|
if (!s) return []
|
||||||
return (s.detected_devices || []).filter(p =>
|
return (s.detected_devices || []).filter(p =>
|
||||||
!dismissedDetectedPaths.value.has(p) &&
|
dismissedDetected.value[p] !== pluggedAt(s, p) &&
|
||||||
// The port the live session occupies is not a candidate…
|
// The port the live session occupies is not a candidate…
|
||||||
!(s.device_connected && s.device_path === p) &&
|
!(s.device_connected && s.device_path === p) &&
|
||||||
// …and a port only qualifies once it has survived the blip debounce.
|
// …and a port only qualifies once it has survived the blip debounce.
|
||||||
(detectSightings.value[p] ?? 0) >= 2
|
(detectSightings.value[p] ?? 0) >= 2
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
/** Called after every status fetch: advance sighting counters and clear
|
/** Called after every status fetch: advance sighting counters and drop
|
||||||
* dismissals/counters for ports that vanished (unplug → replug reshows). */
|
* dismissals for ports that vanished (belt-and-braces with plugged_at). */
|
||||||
function trackDetectedDevices(s: MeshStatus) {
|
function trackDetectedDevices(s: MeshStatus) {
|
||||||
const present = new Set(s.detected_devices || [])
|
const present = new Set(s.detected_devices || [])
|
||||||
const next: Record<string, number> = {}
|
const next: Record<string, number> = {}
|
||||||
@ -336,21 +348,24 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
}
|
}
|
||||||
detectSightings.value = next
|
detectSightings.value = next
|
||||||
let dirty = false
|
let dirty = false
|
||||||
for (const p of [...dismissedDetectedPaths.value]) {
|
for (const p of Object.keys(dismissedDetected.value)) {
|
||||||
if (!present.has(p)) {
|
if (!present.has(p)) {
|
||||||
dismissedDetectedPaths.value.delete(p)
|
delete dismissedDetected.value[p]
|
||||||
dirty = true
|
dirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dirty) {
|
if (dirty) {
|
||||||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
dismissedDetected.value = { ...dismissedDetected.value }
|
||||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function dismissDetectedDevice(path: string) {
|
function dismissDetectedDevice(path: string) {
|
||||||
dismissedDetectedPaths.value.add(path)
|
const s = status.value
|
||||||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
dismissedDetected.value = {
|
||||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
...dismissedDetected.value,
|
||||||
|
[path]: s ? pluggedAt(s, path) : 0,
|
||||||
|
}
|
||||||
|
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
|
||||||
}
|
}
|
||||||
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
||||||
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
||||||
|
|||||||
@ -2,6 +2,22 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* The app is dark-only — NEVER let the OS/browser theme leak into native
|
||||||
|
controls. Without this, select popups / scrollbars / date pickers follow
|
||||||
|
the user's OS setting (a light-mode ThinkPad rendered white dropdown
|
||||||
|
lists inside the dark UI, 2026-07-22). color-scheme pins Chromium's
|
||||||
|
native select popup dark; the explicit select/option rules cover
|
||||||
|
Firefox and the closed control itself. */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
select,
|
||||||
|
select option,
|
||||||
|
select optgroup {
|
||||||
|
background-color: #16181d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Montserrat - header font (used in neode present) */
|
/* Montserrat - header font (used in neode present) */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Montserrat';
|
font-family: 'Montserrat';
|
||||||
|
|||||||
19
neode-ui/src/utils/pip.ts
Normal file
19
neode-ui/src/utils/pip.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/** Video Picture-in-Picture helpers (Chromium/Safari; no-ops elsewhere).
|
||||||
|
* Kiosk note: PiP is skipped on the WM-less kiosk X session by callers that
|
||||||
|
* care — an unmanaged popup there is unusable (docs/tv-input-iframe-apps.md
|
||||||
|
* sibling investigation, task #18). */
|
||||||
|
|
||||||
|
export const pipSupported =
|
||||||
|
typeof document !== 'undefined' &&
|
||||||
|
'pictureInPictureEnabled' in document &&
|
||||||
|
document.pictureInPictureEnabled
|
||||||
|
|
||||||
|
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
|
||||||
|
if (!video || !pipSupported) return
|
||||||
|
try {
|
||||||
|
if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
|
||||||
|
else await video.requestPictureInPicture()
|
||||||
|
} catch {
|
||||||
|
// Permission/transient failure — the button is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-session-root">
|
<!-- The root stays in the layout as a rect placeholder; the session itself
|
||||||
<Teleport to="body" :disabled="inlinePanelMode">
|
ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
|
||||||
|
subtree on panel<->overlay switches, and moving an iframe node reloads
|
||||||
|
it — inline mode is now emulated with a fixed-position rect synced from
|
||||||
|
this placeholder, so the iframe never moves in the DOM. -->
|
||||||
|
<div class="app-session-root" ref="rootRef">
|
||||||
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
:class="backdropClasses"
|
:class="backdropClasses"
|
||||||
|
:style="inlineRectStyle"
|
||||||
@click.self="handleBackdropClick"
|
@click.self="handleBackdropClick"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -103,7 +109,7 @@ import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
|||||||
import MobileGamepad from './appSession/MobileGamepad.vue'
|
import MobileGamepad from './appSession/MobileGamepad.vue'
|
||||||
import {
|
import {
|
||||||
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
||||||
resolveAppUrl, resolveAppTitle,
|
initialDisplayMode, resolveAppUrl, resolveAppTitle,
|
||||||
} from './appSession/appSessionConfig'
|
} from './appSession/appSessionConfig'
|
||||||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||||
@ -142,11 +148,6 @@ let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|||||||
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
||||||
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// Display mode -- persisted in localStorage
|
|
||||||
const displayMode = ref<DisplayMode>(
|
|
||||||
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
|
|
||||||
)
|
|
||||||
|
|
||||||
const appId = computed(() => {
|
const appId = computed(() => {
|
||||||
const id = props.appIdProp || (route.params.appId as string)
|
const id = props.appIdProp || (route.params.appId as string)
|
||||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
||||||
@ -156,6 +157,9 @@ const appId = computed(() => {
|
|||||||
return id
|
return id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Display mode -- per-app user choice → per-app default → last global → panel
|
||||||
|
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
|
||||||
|
|
||||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||||
const appIcon = computed(() =>
|
const appIcon = computed(() =>
|
||||||
@ -230,7 +234,9 @@ function setMode(mode: DisplayMode) {
|
|||||||
document.exitFullscreen().catch(() => {})
|
document.exitFullscreen().catch(() => {})
|
||||||
}
|
}
|
||||||
displayMode.value = mode
|
displayMode.value = mode
|
||||||
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
// Strictly per-app: the pick is remembered for THIS app only (no global
|
||||||
|
// key — one app's mode must never change how another opens).
|
||||||
|
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
|
||||||
|
|
||||||
// Route-based sessions (deep links) hand off to the store-driven session so
|
// Route-based sessions (deep links) hand off to the store-driven session so
|
||||||
// the app keeps floating above the dashboard instead of owning the route.
|
// the app keeps floating above the dashboard instead of owning the route.
|
||||||
@ -251,12 +257,52 @@ function setMode(mode: DisplayMode) {
|
|||||||
|
|
||||||
// Reactive classes based on display mode. The store-driven session honors the
|
// Reactive classes based on display mode. The store-driven session honors the
|
||||||
// selected display mode in place: panel renders inline beside the page,
|
// selected display mode in place: panel renders inline beside the page,
|
||||||
// overlay/fullscreen render above it (teleported to body) — the underlying
|
// overlay/fullscreen render above it — the underlying route never changes.
|
||||||
// route never changes. Mobile always uses the full overlay.
|
// Mobile always uses the full overlay.
|
||||||
const inlinePanelMode = computed(() =>
|
const inlinePanelMode = computed(() =>
|
||||||
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
|
||||||
|
// to the placeholder's box so "inline" looks identical to the old in-place
|
||||||
|
// render while the iframe stays put in the DOM across mode switches.
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||||
|
let rectObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function syncInlineRect() {
|
||||||
|
const el = rootRef.value
|
||||||
|
if (!el) return
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
// A hidden/unmounted placeholder measures 0x0 — keep the last good rect.
|
||||||
|
if (r.width > 0 && r.height > 0) {
|
||||||
|
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
|
||||||
|
if (!inlinePanelMode.value) return undefined
|
||||||
|
const r = inlineRect.value
|
||||||
|
// Never paint the inline backdrop over the whole viewport while unmeasured.
|
||||||
|
if (!r) {
|
||||||
|
const hidden: Record<string, string> = { visibility: 'hidden' }
|
||||||
|
return hidden
|
||||||
|
}
|
||||||
|
const style: Record<string, string> = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${r.top}px`,
|
||||||
|
left: `${r.left}px`,
|
||||||
|
width: `${r.width}px`,
|
||||||
|
height: `${r.height}px`,
|
||||||
|
zIndex: '100',
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(inlinePanelMode, (on) => {
|
||||||
|
if (on) void nextTick(syncInlineRect)
|
||||||
|
})
|
||||||
|
|
||||||
const backdropClasses = computed(() => {
|
const backdropClasses = computed(() => {
|
||||||
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
|
||||||
return 'app-session-backdrop-overlay'
|
return 'app-session-backdrop-overlay'
|
||||||
@ -277,6 +323,9 @@ function onLoad() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
isRefreshing.value = false
|
isRefreshing.value = false
|
||||||
autoRetryCount.value = 0
|
autoRetryCount.value = 0
|
||||||
|
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
|
||||||
|
// virtual keyboard) flow into the iframe without needing a pointer click.
|
||||||
|
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
|
||||||
// Check if iframe actually loaded content (same-origin only)
|
// Check if iframe actually loaded content (same-origin only)
|
||||||
iframeCheckId = setTimeout(() => {
|
iframeCheckId = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@ -366,7 +415,7 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
function onFullscreenChange() {
|
function onFullscreenChange() {
|
||||||
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
||||||
displayMode.value = 'overlay'
|
displayMode.value = 'overlay'
|
||||||
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
|
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,7 +454,16 @@ onMounted(() => {
|
|||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
window.addEventListener('message', onMessage)
|
window.addEventListener('message', onMessage)
|
||||||
window.addEventListener('resize', updateIsMobile)
|
window.addEventListener('resize', updateIsMobile)
|
||||||
|
window.addEventListener('resize', syncInlineRect)
|
||||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
// Track the placeholder's box (sidebar collapse, layout shifts) for the
|
||||||
|
// inline-mode fixed-position emulation. Sync before first paint so the
|
||||||
|
// inline backdrop never flashes at the wrong rect.
|
||||||
|
syncInlineRect()
|
||||||
|
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
|
||||||
|
rectObserver = new ResizeObserver(syncInlineRect)
|
||||||
|
rectObserver.observe(rootRef.value)
|
||||||
|
}
|
||||||
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
iframeBlocked.value = true
|
iframeBlocked.value = true
|
||||||
@ -429,7 +487,10 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
window.removeEventListener('message', onMessage)
|
window.removeEventListener('message', onMessage)
|
||||||
window.removeEventListener('resize', updateIsMobile)
|
window.removeEventListener('resize', updateIsMobile)
|
||||||
|
window.removeEventListener('resize', syncInlineRect)
|
||||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
rectObserver?.disconnect()
|
||||||
|
rectObserver = null
|
||||||
screensaverStore.resume(screensaverReason.value)
|
screensaverStore.resume(screensaverReason.value)
|
||||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -148,6 +148,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═════════════ Paid Files tab — everything this node has purchased ═════════════
|
||||||
|
Source of truth is the purchase cache (content.owned-list): filename,
|
||||||
|
type, price paid and when — the file itself was also auto-filed into
|
||||||
|
Photos/Music/Documents at purchase time (2026-07-22). -->
|
||||||
|
<div v-else-if="activeTab === 'paid'">
|
||||||
|
<div v-if="paidLoading" class="glass-card p-8 text-center text-white/50 text-sm">Loading purchases…</div>
|
||||||
|
<div v-else-if="paidItems.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||||
|
Nothing purchased yet — files you buy from peers appear here and are saved into your folders automatically.
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="it in paidItems"
|
||||||
|
:key="it.onion + it.content_id"
|
||||||
|
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
|
@click="viewPaidItem(it)"
|
||||||
|
>
|
||||||
|
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm text-white/90 truncate">{{ it.filename.split('/').pop() }}</p>
|
||||||
|
<p class="text-[11px] text-white/40">
|
||||||
|
{{ (it.size_bytes / 1024).toFixed(0) }} KB ·
|
||||||
|
<span class="text-orange-300/80">{{ it.paid_sats.toLocaleString() }} sats</span>
|
||||||
|
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
<!-- ═════════════ Peer Files tab — every file shared by every peer ═════════════ -->
|
||||||
<div v-else-if="activeTab === 'peers'">
|
<div v-else-if="activeTab === 'peers'">
|
||||||
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
|
||||||
@ -374,13 +404,14 @@ const sectionCounts = ref<Record<string, number>>({})
|
|||||||
const countsLoading = ref(false)
|
const countsLoading = ref(false)
|
||||||
|
|
||||||
// ── Tabs / categories / search state ────────────────────────────────────────
|
// ── Tabs / categories / search state ────────────────────────────────────────
|
||||||
type TabId = 'folders' | 'mine' | 'peers'
|
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
|
||||||
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
|
||||||
|
|
||||||
const TABS: Array<{ id: TabId; name: string }> = [
|
const TABS: Array<{ id: TabId; name: string }> = [
|
||||||
{ id: 'folders', name: 'Folders' },
|
{ id: 'folders', name: 'Folders' },
|
||||||
{ id: 'mine', name: 'My Files' },
|
{ id: 'mine', name: 'My Files' },
|
||||||
{ id: 'peers', name: 'Peer Files' },
|
{ id: 'peers', name: 'Peer Files' },
|
||||||
|
{ id: 'paid', name: 'Paid Files' },
|
||||||
]
|
]
|
||||||
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
||||||
{ id: 'all', name: 'All' },
|
{ id: 'all', name: 'All' },
|
||||||
@ -390,6 +421,44 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const activeTab = ref<TabId>('folders')
|
const activeTab = ref<TabId>('folders')
|
||||||
|
|
||||||
|
// ── Paid Files tab ──────────────────────────────────────────────────────────
|
||||||
|
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
|
||||||
|
const paidItems = ref<PaidItem[]>([])
|
||||||
|
const paidLoading = ref(false)
|
||||||
|
async function loadPaidItems() {
|
||||||
|
paidLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
|
||||||
|
paidItems.value = (res.items || []).slice().reverse()
|
||||||
|
} catch { paidItems.value = [] } finally { paidLoading.value = false }
|
||||||
|
}
|
||||||
|
async function viewPaidItem(it: PaidItem) {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
||||||
|
method: 'content.owned-get',
|
||||||
|
params: { onion: it.onion, content_id: it.content_id },
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
const b64 = res.data_base64 || res.data
|
||||||
|
if (!b64) return
|
||||||
|
const bin = atob(b64)
|
||||||
|
const arr = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
||||||
|
const mime = res.mime_type || it.mime_type
|
||||||
|
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
|
||||||
|
// Music ALWAYS plays in the global bottom-bar player — never a popup/
|
||||||
|
// lightbox (blob URL stays alive for the bar; it owns playback now).
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(url, '_blank', 'noopener')
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||||
|
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
|
||||||
|
}
|
||||||
|
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
|
||||||
|
|
||||||
const selectedCategory = ref<CategoryId>('all')
|
const selectedCategory = ref<CategoryId>('all')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
const searchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||||
@ -576,6 +645,15 @@ async function handlePlay(path: string, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePreview(path: string, context: FileBrowserItem[]) {
|
function handlePreview(path: string, context: FileBrowserItem[]) {
|
||||||
|
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||||
|
const clicked = context.find(item => item.path === path)
|
||||||
|
if (clicked) {
|
||||||
|
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||||
|
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||||
|
void handlePlay(path, clicked.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// MediaLightbox filters to media internally; index within that filtered list.
|
// MediaLightbox filters to media internally; index within that filtered list.
|
||||||
const mediaItems = context.filter(item => {
|
const mediaItems = context.filter(item => {
|
||||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||||
|
|||||||
@ -323,9 +323,18 @@ const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(n
|
|||||||
const lightboxIndex = ref<number | null>(null)
|
const lightboxIndex = ref<number | null>(null)
|
||||||
|
|
||||||
function handlePreview(path: string) {
|
function handlePreview(path: string) {
|
||||||
|
const items = cloudStore.sortedItems
|
||||||
|
// Audio never opens the lightbox — it belongs to the bottom-bar player.
|
||||||
|
const clicked = items.find(item => item.path === path)
|
||||||
|
if (clicked) {
|
||||||
|
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
|
||||||
|
if (getFileCategory(ext, clicked.isDir) === 'audio') {
|
||||||
|
void handlePlay(path, clicked.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// MediaLightbox internally filters items to media only, so startIndex
|
// MediaLightbox internally filters items to media only, so startIndex
|
||||||
// must be the index within that filtered list
|
// must be the index within that filtered list
|
||||||
const items = cloudStore.sortedItems
|
|
||||||
const mediaItems = items.filter(item => {
|
const mediaItems = items.filter(item => {
|
||||||
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
|
||||||
const cat = getFileCategory(ext, item.isDir)
|
const cat = getFileCategory(ext, item.isDir)
|
||||||
|
|||||||
@ -350,7 +350,7 @@ function updateKeyboardInset() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
document.addEventListener('click', handleDocClickForMenu)
|
document.addEventListener('pointerdown', handleDocClickForMenu)
|
||||||
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||||
if (window.visualViewport) {
|
if (window.visualViewport) {
|
||||||
window.visualViewport.addEventListener('resize', updateKeyboardInset)
|
window.visualViewport.addEventListener('resize', updateKeyboardInset)
|
||||||
@ -403,7 +403,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
document.removeEventListener('click', handleDocClickForMenu)
|
document.removeEventListener('pointerdown', handleDocClickForMenu)
|
||||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||||
if (window.visualViewport) {
|
if (window.visualViewport) {
|
||||||
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
|
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
|
||||||
@ -1337,8 +1337,8 @@ function closeAttachMenuOnOutsideClick(ev: MouseEvent) {
|
|||||||
const target = ev.target as HTMLElement
|
const target = ev.target as HTMLElement
|
||||||
if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
|
if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
|
||||||
}
|
}
|
||||||
onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick))
|
onMounted(() => document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick))
|
||||||
onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick))
|
onUnmounted(() => document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick))
|
||||||
const attachError = ref<string | null>(null)
|
const attachError = ref<string | null>(null)
|
||||||
const fetchingCids = ref<Set<string>>(new Set())
|
const fetchingCids = ref<Set<string>>(new Set())
|
||||||
const fetchedUrls = ref<Map<string, string>>(new Map())
|
const fetchedUrls = ref<Map<string, string>>(new Map())
|
||||||
|
|||||||
@ -251,9 +251,22 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Picture-in-picture -->
|
||||||
|
<button
|
||||||
|
v-if="pipSupported"
|
||||||
|
class="absolute -top-10 right-10 text-white/60 hover:text-white transition-colors"
|
||||||
|
title="Picture-in-picture"
|
||||||
|
@click="togglePip(peerVideoRef)"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
|
||||||
|
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<!-- Video element -->
|
<!-- Video element -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<video
|
<video
|
||||||
|
ref="peerVideoRef"
|
||||||
:src="videoPlayerUrl"
|
:src="videoPlayerUrl"
|
||||||
class="w-full rounded-xl bg-black"
|
class="w-full rounded-xl bg-black"
|
||||||
controls
|
controls
|
||||||
@ -378,9 +391,11 @@
|
|||||||
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Step 1: choose a payment method -->
|
<!-- Step 1: choose a payment method — only the methods the SELLER
|
||||||
|
accepts for this item are offered -->
|
||||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
|
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="ecashPreparing || downloading === payItem.id"
|
:disabled="ecashPreparing || downloading === payItem.id"
|
||||||
@click="prepareEcashPay"
|
@click="prepareEcashPay"
|
||||||
@ -395,6 +410,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="acceptsMethod(payItem.access, 'lightning')"
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="lnPaying"
|
:disabled="lnPaying"
|
||||||
@click="payWithLightning"
|
@click="payWithLightning"
|
||||||
@ -409,6 +425,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="acceptsMethod(payItem.access, 'lightning') || acceptsMethod(payItem.access, 'onchain')"
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="lnPaying || onchainPaying"
|
:disabled="lnPaying || onchainPaying"
|
||||||
@click="openQrPay"
|
@click="openQrPay"
|
||||||
@ -423,6 +440,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="acceptsMethod(payItem.access, 'onchain')"
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="lnPaying || onchainPaying"
|
:disabled="lnPaying || onchainPaying"
|
||||||
@click="payOnchain"
|
@click="payOnchain"
|
||||||
@ -486,10 +504,11 @@
|
|||||||
|
|
||||||
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Method tabs, styled like the wallet Send/Receive modal -->
|
<!-- Method tabs, styled like the wallet Send/Receive modal;
|
||||||
|
only tabs the seller accepts are shown -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
v-for="m in (['onchain', 'lightning'] as const)"
|
v-for="m in (['onchain', 'lightning'] as const).filter(m => acceptsMethod(payItem!.access, m))"
|
||||||
:key="m"
|
:key="m"
|
||||||
@click="selectQrTab(m)"
|
@click="selectQrTab(m)"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||||
@ -580,6 +599,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
|
import { pipSupported, togglePip } from '@/utils/pip'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -674,6 +694,13 @@ async function viewOwned(item: CatalogItem) {
|
|||||||
})
|
})
|
||||||
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
||||||
const mime = res.mime_type || item.mime_type
|
const mime = res.mime_type || item.mime_type
|
||||||
|
// Audio always plays in the global bottom-bar player — never the lightbox
|
||||||
|
// (the blob URL is intentionally not revoked while the bar plays it).
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||||
|
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||||
viewerMime.value = mime
|
viewerMime.value = mime
|
||||||
@ -746,6 +773,7 @@ let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// Video player modal state
|
// Video player modal state
|
||||||
|
const peerVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
const videoPlayerItem = ref<CatalogItem | null>(null)
|
const videoPlayerItem = ref<CatalogItem | null>(null)
|
||||||
const videoPlayerUrl = ref<string | null>(null)
|
const videoPlayerUrl = ref<string | null>(null)
|
||||||
const videoPlayerPaid = ref(false)
|
const videoPlayerPaid = ref(false)
|
||||||
@ -903,6 +931,15 @@ function getItemPrice(access: CatalogItem['access']): number {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Payment methods the seller accepts for this item. Missing/empty = all
|
||||||
|
* (items shared before sellers could restrict methods). */
|
||||||
|
function acceptsMethod(access: CatalogItem['access'], method: string): boolean {
|
||||||
|
if (typeof access !== 'object' || !('paid' in access)) return true
|
||||||
|
const accepted = (access.paid as { accepted?: string[] }).accepted
|
||||||
|
if (!Array.isArray(accepted) || accepted.length === 0) return true
|
||||||
|
return accepted.includes(method)
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadFile(item: CatalogItem) {
|
async function downloadFile(item: CatalogItem) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
@ -981,7 +1018,6 @@ function closePayModal() {
|
|||||||
*/
|
*/
|
||||||
function openQrPay() {
|
function openQrPay() {
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
qrTab.value = 'onchain'
|
|
||||||
invoiceData.value = null
|
invoiceData.value = null
|
||||||
invoiceQr.value = ''
|
invoiceQr.value = ''
|
||||||
invoiceError.value = ''
|
invoiceError.value = ''
|
||||||
@ -989,7 +1025,14 @@ function openQrPay() {
|
|||||||
onchainData.value = null
|
onchainData.value = null
|
||||||
onchainQr.value = ''
|
onchainQr.value = ''
|
||||||
onchainError.value = ''
|
onchainError.value = ''
|
||||||
|
// Start on the first tab the seller actually accepts.
|
||||||
|
if (payItem.value && !acceptsMethod(payItem.value.access, 'onchain')) {
|
||||||
|
qrTab.value = 'lightning'
|
||||||
|
payWithInvoice()
|
||||||
|
} else {
|
||||||
|
qrTab.value = 'onchain'
|
||||||
loadOnchainQr()
|
loadOnchainQr()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
|
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
|
||||||
@ -1212,10 +1255,19 @@ async function confirmEcashPay() {
|
|||||||
// forward; the viewer offers a Save button for an explicit download.
|
// forward; the viewer offers a Save button for an explicit download.
|
||||||
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||||
const mime = result.mime_type || item.mime_type
|
const mime = result.mime_type || item.mime_type
|
||||||
|
// A just-bought song goes straight to the bottom-bar player — the
|
||||||
|
// owned-content lightbox is for images/video only.
|
||||||
|
if (mime.startsWith('audio/')) {
|
||||||
|
audioPlayer.play(
|
||||||
|
URL.createObjectURL(base64ToBlob(result.data, mime)),
|
||||||
|
item.filename.split('/').pop() || item.filename,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
|
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
|
||||||
viewerMime.value = mime
|
viewerMime.value = mime
|
||||||
viewerItem.value = item
|
viewerItem.value = item
|
||||||
|
}
|
||||||
closePayModal()
|
closePayModal()
|
||||||
void loadOwned()
|
void loadOwned()
|
||||||
} else if (result?.error) {
|
} else if (result?.error) {
|
||||||
|
|||||||
@ -7,6 +7,22 @@ export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
|||||||
|
|
||||||
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
|
||||||
|
|
||||||
|
/** Per-app default display mode. Used when the user hasn't explicitly picked
|
||||||
|
* a mode for that app (an explicit pick is remembered per app and wins).
|
||||||
|
* Apps not listed default to 'panel'. */
|
||||||
|
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
|
||||||
|
'indeedhub': 'fullscreen',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initial display mode for an app session: per-app user choice → per-app
|
||||||
|
* default → panel. Strictly per-app — deliberately NO global fallback, so
|
||||||
|
* one app's mode change can never affect how another app opens. */
|
||||||
|
export function initialDisplayMode(id: string): DisplayMode {
|
||||||
|
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
|
||||||
|
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
|
||||||
|
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
|
||||||
|
}
|
||||||
|
|
||||||
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
|
||||||
export const APP_PORTS: Record<string, number> = {
|
export const APP_PORTS: Record<string, number> = {
|
||||||
...GENERATED_APP_PORTS,
|
...GENERATED_APP_PORTS,
|
||||||
|
|||||||
@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean {
|
|||||||
// are explicit because icon extensions vary (.png / .webp / .svg).
|
// are explicit because icon extensions vary (.png / .webp / .svg).
|
||||||
const APP_ICON_FALLBACKS: Record<string, string> = {
|
const APP_ICON_FALLBACKS: Record<string, string> = {
|
||||||
gitea: '/assets/img/app-icons/gitea.svg',
|
gitea: '/assets/img/app-icons/gitea.svg',
|
||||||
|
// Apps whose icon extension isn't .png: without an explicit entry the
|
||||||
|
// default `<id>.png` guess 404s on every render (console spam, and for
|
||||||
|
// mempool the .png→.svg fallback chain 404s TWICE before giving up).
|
||||||
|
pine: '/assets/img/app-icons/pine.svg',
|
||||||
|
mempool: '/assets/img/app-icons/mempool.webp',
|
||||||
|
'mempool-web': '/assets/img/app-icons/mempool.webp',
|
||||||
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
|
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
|
||||||
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
|
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
|
||||||
// immich stack
|
// immich stack
|
||||||
|
|||||||
@ -362,6 +362,33 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.7.112-alpha -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.112-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">July 22, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<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>
|
||||||
|
<p>The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.</p>
|
||||||
|
<p>Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.</p>
|
||||||
|
<p>Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab.</p>
|
||||||
|
<p>Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.</p>
|
||||||
|
<p>Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.</p>
|
||||||
|
<p>The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.</p>
|
||||||
|
<p>Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.</p>
|
||||||
|
<p>The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.</p>
|
||||||
|
<p>Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.</p>
|
||||||
|
<p>Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.</p>
|
||||||
|
<p>On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device.</p>
|
||||||
|
<p>Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."</p>
|
||||||
|
<p>Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.</p>
|
||||||
|
<p>Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.</p>
|
||||||
|
<p>Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.7.111-alpha -->
|
<!-- v1.7.111-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@ -379,20 +379,28 @@ async function loadPeers() {
|
|||||||
|
|
||||||
peers.value = peerList
|
peers.value = peerList
|
||||||
observers.value = observerList
|
observers.value = observerList
|
||||||
for (const p of [...peers.value, ...observers.value]) {
|
// The list is ready — render it and re-enable Refresh NOW. The
|
||||||
|
// reachability dots fill in as probes resolve. Before 2026-07-22 the
|
||||||
|
// probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat
|
||||||
|
// disabled for N-offline-peers × 30s ("takes ages").
|
||||||
|
loadingPeers.value = false
|
||||||
|
void Promise.allSettled(
|
||||||
|
[...peers.value, ...observers.value].map(async (p) => {
|
||||||
try {
|
try {
|
||||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||||
peerReachableLocal.value[p.onion] = check.reachable
|
peerReachableLocal.value[p.onion] = check.reachable
|
||||||
} catch {
|
} catch {
|
||||||
peerReachableLocal.value[p.onion] = false
|
peerReachableLocal.value[p.onion] = false
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
|
).then(() => {
|
||||||
writeConnectedNodesCache({
|
writeConnectedNodesCache({
|
||||||
peers: peers.value,
|
peers: peers.value,
|
||||||
observers: observers.value,
|
observers: observers.value,
|
||||||
peerReachable: peerReachableLocal.value,
|
peerReachable: peerReachableLocal.value,
|
||||||
connectionRequests: connectionRequests.value,
|
connectionRequests: connectionRequests.value,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
|
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
|
||||||
if (!hadPeers) {
|
if (!hadPeers) {
|
||||||
|
|||||||
@ -73,7 +73,9 @@
|
|||||||
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
|
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
|
||||||
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
|
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1">
|
<!-- min 14rem, otherwise scale with the viewport so a long discovery
|
||||||
|
list uses the screen instead of cramming into a fixed 224px box -->
|
||||||
|
<div v-else class="space-y-2 max-h-[max(14rem,45vh)] overflow-y-auto pr-1">
|
||||||
<div
|
<div
|
||||||
v-for="node in discoveredNodes"
|
v-for="node in discoveredNodes"
|
||||||
:key="node.nostr_pubkey"
|
:key="node.nostr_pubkey"
|
||||||
|
|||||||
@ -235,7 +235,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else-if="getItemPrice(pItem.access) > 0"
|
v-else-if="getItemPrice(pItem.access) > 0"
|
||||||
@click="purchaseAndDownload(pItem)"
|
@click="requestPurchase(pItem)"
|
||||||
:disabled="purchasingId === pItem.id"
|
:disabled="purchasingId === pItem.id"
|
||||||
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
|
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
@ -305,6 +305,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Purchase confirmation — payment NEVER starts before the user confirms -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="pendingPurchase" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="pendingPurchase = null">
|
||||||
|
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true">
|
||||||
|
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
|
||||||
|
<p class="text-sm text-white/70 truncate mb-4">{{ pendingPurchase.filename.split('/').pop() }}</p>
|
||||||
|
<div class="p-3 bg-white/5 rounded-lg space-y-1.5 mb-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">Ecash balance</span>
|
||||||
|
<span class="text-sm font-medium text-white/80">{{ pendingBalance === null ? '…' : pendingBalance.toLocaleString() + ' sats' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">Price</span>
|
||||||
|
<span class="text-sm font-medium text-white/80">−{{ getItemPrice(pendingPurchase.access).toLocaleString() }} sats</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">Balance after</span>
|
||||||
|
<span class="text-sm font-medium" :class="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access) ? 'text-red-400' : 'text-white/80'">
|
||||||
|
{{ pendingBalance === null ? '…' : (pendingBalance - getItemPrice(pendingPurchase.access)).toLocaleString() + ' sats' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)" class="text-xs text-red-400 mb-3">Not enough ecash — fund your wallet, or buy from the Peers page to pay another way.</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button @click="pendingPurchase = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="confirmPendingPurchase"
|
||||||
|
:disabled="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)"
|
||||||
|
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
|
>Pay from node ecash</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Add Content Modal -->
|
<!-- Add Content Modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
|
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
|
||||||
@ -550,6 +585,26 @@ function downloadPeerContent(item: PeerContentItem) {
|
|||||||
safeClipboardWrite(url)
|
safeClipboardWrite(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purchase is strictly two-step: the Buy click only opens the confirmation
|
||||||
|
// (with the balance impact); nothing is paid until the user confirms there.
|
||||||
|
const pendingPurchase = ref<PeerContentItem | null>(null)
|
||||||
|
const pendingBalance = ref<number | null>(null)
|
||||||
|
|
||||||
|
function requestPurchase(item: PeerContentItem) {
|
||||||
|
if (purchasingId.value) return
|
||||||
|
pendingPurchase.value = item
|
||||||
|
pendingBalance.value = null
|
||||||
|
rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
|
||||||
|
.then((r) => { pendingBalance.value = r?.balance_sats ?? 0 })
|
||||||
|
.catch(() => { pendingBalance.value = null })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPendingPurchase() {
|
||||||
|
const item = pendingPurchase.value
|
||||||
|
pendingPurchase.value = null
|
||||||
|
if (item) await purchaseAndDownload(item)
|
||||||
|
}
|
||||||
|
|
||||||
async function purchaseAndDownload(item: PeerContentItem) {
|
async function purchaseAndDownload(item: PeerContentItem) {
|
||||||
if (!browsePeerOnion.value || purchasingId.value) return
|
if (!browsePeerOnion.value || purchasingId.value) return
|
||||||
const price = getItemPrice(item.access)
|
const price = getItemPrice(item.access)
|
||||||
@ -557,18 +612,6 @@ async function purchaseAndDownload(item: PeerContentItem) {
|
|||||||
|
|
||||||
purchasingId.value = item.id
|
purchasingId.value = item.id
|
||||||
try {
|
try {
|
||||||
// Check balance first
|
|
||||||
try {
|
|
||||||
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
|
|
||||||
const balance = balRes?.balance_sats ?? 0
|
|
||||||
if (balance < price) {
|
|
||||||
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Balance check failed — try the purchase anyway
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||||
method: 'content.download-peer-paid',
|
method: 'content.download-peer-paid',
|
||||||
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
|
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },
|
||||||
|
|||||||
7
neode-ui/vite.preview.config.mts
Normal file
7
neode-ui/vite.preview.config.mts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import baseConfig from './vite.config'
|
||||||
|
const cfg = { ...(baseConfig as Record<string, unknown>) } as any
|
||||||
|
cfg.server = { ...(cfg.server || {}), port: 8100, strictPort: true }
|
||||||
|
const proxy = { ...(cfg.server.proxy || {}) }
|
||||||
|
for (const k of Object.keys(proxy)) proxy[k] = { ...proxy[k], target: 'http://localhost:5959' }
|
||||||
|
cfg.server.proxy = proxy
|
||||||
|
export default cfg
|
||||||
@ -1256,6 +1256,10 @@
|
|||||||
{
|
{
|
||||||
"key": "FM_API_URL",
|
"key": "FM_API_URL",
|
||||||
"template": "ws://{{HOST_MDNS}}:8174"
|
"template": "ws://{{HOST_MDNS}}:8174"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "FM_BITCOIND_URL",
|
||||||
|
"template": "http://{{BITCOIN_HOST}}:8332"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"entrypoint": [
|
"entrypoint": [
|
||||||
@ -1284,7 +1288,6 @@
|
|||||||
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||||
"environment": [
|
"environment": [
|
||||||
"FM_DATA_DIR=/data",
|
"FM_DATA_DIR=/data",
|
||||||
"FM_BITCOIND_URL=http://bitcoin-knots:8332",
|
|
||||||
"FM_BITCOIND_USERNAME=archipelago",
|
"FM_BITCOIND_USERNAME=archipelago",
|
||||||
"FM_BITCOIN_NETWORK=bitcoin",
|
"FM_BITCOIN_NETWORK=bitcoin",
|
||||||
"FM_BIND_P2P=0.0.0.0:8173",
|
"FM_BIND_P2P=0.0.0.0:8173",
|
||||||
@ -1443,9 +1446,15 @@
|
|||||||
},
|
},
|
||||||
"container": {
|
"container": {
|
||||||
"custom_args": [
|
"custom_args": [
|
||||||
"if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;\nelse\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;\nfi"
|
"if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url \"$FM_BITCOIND_URL\" --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;\nelse\n exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash \"$FEDI_HASH\" --network bitcoin --bitcoind-url \"$FM_BITCOIND_URL\" --bitcoind-username \"$FM_BITCOIND_USERNAME\" --bitcoind-password \"$FM_BITCOIND_PASSWORD\" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;\nfi"
|
||||||
],
|
],
|
||||||
"data_uid": "1000:1000",
|
"data_uid": "1000:1000",
|
||||||
|
"derived_env": [
|
||||||
|
{
|
||||||
|
"key": "FM_BITCOIND_URL",
|
||||||
|
"template": "http://{{BITCOIN_HOST}}:8332"
|
||||||
|
}
|
||||||
|
],
|
||||||
"entrypoint": [
|
"entrypoint": [
|
||||||
"sh",
|
"sh",
|
||||||
"-lc"
|
"-lc"
|
||||||
@ -4157,7 +4166,9 @@
|
|||||||
"--model",
|
"--model",
|
||||||
"base-int8",
|
"base-int8",
|
||||||
"--language",
|
"--language",
|
||||||
"en"
|
"en",
|
||||||
|
"--beam-size",
|
||||||
|
"1"
|
||||||
],
|
],
|
||||||
"image": "docker.io/rhasspy/wyoming-whisper:3.4.1",
|
"image": "docker.io/rhasspy/wyoming-whisper:3.4.1",
|
||||||
"network": "archy-net",
|
"network": "archy-net",
|
||||||
@ -4213,7 +4224,7 @@
|
|||||||
"no_new_privileges": true,
|
"no_new_privileges": true,
|
||||||
"readonly_root": false
|
"readonly_root": false
|
||||||
},
|
},
|
||||||
"version": "3.4.1",
|
"version": "3.4.2",
|
||||||
"volumes": [
|
"volumes": [
|
||||||
{
|
{
|
||||||
"options": [
|
"options": [
|
||||||
@ -4226,7 +4237,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "3.4.1"
|
"version": "3.4.2"
|
||||||
},
|
},
|
||||||
"portainer": {
|
"portainer": {
|
||||||
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
"image": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||||
@ -4729,7 +4740,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"signature": "5ca0f41589ba2a905e68d79388ea819ef983689f5c88a718723280d81782d534b8a1c9b0452f062b59dae3261224b98f2f136be564176a20729c55a748025100",
|
"signature": "4054b2a8659b75a3810ed153798c0e8a08db49ce6237b8ae985e87ace8a35163c24a0190074abd2a8296309ebe9dd583f153642d46d4d7f0a6a4db255743380b",
|
||||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||||
"updated": "2026-07-22"
|
"updated": "2026-07-23"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user