Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cebbde7bde | ||
|
|
a0b80dd27d | ||
|
|
839da80e0b | ||
|
|
f0e9343d74 | ||
|
|
bf6d98195e | ||
|
|
846b2d9646 | ||
|
|
6df776b25a | ||
|
|
1074f89c47 | ||
|
|
726cc132af | ||
|
|
078c1793a9 | ||
|
|
b83e2c2f37 | ||
|
|
a2fa57456d | ||
|
|
64937df8a2 | ||
|
|
6527e66c07 | ||
|
|
07b611d07d | ||
|
|
dcedf9582a | ||
|
|
f2c420d9c0 | ||
|
|
68cd1c120a | ||
|
|
993f30456f | ||
|
|
aa95e42383 | ||
|
|
75e470bfa4 | ||
|
|
0ac67f5092 | ||
|
|
837cc02812 | ||
|
|
1bce694ebb | ||
|
|
c4855526fe | ||
|
|
298595069d | ||
|
|
f636c5d505 | ||
|
|
0f43870e6c | ||
|
|
d1fbcd9b0a | ||
|
|
b5a9deb815 | ||
|
|
d0ca53501c | ||
|
|
790da4bd0f | ||
|
|
cc2e055e09 | ||
|
|
549c6180a2 | ||
|
|
ec644ab90f | ||
|
|
f0fdc23cc9 | ||
|
|
9f2edf6b7a | ||
|
|
3a21243be7 |
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keep the served companion APK in sync with main on every push.
|
||||
#
|
||||
# When a push to main includes Android changes, rebuild the APK, refresh
|
||||
# neode-ui/public/packages/archipelago-companion.apk.zip, commit it, and ask
|
||||
# you to push again (so the refreshed APK rides along in the same push).
|
||||
#
|
||||
# Enable once per clone: git config core.hooksPath .githooks
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
|
||||
# ship-companion.sh already (re)published the APK for this push — don't redo it.
|
||||
[ -n "${SHIP_COMPANION:-}" ] && exit 0
|
||||
|
||||
PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW=""
|
||||
while read -r _local_ref local_sha remote_ref remote_sha; do
|
||||
if [ "${remote_ref##*/}" = "main" ]; then
|
||||
PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha"
|
||||
fi
|
||||
done
|
||||
[ "$PUSH_MAIN" = "1" ] || exit 0
|
||||
|
||||
# Loop-break: if the tip is already the auto APK commit, let the push proceed.
|
||||
case "$(git log -1 --pretty=%s)" in
|
||||
*"companion APK"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# Only rebuild when this push actually touches the Android app.
|
||||
ZEROS="0000000000000000000000000000000000000000"
|
||||
if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then
|
||||
ANDROID_CHANGED=1
|
||||
elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then
|
||||
ANDROID_CHANGED=0
|
||||
else
|
||||
ANDROID_CHANGED=1
|
||||
fi
|
||||
[ "$ANDROID_CHANGED" = "1" ] || exit 0
|
||||
|
||||
bash scripts/publish-companion-apk.sh || exit 0
|
||||
|
||||
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||
if git diff --cached --quiet -- "$DEST"; then
|
||||
exit 0 # APK unchanged — nothing to do
|
||||
fi
|
||||
|
||||
git commit -q -m "chore(android): update companion APK download [skip ci]"
|
||||
echo "" >&2
|
||||
echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2
|
||||
exit 1
|
||||
@@ -20,6 +20,12 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Separate app ID so a debug/test build installs alongside the
|
||||
// release app instead of colliding on signature.
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
|
||||
@@ -18,7 +18,11 @@ data class ServerEntry(
|
||||
val useHttps: Boolean,
|
||||
val port: String = "",
|
||||
val password: String = "",
|
||||
val name: String = "",
|
||||
) {
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
fun toUrl(): String {
|
||||
val scheme = if (useHttps) "https" else "http"
|
||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||
@@ -31,7 +35,9 @@ data class ServerEntry(
|
||||
return "$scheme://$address$portSuffix"
|
||||
}
|
||||
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password"
|
||||
// name is the trailing field so entries saved before naming existed
|
||||
// (4 fields) still deserialize, with name defaulting to "".
|
||||
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
|
||||
|
||||
companion object {
|
||||
fun deserialize(raw: String): ServerEntry? {
|
||||
@@ -42,6 +48,7 @@ data class ServerEntry(
|
||||
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
||||
port = parts.getOrElse(2) { "" },
|
||||
password = parts.getOrElse(3) { "" },
|
||||
name = parts.getOrElse(4) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +60,7 @@ class ServerPreferences(private val context: Context) {
|
||||
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
||||
private val activePortKey = stringPreferencesKey("active_port")
|
||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||
private val activeNameKey = stringPreferencesKey("active_name")
|
||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
|
||||
@@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) {
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
password = prefs[activePasswordKey] ?: "",
|
||||
name = prefs[activeNameKey] ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs[activeHttpsKey] = server.useHttps
|
||||
prefs[activePortKey] = server.port
|
||||
prefs[activePasswordKey] = server.password
|
||||
prefs[activeNameKey] = server.name
|
||||
}
|
||||
addSavedServer(server)
|
||||
}
|
||||
@@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) {
|
||||
prefs.remove(activeHttpsKey)
|
||||
prefs.remove(activePortKey)
|
||||
prefs.remove(activePasswordKey)
|
||||
prefs.remove(activeNameKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +115,16 @@ class ServerPreferences(private val context: Context) {
|
||||
suspend fun removeSavedServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = prefs[savedServersKey] ?: emptySet()
|
||||
prefs[savedServersKey] = current - server.serialize()
|
||||
// Match by connection identity (address/port/scheme) rather than the
|
||||
// exact serialized string, so a rename — or the legacy 4-field format
|
||||
// saved before names existed — still removes the right entry.
|
||||
prefs[savedServersKey] = current.filterNot { raw ->
|
||||
val e = ServerEntry.deserialize(raw)
|
||||
e != null &&
|
||||
e.address == server.address &&
|
||||
e.port == server.port &&
|
||||
e.useHttps == server.useHttps
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
|
||||
.pointerInput(key) {
|
||||
detectTapGestures(onPress = {
|
||||
p = true; onDir(key)
|
||||
job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } }
|
||||
// 500ms initial delay so a normal tap sends one key, not two
|
||||
// (a touch tap often exceeds 350ms → doubled nav sound).
|
||||
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
|
||||
tryAwaitRelease(); p = false; job?.cancel()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -83,13 +83,16 @@ val ClassicPalette = NESPalette(
|
||||
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
|
||||
)
|
||||
|
||||
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
|
||||
// shows through the controller, subtle white-alpha borders, translucent-white
|
||||
// buttons. Accents come from each button's ring.
|
||||
val DarkPalette = NESPalette(
|
||||
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
|
||||
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
|
||||
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
|
||||
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
|
||||
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
|
||||
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
|
||||
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
|
||||
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
|
||||
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
|
||||
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
|
||||
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
|
||||
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
|
||||
)
|
||||
|
||||
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
|
||||
@@ -113,20 +116,10 @@ fun NESController(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Shadow platform
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.86f)
|
||||
.aspectRatio(2.3f)
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(Color(0xFF000000)),
|
||||
)
|
||||
// Controller body
|
||||
Box(
|
||||
Modifier
|
||||
@@ -135,7 +128,7 @@ fun NESController(
|
||||
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
|
||||
Brush.verticalGradient(listOf(c.body, c.body))
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
|
||||
) {
|
||||
@@ -193,13 +186,13 @@ fun NESController(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// C on top (white)
|
||||
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
|
||||
// C on top
|
||||
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// B + A on bottom row
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
||||
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
|
||||
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
||||
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +257,9 @@ fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
|
||||
}
|
||||
activeDir = dir; onDir(dir)
|
||||
job?.cancel()
|
||||
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
|
||||
// 500ms initial delay so a normal tap sends one key, not
|
||||
// two (a touch tap often exceeds 300ms → doubled nav sound).
|
||||
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
|
||||
tryAwaitRelease()
|
||||
job?.cancel(); activeDir = null
|
||||
},
|
||||
@@ -375,6 +370,28 @@ fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
|
||||
@Composable
|
||||
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
Modifier
|
||||
.size(sz)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
|
||||
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
|
||||
)
|
||||
)
|
||||
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
/** START/SELECT capsule */
|
||||
@Composable
|
||||
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.archipelago.app.ui.components
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -34,17 +36,35 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.NES
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/** NES-styled modal menu — dark blue panel with white borders */
|
||||
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
|
||||
// Bitcoin-orange accent.
|
||||
private val PanelBg = SurfaceDark // #0A0A0A
|
||||
private val PanelBorder = Color.White.copy(alpha = 0.12f)
|
||||
private val RowBg = Color.White.copy(alpha = 0.05f)
|
||||
private val RowBorder = Color.White.copy(alpha = 0.08f)
|
||||
private val FieldBg = Color.White.copy(alpha = 0.04f)
|
||||
|
||||
private val PANEL_R = 20.dp
|
||||
private val ROW_R = 14.dp
|
||||
private val ROW_H = 54.dp
|
||||
private val FIELD_H = 58.dp
|
||||
|
||||
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
|
||||
@Composable
|
||||
fun NESMenu(
|
||||
visible: Boolean,
|
||||
@@ -66,7 +86,9 @@ fun NESMenu(
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,29 +108,45 @@ private fun MenuPanel(
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var nm by remember { mutableStateOf("") }
|
||||
var addr by remember { mutableStateOf("") }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
|
||||
fun submit() {
|
||||
if (addr.isNotBlank()) {
|
||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||
nm = ""; addr = ""; pwd = ""; showAdd = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NES.MenuPanel)
|
||||
.border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp))
|
||||
.widthIn(max = 420.dp)
|
||||
.padding(horizontal = 20.dp)
|
||||
.clip(RoundedCornerShape(PANEL_R))
|
||||
.background(PanelBg)
|
||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
// Title
|
||||
Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
|
||||
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Menu",
|
||||
color = TextPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Servers
|
||||
servers.forEach { server ->
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = (if (active) "\u25B6 " else " ") + server.address,
|
||||
label = server.displayName(),
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
@@ -116,69 +154,70 @@ private fun MenuPanel(
|
||||
}
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
// Add server
|
||||
if (showAdd) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true,
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
||||
colors = nesFieldColors(),
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
GlassField(
|
||||
value = nm, onValueChange = { nm = it },
|
||||
placeholder = "Name (optional)",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
GlassField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = "192.168.1.100",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GlassField(
|
||||
value = pwd, onValueChange = { pwd = it },
|
||||
placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) },
|
||||
modifier = Modifier.weight(1f).height(48.dp), singleLine = true,
|
||||
placeholder = "Password",
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = {
|
||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
||||
}),
|
||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
||||
colors = nesFieldColors(),
|
||||
shape = RoundedCornerShape(2.dp),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected)
|
||||
.clickable {
|
||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
||||
},
|
||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { submit() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
|
||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MenuItem(label = " ADD SERVER", onClick = { showAdd = true })
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f)))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Mode toggle
|
||||
MenuItem(
|
||||
label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD",
|
||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||
onClick = onToggleMode,
|
||||
)
|
||||
|
||||
// Style toggle
|
||||
MenuItem(
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK",
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
||||
onClick = onToggleStyle,
|
||||
)
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView)
|
||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,32 +226,69 @@ private fun MenuPanel(
|
||||
private fun MenuItem(
|
||||
label: String,
|
||||
selected: Boolean = false,
|
||||
labelColor: Color = TextPrimary,
|
||||
onClick: () -> Unit,
|
||||
onRemove: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(32.dp)
|
||||
.background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent)
|
||||
.height(ROW_H)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
|
||||
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) BitcoinOrange else labelColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (onRemove != null) {
|
||||
Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp,
|
||||
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp))
|
||||
Text(
|
||||
"✕",
|
||||
color = TextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Glass text field with centered input text. */
|
||||
@Composable
|
||||
private fun nesFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NES.MenuBorder,
|
||||
unfocusedBorderColor = NES.MenuMuted,
|
||||
cursorColor = NES.MenuText,
|
||||
focusedTextColor = NES.MenuText,
|
||||
unfocusedTextColor = NES.MenuText,
|
||||
)
|
||||
private fun GlassField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = {
|
||||
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
},
|
||||
modifier = modifier.fillMaxWidth().height(FIELD_H),
|
||||
singleLine = true,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = BitcoinOrange,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-5
@@ -50,7 +50,6 @@ fun NESPortraitController(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.twoFingerHold(onMenu)
|
||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -62,7 +61,7 @@ fun NESPortraitController(
|
||||
.fillMaxSize()
|
||||
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
|
||||
.background(Brush.verticalGradient(listOf(c.body, c.body)))
|
||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
|
||||
) {
|
||||
// Top highlight
|
||||
@@ -119,11 +118,11 @@ fun NESPortraitController(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
|
||||
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
||||
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
|
||||
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
||||
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -41,7 +42,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
contentAlignment = Alignment.Center,
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
// Reddish synthwave backdrop
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
// Dark scrim so the title/buttons stay legible over the art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.55f),
|
||||
Color.Black.copy(alpha = 0.35f),
|
||||
Color.Black.copy(alpha = 0.75f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Wide pixel-art logo
|
||||
// Circular badge logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(160.dp)
|
||||
.alpha(logoAlpha.value),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
@@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = TextPrimary,
|
||||
color = Color(0xFFFAFAFA),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
@@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = TextMuted,
|
||||
color = Color(0xFFFAFAFA),
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 26.sp,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,13 +25,17 @@ 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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.network.ConnectionState
|
||||
import com.archipelago.app.network.InputWebSocket
|
||||
@@ -58,7 +63,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||
|
||||
val ws = remember { InputWebSocket(scope) }
|
||||
@@ -113,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF0C0C0C))
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
.background(Color(0xFF0C0C0C)),
|
||||
) {
|
||||
// Reddish synthwave backdrop behind the controller
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
// Light scrim — the controller body provides its own contrast, so keep
|
||||
// this subtle and let the backdrop show through around it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.4f),
|
||||
Color.Black.copy(alpha = 0.25f),
|
||||
Color.Black.copy(alpha = 0.45f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
|
||||
when {
|
||||
isGamepadMode && isLandscape -> NESController(
|
||||
style = controllerStyle,
|
||||
@@ -174,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
NESMenu(
|
||||
visible = showModal,
|
||||
@@ -188,7 +216,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
||||
onAddServer = { server ->
|
||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||
},
|
||||
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
|
||||
onRemoveServer = { server ->
|
||||
scope.launch {
|
||||
prefs.removeSavedServer(server)
|
||||
// Deleting the last server leaves nothing to control — drop the
|
||||
// active server and return to the Connect screen.
|
||||
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
||||
if (remaining == 0) {
|
||||
ws.disconnect()
|
||||
prefs.clearActiveServer()
|
||||
showModal = false
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||
onToggleStyle = {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -97,6 +98,7 @@ fun ServerConnectScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -132,12 +134,33 @@ fun ServerConnectScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
.background(SurfaceBlack),
|
||||
) {
|
||||
// Reddish synthwave backdrop
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
// Dark scrim so the form stays legible over the art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Black.copy(alpha = 0.45f),
|
||||
Color.Black.copy(alpha = 0.8f),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(state = rememberScrollState())
|
||||
.drawWithContent { drawContent() }
|
||||
.padding(horizontal = 24.dp)
|
||||
@@ -145,14 +168,11 @@ fun ServerConnectScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Wide logo
|
||||
// Circular badge logo
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
modifier = Modifier.size(96.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
@@ -178,6 +198,7 @@ fun ServerConnectScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
@@ -190,6 +211,34 @@ fun ServerConnectScreen(
|
||||
.padding(20.dp),
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.server_name_label)) },
|
||||
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||
cursorColor = Color.White,
|
||||
focusedLabelColor = Color.White.copy(alpha = 0.7f),
|
||||
unfocusedLabelColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = address,
|
||||
onValueChange = {
|
||||
@@ -275,7 +324,7 @@ fun ServerConnectScreen(
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password))
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
@@ -345,7 +394,7 @@ fun ServerConnectScreen(
|
||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||
onClick = {
|
||||
keyboard?.hide()
|
||||
connect(ServerEntry(address, useHttps, port, password))
|
||||
connect(ServerEntry(address, useHttps, port, password, name))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
@@ -391,6 +440,7 @@ private fun SavedServerItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
@@ -414,9 +464,15 @@ private fun SavedServerItem(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (server.port.isNotBlank()) {
|
||||
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
|
||||
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val secondary = buildString {
|
||||
if (server.name.isNotBlank()) append(server.address)
|
||||
if (server.port.isNotBlank()) {
|
||||
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
|
||||
}
|
||||
}
|
||||
if (secondary.isNotBlank()) {
|
||||
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -28,8 +29,11 @@ import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -41,8 +45,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.archipelago.app.R
|
||||
@@ -51,7 +57,67 @@ import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder thread
|
||||
// (the JS bridge below can run off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/** True when [url] points at the same host as the connected Archipelago node
|
||||
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
|
||||
* and should stay inside the app rather than bouncing out to the browser. */
|
||||
private fun isSameHost(url: String, base: String): Boolean {
|
||||
return try {
|
||||
val a = android.net.Uri.parse(url).host ?: return false
|
||||
val b = android.net.Uri.parse(base).host ?: return false
|
||||
a.equals(b, ignoreCase = true)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
* These are tuned for SPA performance and parity with the mobile browser;
|
||||
* none of them alter how a page renders visually. */
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun WebView.applyArchipelagoSettings() {
|
||||
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
|
||||
// show blank checkerboarding — the single biggest scroll-smoothness win and
|
||||
// a major part of the "feels slower than the browser" gap. (API 23+)
|
||||
settings.setOffscreenPreRaster(true)
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
}
|
||||
|
||||
// chrome://inspect profiling on debuggable builds only — lets us measure the
|
||||
// real in-page bottleneck rather than guess. No effect on release builds.
|
||||
val debuggable = 0 != (context.applicationInfo.flags and
|
||||
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
|
||||
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||
@Composable
|
||||
fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
@@ -63,7 +129,12 @@ fun WebViewScreen(
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
BackHandler(enabled = webView?.canGoBack() == true) {
|
||||
// A node app that refused iframing, opened in a local WebView overlay.
|
||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||
// while this is shown, so closing it returns instantly with no reload.
|
||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
@@ -132,20 +203,6 @@ fun WebViewScreen(
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
fun openExternalUrl(url: String) {
|
||||
try {
|
||||
val intent = android.content.Intent(
|
||||
android.content.Intent.ACTION_VIEW,
|
||||
android.net.Uri.parse(url),
|
||||
).apply {
|
||||
// Required when launching from a non-Activity/binder
|
||||
// thread (the JS bridge below runs off the UI thread).
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
WebView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -159,19 +216,8 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptCookie(true)
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||
useWideViewPort = true
|
||||
loadWithOverviewMode = true
|
||||
setSupportZoom(false)
|
||||
builtInZoomControls = false
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
allowContentAccess = true
|
||||
allowFileAccess = false
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
// chain; without this, window.open() from a Vue click
|
||||
@@ -179,18 +225,35 @@ fun WebViewScreen(
|
||||
javaScriptCanOpenWindowsAutomatically = true
|
||||
}
|
||||
|
||||
// Deterministic bridge for "open in the phone's browser".
|
||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||
// when present (companion app), falling back to window.open
|
||||
// in a plain mobile browser. This avoids relying on the
|
||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||
// can suppress in the WebView.
|
||||
val webViewRef = this
|
||||
|
||||
// Decide where an outbound URL goes:
|
||||
// - same host as the node → in-app WebView overlay
|
||||
// (this is the "open in browser" target for apps the
|
||||
// kiosk couldn't iframe — keep the user inside the app)
|
||||
// - different host → the phone's real browser
|
||||
fun routeOutbound(url: String) {
|
||||
if (isSameHost(url, serverUrl)) {
|
||||
inAppUrl = url
|
||||
} else {
|
||||
openExternalUrl(context, url)
|
||||
}
|
||||
}
|
||||
|
||||
// JS bridge. The web UI calls:
|
||||
// window.ArchipelagoNative.openExternal(url) — host-routed
|
||||
// window.ArchipelagoNative.openInApp(url) — force in-app
|
||||
// Falls back to window.open in a plain mobile browser.
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openExternal(url: String) {
|
||||
webViewRef.post { openExternalUrl(url) }
|
||||
webViewRef.post { routeOutbound(url) }
|
||||
}
|
||||
|
||||
@android.webkit.JavascriptInterface
|
||||
fun openInApp(url: String) {
|
||||
webViewRef.post { inAppUrl = url }
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
@@ -252,10 +315,10 @@ fun WebViewScreen(
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return false
|
||||
// Keep navigation within the Archipelago server
|
||||
// Keep kiosk navigation (same origin incl. port) in place
|
||||
if (url.startsWith(serverUrl)) return false
|
||||
// Open external URLs in the system browser
|
||||
openExternalUrl(url)
|
||||
// Same node (other port) → in-app; external → browser
|
||||
routeOutbound(url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -265,7 +328,9 @@ fun WebViewScreen(
|
||||
loadProgress = newProgress
|
||||
}
|
||||
|
||||
// Handle window.open() — open in system browser
|
||||
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||
// for an app that can't be iframed. Capture the target
|
||||
// URL via a throwaway WebView and route it ourselves.
|
||||
override fun onCreateWindow(
|
||||
view: WebView?,
|
||||
isDialog: Boolean,
|
||||
@@ -283,12 +348,12 @@ fun WebViewScreen(
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val url = request?.url?.toString() ?: return true
|
||||
openExternalUrl(url)
|
||||
routeOutbound(url)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
if (url != null) openExternalUrl(url)
|
||||
if (url != null) routeOutbound(url)
|
||||
view?.stopLoading()
|
||||
}
|
||||
}
|
||||
@@ -350,6 +415,140 @@ fun WebViewScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||
inAppUrl?.let { target ->
|
||||
InAppBrowser(
|
||||
url = target,
|
||||
serverUrl = serverUrl,
|
||||
onClose = { inAppUrl = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight in-app browser used when the kiosk hands off an app that can't be
|
||||
* shown in an iframe. Loads the app in a local WebView with a minimal top bar
|
||||
* (close + title + escalate-to-real-browser). Same-host navigation stays here;
|
||||
* any genuinely external link escapes to the phone's browser.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
private fun InAppBrowser(
|
||||
url: String,
|
||||
serverUrl: String,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
// Back: walk the in-app history first, then close the overlay.
|
||||
BackHandler {
|
||||
val b = browser
|
||||
if (b != null && b.canGoBack()) b.goBack() else onClose()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(SurfaceBlack)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.close),
|
||||
tint = TextPrimary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.OpenInBrowser,
|
||||
contentDescription = stringResource(R.string.open_in_browser),
|
||||
tint = TextMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = BitcoinOrange,
|
||||
trackColor = SurfaceBlack,
|
||||
)
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
isVerticalScrollBarEnabled = false
|
||||
isHorizontalScrollBarEnabled = false
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
applyArchipelagoSettings()
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
override fun onReceivedTitle(view: WebView?, t: String?) {
|
||||
if (!t.isNullOrBlank()) title = t
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, u: String?) {
|
||||
loading = false
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
): Boolean {
|
||||
val u = request?.url?.toString() ?: return false
|
||||
// Stay in the overlay for same-node navigation;
|
||||
// hand genuinely external links to the real browser.
|
||||
if (isSameHost(u, serverUrl)) return false
|
||||
openExternalUrl(ctx, u)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
browser = this
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@@ -1,10 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Plain dark tile. The badge (single SVG-matched ring + grid) is in the
|
||||
foreground so there's exactly one ring. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#030202"
|
||||
android:fillColor="#0A0A0A"
|
||||
android:pathData="M0,0h108v108H0z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered -->
|
||||
<!-- Complete Archipelago badge (gradient ring + white grid) scaled to ~0.64 so
|
||||
the whole coin — including the ring — sits inside the adaptive safe zone and
|
||||
is never clipped. Ring is bold + bright (grey→white) to read on #0A0A0A. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.72"
|
||||
android:scaleY="0.72">
|
||||
|
||||
<!-- Row 1: 4 blocks -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
||||
<!-- Ringed circle -->
|
||||
<path
|
||||
android:fillColor="#0A0A0A"
|
||||
android:strokeWidth="22.8834"
|
||||
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="751.337"
|
||||
android:startY="751.338"
|
||||
android:endX="0"
|
||||
android:endY="0">
|
||||
<item android:offset="0" android:color="#FF000000" />
|
||||
<item android:offset="1" android:color="#FF666666" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- Row 2: 2 blocks (right side) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
||||
|
||||
<!-- Row 3: 6 blocks (full width) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
||||
|
||||
<!-- Row 4: 4 blocks (sides only — the "A" gap) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
||||
|
||||
<!-- Row 5: 4 blocks (bottom) -->
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
||||
<!-- White Archipelago pixel grid -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago circular badge logo (from logo.svg):
|
||||
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
|
||||
<path
|
||||
android:fillColor="#0A0A0A"
|
||||
android:strokeWidth="22.8834"
|
||||
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="751.337"
|
||||
android:startY="751.338"
|
||||
android:endX="0"
|
||||
android:endY="0">
|
||||
<item android:offset="0" android:color="#FF000000" />
|
||||
<item android:offset="1" android:color="#FF666666" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
|
||||
<!-- White Archipelago pixel grid -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||
</vector>
|
||||
@@ -21,4 +21,8 @@
|
||||
<string name="retry">Retry</string>
|
||||
<string name="remote_input">Remote Control</string>
|
||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="open_in_browser">Open in browser</string>
|
||||
<string name="server_name_label">Server Name (optional)</string>
|
||||
<string name="server_name_placeholder">My Archipelago</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/>
|
||||
<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-color="#666666"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the Android companion app and publish it as the served download
|
||||
# (neode-ui/public/packages/archipelago-companion.apk.zip), then commit + push.
|
||||
#
|
||||
# Use this INSTEAD of `git push` when shipping the companion app, so the
|
||||
# downloadable APK on the node always matches what's on main.
|
||||
#
|
||||
# ./Android/ship-companion.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
|
||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
|
||||
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||
|
||||
echo "==> Building debug APK"
|
||||
( cd Android && ./gradlew :app:assembleDebug --console=plain -q )
|
||||
[ -f "$APK" ] || { echo "ERROR: APK not found at $APK" >&2; exit 1; }
|
||||
|
||||
echo "==> Publishing -> $DEST"
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
rm -f "$DEST"
|
||||
( cd "$(dirname "$APK")" && zip -j -q "$ROOT/$DEST" "$(basename "$APK")" )
|
||||
|
||||
git add "$DEST"
|
||||
if git diff --cached --quiet; then
|
||||
echo "==> Nothing to commit (working tree + APK unchanged)"
|
||||
else
|
||||
git commit -q -m "chore(android): update companion apk download"
|
||||
echo "==> Committed"
|
||||
fi
|
||||
|
||||
echo "==> Pushing $(git branch --show-current)"
|
||||
# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed.
|
||||
SHIP_COMPANION=1 git push origin "$(git branch --show-current)"
|
||||
echo "==> Done — companion APK published and pushed."
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.00-alpha (2026-06-18)
|
||||
|
||||
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
|
||||
|
||||
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
|
||||
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
|
||||
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
|
||||
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
|
||||
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
|
||||
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
|
||||
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
|
||||
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
|
||||
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
|
||||
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
|
||||
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
|
||||
|
||||
All the v1.7.99-alpha features are included as well:
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
|
||||
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
|
||||
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
|
||||
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
|
||||
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
|
||||
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
|
||||
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
|
||||
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
|
||||
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
|
||||
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
|
||||
|
||||
## v1.7.99-alpha (2026-06-17)
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
|
||||
|
||||
@@ -36,9 +36,12 @@ app:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||
# relays to reach iroh-transport federations. Lock down once the default
|
||||
# federation's reachability model is finalized.
|
||||
network_policy: open
|
||||
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
||||
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
|
||||
# wallet bridge targets. ("open" is not a valid policy — it made the loader
|
||||
# skip this whole manifest, so fmcd never ran and federations never joined.)
|
||||
# Lock down once the default federation's reachability model is finalized.
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
||||
|
||||
@@ -146,7 +146,9 @@ impl ApiHandler {
|
||||
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
|
||||
hyper::Body::from(
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -222,8 +224,12 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
||||
)),
|
||||
Err(e) => {
|
||||
// Surface the FULL error chain ({:#}) — the generic top-level
|
||||
// message hid the real cause (e.g. the LND REST connection
|
||||
// failing), which made this 503 undiagnosable.
|
||||
tracing::warn!("content invoice creation failed: {e:#}");
|
||||
let body = serde_json::json!({
|
||||
"error": format!("Could not create invoice: {e}")
|
||||
"error": format!("Could not create invoice: {e:#}")
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -260,6 +260,20 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
|
||||
// the host's federation peers only — federate first"). Surface that to
|
||||
// the user instead of a bare "Peer returned: 403 Forbidden".
|
||||
if response.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
let msg = body
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("Peer returned: {status}"));
|
||||
return Err(anyhow::anyhow!(msg));
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||
}
|
||||
@@ -463,12 +477,16 @@ impl RpcHandler {
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
|
||||
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
|
||||
// give Tor a short-but-real window (onion circuits need a few seconds).
|
||||
let path = format!("/content/{}/invoice", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -524,11 +542,15 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
|
||||
// FIPS and keep a short Tor window; an unreachable peer just reads as
|
||||
// "not yet paid" and the UI polls again.
|
||||
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -652,12 +674,15 @@ impl RpcHandler {
|
||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
|
||||
// Issuing an address is a tiny request/response — fast-fail FIPS, short
|
||||
// Tor window (same budget shape as the invoice path, #6).
|
||||
let path = format!("/content/{}/onchain", content_id);
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.header("X-Federation-DID", local_did)
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.timeout(std::time::Duration::from_secs(25))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
@@ -715,7 +740,8 @@ impl RpcHandler {
|
||||
let (response, _transport) =
|
||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_get()
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -156,6 +156,35 @@ impl RpcHandler {
|
||||
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
|
||||
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
|
||||
/// it as hex so it can be used as a stable lookup key and passed in URLs.
|
||||
/// Whether LND reports it's synced to its Bitcoin chain backend. Used to
|
||||
/// fail invoice minting FAST with a clear reason while the node's Bitcoin
|
||||
/// backend is still in initial block download — otherwise the `/v1/invoices`
|
||||
/// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as
|
||||
/// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns
|
||||
/// `Some(false)` only when LND is reachable AND explicitly not synced;
|
||||
/// `None` when we couldn't tell (let the mint attempt proceed and report its
|
||||
/// own error rather than guess "syncing").
|
||||
pub(crate) async fn lnd_chain_synced(&self) -> Option<bool> {
|
||||
let (client, macaroon_hex) = self.lnd_client().await.ok()?;
|
||||
let resp = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body.get("synced_to_chain").and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
/// Error returned when the node can't mint a Lightning invoice because its
|
||||
/// Bitcoin backend is still syncing. Kept as one string so every invoice
|
||||
/// entry point surfaces the same clear, user-facing reason.
|
||||
fn syncing_invoice_err() -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced."
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_invoice(
|
||||
&self,
|
||||
amount_sats: i64,
|
||||
@@ -173,13 +202,55 @@ impl RpcHandler {
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
// LND's REST endpoint can briefly drop/reset connections under load
|
||||
// (swap pressure, just-restarted, TLS handshake races), which used to
|
||||
// hard-fail the buy-file invoice with an opaque 503. Retry on a
|
||||
// CONNECTION error with short backoff so a transient blip doesn't
|
||||
// surface as a payment failure. A *timeout* is NOT retried: it means LND
|
||||
// accepted the connection but isn't answering the mint (e.g. a degraded
|
||||
// node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail
|
||||
// after the first hang and let the caller surface the real reason.
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let mut resp = None;
|
||||
for attempt in 0..3u32 {
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
resp = Some(r);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let timed_out = e.is_timeout();
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"LND REST send failed (attempt {}): {e}",
|
||||
attempt + 1
|
||||
));
|
||||
if timed_out {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = match resp {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// If LND is reachable but explicitly not synced to chain, say so —
|
||||
// it's the most common reason a just-restored/syncing node can't
|
||||
// mint. Otherwise surface the underlying transport error.
|
||||
if self.lnd_chain_synced().await == Some(false) {
|
||||
return Err(Self::syncing_invoice_err());
|
||||
}
|
||||
return Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("Failed to reach LND REST to create invoice")
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
@@ -356,13 +427,23 @@ impl RpcHandler {
|
||||
"memo": memo,
|
||||
});
|
||||
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// A hung/failed mint while LND is explicitly not synced to chain
|
||||
// gets a clear, user-facing reason instead of an opaque error.
|
||||
if self.lnd_chain_synced().await == Some(false) {
|
||||
return Err(Self::syncing_invoice_err());
|
||||
}
|
||||
return Err(anyhow::anyhow!(e).context("Failed to create invoice"));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
|
||||
@@ -14,12 +14,12 @@ impl RpcHandler {
|
||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let cfg = {
|
||||
let (cfg, denied_askers) = {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
svc.assistant_config().await
|
||||
(svc.assistant_config().await, svc.assistant_denied_askers().await)
|
||||
};
|
||||
|
||||
let (ollama_detected, models) = detect_ollama().await;
|
||||
@@ -32,10 +32,12 @@ impl RpcHandler {
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"ollama_detected": ollama_detected,
|
||||
"claude_available": claude_available,
|
||||
"models": models,
|
||||
"denied_askers": denied_askers,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -64,8 +66,18 @@ impl RpcHandler {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// allowed_contacts: present + array => replace the allowlist (pubkey hex
|
||||
// strings); absent => leave unchanged.
|
||||
let allowed_contacts = params
|
||||
.get("allowed_contacts")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|e| e.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend)
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
|
||||
.await?;
|
||||
let cfg = svc.assistant_config().await;
|
||||
Ok(serde_json::json!({
|
||||
@@ -73,6 +85,7 @@ impl RpcHandler {
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -258,43 +258,45 @@ impl RpcHandler {
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let state = svc.state();
|
||||
|
||||
// Snapshot the firmware pubkeys we currently know about, then
|
||||
// add them to the radio-contact blocklist. MeshCore's on-device
|
||||
// contact table is persistent and reads back stale rows on the
|
||||
// next refresh_contacts, so without this step `clear-all` only
|
||||
// wipes the app view for a few seconds before the old entries
|
||||
// reappear. The blocklist is also saved to disk so the filter
|
||||
// survives a restart.
|
||||
let firmware_pubkeys: Vec<String> = state
|
||||
// NOTE: `clear-all` intentionally does NOT build a radio-contact
|
||||
// blocklist. Permanently ignoring firmware contacts meant a cleared
|
||||
// peer could never return even when it re-advertised (it also broke
|
||||
// re-pairing a phone after a clear). Real per-contact blocking will
|
||||
// be a separate, explicit feature. Here we just wipe the app-side
|
||||
// view and ALSO clear any blocklist left over from older builds, so
|
||||
// previously-hidden contacts can re-appear when next heard. The
|
||||
// firmware's own contact table is the source of truth on refresh.
|
||||
{
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
set.clear();
|
||||
}
|
||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
|
||||
|
||||
// Actually DELETE each radio contact from the firmware table (via
|
||||
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||
// refresh. They come back only when they re-advertise (reachable).
|
||||
// Federation-synthetic peers (high contact_id bit) aren't firmware
|
||||
// contacts, so skip those.
|
||||
let firmware_pubkeys: Vec<[u8; 32]> = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
// Federation-synthetic peers have their contact_id in the
|
||||
// high half of u32 and carry the archipelago key — those
|
||||
// aren't firmware contacts and must not go on the list.
|
||||
if p.contact_id & 0x8000_0000 != 0 {
|
||||
None
|
||||
} else {
|
||||
p.pubkey_hex.clone()
|
||||
}
|
||||
.filter(|p| p.contact_id & 0x8000_0000 == 0)
|
||||
.filter_map(|p| p.pubkey_hex.as_deref())
|
||||
.filter_map(|h| hex::decode(h).ok())
|
||||
.filter(|b| b.len() == 32)
|
||||
.map(|b| {
|
||||
let mut k = [0u8; 32];
|
||||
k.copy_from_slice(&b);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
for pk in &firmware_pubkeys {
|
||||
set.insert(pk.clone());
|
||||
}
|
||||
for pk in firmware_pubkeys {
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
|
||||
.await;
|
||||
}
|
||||
let persisted: Vec<String> = state
|
||||
.radio_contact_blocklist
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
|
||||
|
||||
state.peers.write().await.clear();
|
||||
state.messages.write().await.clear();
|
||||
|
||||
@@ -349,13 +349,37 @@ fn http_probe_cmd(url: &'static str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitcoin UTXO cache (`-dbcache`) in MB, sized to host RAM.
|
||||
///
|
||||
/// A fixed large dbcache on a small box pushes bitcoind + the ~20 app
|
||||
/// containers past physical RAM and triggers system-wide swap thrash: the
|
||||
/// disk saturates, bitcoind can't answer its own RPC, and the dashboard
|
||||
/// backend's sqlite reads stall — surfacing as /rpc/v1 502s and a blank
|
||||
/// Bitcoin UI. Budget ~1/16 of RAM for the cache (floor 300 MB — bitcoind's
|
||||
/// own default is 450 — cap 4096 MB), mirroring scripts/container-specs.sh.
|
||||
pub(super) fn bitcoin_dbcache_mb() -> u64 {
|
||||
let total_mb = std::fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
c.lines()
|
||||
.find_map(|l| l.strip_prefix("MemTotal:"))
|
||||
.and_then(|v| v.split_whitespace().next())
|
||||
.and_then(|kb| kb.parse::<u64>().ok())
|
||||
})
|
||||
.map(|kb| kb / 1024)
|
||||
.unwrap_or(16000); // assume a comfortable host if /proc/meminfo is unreadable
|
||||
(total_mb / 16).clamp(300, 4096)
|
||||
}
|
||||
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
||||
// headroom for mempool + connection buffers + script-verifier
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
// Heavy apps. Bitcoin: dbcache is now host-RAM-aware (see
|
||||
// bitcoin_dbcache_mb), so the daemon's footprint scales with the box.
|
||||
// This cgroup cap is an upper bound for mempool + connection buffers +
|
||||
// script-verifier memory + I/O; a tight cap (4g) previously caused
|
||||
// OOM-cascades during IBD, so keep 8g as a generous ceiling rather
|
||||
// than a tight limit — swap thrash is prevented at the dbcache layer.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX indexing spikes above its cache size due Python,
|
||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||
@@ -674,9 +698,10 @@ pub(super) async fn get_app_config(
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
//
|
||||
// Sync-speed flags:
|
||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
||||
// diminishing returns. Container has --memory=8g now so
|
||||
// there's headroom for mempool + connections.
|
||||
// -dbcache — UTXO set cache, sized to host RAM via
|
||||
// bitcoin_dbcache_mb() (see there). A fixed 4GB cache swap-
|
||||
// thrashed small nodes into fleet-wide 502s; ~1/16 of RAM
|
||||
// keeps headroom for mempool + connections + the app stack.
|
||||
// -par=0 — use all available cores for script
|
||||
// verification (defaults to NCPU-1 capped at 16). Was
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
@@ -689,7 +714,7 @@ pub(super) async fn get_app_config(
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
"-dbcache=4096".to_string(),
|
||||
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||
"-par=0".to_string(),
|
||||
"-maxconnections=125".to_string(),
|
||||
]),
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ecash, profits};
|
||||
use crate::wallet::{ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu`
|
||||
/// string is routed to the Fedimint reissue path.
|
||||
fn is_cashu_token(token: &str) -> bool {
|
||||
token.trim_start().starts_with("cashu")
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
@@ -129,11 +136,27 @@ impl RpcHandler {
|
||||
let token = params
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
||||
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
// Dual-ecash: one "Receive ecash" box accepts either a Cashu token
|
||||
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||
// sidecar). Detect by prefix and route accordingly.
|
||||
if is_cashu_token(token) {
|
||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "cashu",
|
||||
}));
|
||||
}
|
||||
|
||||
let (amount, federation_id) =
|
||||
fedimint_client::reissue_into_any(&self.config.data_dir, token).await?;
|
||||
Ok(serde_json::json!({
|
||||
"received_sats": amount,
|
||||
"kind": "fedimint",
|
||||
"federation_id": federation_id,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,32 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CACHE_REFRESH_SECS: u64 = 10;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
|
||||
// Poll frequently and recover fast so the cached snapshot tracks bitcoind's
|
||||
// responsive windows during IBD. During heavy block-connection, getblockchaininfo
|
||||
// can block briefly; a slow 10s/15s/20s cadence let one missed poll age the
|
||||
// snapshot past the UI's 30s "stale" threshold, so the UI dwelled on
|
||||
// "reconnecting…" long after bitcoind was answering again. Tight cadence + short
|
||||
// timeout keeps last-known state fresh and clears the stale banner promptly.
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
|
||||
|
||||
// Grace window before a failing poll marks the snapshot "stale" for the UI.
|
||||
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
|
||||
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
|
||||
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
|
||||
// several polls failed in a row — do we surface the banner.
|
||||
const STALE_GRACE_MS: u64 = 20_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
pub ok: bool,
|
||||
pub stale: bool,
|
||||
pub updated_at_ms: u64,
|
||||
// Server-computed age of the snapshot, filled in at serve time. The browser
|
||||
// must not derive this itself (Date.now() - updated_at_ms) because that
|
||||
// compares the browser clock against this node's clock — any skew made a
|
||||
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
|
||||
pub age_ms: u64,
|
||||
pub error: Option<String>,
|
||||
pub blockchain_info: Option<serde_json::Value>,
|
||||
pub network_info: Option<serde_json::Value>,
|
||||
@@ -34,6 +52,7 @@ impl Default for BitcoinNodeStatus {
|
||||
ok: false,
|
||||
stale: false,
|
||||
updated_at_ms: 0,
|
||||
age_ms: 0,
|
||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||
blockchain_info: None,
|
||||
network_info: None,
|
||||
@@ -122,7 +141,11 @@ pub fn spawn_status_cache() {
|
||||
|
||||
if cached.blockchain_info.is_some() {
|
||||
cached.ok = false;
|
||||
cached.stale = true;
|
||||
// Only flip to "stale" once the last good snapshot is older
|
||||
// than the grace window. A brief RPC gap on a busy node keeps
|
||||
// showing last-known state silently instead of a banner flicker.
|
||||
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
|
||||
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
|
||||
cached.error = Some(friendly_transient_error(true, &err_msg));
|
||||
} else {
|
||||
*cached = BitcoinNodeStatus {
|
||||
@@ -142,40 +165,46 @@ pub fn spawn_status_cache() {
|
||||
}
|
||||
|
||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
cache().read().await.clone()
|
||||
let mut status = cache().read().await.clone();
|
||||
// Compute age here (server clock only) so the browser never has to subtract
|
||||
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
|
||||
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
|
||||
if status.updated_at_ms > 0 {
|
||||
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
|
||||
// but correctly; too tight a timeout turned working-but-slow polls into
|
||||
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getblockchaininfo")?;
|
||||
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getnetworkinfo")
|
||||
.ok();
|
||||
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getindexinfo")
|
||||
.ok();
|
||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
// Fetch all four calls concurrently: getblockchaininfo gates freshness, so a
|
||||
// slow auxiliary call (network/index/zmq) must not delay the snapshot or block
|
||||
// the next refresh. Only getblockchaininfo failing marks the status stale.
|
||||
let (blockchain_info, network_info, index_info, zmq_notifications) = tokio::join!(
|
||||
bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])),
|
||||
);
|
||||
let blockchain_info = blockchain_info.context("getblockchaininfo")?;
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
stale: false,
|
||||
updated_at_ms: now_ms(),
|
||||
age_ms: 0,
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info,
|
||||
index_info,
|
||||
zmq_notifications,
|
||||
network_info: network_info.ok(),
|
||||
index_info: index_info.ok(),
|
||||
zmq_notifications: zmq_notifications.ok(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
],
|
||||
pre_start: None,
|
||||
bind_mounts: &[],
|
||||
ports: &[(18083, 80)],
|
||||
host_network: false,
|
||||
// Host networking so the app's own nginx can proxy the archipelago backend
|
||||
// same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The
|
||||
// previous bridge + 18083→80 mapping forced the browser to fetch the
|
||||
// backend cross-origin from the app's port, which depended on the host
|
||||
// nginx route + a CORS Origin/Host match and broke on http-only nodes
|
||||
// (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now
|
||||
// listens on 18083 directly (NOT 80 — that would collide with host nginx).
|
||||
ports: &[],
|
||||
host_network: true,
|
||||
}];
|
||||
|
||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||
@@ -439,12 +446,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lnd_ui_uses_port_mapping_not_host_port_80() {
|
||||
fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() {
|
||||
// lnd-ui is host-networked (its nginx listens on 18083 directly) so the
|
||||
// app can proxy the archipelago backend same-origin instead of fetching
|
||||
// it cross-origin from its app port — see the spec comment for why.
|
||||
let spec = &LND_UI[0];
|
||||
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
||||
assert_eq!(u.name, "archy-lnd-ui");
|
||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
|
||||
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
|
||||
assert!(matches!(u.network, NetworkMode::Host));
|
||||
assert!(u.ports.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -365,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"fedimint-clientd" | "fmcd" => AppMetadata {
|
||||
title: "Fedimint Client".to_string(),
|
||||
description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(),
|
||||
icon: "/assets/img/app-icons/fedimint.png".to_string(),
|
||||
repo: "https://github.com/minmoto/fmcd".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"morphos" | "morphos-server" => AppMetadata {
|
||||
title: "Morphos".to_string(),
|
||||
description: "Self-hosted file converter".to_string(),
|
||||
|
||||
@@ -70,6 +70,10 @@ fn is_required_baseline_app(app_id: &str) -> bool {
|
||||
| "mempool"
|
||||
| "archy-mempool-db"
|
||||
| "filebrowser"
|
||||
// fmcd: bundled on every node so the wallet's Fedimint side works
|
||||
// out of the box (auto-joins the default federation). Self-heals if
|
||||
// removed, like the other baseline services.
|
||||
| "fedimint-clientd"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2728,6 +2732,13 @@ impl ProdContainerOrchestrator {
|
||||
.await
|
||||
.context("ensuring bitcoin tx-relay credentials")?;
|
||||
}
|
||||
if app_id == "fedimint-clientd" {
|
||||
// The fmcd container's secret_env (fmcd-password) and the wallet
|
||||
// bridge both read this; generate it before secret_env resolves.
|
||||
crate::wallet::fedimint_client::ensure_fmcd_password(&self.secrets_dir)
|
||||
.await
|
||||
.context("ensuring fmcd password secret")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -254,11 +254,57 @@ pub(crate) async fn notify_join(
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let _ = crate::fips::dial::PeerRequest::new(remote_fips_npub, remote_onion, "/rpc/v1")
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send_json(&body)
|
||||
.await;
|
||||
// Deliver the notification in the BACKGROUND with retries, and return
|
||||
// immediately. Two reasons:
|
||||
// 1. The join RPC must not block on this. Awaiting a cold FIPS overlay
|
||||
// (no shared FIPS path between LAN and remote/Tailscale peers) stalled
|
||||
// the whole join until FIPS timed out, surfacing as "Request timeout"
|
||||
// in the UI even though the local membership was already saved.
|
||||
// 2. If this single best-effort POST failed, the inviter never learned
|
||||
// about us → asymmetric federation (they couldn't see us). Retrying in
|
||||
// the background until it lands makes federation converge to symmetric.
|
||||
// `fips_timeout` fast-fails a dead FIPS path so the Tor fallback (which
|
||||
// answers an onion in ~3-5s) is reached quickly on each attempt.
|
||||
let remote_onion = remote_onion.to_string();
|
||||
let remote_fips_npub = remote_fips_npub.map(|s| s.to_string());
|
||||
tokio::spawn(async move {
|
||||
// ~5 attempts with linear backoff: 0s, 10s, 20s, 30s, 40s — covers a
|
||||
// peer that is briefly unreachable (restarting, publishing its onion)
|
||||
// without hammering it.
|
||||
for attempt in 1..=5u32 {
|
||||
let res = crate::fips::dial::PeerRequest::new(
|
||||
remote_fips_npub.as_deref(),
|
||||
&remote_onion,
|
||||
"/rpc/v1",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Federation)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_json(&body)
|
||||
.await;
|
||||
match res {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
tracing::info!(
|
||||
attempt,
|
||||
transport = %transport,
|
||||
"peer-joined notification delivered to inviter"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok((resp, _)) => tracing::warn!(
|
||||
attempt,
|
||||
status = %resp.status(),
|
||||
"peer-joined notification rejected; will retry"
|
||||
),
|
||||
Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"),
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
|
||||
}
|
||||
tracing::warn!(
|
||||
onion = %remote_onion,
|
||||
"peer-joined notification gave up after retries — peer may not see us until next sync"
|
||||
);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ mod types;
|
||||
|
||||
// Re-export all public items so `crate::federation::*` continues to work.
|
||||
pub use invites::{accept_invite, create_invite};
|
||||
// Crate-internal: used by the periodic federation auto-sync to re-assert
|
||||
// membership to peers that don't list us back (asymmetry self-heal).
|
||||
pub(crate) use invites::notify_join;
|
||||
#[allow(unused_imports)]
|
||||
pub use storage::{
|
||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||
|
||||
@@ -33,6 +33,12 @@ pub async fn sync_with_peer(
|
||||
.header("X-Federation-Sig", signature)
|
||||
.header("X-Federation-Timestamp", timestamp)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
// Fast-fail a cold/unreachable FIPS overlay (common between LAN and
|
||||
// remote/Tailscale peers that share no FIPS path) so the Tor fallback —
|
||||
// which answers an onion in ~3-5s — isn't stuck behind the full 30s FIPS
|
||||
// budget. Without this, a state sync to a FIPS-unreachable peer "took
|
||||
// ages" and join/sync appeared to time out even though Tor was healthy.
|
||||
.fips_timeout(std::time::Duration::from_secs(6))
|
||||
.send_json(&body)
|
||||
.await
|
||||
.context("Failed to reach federated peer")?;
|
||||
|
||||
@@ -308,6 +308,14 @@ pub struct PeerRequest<'a> {
|
||||
pub path: &'a str,
|
||||
pub headers: Vec<(&'a str, String)>,
|
||||
pub timeout: std::time::Duration,
|
||||
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
|
||||
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
|
||||
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
|
||||
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
|
||||
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
|
||||
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
|
||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||
pub fips_timeout: Option<std::time::Duration>,
|
||||
pub service: Option<crate::settings::transport::PeerService>,
|
||||
}
|
||||
|
||||
@@ -319,10 +327,26 @@ impl<'a> PeerRequest<'a> {
|
||||
path,
|
||||
headers: Vec::new(),
|
||||
timeout: std::time::Duration::from_secs(30),
|
||||
fips_timeout: None,
|
||||
service: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
|
||||
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
|
||||
/// budget. Use on short request/response calls (invoice, status); leave
|
||||
/// unset for large downloads that legitimately need a long FIPS transfer.
|
||||
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
|
||||
self.fips_timeout = Some(t);
|
||||
self
|
||||
}
|
||||
|
||||
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
|
||||
/// overall request timeout.
|
||||
fn fips_attempt_timeout(&self) -> std::time::Duration {
|
||||
self.fips_timeout.unwrap_or(self.timeout)
|
||||
}
|
||||
|
||||
/// Tie this request to a user-configurable service preference. If
|
||||
/// the user has set that service to `Fips` or `Tor`, the builder
|
||||
/// respects it.
|
||||
@@ -423,7 +447,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.post(&url).json(body);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
@@ -456,7 +480,7 @@ impl<'a> PeerRequest<'a> {
|
||||
}
|
||||
};
|
||||
let url = format!("{}{}", base, self.path);
|
||||
let c = client_with_timeout(self.timeout);
|
||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||
let mut rb = c.get(&url);
|
||||
for (k, v) in &self.headers {
|
||||
rb = rb.header(*k, v);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! asker is limited to one in-flight query.
|
||||
|
||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||
use super::super::types::MeshEvent;
|
||||
use super::bitcoin::send_to_peer;
|
||||
use super::{MeshCommand, MeshState};
|
||||
use crate::federation::TrustLevel;
|
||||
@@ -42,28 +43,46 @@ pub(super) enum AssistReply {
|
||||
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||
ChannelText { channel: u8 },
|
||||
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
|
||||
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
|
||||
/// direct message, so the answer lands inline in that same conversation
|
||||
/// (encrypted, peer-addressed) rather than as a separate widget.
|
||||
ChatText { contact_id: u32 },
|
||||
/// Plain-text NATIVE direct message back to the asker's radio contact —
|
||||
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
|
||||
/// answer goes as a real unicast DM (not a public-channel broadcast), so
|
||||
/// only the asker sees it and a stock client can read it.
|
||||
RadioDm { dest_prefix: [u8; 6] },
|
||||
}
|
||||
|
||||
/// Entry point: gate the query, run the model, send the answer back via the
|
||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn run_assist(
|
||||
prompt: String,
|
||||
model_override: Option<String>,
|
||||
req_id: u64,
|
||||
asker_contact_id: u32,
|
||||
sender_name: String,
|
||||
// Whether the asker's message was cryptographically authenticated (a
|
||||
// verified signature, or arrival over the federation transport). Required
|
||||
// for any identity-based allow under `trusted_only`/the allowlist.
|
||||
authenticated: bool,
|
||||
reply: AssistReply,
|
||||
state: Arc<MeshState>,
|
||||
) {
|
||||
let asker = asker_contact_id;
|
||||
|
||||
// Trust + block gate.
|
||||
if !is_sender_allowed(&state, asker).await {
|
||||
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||
warn!(
|
||||
from = asker,
|
||||
name = %sender_name,
|
||||
"AssistQuery denied — sender not permitted by assistant policy"
|
||||
);
|
||||
// Record who was turned away so the operator can find + allow them from
|
||||
// the UI (the silent-on-wire denial otherwise only shows in the journal).
|
||||
record_denied(&state, asker, &sender_name).await;
|
||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||
let _ = state
|
||||
.event_tx
|
||||
@@ -144,13 +163,25 @@ pub(super) async fn run_assist(
|
||||
}
|
||||
|
||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||
/// Always denies user-blocked contacts. With `trusted_only`, requires a
|
||||
/// federation-Trusted match on the peer's pubkey or DID.
|
||||
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
|
||||
///
|
||||
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
|
||||
/// allowlist and the federation-Trusted match) require `authenticated == true` —
|
||||
/// i.e. the asker's message carried a signature that verified against its known
|
||||
/// key (or it arrived over the federation transport, which verifies upstream).
|
||||
/// A bare radio packet can CLAIM any key or DID, so without that proof the
|
||||
/// allowlist and trust list are spoofable; only the explicit "anyone on the
|
||||
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
|
||||
async fn is_sender_allowed(
|
||||
state: &Arc<MeshState>,
|
||||
sender_contact_id: u32,
|
||||
authenticated: bool,
|
||||
) -> bool {
|
||||
let (pubkey_hex, did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&sender_contact_id) {
|
||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
||||
// Match identity on the bound archipelago key (stable, advert/
|
||||
// federation-verified), not the firmware routing key.
|
||||
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
|
||||
None => (None, None),
|
||||
}
|
||||
};
|
||||
@@ -169,11 +200,30 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of the
|
||||
// trusted_only policy — but only when the message is authenticated, so a
|
||||
// spoofed packet claiming an allowlisted key can't slip through.
|
||||
if authenticated {
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !state.assistant.read().await.trusted_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trusted-only: match against the federation trust list.
|
||||
// Trusted-only from here: an unauthenticated asker can never match the trust
|
||||
// list (it could otherwise just claim a trusted node's public key/DID).
|
||||
if !authenticated {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match against the federation trust list by the asker's verified archipelago
|
||||
// pubkey or DID (a radio peer gets these from its signed identity advert).
|
||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -183,6 +233,36 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
})
|
||||
}
|
||||
|
||||
/// Newest-first cap on the denied-asker buffer — enough to surface the people
|
||||
/// who recently tried, without unbounded growth from a spammer.
|
||||
const MAX_DENIED_ASKERS: usize = 25;
|
||||
|
||||
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
|
||||
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
|
||||
/// timestamp/name) so repeated denials from one device don't flood the list.
|
||||
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
|
||||
// Capture the bound archipelago identity key (NOT the firmware routing key):
|
||||
// one-click "Allow" adds this to the allowlist, which the gate matches on the
|
||||
// archipelago key. A peer with no advert has no arch key → None → the UI shows
|
||||
// "no key" (only the "anyone on the mesh" policy can admit it).
|
||||
let pubkey_hex = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&asker_contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
};
|
||||
let entry = super::DeniedAsker {
|
||||
contact_id: asker_contact_id,
|
||||
name: sender_name.to_string(),
|
||||
pubkey_hex,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
let mut denied = state.assist_denied.write().await;
|
||||
denied.retain(|d| d.contact_id != asker_contact_id);
|
||||
denied.push_front(entry);
|
||||
denied.truncate(MAX_DENIED_ASKERS);
|
||||
}
|
||||
|
||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||
/// Returns (text_to_send, was_truncated).
|
||||
fn cap_reply(answer: &str) -> (String, bool) {
|
||||
@@ -205,6 +285,19 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
|
||||
let text = cap_channel(answer);
|
||||
send_channel_text(state, *channel, &text).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
send_chat_text(state, *contact_id, &text).await;
|
||||
}
|
||||
AssistReply::RadioDm { dest_prefix } => {
|
||||
let text = cap_channel(answer);
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: *dest_prefix,
|
||||
payload: text.into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +317,17 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
|
||||
AssistReply::ChannelText { channel } => {
|
||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::RadioDm { dest_prefix } => {
|
||||
let _ = state
|
||||
.send_cmd(MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: *dest_prefix,
|
||||
payload: format!("AI: {msg}").into_bytes(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +376,23 @@ async fn send_typed_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
|
||||
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
|
||||
/// than sending here, because the reply must be routed transport-aware:
|
||||
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
|
||||
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
|
||||
/// to POST over the peer's onion for a federation-synthetic contact_id. The
|
||||
/// radio-only path used to drop the reply for federation askers — the answer
|
||||
/// showed on the answering node but never reached the asker. A server-layer
|
||||
/// consumer fulfils this event via `send_message`, which also records the
|
||||
/// Sent bubble and allocates the seq.
|
||||
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
|
||||
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||
let _ = state
|
||||
|
||||
@@ -353,26 +353,40 @@ pub(super) async fn store_plain_message(
|
||||
state.status.write().await.messages_received += 1;
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
|
||||
// channel is answered by this node's local model when the assistant is on.
|
||||
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
||||
// see it. The trust/rate gate lives in run_assist.
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
|
||||
// by this node's local model when the assistant is on. The trust/rate gate
|
||||
// lives in run_assist. The reply goes back as a private NATIVE DM to the
|
||||
// asker whenever we know its radio pubkey (so it does NOT land on the public
|
||||
// channel and a stock meshcore client can read it); we only fall back to a
|
||||
// channel reply if the sender has no resolvable pubkey (rare).
|
||||
if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = strip_ai_trigger(text) {
|
||||
if !prompt.is_empty() {
|
||||
let reply = {
|
||||
let peers = state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.and_then(|p| p.pubkey_hex.clone())
|
||||
.filter(|h| h.len() >= 12)
|
||||
.and_then(|h| hex::decode(&h[..12]).ok())
|
||||
.filter(|b| b.len() == 6)
|
||||
.map(|b| {
|
||||
let mut pre = [0u8; 6];
|
||||
pre.copy_from_slice(&b);
|
||||
super::assist::AssistReply::RadioDm { dest_prefix: pre }
|
||||
})
|
||||
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
|
||||
};
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
// A bare plain-text channel `!ai` carries no signature, so it
|
||||
// is NOT authenticated — under trusted_only it'll be denied,
|
||||
// and it can only be answered under the "anyone" policy.
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
contact_id,
|
||||
name,
|
||||
super::assist::AssistReply::ChannelText { channel: 0 },
|
||||
st,
|
||||
prompt, None, req_id, contact_id, name, false, reply, st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -383,7 +397,7 @@ pub(super) async fn store_plain_message(
|
||||
|
||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
||||
/// trimmed question after it, or `None` if the text isn't an AI command.
|
||||
fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
let t = text.trim_start();
|
||||
for p in ["!ai ", "!ask "] {
|
||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||
@@ -475,11 +489,18 @@ pub(super) async fn handle_identity_received(
|
||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
// The advert signature was verified above, so this is an authenticated
|
||||
// archipelago identity. Bind it separately so a later refresh_contacts
|
||||
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
x25519_pubkey: Some(x25519_bytes),
|
||||
rssi: Some(rssi),
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
// We just heard this peer's identity advert, so it's reachable.
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
|
||||
@@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
sender_name: &str,
|
||||
envelope: TypedEnvelope,
|
||||
) {
|
||||
// Verify envelope signature if present, using the sender's known Ed25519 key
|
||||
// Verify the envelope signature (if present) against the sender's known
|
||||
// Ed25519 key, and record whether the sender is cryptographically
|
||||
// authenticated. A federation peer (synthetic high-half contact_id) arrived
|
||||
// over the Tor relay, which verifies the sender signature upstream before
|
||||
// injecting here, so it counts as authenticated. This flag gates the
|
||||
// identity-based `!ai` allows (allowlist / federation-trust) downstream.
|
||||
let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE;
|
||||
if envelope.sig.is_some() {
|
||||
let peer_pubkey = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&sender_contact_id)
|
||||
.and_then(|p| p.pubkey_hex.as_ref())
|
||||
// Verify against the bound archipelago identity key, not the
|
||||
// firmware routing key — only the former is what the peer signs with.
|
||||
.and_then(|p| p.identity_pubkey_hex())
|
||||
.and_then(|hex_str| hex::decode(hex_str).ok())
|
||||
.and_then(|bytes| {
|
||||
if bytes.len() == 32 {
|
||||
@@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
});
|
||||
if let Some(vk) = peer_pubkey {
|
||||
match envelope.verify_signature(&vk) {
|
||||
Ok(true) => {}
|
||||
Ok(true) => {
|
||||
authenticated = true;
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
peer = sender_contact_id,
|
||||
@@ -679,6 +689,37 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Mesh-AI assistant (issue #50): a `!ai`/`!ask <question>` typed in
|
||||
// the normal 1:1 chat triggers this node's assistant, with the
|
||||
// answer sent back as a chat bubble in the same thread. The typed
|
||||
// DM carries the peer's federation identity (via sender_contact_id),
|
||||
// so the `trusted_only` gate in run_assist resolves correctly —
|
||||
// unlike the bare channel-text path, which only knows the radio key.
|
||||
if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = super::decode::strip_ai_trigger(&text) {
|
||||
if !prompt.is_empty() {
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = sender_name.to_string();
|
||||
let cid = sender_contact_id;
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
cid,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::ChatText { contact_id: cid },
|
||||
st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::AssistQuery) => {
|
||||
@@ -718,6 +759,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
query.req_id,
|
||||
sender_contact_id,
|
||||
name,
|
||||
authenticated,
|
||||
super::assist::AssistReply::Typed {
|
||||
contact_id: sender_contact_id,
|
||||
},
|
||||
|
||||
@@ -22,8 +22,33 @@ pub(super) async fn handle_frame(
|
||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||
info!(
|
||||
code = frame.code,
|
||||
data_len = frame.data.len(),
|
||||
"Contact discovery event — refreshing contacts"
|
||||
);
|
||||
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
|
||||
// pubkey of a node we just heard. If it isn't already a contact,
|
||||
// add it to the firmware table so it shows up immediately — no
|
||||
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
|
||||
// added by the firmware, so we skip it.)
|
||||
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
|
||||
let mut pubkey = [0u8; 32];
|
||||
pubkey.copy_from_slice(&frame.data[..32]);
|
||||
let pk_hex = hex::encode(pubkey);
|
||||
let known = state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
|
||||
if !known {
|
||||
let _ = state
|
||||
.send_cmd(super::MeshCommand::AddContact {
|
||||
pubkey,
|
||||
name: String::new(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return true; // Signal caller to fetch contacts
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ pub enum MeshCommand {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
|
||||
SendNativeText {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Broadcast pre-encoded binary on a mesh channel.
|
||||
BroadcastChannel {
|
||||
channel: u8,
|
||||
@@ -71,6 +79,16 @@ pub enum MeshCommand {
|
||||
SendAdvert,
|
||||
/// Re-fetch contact list from the radio device.
|
||||
RefreshContacts,
|
||||
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
|
||||
RemoveContact {
|
||||
pubkey: [u8; 32],
|
||||
},
|
||||
/// Import/add a heard advert as a firmware contact so it shows up without
|
||||
/// needing a flood advert. Name may be empty (firmware fills from advert).
|
||||
AddContact {
|
||||
pubkey: [u8; 32],
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Shared state for the mesh listener, accessible from RPC handlers.
|
||||
@@ -135,6 +153,28 @@ pub struct MeshState {
|
||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
|
||||
/// rejects a sender — typically a radio (meshcore) device that presents a
|
||||
/// firmware key rather than an archipelago DID — we record who tried so the
|
||||
/// UI can surface them and let the operator one-click allow their key.
|
||||
/// Silent on the wire (no airtime spent), visible to the operator here.
|
||||
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
|
||||
}
|
||||
|
||||
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
|
||||
/// the operator can add their key to the allowlist without hunting the journal.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeniedAsker {
|
||||
/// Meshcore contact id of the asker.
|
||||
pub contact_id: u32,
|
||||
/// Best-known display name (advert name) at denial time.
|
||||
pub name: String,
|
||||
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
|
||||
/// that hasn't advertised an archipelago key — such a sender can only be
|
||||
/// admitted by switching the policy to "anyone", not via the allowlist.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// ISO-8601 timestamp of the (most recent) denial.
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||
@@ -148,6 +188,10 @@ pub struct AssistantConfig {
|
||||
pub trusted_only: bool,
|
||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||
pub backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
|
||||
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
|
||||
/// applies. A user-blocked contact is always denied even if listed here.
|
||||
pub allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||
@@ -226,6 +270,7 @@ impl MeshState {
|
||||
assistant: RwLock::new(assistant),
|
||||
data_dir,
|
||||
assist_inflight: RwLock::new(HashSet::new()),
|
||||
assist_denied: RwLock::new(VecDeque::new()),
|
||||
});
|
||||
(state, rx, cmd_rx)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,43 @@ impl MeshRadioDevice {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.remove_contact(pubkey).await,
|
||||
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_contact(
|
||||
&mut self,
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => {
|
||||
device
|
||||
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||
.await
|
||||
}
|
||||
Self::Meshtastic(device) => {
|
||||
device
|
||||
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
@@ -151,6 +188,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:";
|
||||
/// route inbound DMs to the correct contact_id thread.
|
||||
pub(super) const DM_V2_MARKER: &str = "@DM2:";
|
||||
|
||||
#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast
|
||||
fn wrap_dm_for_channel(
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
sender_arch_prefix: &[u8; 6],
|
||||
@@ -169,6 +207,7 @@ fn wrap_dm_for_channel(
|
||||
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
|
||||
/// caller constructed `MeshState` with a bad value — empty string yields
|
||||
/// all-zero, which won't match any real peer on the receiver side).
|
||||
#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it
|
||||
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
let mut out = [0u8; 6];
|
||||
if state.our_ed_pubkey_hex.len() >= 12 {
|
||||
@@ -195,39 +234,42 @@ async fn send_dm_via_channel(
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
use base64::Engine;
|
||||
let sender_prefix = our_sender_prefix(state);
|
||||
// First try a single frame with the raw payload directly wrapped.
|
||||
// This keeps small plain-text messages at minimal overhead.
|
||||
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
|
||||
if single.len() <= 140 {
|
||||
match device.send_channel_text(0, single.as_bytes()).await {
|
||||
let _ = state; // native unicast carries no separate sender prefix
|
||||
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
|
||||
// contact, NOT a broadcast on the shared public channel. This is the fix
|
||||
// for the long-standing public-channel pollution — archy used to tunnel
|
||||
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
|
||||
// mesh participant saw as spam and (b) stock meshcore clients (e.g. a
|
||||
// phone) couldn't decode. A native DM is private and decodes everywhere.
|
||||
// The receive side handles these via the existing RESP_CONTACT_MSG path.
|
||||
//
|
||||
// Small payloads send in one frame; larger ones are base64 + MC-chunked
|
||||
// and reassembled by the receiver (try_chunk_reassemble).
|
||||
if payload.len() <= 140 {
|
||||
match device.send_text_msg(dest_pubkey_prefix, payload).await {
|
||||
Ok(()) => {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
len = payload.len(),
|
||||
wire_len = single.len(),
|
||||
"Sent mesh message (DM via channel)"
|
||||
"Sent mesh DM (native unicast)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Failed to send DM via channel: {}", e
|
||||
"Failed to send native DM: {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Payload too large for one wrap — base64 then MC-chunk. Receiver
|
||||
// reassembles base64 chunks and routes the decoded bytes back through
|
||||
// the typed-envelope ladder in handle_channel_payload.
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
||||
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
||||
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let chunk_data_size = 80;
|
||||
let chunk_data_size = 100;
|
||||
let chunks: Vec<&str> = encoded
|
||||
.as_bytes()
|
||||
.chunks(chunk_data_size)
|
||||
@@ -239,18 +281,20 @@ async fn send_dm_via_channel(
|
||||
raw_len = payload.len(),
|
||||
b64_len = encoded.len(),
|
||||
chunks = total,
|
||||
"Sending chunked mesh message (DM via channel)"
|
||||
"Sending chunked mesh DM (native unicast)"
|
||||
);
|
||||
let mut any_err = false;
|
||||
for (idx, chunk) in chunks.iter().enumerate() {
|
||||
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
|
||||
let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes());
|
||||
if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await {
|
||||
if let Err(e) = device
|
||||
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
|
||||
.await
|
||||
{
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
chunk = idx,
|
||||
"Chunk DM-via-channel send failed: {}",
|
||||
"Chunk native DM send failed: {}",
|
||||
e
|
||||
);
|
||||
any_err = true;
|
||||
@@ -263,20 +307,72 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send PLAIN text to a stock meshcore client as one or more native DMs.
|
||||
/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock
|
||||
/// clients can't reassemble it) — if the text exceeds one LoRa frame it is
|
||||
/// split into multiple readable plain messages on UTF-8 char boundaries.
|
||||
async fn send_plain_native_text(
|
||||
device: &mut MeshRadioDevice,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
text: &[u8],
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
// Split on char boundaries so we never break a multi-byte UTF-8 sequence.
|
||||
const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room
|
||||
let s = String::from_utf8_lossy(text);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
for ch in s.chars() {
|
||||
if cur.len() + ch.len_utf8() > FRAME {
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.push(ch);
|
||||
}
|
||||
if !cur.is_empty() || parts.is_empty() {
|
||||
parts.push(cur);
|
||||
}
|
||||
let total = parts.len();
|
||||
for (idx, part) in parts.iter().enumerate() {
|
||||
match device
|
||||
.send_text_msg(dest_pubkey_prefix, part.as_bytes())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
part = idx + 1,
|
||||
total,
|
||||
"Sent plain native DM"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Plain native DM send failed: {}", e
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if total > 1 {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
// mesh.clear-all. MeshCore keeps its own persistent contact
|
||||
// table the app can't remove from, so we filter on read to
|
||||
// keep cleared entries out of the chat list.
|
||||
let blocklist = state.radio_contact_blocklist.read().await.clone();
|
||||
// Contact blocking is intentionally NOT applied here. A read-time
|
||||
// blocklist meant a wiped/re-paired contact could never come back
|
||||
// even when it re-advertised (it broke phone re-pairing after a
|
||||
// clear). Per-contact blocking will return later as an explicit,
|
||||
// user-controlled feature; until then every firmware contact is
|
||||
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||
let mut peers = state.peers.write().await;
|
||||
for (idx, contact) in contacts.iter().enumerate() {
|
||||
if blocklist.contains(&contact.public_key_hex) {
|
||||
continue;
|
||||
}
|
||||
let contact_id = idx as u32;
|
||||
let existing = peers.get(&contact_id);
|
||||
let peer = super::super::types::MeshPeer {
|
||||
@@ -284,14 +380,31 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
advert_name: contact.advert_name.clone(),
|
||||
did: existing.and_then(|p| p.did.clone()),
|
||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||
// Preserve any archipelago identity bound by an earlier
|
||||
// identity advert — NEVER overwrite it with the firmware
|
||||
// contact key, or a signed `!ai` query from this peer would
|
||||
// fail authentication after the next contact refresh.
|
||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
last_advert: contact.last_advert,
|
||||
// A non-zero path_len means the firmware has a route (direct
|
||||
// or flood) to this contact — i.e. we can deliver to it.
|
||||
reachable: contact.path_len != 0,
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
}
|
||||
// A radio contact that shares an exact advert_name with a known
|
||||
// federation peer is the same physical node — bind the federation
|
||||
// peer's archipelago identity onto the radio record so a signed
|
||||
// `!ai`/typed message over LoRa authenticates (and the contact stops
|
||||
// showing as a radio/federation duplicate). Security is unchanged:
|
||||
// the bound key is only a candidate the inbound signature must still
|
||||
// verify against. See `bind_federation_twins`.
|
||||
super::super::bind_federation_twins(&mut peers);
|
||||
drop(peers);
|
||||
state.update_peer_count().await;
|
||||
if !contacts.is_empty() {
|
||||
@@ -424,21 +537,16 @@ pub(super) async fn run_mesh_session(
|
||||
warn!("Failed to send initial advert: {}", e);
|
||||
}
|
||||
|
||||
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
|
||||
// text so peers can bind our radio presence to our DID + keys. The firmware
|
||||
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
|
||||
// is what makes trust-gating + encrypted DMs work across BOTH transports.
|
||||
let identity_advert = super::super::protocol::encode_identity_broadcast(
|
||||
our_did,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
);
|
||||
if let Err(e) = device
|
||||
.send_channel_text(0, identity_advert.as_bytes())
|
||||
.await
|
||||
{
|
||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
||||
}
|
||||
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
|
||||
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
|
||||
// participant on that channel — including plain Meshtastic/meshcore users who
|
||||
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
|
||||
// The inbound parser in frames.rs still accepts these from any legacy peer that
|
||||
// sends them, so trust-binding keeps working when a peer advertises; we simply
|
||||
// don't pollute the public channel ourselves. A dedicated control channel (or a
|
||||
// DM-targeted handshake) is the proper transport for this and is tracked
|
||||
// separately. See encode_identity_broadcast / parse_identity_broadcast.
|
||||
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
|
||||
|
||||
// Fetch existing contacts from the device
|
||||
refresh_contacts(&mut device, state).await;
|
||||
@@ -507,11 +615,9 @@ pub(super) async fn run_mesh_session(
|
||||
} else {
|
||||
consecutive_write_failures = 0;
|
||||
}
|
||||
// Re-broadcast archipelago identity so peers that joined since
|
||||
// startup (or missed it) can bind our DID/keys.
|
||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
||||
warn!("Failed to re-broadcast archipelago identity: {}", e);
|
||||
}
|
||||
// (Identity re-broadcast on the public channel intentionally
|
||||
// removed — see the note at session startup. It spammed the
|
||||
// shared channel every advert tick.)
|
||||
refresh_contacts(&mut device, state).await;
|
||||
}
|
||||
|
||||
@@ -562,6 +668,18 @@ async fn handle_send_command(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
} => {
|
||||
send_plain_native_text(
|
||||
device,
|
||||
&dest_pubkey_prefix,
|
||||
&payload,
|
||||
consecutive_write_failures,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
@@ -615,5 +733,22 @@ async fn handle_send_command(
|
||||
MeshCommand::RefreshContacts => {
|
||||
refresh_contacts(device, state).await;
|
||||
}
|
||||
MeshCommand::RemoveContact { pubkey } => {
|
||||
if let Err(e) = device.remove_contact(&pubkey).await {
|
||||
warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e);
|
||||
} else {
|
||||
info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact");
|
||||
}
|
||||
}
|
||||
MeshCommand::AddContact { pubkey, name } => {
|
||||
// type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood
|
||||
// until a path is learned). last_advert=0 lets the firmware keep its
|
||||
// own advert timestamp.
|
||||
if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await {
|
||||
warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e);
|
||||
} else {
|
||||
info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,14 @@ pub struct MeshtasticDevice {
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
/// Real Curve25519 public keys, keyed by node-num, as learned from NodeInfo
|
||||
/// (`User.public_key`) or PKC-encrypted inbound packets (`MeshPacket
|
||||
/// .public_key`). Kept SEPARATE from `contacts[*].public_key_hex`, which is
|
||||
/// the synthetic node-num-derived routing key that `send_text_msg` relies
|
||||
/// on — we must not overwrite that or unicast routing breaks. This map only
|
||||
/// records which peers are PKC-capable, so we can tell a true end-to-end
|
||||
/// (PKI) DM from a channel-PSK fallback.
|
||||
peer_pubkeys: HashMap<u32, Vec<u8>>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
@@ -68,6 +76,7 @@ impl MeshtasticDevice {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
peer_pubkeys: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -150,6 +159,52 @@ impl MeshtasticDevice {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Native Meshtastic unicast DM. Our synthetic Meshtastic pubkeys carry the
|
||||
/// numeric node-id in their first 4 bytes (little-endian, see
|
||||
/// `synthetic_pubkey`), so `dest_pubkey_prefix` directly yields the
|
||||
/// destination node number. We send a directed MeshPacket (`to` = node num)
|
||||
/// rather than a `BROADCAST_NUM` channel blast — this is the Meshtastic
|
||||
/// analog of the meshcore `CMD_SEND_TXT_MSG` fix: the message is delivered
|
||||
/// as a real DM (only the recipient's client surfaces it) instead of
|
||||
/// polluting the shared primary channel where every node would see it.
|
||||
///
|
||||
/// If the prefix decodes to node 0 / broadcast (e.g. a non-Meshtastic
|
||||
/// synthetic key routed here by mistake), fall back to a channel send so the
|
||||
/// device interface stays uniform and the message still goes out.
|
||||
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
let node_num = u32::from_le_bytes([
|
||||
dest_pubkey_prefix[0],
|
||||
dest_pubkey_prefix[1],
|
||||
dest_pubkey_prefix[2],
|
||||
dest_pubkey_prefix[3],
|
||||
]);
|
||||
if node_num == 0 || node_num == BROADCAST_NUM {
|
||||
return self.send_channel_text(0, msg).await;
|
||||
}
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(node_num, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
||||
/// device interface stays uniform.
|
||||
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_contact(
|
||||
&mut self,
|
||||
_pubkey: &[u8; 32],
|
||||
_contact_type: u8,
|
||||
_flags: u8,
|
||||
_out_path_len: u8,
|
||||
_name: &str,
|
||||
_last_advert: u32,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
if self.contacts.is_empty() {
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
@@ -188,6 +243,19 @@ impl MeshtasticDevice {
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
|
||||
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
|
||||
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
|
||||
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
|
||||
/// distinguish a true E2E DM from a channel-encrypted one without changing
|
||||
/// the shared device interface (which would break meshcore hot-swap).
|
||||
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
|
||||
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
|
||||
self.peer_pubkeys
|
||||
.get(&node_num)
|
||||
.is_some_and(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
@@ -260,6 +328,15 @@ impl MeshtasticDevice {
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
if let Some(pk) = node.public_key.as_ref() {
|
||||
if self.peer_pubkeys.insert(node.num, pk.clone()).is_none() {
|
||||
debug!(
|
||||
node = node.num,
|
||||
key_len = pk.len(),
|
||||
"Meshtastic peer is PKC-capable (NodeInfo public_key)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
@@ -292,6 +369,18 @@ impl MeshtasticDevice {
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
// Record E2E status: a `pki_encrypted` packet (or one carrying the
|
||||
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
|
||||
// the PKI, not the shared channel PSK. We learn the sender's key here too
|
||||
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
|
||||
// device interface stays identical to meshcore's and the two remain
|
||||
// hot-swappable behind the mesh listener.
|
||||
if let Some(pk) = packet.public_key.as_ref() {
|
||||
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
|
||||
}
|
||||
if packet.pki_encrypted {
|
||||
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
@@ -418,6 +507,7 @@ struct ParsedNode {
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
@@ -428,6 +518,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
public_key: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
@@ -440,6 +531,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
node.public_key = user.public_key;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
@@ -457,6 +549,7 @@ struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
@@ -465,6 +558,7 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
public_key: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
@@ -473,6 +567,9 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
// User.public_key (field 8): the peer's Curve25519 key. Its presence
|
||||
// means the radio can PKC-encrypt DMs to this node end-to-end.
|
||||
(8, FieldValue::Bytes(b)) if !b.is_empty() => user.public_key = Some(b.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -483,18 +580,28 @@ struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
|
||||
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
|
||||
/// rather than via the shared channel PSK.
|
||||
pki_encrypted: bool,
|
||||
/// MeshPacket.public_key (field 16): the sender's key, carried on PKC DMs.
|
||||
public_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
let mut pki_encrypted = false;
|
||||
let mut public_key = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
|
||||
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -515,6 +622,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
pki_encrypted,
|
||||
public_key,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||
/// high half of u32 space to avoid collision. Both the receive path
|
||||
/// (`inject_typed_from_federation`) and the startup pre-seed use this
|
||||
/// formula so they always produce the same id for the same peer.
|
||||
/// Mesh contacts at or above this id are synthetic federation peers (the high
|
||||
/// half of the u32 space). Meshcore radio contacts use the firmware's low-int id
|
||||
/// space, so this bit cleanly distinguishes "arrived over the authenticated
|
||||
/// federation transport" from "heard over the radio".
|
||||
pub(crate) const FEDERATION_CONTACT_ID_BASE: u32 = 0x8000_0000;
|
||||
|
||||
pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
|
||||
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
|
||||
if bytes.len() < 4 {
|
||||
@@ -55,6 +61,72 @@ pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
|
||||
0x8000_0000 | (low & 0x7FFF_FFFF)
|
||||
}
|
||||
|
||||
/// Bind radio (LoRa) contacts to their federation twin's archipelago identity.
|
||||
///
|
||||
/// The same physical node commonly appears twice in the peer table: a radio
|
||||
/// contact (low `contact_id`, firmware routing key only, `arch_pubkey_hex ==
|
||||
/// None`) and a federation peer (high `contact_id`, `arch_pubkey_hex` set). The
|
||||
/// radio half carries no archipelago identity because identity adverts are no
|
||||
/// longer broadcast on the public channel (anti-spam), so the `!ai` trust gate
|
||||
/// and envelope signature verification have no key to check a radio asker
|
||||
/// against — a `!ai` from a trusted node over LoRa is therefore denied, and the
|
||||
/// node shows up as two separate contacts.
|
||||
///
|
||||
/// We correlate the two halves by exact, case-insensitive `advert_name` and copy
|
||||
/// the federation peer's `arch_pubkey_hex`/`did`/`x25519` onto the radio peer.
|
||||
/// This only supplies a CANDIDATE identity key; it does NOT bypass
|
||||
/// authentication. A radio envelope must still carry an Ed25519 signature that
|
||||
/// verifies against this bound key (see `handle_typed_envelope_direct`), so a
|
||||
/// meshcore node merely *named* like a trusted node cannot impersonate it — it
|
||||
/// cannot produce the signature. The candidate key comes from the authenticated
|
||||
/// federation handshake (`nodes.json`), never from anything the radio packet
|
||||
/// claims. Names held by more than one federation peer are treated as ambiguous
|
||||
/// and skipped so a duplicate name can't bind the wrong identity.
|
||||
pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, MeshPeer>) {
|
||||
// name (lowercased) -> federation identity; `None` marks an ambiguous name
|
||||
// (seen on more than one federation peer) which we must not bind.
|
||||
type FedIdentity = (String, Option<String>, Option<[u8; 32]>);
|
||||
let mut fed_by_name: std::collections::HashMap<String, Option<FedIdentity>> =
|
||||
std::collections::HashMap::new();
|
||||
for p in peers.values() {
|
||||
if p.contact_id < FEDERATION_CONTACT_ID_BASE {
|
||||
continue;
|
||||
}
|
||||
let Some(arch) = p.arch_pubkey_hex.clone() else {
|
||||
continue;
|
||||
};
|
||||
let name = p.advert_name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
fed_by_name
|
||||
.entry(name)
|
||||
.and_modify(|e| *e = None) // a second federation peer with this name → ambiguous
|
||||
.or_insert(Some((arch, p.did.clone(), p.x25519_pubkey)));
|
||||
}
|
||||
if fed_by_name.is_empty() {
|
||||
return;
|
||||
}
|
||||
for p in peers.values_mut() {
|
||||
if p.contact_id >= FEDERATION_CONTACT_ID_BASE || p.arch_pubkey_hex.is_some() {
|
||||
continue;
|
||||
}
|
||||
let name = p.advert_name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some((arch, did, x25519))) = fed_by_name.get(&name) {
|
||||
p.arch_pubkey_hex = Some(arch.clone());
|
||||
if p.did.is_none() {
|
||||
p.did = did.clone();
|
||||
}
|
||||
if p.x25519_pubkey.is_none() {
|
||||
p.x25519_pubkey = *x25519;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsert a mesh peer record representing a federation node so the UI can
|
||||
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
|
||||
/// Existing entries (same contact_id) are updated in place, preserving any
|
||||
@@ -77,13 +149,24 @@ pub(crate) async fn upsert_federation_peer(
|
||||
advert_name: display_name,
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
// Federation peers are authenticated by the Tor relay upstream; their
|
||||
// archipelago key is known, so bind it as the identity key too.
|
||||
arch_pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
|
||||
x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
|
||||
rssi: existing.as_ref().and_then(|p| p.rssi),
|
||||
snr: existing.as_ref().and_then(|p| p.snr),
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
|
||||
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
|
||||
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
|
||||
reachable: true,
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
||||
// inherit this federation peer's archipelago key — so a signed `!ai`/typed
|
||||
// message arriving over LoRa from it authenticates and the duplicate radio
|
||||
// contact resolves to the same identity.
|
||||
bind_federation_twins(&mut peers);
|
||||
drop(peers);
|
||||
state.update_peer_count().await;
|
||||
contact_id
|
||||
@@ -197,6 +280,10 @@ pub struct MeshConfig {
|
||||
/// local GPU) or "ollama" (a local model on this node).
|
||||
#[serde(default = "default_assistant_backend")]
|
||||
pub assistant_backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai` even
|
||||
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
|
||||
#[serde(default)]
|
||||
pub assistant_allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_assistant_backend() -> String {
|
||||
@@ -224,6 +311,7 @@ impl Default for MeshConfig {
|
||||
assistant_model: None,
|
||||
assistant_trusted_only: true,
|
||||
assistant_backend: default_assistant_backend(),
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,6 +489,7 @@ impl MeshService {
|
||||
model: config.assistant_model.clone(),
|
||||
trusted_only: config.assistant_trusted_only,
|
||||
backend: config.assistant_backend.clone(),
|
||||
allowed_contacts: config.assistant_allowed_contacts.clone(),
|
||||
},
|
||||
data_dir.to_path_buf(),
|
||||
);
|
||||
@@ -578,6 +667,7 @@ impl MeshService {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await; // skip first
|
||||
let mut last_announced_height: u64 = 0;
|
||||
let mut last_announce_at: Option<std::time::Instant> = None;
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
@@ -595,6 +685,18 @@ impl MeshService {
|
||||
// Poll Bitcoin Core for latest block
|
||||
match bitcoin_rpc_getblockcount(&client).await {
|
||||
Ok(height) if height > last_announced_height => {
|
||||
// Advance the tip baseline immediately so a fast Bitcoin
|
||||
// catch-up (a new block every poll) doesn't re-fire each tick.
|
||||
last_announced_height = height;
|
||||
// Throttle: at most one announcement per ~9 min. Real ~10 min
|
||||
// blocks still propagate, but a rapid catch-up can no longer
|
||||
// flood the shared LoRa channel.
|
||||
if last_announce_at
|
||||
.map(|t| t.elapsed() < Duration::from_secs(540))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
|
||||
// Store in cache
|
||||
let payload = message_types::BlockHeaderPayload {
|
||||
@@ -640,30 +742,15 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: any peer if no Archy nodes found
|
||||
if sent == 0 {
|
||||
for peer in peers.values() {
|
||||
if sent >= max_peers { break; }
|
||||
if let Some(ref pk) = peer.pubkey_hex {
|
||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||
if pk_bytes.len() >= 6 {
|
||||
let mut prefix = [0u8; 6];
|
||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||
let _ = bha_state.send_cmd(
|
||||
listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire.clone(),
|
||||
},
|
||||
).await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOTE: intentionally NO fallback to arbitrary
|
||||
// peers. Block headers go ONLY to known Archy
|
||||
// (federated) nodes — never to random meshcore
|
||||
// devices on the shared public channel.
|
||||
drop(peers);
|
||||
last_announced_height = height;
|
||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||
if sent > 0 {
|
||||
last_announce_at = Some(std::time::Instant::now());
|
||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to build block announcement: {}", e),
|
||||
}
|
||||
@@ -917,7 +1004,25 @@ impl MeshService {
|
||||
// over Tor; otherwise the send falls through to LoRa.
|
||||
let is_federation_synthetic = contact_id & 0x8000_0000 != 0;
|
||||
let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN;
|
||||
if is_federation_synthetic || exceeds_lora {
|
||||
// Mesh-preferred routing with a federation fallback. A normal radio
|
||||
// contact is delivered over LoRa (preferred — free, local, no internet).
|
||||
// But if that contact is the same node as a federated peer — we know its
|
||||
// archipelago identity (`arch_pubkey_hex`) → onion — AND it is NOT
|
||||
// currently reachable over the radio (out of LoRa range, e.g. a peer on
|
||||
// another continent), route the message over the federation transport
|
||||
// (FIPS→Tor) instead of handing it to a radio that physically cannot
|
||||
// deliver it. Reachable radio peers stay on the mesh; oversized
|
||||
// envelopes (file shares etc.) always take the federation path.
|
||||
let radio_federated_unreachable = !is_federation_synthetic
|
||||
&& !exceeds_lora
|
||||
&& {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if is_federation_synthetic || exceeds_lora || radio_federated_unreachable {
|
||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||
// but fall back to federation storage for federation-synthetic ids
|
||||
// that were never seeded into `state.peers` — e.g. a radio-less
|
||||
@@ -926,9 +1031,15 @@ impl MeshService {
|
||||
// even though we know its onion from nodes.json.
|
||||
let from_table = {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| (p.pubkey_hex.clone(), p.did.clone()))
|
||||
peers.get(&contact_id).map(|p| {
|
||||
// Resolve via the archipelago IDENTITY key (not the firmware
|
||||
// routing key) — that's what matches the peer's onion entry
|
||||
// in nodes.json for the federation lookup below.
|
||||
(
|
||||
p.arch_pubkey_hex.clone().or_else(|| p.pubkey_hex.clone()),
|
||||
p.did.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let (peer_pubkey, peer_did) = match from_table {
|
||||
Some(v) => v,
|
||||
@@ -1062,7 +1173,14 @@ impl MeshService {
|
||||
"/archipelago/mesh-typed",
|
||||
)
|
||||
.service(crate::settings::transport::PeerService::Messaging)
|
||||
.timeout(std::time::Duration::from_secs(120));
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
// Fast-fail a FIPS path the peer isn't reachable on (the common case
|
||||
// for remote/Tailscale peers that share no FIPS overlay with us) so
|
||||
// the Tor fallback delivers the message in ~3-5s instead of the send
|
||||
// hanging on FIPS. FIPS-reachable peers connect in <1s and still use
|
||||
// it; only an unreachable FIPS path is short-circuited. Matches the
|
||||
// federation-sync fix. 8s ≈ the FIPS connect_timeout headroom.
|
||||
.fips_timeout(std::time::Duration::from_secs(8));
|
||||
match req.send_json(&body).await {
|
||||
Ok((resp, transport)) if resp.status().is_success() => {
|
||||
tracing::debug!(contact_id, transport = %transport, "Federation envelope delivered");
|
||||
@@ -1267,13 +1385,57 @@ impl MeshService {
|
||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
|
||||
let seq = self.state.next_send_seq(contact_id).await;
|
||||
// Stock (non-archipelago) radio contacts — e.g. a phone running the
|
||||
// MeshCore app — can't decode our typed envelope and would render it as
|
||||
// garbled bytes. Send them the raw text as a plain native DM instead.
|
||||
// Archipelago peers still get the typed envelope (seq/reply/reaction
|
||||
// addressing + encryption).
|
||||
if !self.is_archy_peer(contact_id).await {
|
||||
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||
self.state
|
||||
.send_cmd(listener::MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix: dest_prefix,
|
||||
payload: text.as_bytes().to_vec(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
return Ok(self
|
||||
.record_sent_typed(contact_id, "text", text, None, seq)
|
||||
.await);
|
||||
}
|
||||
// Sign the envelope with our archipelago identity key so the receiver
|
||||
// can authenticate us over LoRa (it verifies against our bound
|
||||
// `arch_pubkey_hex`). This is what lets a `!ai` typed in chat to a
|
||||
// trusted node pass the receiver's `trusted_only` gate over the radio —
|
||||
// an unsigned radio packet can never authenticate. The signature is
|
||||
// optional on the wire and ignored by peers that don't know our key, so
|
||||
// it stays backward compatible. (Federation/Tor sends already sign in
|
||||
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
|
||||
// — seq is not covered by the signature.
|
||||
let envelope =
|
||||
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
|
||||
TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key)
|
||||
.with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Whether `contact_id` is an archipelago peer (vs a stock meshcore client).
|
||||
/// Federation-synthetic ids are always archy; radio contacts count as archy
|
||||
/// only once we've learned their archipelago identity (DID or x25519 key,
|
||||
/// from federation seeding or an identity exchange). Stock clients have
|
||||
/// neither, so we send them plain text rather than typed envelopes.
|
||||
async fn is_archy_peer(&self, contact_id: u32) -> bool {
|
||||
if contact_id & 0x8000_0000 != 0 {
|
||||
return true;
|
||||
}
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.map(|p| p.did.is_some() || p.x25519_pubkey.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Record a Sent MeshMessage for a typed envelope that has already been
|
||||
/// transmitted by the caller. Used by the RPC layer after sending
|
||||
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
|
||||
@@ -1392,6 +1554,12 @@ impl MeshService {
|
||||
self.state.assistant.read().await.clone()
|
||||
}
|
||||
|
||||
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
|
||||
/// them. Cleared implicitly as new denials rotate older ones out.
|
||||
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
|
||||
self.state.assist_denied.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Update the mesh-AI assistant settings live (no listener restart) and
|
||||
/// persist them to the mesh config. `model: Some(None)` clears the override
|
||||
/// (falls back to the built-in default); `None` leaves a field unchanged.
|
||||
@@ -1401,6 +1569,7 @@ impl MeshService {
|
||||
model: Option<Option<String>>,
|
||||
trusted_only: Option<bool>,
|
||||
backend: Option<String>,
|
||||
allowed_contacts: Option<Vec<String>>,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let mut a = self.state.assistant.write().await;
|
||||
@@ -1416,6 +1585,9 @@ impl MeshService {
|
||||
if let Some(b) = backend {
|
||||
a.backend = b;
|
||||
}
|
||||
if let Some(list) = allowed_contacts {
|
||||
a.allowed_contacts = list;
|
||||
}
|
||||
}
|
||||
// Persist by updating the on-disk config (the in-memory `self.config`
|
||||
// snapshot stays as-is; the live `state.assistant` is the runtime
|
||||
@@ -1427,6 +1599,7 @@ impl MeshService {
|
||||
cfg.assistant_model = a.model.clone();
|
||||
cfg.assistant_trusted_only = a.trusted_only;
|
||||
cfg.assistant_backend = a.backend.clone();
|
||||
cfg.assistant_allowed_contacts = a.allowed_contacts.clone();
|
||||
}
|
||||
save_config(&self.data_dir, &cfg).await?;
|
||||
Ok(())
|
||||
|
||||
@@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
|
||||
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
||||
/// frames to such contacts.
|
||||
pub const CMD_RESET_PATH: u8 = 0x0D;
|
||||
/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware
|
||||
/// table. 144-byte frame (see `build_add_contact`).
|
||||
pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09;
|
||||
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the
|
||||
/// firmware's persistent table (used by clear-all so wiped contacts actually
|
||||
/// go away and only return when they re-advertise).
|
||||
pub const CMD_REMOVE_CONTACT: u8 = 0x0F;
|
||||
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
|
||||
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
|
||||
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
|
||||
@@ -258,6 +265,45 @@ pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from
|
||||
/// the firmware's persistent contact table.
|
||||
pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||
let mut data = vec![CMD_REMOVE_CONTACT];
|
||||
data.extend_from_slice(pubkey);
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body:
|
||||
/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32]
|
||||
/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`.
|
||||
/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard
|
||||
/// advert on its side too, so an empty name still resolves on get-contacts).
|
||||
pub fn build_add_contact(
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut data = Vec::with_capacity(144);
|
||||
data.push(CMD_ADD_UPDATE_CONTACT);
|
||||
data.extend_from_slice(pubkey); // 32
|
||||
data.push(contact_type); // 1
|
||||
data.push(flags); // 1
|
||||
data.push(out_path_len); // 1
|
||||
data.extend_from_slice(&[0u8; 64]); // out_path (64)
|
||||
let mut name_buf = [0u8; 32];
|
||||
let nb = name.as_bytes();
|
||||
let n = nb.len().min(32);
|
||||
name_buf[..n].copy_from_slice(&nb[..n]);
|
||||
data.extend_from_slice(&name_buf); // name (32)
|
||||
data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4)
|
||||
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4)
|
||||
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4)
|
||||
encode_frame(&data)
|
||||
}
|
||||
|
||||
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
|
||||
pub fn build_sync_next_message() -> Vec<u8> {
|
||||
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
||||
|
||||
@@ -206,6 +206,24 @@ impl MeshcoreDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact,
|
||||
/// addressed by the first 6 bytes of its public key. Unlike the
|
||||
/// `@DM2`-over-channel path, this is a real unicast — it does not appear on
|
||||
/// the public channel, and a stock meshcore client receives it as a normal
|
||||
/// DM. The contact must already exist in the firmware table (with a path).
|
||||
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
|
||||
self.send_raw(&frame_data).await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Direct text send failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the stored routing path for a contact so the firmware flood-
|
||||
/// routes future messages instead of dropping them when path_len=0.
|
||||
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
@@ -217,6 +235,47 @@ impl MeshcoreDevice {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a contact from the firmware's persistent contact table.
|
||||
pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
self.send_raw(&protocol::build_remove_contact(pubkey))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!(
|
||||
"Remove contact failed: {}",
|
||||
protocol::parse_error(&frame.data)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT).
|
||||
/// Used to import a heard advert so it shows up as a contact immediately.
|
||||
pub async fn add_contact(
|
||||
&mut self,
|
||||
pubkey: &[u8; 32],
|
||||
contact_type: u8,
|
||||
flags: u8,
|
||||
out_path_len: u8,
|
||||
name: &str,
|
||||
last_advert: u32,
|
||||
) -> Result<()> {
|
||||
self.send_raw(&protocol::build_add_contact(
|
||||
pubkey,
|
||||
contact_type,
|
||||
flags,
|
||||
out_path_len,
|
||||
name,
|
||||
last_advert,
|
||||
))
|
||||
.await?;
|
||||
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||
if frame.code == protocol::RESP_ERR {
|
||||
anyhow::bail!("Add contact failed: {}", protocol::parse_error(&frame.data));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the list of known contacts from the device.
|
||||
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
|
||||
|
||||
@@ -32,8 +32,18 @@ pub struct MeshPeer {
|
||||
pub advert_name: String,
|
||||
/// Archipelago DID (did:key:z...) if identity was received.
|
||||
pub did: Option<String>,
|
||||
/// Ed25519 public key hex if identity was received.
|
||||
/// Routing key hex. For a radio (meshcore) peer this is the firmware
|
||||
/// contact public key used to address outbound DMs; for a federation-
|
||||
/// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT
|
||||
/// for authentication — see `arch_pubkey_hex`.
|
||||
pub pubkey_hex: Option<String>,
|
||||
/// Verified archipelago ed25519 identity key hex, bound from a signed
|
||||
/// identity advert (`handle_identity_received`) or federation seeding.
|
||||
/// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts`
|
||||
/// with the firmware routing key, so it stays stable for the `!ai` auth
|
||||
/// gate, envelope signature verification, and federation-trust matching.
|
||||
#[serde(default)]
|
||||
pub arch_pubkey_hex: Option<String>,
|
||||
/// X25519 public key (32 bytes) for key agreement.
|
||||
#[serde(skip)]
|
||||
pub x25519_pubkey: Option<[u8; 32]>,
|
||||
@@ -45,6 +55,28 @@ pub struct MeshPeer {
|
||||
pub last_heard: String,
|
||||
/// Number of hops to reach this peer.
|
||||
pub hops: u8,
|
||||
/// Firmware advert timestamp (unix secs) of the contact's last advert, or
|
||||
/// 0 if unknown. Used to gauge reachability/recency in the UI.
|
||||
#[serde(default)]
|
||||
pub last_advert: u32,
|
||||
/// Best-effort "currently reachable" flag: the radio has a route to this
|
||||
/// contact (or it's a federation/identity peer reachable off-radio). A
|
||||
/// contact with no path and no recent advert is shown as unreachable.
|
||||
#[serde(default)]
|
||||
pub reachable: bool,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
/// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist,
|
||||
/// envelope signature verification): the verified archipelago identity key
|
||||
/// if one is bound, otherwise the routing key. Never use the firmware
|
||||
/// routing key for auth when an archipelago identity is known — a radio
|
||||
/// peer's firmware key won't match its `nodes.json` archipelago key.
|
||||
pub fn identity_pubkey_hex(&self) -> Option<&str> {
|
||||
self.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.or(self.pubkey_hex.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction of a mesh message.
|
||||
@@ -172,4 +204,61 @@ pub enum MeshEvent {
|
||||
to_contact_id: u32,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// A local-AI answer to a `!ai`-in-chat query, to be delivered back into
|
||||
/// the 1:1 thread via the transport-aware `MeshService::send_message`
|
||||
/// (Tor for federation peers, LoRa for radio peers). The mesh listener
|
||||
/// emits this because it can't route over federation itself — the signing
|
||||
/// key and Tor client live on MeshService. Consumed at the server layer.
|
||||
AssistChatReply {
|
||||
contact_id: u32,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id: 1,
|
||||
advert_name: "Test".into(),
|
||||
did: None,
|
||||
pubkey_hex: routing.map(|s| s.to_string()),
|
||||
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: String::new(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() {
|
||||
// A radio peer that sent an identity advert: routing key is the firmware
|
||||
// contact key, but auth must use the bound archipelago key.
|
||||
let p = peer(Some("archkey"), Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_falls_back_to_routing_key_when_no_advert() {
|
||||
// A plain peer with no archipelago identity bound: fall back to whatever
|
||||
// key we have (federation peers carry the arch key in pubkey_hex).
|
||||
let p = peer(None, Some("firmwarekey"));
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey"));
|
||||
assert_eq!(peer(None, None).identity_pubkey_hex(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_style_routing_update_does_not_change_identity() {
|
||||
// Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new
|
||||
// firmware key while arch_pubkey_hex (identity) is preserved.
|
||||
let mut p = peer(Some("archkey"), Some("firmware-old"));
|
||||
p.pubkey_hex = Some("firmware-new".into());
|
||||
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,47 @@ impl Server {
|
||||
.rpc_handler()
|
||||
.set_mesh_service(mesh_service)
|
||||
.await;
|
||||
|
||||
// Mesh-AI assistant (#50): deliver `!ai`-in-chat answers via
|
||||
// the transport-aware send path. The listener can't route
|
||||
// over federation itself (send_message needs the signing key
|
||||
// + Tor client on MeshService), so it emits AssistChatReply
|
||||
// and we fulfil it here through the shared MeshService —
|
||||
// which POSTs over Tor for federation askers and falls back
|
||||
// to LoRa for radio askers, recording the Sent bubble.
|
||||
{
|
||||
let mesh_arc = api_handler.rpc_handler().mesh_service_arc();
|
||||
let mut reply_rx = {
|
||||
let guard = mesh_arc.read().await;
|
||||
guard.as_ref().map(|svc| svc.state().event_tx.subscribe())
|
||||
};
|
||||
if let Some(mut rx) = reply_rx.take() {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(crate::mesh::MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text,
|
||||
}) => {
|
||||
let guard = mesh_arc.read().await;
|
||||
if let Some(svc) = guard.as_ref() {
|
||||
if let Err(e) =
|
||||
svc.send_message(contact_id, &text).await
|
||||
{
|
||||
warn!("AI chat reply send failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||
_,
|
||||
)) => continue,
|
||||
Err(_) => break, // sender dropped → mesh stopped
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
info!("📡 Mesh service initialized");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -387,6 +428,99 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic federation auto-sync. Pulls every federated peer's state on a
|
||||
// timer so renamed nodes and roster changes propagate WITHOUT a manual
|
||||
// "Sync" click. Each sync now fast-fails a dead FIPS path and falls back
|
||||
// to Tor (~3-5s), so a full pass over a handful of peers is quick.
|
||||
{
|
||||
let data_dir = config.data_dir.clone();
|
||||
let state = state_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
// Delay the first pass so Tor/onion publishing settles after boot.
|
||||
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(90));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let nodes = match crate::federation::load_nodes(&data_dir).await {
|
||||
Ok(n) if !n.is_empty() => n,
|
||||
_ => continue,
|
||||
};
|
||||
let (snap, _) = state.get_snapshot().await;
|
||||
let local_did =
|
||||
match crate::identity::did_key_from_pubkey_hex(&snap.server_info.pubkey) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_identity =
|
||||
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
|
||||
Ok(id) => id,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Our own identity, for re-asserting membership to any peer
|
||||
// that doesn't list us back (asymmetry self-heal, below).
|
||||
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
|
||||
let local_pubkey = snap.server_info.pubkey.clone();
|
||||
let local_name = snap.server_info.name.clone();
|
||||
let local_fips_npub =
|
||||
crate::identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||
let mut ok = 0usize;
|
||||
let mut healed = 0usize;
|
||||
for node in &nodes {
|
||||
if node.trust_level == crate::federation::TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
match crate::federation::sync_with_peer(&data_dir, node, &local_did, |b| {
|
||||
node_identity.sign(b)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
ok += 1;
|
||||
// Asymmetry self-heal: if this peer's exported
|
||||
// trusted list doesn't include us, our original
|
||||
// peer-joined never landed (e.g. it was sent
|
||||
// before the reliable-notify fix, or the peer was
|
||||
// down). Re-assert membership over the now
|
||||
// FIPS-fast-failing/Tor path so they add us back.
|
||||
// Without this, a node that joined everyone stays
|
||||
// invisible to the whole fleet until a manual
|
||||
// re-add (the "X250-EXP missing everywhere" case).
|
||||
let they_list_us = state
|
||||
.federated_peers
|
||||
.iter()
|
||||
.any(|h| h.did == local_did);
|
||||
if !they_list_us && !local_onion.is_empty() {
|
||||
crate::federation::notify_join(
|
||||
&node.onion,
|
||||
node.fips_npub.as_deref(),
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
local_name.as_deref(),
|
||||
|b| node_identity.sign(b),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
healed += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(peer = %node.did, error = %e, "federation auto-sync (non-fatal)")
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
synced = ok,
|
||||
reasserted = healed,
|
||||
total = nodes.len(),
|
||||
"federation auto-sync pass complete"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize container scanner — discovers installed apps from Podman/Docker
|
||||
{
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
|
||||
@@ -1175,8 +1175,13 @@ pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Default mint URL (local Fedimint).
|
||||
/// Default Cashu mint. Minibits is a well-known public Cashu mint — note this
|
||||
/// is a CASHU mint, distinct from the local Fedimint guardian (:8175), which is
|
||||
/// a separate ecash protocol managed under the Fedimint Federations tab. The
|
||||
/// old default pointed at :8175, which incorrectly surfaced the Fedimint URL in
|
||||
/// the Cashu mints list.
|
||||
fn default_mint_url() -> String {
|
||||
"http://127.0.0.1:8175".to_string()
|
||||
"https://mint.minibits.cash/Bitcoin".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1359,11 +1364,11 @@ mod tests {
|
||||
async fn test_save_and_load_wallet_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut wallet = WalletState {
|
||||
mint_url: "http://127.0.0.1:8175".into(),
|
||||
mint_url: "https://mint.minibits.cash/Bitcoin".into(),
|
||||
..Default::default()
|
||||
};
|
||||
wallet.add_proofs(
|
||||
"http://127.0.0.1:8175",
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
vec![Proof {
|
||||
amount: 42,
|
||||
id: "ks1".into(),
|
||||
@@ -1375,7 +1380,7 @@ mod tests {
|
||||
TransactionType::Mint,
|
||||
42,
|
||||
"Test mint",
|
||||
"http://127.0.0.1:8175",
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
"",
|
||||
);
|
||||
|
||||
@@ -1498,7 +1503,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_default_mint_url() {
|
||||
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
|
||||
assert_eq!(default_mint_url(), "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1517,9 +1522,11 @@ mod tests {
|
||||
.await
|
||||
.unwrap());
|
||||
// Trailing slash on the home URL still matches.
|
||||
assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/")
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(
|
||||
is_mint_trusted(tmp.path(), "https://mint.minibits.cash/Bitcoin/")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1553,7 +1560,7 @@ mod tests {
|
||||
let err = swap_between_mints(
|
||||
tmp.path(),
|
||||
&default_mint_url(),
|
||||
"http://127.0.0.1:8175/",
|
||||
"https://mint.minibits.cash/Bitcoin/",
|
||||
100,
|
||||
10,
|
||||
)
|
||||
|
||||
@@ -49,6 +49,39 @@ pub struct FederationRegistry {
|
||||
|
||||
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
|
||||
|
||||
/// Shared HTTP-Basic password between the fmcd container and this bridge. The
|
||||
/// fedimint-clientd manifest reads it via `secret_env: fmcd-password`, resolved
|
||||
/// from `<data_dir>/secrets/`; the bridge reads the same file in `from_node`.
|
||||
const FMCD_PASSWORD_SECRET: &str = "fmcd-password";
|
||||
|
||||
/// Generate the fmcd Basic-auth password once, so the fmcd container
|
||||
/// (`secret_env: fmcd-password`) and this bridge (`from_node`) agree on it.
|
||||
/// Idempotent: a non-empty existing secret is left untouched. Mirrors the
|
||||
/// bitcoin-rpc secret pattern (random hex, 0600). Called from the orchestrator's
|
||||
/// `ensure_app_secrets` before the container's `secret_env` is resolved.
|
||||
pub async fn ensure_fmcd_password(secrets_dir: &Path) -> Result<()> {
|
||||
let path = secrets_dir.join(FMCD_PASSWORD_SECRET);
|
||||
if let Ok(existing) = fs::read_to_string(&path).await {
|
||||
if !existing.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
fs::create_dir_all(secrets_dir)
|
||||
.await
|
||||
.context("creating secrets dir for fmcd password")?;
|
||||
let bytes: [u8; 16] = rand::random();
|
||||
let password = hex::encode(bytes);
|
||||
fs::write(&path, &password)
|
||||
.await
|
||||
.context("writing fmcd password secret")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
|
||||
let path = data_dir.join(REGISTRY_FILE);
|
||||
if !path.exists() {
|
||||
@@ -95,13 +128,39 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id,
|
||||
name: None,
|
||||
name: Some("Archipelago Federation".to_string()),
|
||||
});
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
|
||||
/// per-federation, but a token only validates against the federation that
|
||||
/// minted it, so we try each joined federation (default first) and return the
|
||||
/// first that accepts the notes, along with its id. Errors clearly when the
|
||||
/// fmcd sidecar isn't installed or no federation is joined — the Cashu path is
|
||||
/// handled separately by the caller.
|
||||
pub async fn reissue_into_any(data_dir: &Path, notes: &str) -> Result<(u64, String)> {
|
||||
// Make sure at least the default federation is tracked before we try.
|
||||
let _ = ensure_default_federation(data_dir).await;
|
||||
|
||||
let client = FedimintClient::from_node(data_dir).await?;
|
||||
let reg = load_registry(data_dir).await?;
|
||||
if reg.federations.is_empty() {
|
||||
anyhow::bail!("No Fedimint federation joined to redeem these notes into");
|
||||
}
|
||||
|
||||
let mut last_err = None;
|
||||
for fed in ®.federations {
|
||||
match client.reissue(&fed.federation_id, notes).await {
|
||||
Ok(sats) => return Ok((sats, fed.federation_id.clone())),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Fedimint reissue failed")))
|
||||
}
|
||||
|
||||
/// HTTP client for a `fedimint-clientd` instance.
|
||||
pub struct FedimintClient {
|
||||
base_url: String,
|
||||
@@ -135,14 +194,25 @@ impl FedimintClient {
|
||||
let password = match std::env::var("FMCD_PASSWORD") {
|
||||
Ok(p) if !p.is_empty() => p,
|
||||
_ => {
|
||||
let secret = data_dir.join("fmcd").join("password");
|
||||
fs::read_to_string(&secret)
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
.context(
|
||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||
fmcd/password secret). Install the Fedimint client app.",
|
||||
)?
|
||||
// The shared secret the fmcd container also reads (manifest
|
||||
// secret_env: fmcd-password, resolved from <data_dir>/secrets).
|
||||
// Legacy <data_dir>/fmcd/password kept as a fallback.
|
||||
let shared = data_dir.join("secrets").join(FMCD_PASSWORD_SECRET);
|
||||
let legacy = data_dir.join("fmcd").join("password");
|
||||
let mut found = None;
|
||||
for candidate in [shared, legacy] {
|
||||
if let Ok(s) = fs::read_to_string(&candidate).await {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
found = Some(s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found.context(
|
||||
"Fedimint client not configured (no FMCD_PASSWORD and no \
|
||||
fmcd-password secret). Install the Fedimint client app.",
|
||||
)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &password)
|
||||
|
||||
@@ -737,6 +737,15 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Snapshot age in ms. Prefer the server-computed age_ms (single clock, no
|
||||
// skew). Fall back to the old browser-vs-server subtraction only for an
|
||||
// older backend that doesn't send age_ms. Mixing clocks was why the
|
||||
// "reconnecting…" banner could stick on nodes whose clock drifted.
|
||||
function snapshotAgeMs(status) {
|
||||
if (typeof status.age_ms === 'number') return status.age_ms;
|
||||
return status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function cookieValue(name) {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
@@ -1127,7 +1136,7 @@
|
||||
const rpcEl = document.getElementById('settingsRpc');
|
||||
if (rpcEl) {
|
||||
const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443));
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const statusAgeMs = snapshotAgeMs(status);
|
||||
const displayStale = status.stale === true && statusAgeMs > 30000;
|
||||
rpcEl.textContent = displayStale
|
||||
? `Reconnecting on port ${port}`
|
||||
@@ -1143,7 +1152,7 @@
|
||||
const diskSize = formatBytes(blockchainInfo.size_on_disk || 0);
|
||||
const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024;
|
||||
const previousBlockCount = lastBlockCount;
|
||||
const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY;
|
||||
const statusAgeMs = snapshotAgeMs(status);
|
||||
const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount;
|
||||
const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000;
|
||||
|
||||
|
||||
@@ -22,6 +22,6 @@ RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
|
||||
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
|
||||
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
|
||||
/var/cache/nginx/scgi_temp
|
||||
EXPOSE 80
|
||||
EXPOSE 18083
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
+30
-32
@@ -433,8 +433,12 @@
|
||||
const host = window.location.hostname;
|
||||
|
||||
function getBackendUrl() {
|
||||
// Same-origin by default: the app's own nginx (:18083) proxies these
|
||||
// paths to the archipelago backend, so a relative base ('') avoids
|
||||
// any cross-origin/CORS issues (which broke this on http-only nodes).
|
||||
// ?backend=http://HOST:5678 still overrides for local dev.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('backend') || (window.location.protocol + '//' + window.location.hostname);
|
||||
return params.get('backend') || '';
|
||||
}
|
||||
|
||||
function setSettingsTab(tabId) {
|
||||
@@ -499,42 +503,36 @@
|
||||
async function loadLogs() {
|
||||
const logsContent = document.getElementById('logsContent');
|
||||
const backendUrl = getBackendUrl();
|
||||
if (backendUrl) {
|
||||
logsContent.textContent = 'Loading logs...';
|
||||
try {
|
||||
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200');
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
|
||||
logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
|
||||
} catch (e) {
|
||||
logsContent.textContent = 'Could not load logs: ' + e.message;
|
||||
}
|
||||
} else {
|
||||
logsContent.textContent = 'Open this app with ?backend=http://HOST:5678 to load logs from the server.';
|
||||
logsContent.textContent = 'Loading logs...';
|
||||
try {
|
||||
const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200', { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
const json = await res.json();
|
||||
const lines = json.result || json.logs || (Array.isArray(json) ? json : []);
|
||||
logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines);
|
||||
} catch (e) {
|
||||
logsContent.textContent = 'Could not load logs: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLiveData() {
|
||||
const backendUrl = getBackendUrl();
|
||||
const data = { channelCount: 0, restReachable: false, grpcReachable: false };
|
||||
if (backendUrl) {
|
||||
try {
|
||||
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo');
|
||||
if (getinfoRes.ok) {
|
||||
data.getinfo = await getinfoRes.json();
|
||||
data.restReachable = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels');
|
||||
if (chRes.ok) {
|
||||
const ch = await chRes.json();
|
||||
data.channelCount = (ch.channels && ch.channels.length) || 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
data.grpcReachable = data.restReachable;
|
||||
}
|
||||
try {
|
||||
const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo', { credentials: 'include' });
|
||||
if (getinfoRes.ok) {
|
||||
data.getinfo = await getinfoRes.json();
|
||||
data.restReachable = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels', { credentials: 'include' });
|
||||
if (chRes.ok) {
|
||||
const ch = await chRes.json();
|
||||
data.channelCount = (ch.channels && ch.channels.length) || 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
data.grpcReachable = data.restReachable;
|
||||
applyLiveData(data);
|
||||
}
|
||||
|
||||
@@ -629,7 +627,7 @@
|
||||
|
||||
async function fetchConnectInfo() {
|
||||
try {
|
||||
const resp = await fetch(window.location.protocol + '//' + window.location.hostname + '/lnd-connect-info', { credentials: 'include' });
|
||||
const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' });
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
if (data.cert_base64url) {
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
server {
|
||||
listen 80;
|
||||
# Host-networked: listen on the app's own port directly (NOT 80, which the
|
||||
# host's main nginx already owns). The app is reached at http(s)://<node>:18083.
|
||||
listen 18083;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# lnd-connect-info is fetched via absolute URL path,
|
||||
# handled by the host nginx → backend at :5678 directly
|
||||
# Proxy the archipelago backend same-origin so the browser never makes a
|
||||
# cross-origin request (no CORS, no host-nginx route dependency). The app is
|
||||
# served on this node's :18083; cookies are scoped by host (not port), so the
|
||||
# browser already carries the `session` (HttpOnly) and `csrf_token` cookies
|
||||
# set by the main UI. We forward both, plus the X-CSRF-Token header, to the
|
||||
# backend on 127.0.0.1:5678 (reachable because this container is host-networked).
|
||||
#
|
||||
# This mirrors fips-ui / electrs-ui. The old bridge + 18083→80 mapping forced
|
||||
# cross-origin fetches that broke on http-only nodes (blank fields, QR
|
||||
# "failed to fetch").
|
||||
location = /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location /proxy/lnd/ {
|
||||
proxy_pass http://127.0.0.1:5678/proxy/lnd/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location /api/container/logs {
|
||||
proxy_pass http://127.0.0.1:5678/api/container/logs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-CSRF-Token $http_x_csrf_token;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Session handoff — 2026-06-18
|
||||
|
||||
> **UPDATE (later same day): ALL OPEN ITEMS RESOLVED + DEPLOYED** (v1.7.99-alpha → .116 + .198).
|
||||
> - **#6 Pay-with-QR timeout** — real bug (both LNDs confirmed healthy by user). FIPS-first dial ate the whole budget before the working Tor fallback ran. Added `PeerRequest.fips_timeout` cap (`fips/dial.rs`); invoice/onchain request+status calls fast-fail FIPS (6s) + short Tor window (25s/15s); frontend ceilings 60s→45s. Large downloads keep the full FIPS timeout.
|
||||
> - **#7 `!ai` gate** — added denied-asker capture (`MeshState.assist_denied`/`DeniedAsker`, `assist.rs::record_denied`) → `mesh.assistant-status.denied_askers` → "Recently denied" list with one-click Allow in `MeshAssistantPanel.vue`.
|
||||
> - **#8 peer-file 403** — NOT a DID reset. Asymmetric federation: .198 had .116 trusted but .116 never added .198. Re-federated (.198 → .116 `nodes.json`, trusted). **Verified:** .116 `/content/<peersonly>` = 403 w/o DID, **200 (177KB png) with .198's DID**. Plus clearer 403 message + client surfaces the body. Listing left visible ("locked preview", user's choice).
|
||||
> - **Dual-ecash receive** — active modal is `ReceiveBitcoinModal.vue` (not the commented-out `Web5SendReceiveModals.vue`); already used dual-detect `wallet.ecash-receive`, fixed Cashu-only wording.
|
||||
> - **fedimint-clientd icon** — `docker_packages.rs` arm → `fedimint.png` + `fedimint-clientd.png` asset.
|
||||
> - **Cashu → 🥜** — `HomeWalletCard.vue`.
|
||||
>
|
||||
> Deploy notes confirmed: binary swap needs atomic `mv` over the running file (`cp` → "Text file busy"); frontend rsync WITHOUT `--delete` to preserve the `aiui/` subdir in `/opt/archipelago/web-ui`.
|
||||
|
||||
|
||||
Resume point for the multi-issue bug-fix + deploy session on **.116** (archi-thinkpad,
|
||||
local dev/validation node) and **.198** (resilience node). Work was done in
|
||||
`~/Projects/archy`. A separate agent's **fedimint dual-ecash** work landed as commit
|
||||
`4288ae78` during the session (don't re-touch `wallet.rs` / `fedimint_client.rs` /
|
||||
`prod_orchestrator.rs` / `Web5SendReceiveModals.vue` without checking with them).
|
||||
|
||||
## DEPLOY STATUS — done
|
||||
|
||||
A surgical deploy (binary + frontend + 2 companion images, **not** the .228-centric
|
||||
`deploy-to-target.sh`, to avoid clobbering .116's custom nginx) shipped to BOTH nodes:
|
||||
|
||||
- **.116**: new binary `/usr/local/bin/archipelago` (backup at `archipelago.bak-pre-deploy-*`),
|
||||
frontend at `/opt/archipelago/web-ui`, `localhost/{lnd-ui,bitcoin-ui}:latest` rebuilt,
|
||||
`:local` tags dropped. Verified: `/bitcoin-status` serves `age_ms`; lnd-ui on `Network=host`
|
||||
listening 18083; `/lnd-connect-info` → 200; both companion containers carry new index.html.
|
||||
- **.198**: same (binary copied — .198 has **no Rust toolchain**, only npm+podman, so
|
||||
build-on-.116-then-copy is mandatory). Verified identically. Force-recreated both companions.
|
||||
|
||||
Build notes: release build ~9 min (opt-level 3). Frontend vite outDir = `web/dist/neode-ui/`
|
||||
(NOT `neode-ui/dist`). Companion images: `ensure_image_present` only builds if image ABSENT,
|
||||
and prefers `localhost/<base>:local` over `:latest` — so to ship docker changes you must drop
|
||||
`:local` and rebuild `:latest`, then the reconciler (`needs_repair` compares rendered quadlet
|
||||
unit vs disk) recreates containers. bitcoin-ui needed an explicit `systemctl --user restart`
|
||||
(its quadlet unit text didn't change, so the reconciler didn't auto-recreate it).
|
||||
|
||||
## FIXED & DEPLOYED
|
||||
|
||||
1. **Mesh chat/peer double-scroll** — `useControllerNav.ts` (wheel scrolls container under
|
||||
pointer, not focused el) + `Mesh.vue` (`@wheel.stop.prevent`).
|
||||
2. **Second-level cloud folder zoom** — `CloudFolder.vue` direction-aware
|
||||
(`cloud-zoom-forward`/`-back`, matched depth-forward/back magnitudes 0.75↔1.2).
|
||||
3. **"FIPS Mesh" → "Fuck IPs Mesh"** — `FipsNetworkCard.vue`, `Server.vue`.
|
||||
4. **.116 connect-wallet QR "failed to fetch"** — lnd-ui migrated to host-network +
|
||||
same-origin nginx proxy: `companion.rs` (host_network:true, ports:[]),
|
||||
`docker/lnd-ui/{Dockerfile(EXPOSE 18083),nginx.conf(listen 18083 + proxy /lnd-connect-info,
|
||||
/proxy/lnd/, /api/container/logs to 127.0.0.1:5678),index.html(getBackendUrl()→'' relative,
|
||||
credentials:'include')}`. ROOT CAUSE was a cross-origin CORS failure (page on :18083 fetching
|
||||
:80). Verified working in incognito; the user's earlier "still broken" was a **stale cached
|
||||
old page**. Unit test `lnd_ui_uses_host_network` passes.
|
||||
5. **.198 Bitcoin Knots stale "reconnecting" banner** — `bitcoin_status.rs` (new server-computed
|
||||
`age_ms` field so the browser never subtracts across clocks; 20s `STALE_GRACE_MS` before
|
||||
flipping stale; RPC timeout 8s→12s) + `docker/bitcoin-ui/index.html` (`snapshotAgeMs()` uses
|
||||
server `age_ms`, falls back to old calc). Two root causes: browser/node clock skew + no grace
|
||||
on single failed polls (swap-thrash node).
|
||||
|
||||
## OPEN ISSUES (diagnosed, NOT fixed)
|
||||
|
||||
6. **"Pay with QR" → request timeout** — full invoice chain intact (hardened in `790da4bd`);
|
||||
60s timeout = seller node never answers (unreachable transport or hung LND). Runtime, needs
|
||||
2 live nodes to repro. NOT a code defect found.
|
||||
|
||||
7. **`!ai` not working** — DIAGNOSED, config fix (awaiting user policy decision). Assistant is
|
||||
`assistant_trusted_only:true` (`/var/lib/archipelago/mesh-config.json`). The trust gate
|
||||
`is_sender_allowed` (mesh/listener/assist.rs) only matches askers by archipelago pubkey/DID
|
||||
against federation-Trusted `nodes.json`, but RADIO (meshcore) askers present a firmware key,
|
||||
not the archipelago identity, so they're silently denied (journal: "AssistQuery denied … from=15
|
||||
name=Arch Optiplex"; federation contact_id ≥ 0x80000000, low ids = radio). Claude key + model
|
||||
(`claude-opus-4-8`) tested HTTP 200 — NOT the problem. FIX: disable trusted_only, or add the
|
||||
asker's presented key to the allowlist. Full notes in memory `project_mesh_ai_trusted_only_gate`.
|
||||
|
||||
8. **Peer-file download .116→.198 "Access denied — federation peer required"** — NEW, NOT yet
|
||||
fixed. Gate at `content.rs:149` (returns on `content_server::ServeResult::Forbidden`). The
|
||||
requesting node isn't recognized as an authorized federation peer by the content server /
|
||||
per-file sharing ACL. User's strong hypothesis: a **DID/identity reset** changed a node's DID,
|
||||
so the sharing ACL / nodes.json holds the OLD identity and no longer matches. User also notes
|
||||
the file is still VISIBLE in the listing (so listing and download use different identity checks
|
||||
— inconsistency to investigate). NEXT: read `content_server` Forbidden logic, compare the
|
||||
requester DID/pubkey vs what's stored; check both nodes' `server_info`/identity vs each other's
|
||||
`federation/nodes.json`. Same THEME as #7 (identity matching) but a different mechanism.
|
||||
|
||||
## NEW FRONTEND REQUESTS (not started — batch into one frontend rebuild+redeploy)
|
||||
|
||||
- **`fedimint-clientd.svg` 404** — new fedimint core-app (`public/catalog.json:294`) has no icon.
|
||||
App-icon convention `/assets/img/app-icons/<id>.png` (default) — add a `fedimint-clientd` icon
|
||||
(there's an existing `fedimint.png` to reuse/adapt). The 404 requests `.svg` so check the
|
||||
catalog/curated-icon entry.
|
||||
- **Cashu icon → cashew emoji** (🥜) — change the cashu wallet icon to a cashew nut emoji.
|
||||
- **Receive › ecash should support BOTH fedimint + cashu paste** — currently the ecash receive
|
||||
only mentions Cashu for pasting a token; user expected the paste box to redeem both Cashu AND
|
||||
Fedimint ecash. Lives in the fedimint agent's recently-committed dual-ecash UI
|
||||
(`Web5SendReceiveModals.vue` / `Web5Wallet.vue` / `WalletSettingsModal.vue`) — investigate what
|
||||
they built before changing.
|
||||
- **Console noise** (lower priority): `cdn.tailwindcss.com` production warning in lnd-ui +
|
||||
bitcoin-ui (uses Tailwind CDN); `api/app-catalog` 502 (check if persistent). Latent backend
|
||||
nicety: `/lnd-connect-info` emits a DOUBLED `Access-Control-Allow-Origin` (backend empty ACAO
|
||||
+ main-nginx `add_header $http_origin`) — harmless on the new same-origin page but should drop
|
||||
the backend's redundant CORS since lnd-ui now fetches same-origin.
|
||||
|
||||
## ENV QUICK-REF
|
||||
|
||||
- .116 archi-thinkpad: data `/var/lib/archipelago`, nginx root `/opt/archipelago/web-ui`,
|
||||
http :80 + custom nginx-proxy-manager; user reaches UI via Tailscale `100.69.68.39` AND LAN.
|
||||
Deploy SSH key `~/.ssh/archipelago-deploy` is passphraseless; SSH-to-self + .198 work non-interactively.
|
||||
- .198: `ssh archipelago@192.168.1.198` (passwordless sudo), podman+npm, NO cargo.
|
||||
- Companion build-dir precedence: `/opt/archipelago/docker` > `~/archy/docker` > `~/Projects/archy/docker`.
|
||||
- Uncommitted working-tree changes (mine, not yet committed): the 11 files for fixes #1–#5.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
@@ -304,9 +304,17 @@ function refreshIframe() {
|
||||
}
|
||||
|
||||
function openInNewTab() {
|
||||
if (store.url) {
|
||||
window.open(store.url, '_blank', 'noopener,noreferrer')
|
||||
if (!store.url) return
|
||||
// Inside the Archipelago companion app, open the app in the in-app WebView
|
||||
// instead of window.open — which the WebView suppresses for noopener popups
|
||||
// (so the tap silently no-ops). The native bridge is reliable; fall back to
|
||||
// window.open in a plain mobile browser.
|
||||
const native = (window as any).ArchipelagoNative
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(store.url)
|
||||
return
|
||||
}
|
||||
window.open(store.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function openInNewTabAndClose() {
|
||||
|
||||
@@ -79,7 +79,10 @@ import { ref, onMounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
const DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
// host to resolve). Points at the companion APK hosted on the 146 release server
|
||||
// (publicly reachable) rather than the local node's /packages copy.
|
||||
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk.zip'
|
||||
|
||||
const visible = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<template>
|
||||
<!-- Spacer to prevent content from being hidden behind the player -->
|
||||
<div v-if="audioPlayer.currentName.value" class="h-14"></div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="audioPlayer.currentName.value"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 audio-player-bar"
|
||||
ref="barEl"
|
||||
class="fixed left-0 right-0 z-40 audio-player-bar"
|
||||
>
|
||||
<!-- Progress bar (clickable) -->
|
||||
<div
|
||||
@@ -60,9 +58,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const barEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Publish the player's height as a CSS variable so page scroll containers can
|
||||
// reserve space for it (the same mechanism the mobile tab bar uses). This is
|
||||
// what pushes the rest of the site up instead of letting the fixed bar overlap
|
||||
// and block the bottom controls — on desktop AND mobile, on every page.
|
||||
function setPlayerHeightVar() {
|
||||
if (typeof document === 'undefined') return
|
||||
const h = barEl.value?.offsetHeight || 60
|
||||
document.documentElement.style.setProperty('--audio-player-height', `${h}px`)
|
||||
document.documentElement.classList.add('audio-active')
|
||||
}
|
||||
|
||||
function clearPlayerHeightVar() {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.style.setProperty('--audio-player-height', '0px')
|
||||
document.documentElement.classList.remove('audio-active')
|
||||
}
|
||||
|
||||
watch(() => audioPlayer.currentName.value, (name) => {
|
||||
if (name) {
|
||||
nextTick(setPlayerHeightVar)
|
||||
} else {
|
||||
clearPlayerHeightVar()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(clearPlayerHeightVar)
|
||||
|
||||
function togglePlay() {
|
||||
if (audioPlayer.playing.value) {
|
||||
@@ -90,6 +117,10 @@ function formatTime(seconds: number): string {
|
||||
|
||||
<style scoped>
|
||||
.audio-player-bar {
|
||||
/* Sit directly above the mobile tab bar (its height is published as
|
||||
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
|
||||
resolves to 0px and the bar docks flush to the bottom of the viewport. */
|
||||
bottom: var(--mobile-tab-bar-height, 0px);
|
||||
background: rgba(15, 15, 15, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
|
||||
@@ -471,6 +471,9 @@ onUnmounted(() => {
|
||||
.mesh-map-toggle {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
/* The global mobile rule forces buttons to min-height:44px, which stretches
|
||||
this switch and pushes the knob off-centre. Pin it back to the pill size. */
|
||||
min-height: 20px !important;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">{{ t('receiveBitcoin.pasteEcashToken') }}</label>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass font-mono"></textarea>
|
||||
<textarea v-model="ecashToken" rows="3" placeholder="cashuB… (Cashu) or Fedimint notes" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="ecashResult" class="mb-3 text-xs text-green-400">{{ ecashResult }}</div>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ async function receive() {
|
||||
if (receiveMethod.value === 'lightning') {
|
||||
if (!invoiceAmount.value) { error.value = t('receiveBitcoin.enterAnAmount'); return }
|
||||
const res = await rpcClient.call<{ payment_request: string }>({
|
||||
method: 'lnd.addinvoice',
|
||||
method: 'lnd.createinvoice',
|
||||
params: { amount_sats: invoiceAmount.value, memo: invoiceMemo.value || undefined },
|
||||
})
|
||||
invoiceResult.value = res.payment_request
|
||||
@@ -133,11 +133,16 @@ async function receive() {
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else {
|
||||
if (!ecashToken.value.trim()) { error.value = t('receiveBitcoin.pasteAnEcashToken'); return }
|
||||
await rpcClient.call<{ amount_sats: number }>({
|
||||
// The backend auto-detects the token type: a Cashu token (cashuA/B…) is
|
||||
// redeemed at its mint, anything else is reissued as Fedimint notes.
|
||||
const res = await rpcClient.call<{ received_sats?: number; kind?: string }>({
|
||||
method: 'wallet.ecash-receive',
|
||||
params: { token: ecashToken.value.trim() },
|
||||
})
|
||||
ecashResult.value = t('receiveBitcoin.tokenReceivedSuccess')
|
||||
const kind = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||
ecashResult.value = res.received_sats != null
|
||||
? `Received ${res.received_sats.toLocaleString()} sats (${kind})!`
|
||||
: t('receiveBitcoin.tokenReceivedSuccess')
|
||||
emit('received')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- Cover / Thumbnail area -->
|
||||
<div class="cloud-grid-card-cover" :class="aspectClass">
|
||||
<div class="cloud-grid-card-cover">
|
||||
<!-- Image thumbnail -->
|
||||
<img
|
||||
v-if="isImage && thumbnailUrl && !imgFailed"
|
||||
@@ -152,11 +152,12 @@ const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
|
||||
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
|
||||
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
|
||||
|
||||
const aspectClass = computed(() => {
|
||||
if (isImage.value || isVideo.value) return 'aspect-square'
|
||||
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]'
|
||||
return 'aspect-square'
|
||||
})
|
||||
// Uniform card cover ratio across every file type so folders, images, videos
|
||||
// and documents all render at the same height in the grid (previously images/
|
||||
// videos were square while folders were 4/3, giving a ragged, mismatched grid).
|
||||
// Aspect is now driven entirely by .cloud-grid-card-cover CSS (4/3 desktop,
|
||||
// square on mobile) so the ratio is deterministic regardless of Tailwind layer
|
||||
// ordering.
|
||||
|
||||
const coverBg = computed(() => {
|
||||
if (props.item.isDir) return 'bg-amber-500/10'
|
||||
|
||||
@@ -613,9 +613,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
// ─── Scroll Support ────────────────────────────────────────
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active) return
|
||||
let p = active.parentElement
|
||||
// Scroll the container UNDER THE POINTER, not the focused element. Real
|
||||
// wheel events always target the element beneath the cursor, so walking up
|
||||
// from e.target matches native behaviour. Using document.activeElement here
|
||||
// caused the wheel to scroll a previously-clicked container (e.g. the mesh
|
||||
// peer list, still focused after a click) instead of the panel actually
|
||||
// being hovered — producing a double-scroll where both moved at once.
|
||||
const start = (e.target as HTMLElement | null) ?? (document.activeElement as HTMLElement | null)
|
||||
if (!start) return
|
||||
let p: HTMLElement | null = start
|
||||
while (p) {
|
||||
const style = getComputedStyle(p)
|
||||
if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) {
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface MeshPeer {
|
||||
snr: number | null
|
||||
last_heard: string
|
||||
hops: number
|
||||
last_advert?: number
|
||||
reachable?: boolean
|
||||
}
|
||||
|
||||
export interface MeshChannel {
|
||||
@@ -125,15 +127,24 @@ export interface BlockHeader {
|
||||
announced_by: string
|
||||
}
|
||||
|
||||
export interface DeniedAsker {
|
||||
contact_id: number
|
||||
name: string
|
||||
pubkey_hex: string | null
|
||||
at: string
|
||||
}
|
||||
|
||||
export interface AssistantStatus {
|
||||
enabled: boolean
|
||||
model: string | null
|
||||
trusted_only: boolean
|
||||
backend: string
|
||||
allowed_contacts: string[]
|
||||
default_model: string
|
||||
ollama_detected: boolean
|
||||
claude_available: boolean
|
||||
models: string[]
|
||||
denied_askers?: DeniedAsker[]
|
||||
}
|
||||
|
||||
export interface ScheduledMessage {
|
||||
@@ -613,6 +624,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
model?: string | null
|
||||
trusted_only?: boolean
|
||||
backend?: string
|
||||
allowed_contacts?: string[]
|
||||
}) {
|
||||
const res = await rpcClient.call<Partial<AssistantStatus>>({
|
||||
method: 'mesh.assistant-configure',
|
||||
|
||||
+57
-10
@@ -130,19 +130,45 @@ select:focus-visible {
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room */
|
||||
/* Height of the global audio player bar — 0 unless it is visible. Set on
|
||||
<html> by GlobalAudioPlayer.vue. Scroll containers add it to their bottom
|
||||
padding so the fixed player pushes content up instead of covering it. */
|
||||
:root {
|
||||
--audio-player-height: 0px;
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room. (On desktop the
|
||||
audio player instead shrinks the whole #main-content area — see the
|
||||
html.audio-active rule below — so no player offset is added here.) */
|
||||
.mobile-scroll-pad,
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
/* Audio player docked: shrink the whole interface into the space above it so
|
||||
the entire view scales up (like the AIUI iframe) instead of just gaining
|
||||
scroll padding. On desktop the player spans full width and BOTH the sidebar
|
||||
and the main content scale into the reduced height above it. Mobile keeps the
|
||||
tab-bar + player handled via .mobile-scroll-pad padding. */
|
||||
@media (min-width: 768px) {
|
||||
html.audio-active .dashboard-view {
|
||||
height: calc(100dvh - var(--audio-player-height, 0px));
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Sidebar uses h-screen (100vh) — pin it to the reduced container height. */
|
||||
html.audio-active .dashboard-view [data-controller-zone="sidebar"] {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: override with tab bar clearance */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||
}
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
|
||||
/* Safe area top padding for all mobile content views.
|
||||
@@ -174,11 +200,11 @@ select:focus-visible {
|
||||
}
|
||||
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 16px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 16px);
|
||||
}
|
||||
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 64px);
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
|
||||
}
|
||||
|
||||
.mobile-safe-top {
|
||||
@@ -525,6 +551,7 @@ input[type="radio"]:active + * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
/* On mobile browsers, cap chat height to the dynamic viewport to prevent
|
||||
content extending behind browser chrome (address bar / toolbar). */
|
||||
@media (max-width: 767px) {
|
||||
@@ -555,8 +582,8 @@ input[type="radio"]:active + * {
|
||||
context) stay above the tab bar instead of sliding underneath it. */
|
||||
@media (max-width: 767px) {
|
||||
.chat-iframe-mobile {
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
|
||||
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
|
||||
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--audio-player-height, 0px) - 16px) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@@ -1800,6 +1827,22 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: square, tappable tiles + bottom clearance so the last row scrolls
|
||||
above the tab bar / back button (matches .mobile-scroll-pad). */
|
||||
@media (max-width: 767px) {
|
||||
.cloud-card-grid,
|
||||
.cloud-file-list {
|
||||
padding-bottom: calc(
|
||||
var(--mobile-tab-bar-height, 88px) +
|
||||
var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) +
|
||||
var(--audio-player-height, 0px) + 24px
|
||||
);
|
||||
}
|
||||
.cloud-grid-card-cover {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-grid-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1829,6 +1872,9 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
.cloud-grid-card-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* Fallback aspect when the Tailwind aspect-[4/3] utility is unavailable, so
|
||||
the cover never collapses to zero height. */
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
@@ -1926,18 +1972,19 @@ html.modal-scroll-locked .dashboard-scroll-panel {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ── Mobile floating back/close button (always 8px above tab bar) ──── */
|
||||
/* ── Mobile floating back/close button (always 8px above tab bar — and above
|
||||
the audio player too when it is showing, so it never hides behind it) ── */
|
||||
.mobile-back-btn {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + 8px);
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.mobile-filter-btn {
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
|
||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
|
||||
@@ -93,16 +93,25 @@
|
||||
@upload="handleUpload"
|
||||
@update:view-mode="viewMode = $event"
|
||||
/>
|
||||
<FileGrid
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
<!-- Re-key on the current folder path so the depth/zoom animation replays
|
||||
at every level (folder → subfolder → …), not just on first entry.
|
||||
The transition name flips with navigation direction so descending
|
||||
zooms forward and going back up zooms in reverse — matching the
|
||||
cloud → folder route transition. Only the file content zooms; the
|
||||
header + breadcrumb nav above stay fixed in place. -->
|
||||
<Transition :name="folderTransition" mode="out-in">
|
||||
<FileGrid
|
||||
:key="cloudStore.currentPath"
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Audio player is now the global bottom bar (GlobalAudioPlayer in App.vue) -->
|
||||
</div>
|
||||
@@ -170,6 +179,19 @@ const cloudStore = useCloudStore()
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// Direction-aware folder zoom: descending into a subfolder plays the same
|
||||
// "depth-forward" feel as the cloud → folder route transition (new arrives from
|
||||
// depth, current zooms out toward the viewer); navigating back up plays its
|
||||
// mirror ("depth-back"). Picked by comparing folder depth on each path change.
|
||||
const folderTransition = ref<'cloud-zoom-forward' | 'cloud-zoom-back'>('cloud-zoom-forward')
|
||||
let prevFolderDepth = -1
|
||||
watch(() => cloudStore.currentPath, (path) => {
|
||||
const depth = path.split('/').filter(Boolean).length
|
||||
// First render (prevFolderDepth === -1) defaults to forward.
|
||||
folderTransition.value = depth < prevFolderDepth ? 'cloud-zoom-back' : 'cloud-zoom-forward'
|
||||
prevFolderDepth = depth
|
||||
})
|
||||
|
||||
const iframeLoaded = ref(false)
|
||||
const uploading = ref(false)
|
||||
const folderId = computed(() => route.params.folderId as string)
|
||||
@@ -386,3 +408,64 @@ function goBack() {
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
|
||||
element, which lives outside this component's style scope. Mirrors the
|
||||
`depth-forward` / `depth-back` route transitions (same scale magnitudes +
|
||||
blur) so descending into a folder and going back up feel identical to the
|
||||
cloud ⇄ folder route change. -->
|
||||
<style>
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition:
|
||||
opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/* Forward (into a deeper folder): new folder arrives from depth while the
|
||||
current one zooms out toward the viewer — matches depth-forward. */
|
||||
.cloud-zoom-forward-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
.cloud-zoom-forward-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Back (up to a parent folder): the mirror — new folder shrinks in from the
|
||||
front while the current one recedes into depth — matches depth-back. */
|
||||
.cloud-zoom-back-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
filter: blur(8px);
|
||||
}
|
||||
.cloud-zoom-back-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.75);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cloud-zoom-forward-enter-active,
|
||||
.cloud-zoom-forward-leave-active,
|
||||
.cloud-zoom-back-enter-active,
|
||||
.cloud-zoom-back-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cloud-zoom-forward-enter-from,
|
||||
.cloud-zoom-forward-leave-to,
|
||||
.cloud-zoom-back-enter-from,
|
||||
.cloud-zoom-back-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
tabindex="-1"
|
||||
@pointerenter="activateMainScroll"
|
||||
@wheel.capture="activateMainScroll"
|
||||
@touchstart.passive="onContentTouchStart"
|
||||
@touchend.passive="onContentTouchEnd"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
<!-- Controller zone entry point - no switcher -->
|
||||
@@ -253,6 +255,79 @@ function activateMainScroll() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Swipe left/right to move between the mobile top tabs ──────────────────
|
||||
// Apps screen: My Apps ⇄ App Store ⇄ Services
|
||||
// Web5 screen: Web5 ⇄ Cloud ⇄ Network ⇄ Mesh
|
||||
// Only active while the matching tab strip is actually showing (mobile).
|
||||
type TabTarget = { key: string; to: { path: string; query: Record<string, string> } }
|
||||
const APPS_TABS: TabTarget[] = [
|
||||
{ key: 'myapps', to: { path: '/dashboard/apps', query: {} } },
|
||||
{ key: 'store', to: { path: '/dashboard/discover', query: {} } },
|
||||
{ key: 'services', to: { path: '/dashboard/apps', query: { tab: 'services' } } },
|
||||
]
|
||||
const NET_TABS: TabTarget[] = [
|
||||
{ key: 'web5', to: { path: '/dashboard/web5', query: {} } },
|
||||
{ key: 'cloud', to: { path: '/dashboard/cloud', query: {} } },
|
||||
{ key: 'server', to: { path: '/dashboard/server', query: {} } },
|
||||
{ key: 'mesh', to: { path: '/dashboard/mesh', query: {} } },
|
||||
]
|
||||
|
||||
function activeAppsKey(): string {
|
||||
if (route.query.tab === 'services' || route.query.tab === 'websites') return 'services'
|
||||
if (route.path.includes('/marketplace') || route.path.includes('/discover')) return 'store'
|
||||
if (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps')) return 'myapps'
|
||||
return ''
|
||||
}
|
||||
function activeNetKey(): string {
|
||||
const p = route.path
|
||||
if (p.startsWith('/dashboard/web5')) return 'web5'
|
||||
if (p.startsWith('/dashboard/cloud')) return 'cloud'
|
||||
if (p.startsWith('/dashboard/server')) return 'server'
|
||||
if (p.startsWith('/dashboard/mesh')) return 'mesh'
|
||||
return ''
|
||||
}
|
||||
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let touchStartTime = 0
|
||||
let swipeSuppressed = false
|
||||
function onContentTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
// Don't begin a tab swipe when the gesture starts on an app icon — let the
|
||||
// icon handle the tap/long-press. Swiping anywhere else still changes tabs.
|
||||
swipeSuppressed = !!(e.target instanceof Element && e.target.closest('.app-icon-item'))
|
||||
touchStartX = t.clientX
|
||||
touchStartY = t.clientY
|
||||
touchStartTime = e.timeStamp
|
||||
}
|
||||
function onContentTouchEnd(e: TouchEvent) {
|
||||
if (swipeSuppressed) { swipeSuppressed = false; return }
|
||||
const t = e.changedTouches[0]
|
||||
if (!t) return
|
||||
const dx = t.clientX - touchStartX
|
||||
const dy = t.clientY - touchStartY
|
||||
const dt = e.timeStamp - touchStartTime
|
||||
// Clear horizontal flick: far enough, mostly sideways, and quick.
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.8 || dt > 600) return
|
||||
|
||||
const nav = mobileNavRef.value
|
||||
if (!nav) return
|
||||
let tabs: TabTarget[] | null = null
|
||||
let activeKey = ''
|
||||
if (nav.showAppsTabs) { tabs = APPS_TABS; activeKey = activeAppsKey() }
|
||||
else if (nav.showNetworkTabs) { tabs = NET_TABS; activeKey = activeNetKey() }
|
||||
if (!tabs) return
|
||||
|
||||
const idx = tabs.findIndex(tb => tb.key === activeKey)
|
||||
if (idx < 0) return
|
||||
const next = idx + (dx < 0 ? 1 : -1) // swipe left → next tab, right → previous
|
||||
if (next < 0 || next >= tabs.length) return
|
||||
const target = tabs[next]
|
||||
if (!target) return
|
||||
router.push(target.to).catch(() => {})
|
||||
}
|
||||
|
||||
watch(() => route.path, (newPath) => {
|
||||
const isAppDetails = isDetailRoute(newPath)
|
||||
const wasAppDetails = showAltBackground.value
|
||||
|
||||
+131
-24
@@ -38,6 +38,8 @@ const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||
const mobileShowChat = ref(false)
|
||||
// Device status panel starts collapsed on mobile (expandable via its header).
|
||||
const deviceExpanded = ref(false)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let wsUnsub: (() => void) | null = null
|
||||
|
||||
@@ -261,6 +263,15 @@ const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('cha
|
||||
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||
const toolsTab = ref<'bitcoin' | 'deadman' | 'assistant' | 'map'>('bitcoin')
|
||||
|
||||
// Mobile: a single set of floating tabs drives the whole pane (Chat + tools).
|
||||
// 'chat' shows the peers list / active conversation; the rest swap the pane to
|
||||
// that tool. Selecting a tool leaves any open conversation.
|
||||
const mobileTab = ref<'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map'>('chat')
|
||||
function selectMobileTab(tab: 'chat' | 'bitcoin' | 'deadman' | 'assistant' | 'map') {
|
||||
mobileTab.value = tab
|
||||
if (tab !== 'chat') mobileShowChat.value = false
|
||||
}
|
||||
|
||||
// Panel visibility computeds
|
||||
const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
@@ -268,28 +279,29 @@ const showChatPanel = computed(() =>
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
|
||||
if (isMobile.value) return mobileTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
|
||||
if (isMobile.value) return mobileTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
const showAssistantPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'assistant'
|
||||
if (isMobile.value) return mobileTab.value === 'assistant'
|
||||
return activeTab.value === 'assistant'
|
||||
})
|
||||
const showMapPanel = computed(() => {
|
||||
if (isVeryWideDesktop.value) return true
|
||||
if (isWideDesktop.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
|
||||
if (isMobile.value) return mobileTab.value === 'map'
|
||||
return activeTab.value === 'map'
|
||||
})
|
||||
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||
// Mobile: tool pane shows whenever a non-chat tab is active.
|
||||
const showMobileTools = computed(() => isMobile.value && mobileTab.value !== 'chat')
|
||||
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
|
||||
|
||||
// Fetch session status when active peer changes
|
||||
@@ -313,10 +325,25 @@ async function handleToggleOffGrid() {
|
||||
} finally { togglingOffGrid.value = false }
|
||||
}
|
||||
|
||||
// Track the on-screen keyboard height (mobile) so the conversation pane + back
|
||||
// button can sit just above it — fixed elements ignore the keyboard otherwise
|
||||
// and the input ends up hidden behind it. Publishes --keyboard-inset on <html>.
|
||||
function updateKeyboardInset() {
|
||||
const vv = window.visualViewport
|
||||
if (!vv) return
|
||||
const inset = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop))
|
||||
document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('click', handleDocClickForMenu)
|
||||
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateKeyboardInset)
|
||||
window.visualViewport.addEventListener('scroll', updateKeyboardInset)
|
||||
updateKeyboardInset()
|
||||
}
|
||||
loadPendingFromSession()
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
|
||||
refreshOutboxCount()
|
||||
@@ -355,6 +382,11 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
document.removeEventListener('click', handleDocClickForMenu)
|
||||
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
|
||||
window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
|
||||
}
|
||||
document.documentElement.style.removeProperty('--keyboard-inset')
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||
if (wsUnsub) { wsUnsub(); wsUnsub = null }
|
||||
@@ -501,6 +533,7 @@ interface MergedPeer {
|
||||
primary_pubkey_hex: string | null
|
||||
primary_rssi: number | null
|
||||
is_archy: boolean
|
||||
reachable: boolean
|
||||
// The original active-chat marker uses contact_id equality, so keep a
|
||||
// representative MeshPeer for the rest of the codepaths that still want
|
||||
// a single object (peer header rssi, prekey rotation, etc).
|
||||
@@ -622,6 +655,7 @@ const mergedPeers = computed<MergedPeer[]>(() => {
|
||||
primary_pubkey_hex: peer.pubkey_hex,
|
||||
primary_rssi: peer.rssi,
|
||||
is_archy: isArchyNode(peer) || !!matchedFed,
|
||||
reachable: peer.reachable ?? true,
|
||||
primary: peer,
|
||||
})
|
||||
}
|
||||
@@ -671,12 +705,27 @@ const mergedPeers = computed<MergedPeer[]>(() => {
|
||||
primary_pubkey_hex: fed.pubkey,
|
||||
primary_rssi: null,
|
||||
is_archy: true,
|
||||
reachable: true,
|
||||
primary: placeholder,
|
||||
})
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
})
|
||||
|
||||
// Contact search — filters the Peers list by name, DID, npub, or pubkey.
|
||||
const peerSearch = ref('')
|
||||
const displayedPeers = computed<MergedPeer[]>(() => {
|
||||
const q = peerSearch.value.trim().toLowerCase()
|
||||
if (!q) return mergedPeers.value
|
||||
return mergedPeers.value.filter((mp) =>
|
||||
mp.display_name.toLowerCase().includes(q) ||
|
||||
(mp.short_did?.toLowerCase().includes(q) ?? false) ||
|
||||
(mp.did?.toLowerCase().includes(q) ?? false) ||
|
||||
(mp.npub?.toLowerCase().includes(q) ?? false) ||
|
||||
(mp.primary_pubkey_hex?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
})
|
||||
|
||||
// Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the
|
||||
// first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top
|
||||
// bit, then set it as the federation marker. Producing the SAME id here means a
|
||||
@@ -867,6 +916,18 @@ function scrollChatToBottom() {
|
||||
}
|
||||
}
|
||||
|
||||
// Wheel over the chat must scroll ONLY the chat — never leak to the contacts
|
||||
// list or the page. Bound with `@wheel.stop.prevent`: `.stop` keeps the event
|
||||
// from reaching the global controller-nav wheel handler (which would otherwise
|
||||
// also scroll whatever container is focused, e.g. the peer list after a click),
|
||||
// and `.prevent` stops the native page scroll. We then apply the delta to the
|
||||
// chat container directly.
|
||||
function onChatWheel(e: WheelEvent) {
|
||||
const el = chatScrollEl.value
|
||||
if (!el) return
|
||||
el.scrollTop += e.deltaY
|
||||
}
|
||||
|
||||
async function handleBroadcast() {
|
||||
broadcasting.value = true
|
||||
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
|
||||
@@ -1326,12 +1387,15 @@ function isImageMime(mime?: string): boolean {
|
||||
<!-- Responsive column layout -->
|
||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
|
||||
<!-- LEFT COLUMN: Status + Peers -->
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat || mobileTab !== 'chat' }">
|
||||
<!-- Device Status -->
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card">
|
||||
<div class="mesh-status-header">
|
||||
<div data-controller-container tabindex="0" class="glass-card mesh-status-card" :class="{ 'mesh-status-collapsed': !deviceExpanded }">
|
||||
<div class="mesh-status-header" role="button" tabindex="0" @click="deviceExpanded = !deviceExpanded" @keydown.enter.prevent="deviceExpanded = !deviceExpanded" @keydown.space.prevent="deviceExpanded = !deviceExpanded">
|
||||
<div class="mesh-status-indicator" :class="mesh.status?.device_connected ? 'connected' : 'disconnected'" />
|
||||
<h2 class="mesh-section-title">Device</h2>
|
||||
<svg class="mesh-status-chevron" :aria-expanded="deviceExpanded" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-if="mesh.loading && !mesh.status" class="mesh-loading">Loading...</div>
|
||||
@@ -1417,6 +1481,25 @@ function isImageMime(mime?: string): boolean {
|
||||
<button class="text-xs text-white/40 hover:text-red-400 transition-colors px-2 py-1" @click="clearAllMesh" title="Clear all peers, messages, and chat history">Clear All</button>
|
||||
</div>
|
||||
|
||||
<!-- Contact search: filters the list below by name / DID / npub / pubkey -->
|
||||
<div class="mesh-peer-search-wrap">
|
||||
<input
|
||||
v-model="peerSearch"
|
||||
type="text"
|
||||
class="mesh-peer-search"
|
||||
placeholder="Search contacts…"
|
||||
aria-label="Search contacts"
|
||||
/>
|
||||
<button
|
||||
v-if="peerSearch"
|
||||
type="button"
|
||||
class="mesh-peer-search-clear"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
@click="peerSearch = ''"
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||
No peers discovered yet.
|
||||
</div>
|
||||
@@ -1454,8 +1537,11 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
|
||||
</div>
|
||||
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
|
||||
No contacts match “{{ peerSearch.trim() }}”.
|
||||
</div>
|
||||
<div
|
||||
v-for="mp in mergedPeers" :key="mp.key"
|
||||
v-for="mp in displayedPeers" :key="mp.key"
|
||||
class="mesh-peer-row"
|
||||
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
|
||||
tabindex="0"
|
||||
@@ -1466,6 +1552,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
|
||||
<AnimatedLogo v-if="mp.is_archy" size="sm" />
|
||||
<template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
|
||||
<span class="mesh-peer-reach" :class="mp.reachable ? 'is-reachable' : 'is-unreachable'" :title="mp.reachable ? 'Reachable' : 'Not currently reachable'"></span>
|
||||
</div>
|
||||
<div class="mesh-peer-info">
|
||||
<div class="mesh-peer-name">
|
||||
@@ -1492,7 +1579,7 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Tabbed panels -->
|
||||
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat }">
|
||||
<div class="mesh-right" data-controller-zone="mesh-chat" :class="{ 'mobile-hidden': !mobileShowChat || mobileTab !== 'chat' }">
|
||||
<!-- Tab bar (medium desktop only) -->
|
||||
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||
@@ -1510,15 +1597,29 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card">
|
||||
<div v-if="showChatPanel" data-controller-container tabindex="0" class="glass-card mesh-chat-card" :class="{ 'mesh-chat-card-active': hasActiveChat }">
|
||||
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||
<div class="mesh-chat-empty-icon">📡</div>
|
||||
<p>Select a peer or channel to chat</p>
|
||||
<p class="mesh-chat-empty-sub">Messages are sent over LoRa mesh radio</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Mobile: floating back button (shared glass pill style), pinned
|
||||
above the tab bar — replaces the in-header arrow so the back
|
||||
control is no longer crammed inside the chat container. -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
type="button"
|
||||
class="mesh-chat-mobile-back mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium items-center justify-center gap-2"
|
||||
@click="closeChat"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<div class="mesh-chat-header">
|
||||
<button class="mesh-chat-back" @click="closeChat">←</button>
|
||||
<div class="mesh-chat-header-info">
|
||||
<div class="mesh-chat-header-name">
|
||||
<template v-if="renamingActive">
|
||||
@@ -1553,7 +1654,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt">
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
|
||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
@@ -1800,17 +1901,6 @@ function isImageMime(mime?: string): boolean {
|
||||
|
||||
<!-- Mobile tools: show under peers list on first view -->
|
||||
<div v-if="showMobileTools" class="mesh-mobile-tools">
|
||||
<div class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'assistant' }" @click="toolsTab = 'assistant'">
|
||||
AI
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'map' }" @click="toolsTab = 'map'">Map</button>
|
||||
</div>
|
||||
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
|
||||
<MeshBitcoinPanel v-if="showBitcoinPanel" />
|
||||
<MeshDeadmanPanel v-if="showDeadmanPanel" />
|
||||
@@ -1818,6 +1908,23 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: floating tab strip pinned above the global tab bar (same
|
||||
placement as the mobile back button). Switches the whole pane between
|
||||
the chat and each tool. Hidden while an individual conversation is open
|
||||
(the back button takes over there). -->
|
||||
<Teleport to="body">
|
||||
<div v-show="!mobileShowChat" class="mesh-mobile-tabbar">
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'chat' }" @click="selectMobileTab('chat')">Chat</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'bitcoin' }" @click="selectMobileTab('bitcoin')">BTC</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'deadman' }" @click="selectMobileTab('deadman')">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'assistant' }" @click="selectMobileTab('assistant')">AI</button>
|
||||
<button class="mesh-mtab" :class="{ active: mobileTab === 'map' }" @click="selectMobileTab('map')">Map</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Transport chooser modal: shown when attachment size fits both mesh
|
||||
(inline-chunked) and Tor. User picks which path to send it over. -->
|
||||
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
|
||||
|
||||
@@ -316,14 +316,14 @@
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payWithInvoice"
|
||||
@click="openQrPay"
|
||||
>
|
||||
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">Pay from another wallet (QR)</span>
|
||||
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
|
||||
<span class="block text-sm text-white/50">Scan an on-chain or Lightning QR with any wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -344,40 +344,84 @@
|
||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Lightning invoice -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
||||
<div v-else>
|
||||
<!-- Method tabs, styled like the wallet Send/Receive modal -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
v-for="m in (['onchain', 'lightning'] as const)"
|
||||
:key="m"
|
||||
@click="selectQrTab(m)"
|
||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
||||
:class="qrTab === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
||||
>{{ m === 'onchain' ? 'On-chain' : 'Lightning' }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<!-- On-chain QR -->
|
||||
<div v-if="qrTab === 'onchain'" class="text-center">
|
||||
<div v-if="onchainWaiting && !onchainData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<span class="text-sm text-white/70">Requesting an address from the seller…</span>
|
||||
</div>
|
||||
<div v-else-if="onchainData">
|
||||
<div v-if="onchainQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="onchainQr" alt="On-chain payment QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ onchainData.amount_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-orange-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ onchainData.address }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyOnchain">
|
||||
{{ onchainCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40 mt-2">Needs 1 confirmation before the file unlocks.</p>
|
||||
</div>
|
||||
<p v-if="onchainError" class="text-sm text-red-400 mt-3">{{ onchainError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Lightning invoice QR -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
</div>
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
<button
|
||||
v-if="invoiceError"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-4 w-full"
|
||||
@click="payMode = 'choose'"
|
||||
>
|
||||
Back
|
||||
@@ -453,12 +497,21 @@ const audioPlayer = useAudioPlayer()
|
||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||
// they can pay from any external wallet by scanning a QR.
|
||||
const payItem = ref<CatalogItem | null>(null)
|
||||
const payMode = ref<'choose' | 'invoice'>('choose')
|
||||
const payMode = ref<'choose' | 'qr'>('choose')
|
||||
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
|
||||
// on-chain first (the default).
|
||||
const qrTab = ref<'onchain' | 'lightning'>('onchain')
|
||||
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
|
||||
const invoiceQr = ref('')
|
||||
const invoiceWaiting = ref(false)
|
||||
const invoiceError = ref('')
|
||||
const invoiceCopied = ref(false)
|
||||
// On-chain QR (pay the seller's address from any external wallet).
|
||||
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
|
||||
const onchainQr = ref('')
|
||||
const onchainWaiting = ref(false)
|
||||
const onchainError = ref('')
|
||||
const onchainCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
const onchainPaying = ref(false)
|
||||
@@ -660,11 +713,17 @@ async function downloadFile(item: CatalogItem) {
|
||||
function openPayModal(item: CatalogItem) {
|
||||
payItem.value = item
|
||||
payMode.value = 'choose'
|
||||
qrTab.value = 'onchain'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceWaiting.value = false
|
||||
invoiceError.value = ''
|
||||
invoiceCopied.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = false
|
||||
onchainError.value = ''
|
||||
onchainCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
onchainPaying.value = false
|
||||
@@ -675,9 +734,102 @@ function closePayModal() {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
payItem.value = null
|
||||
invoiceWaiting.value = false
|
||||
onchainWaiting.value = false
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the "pay from another wallet" view: tabbed QR like the wallet's
|
||||
* Send/Receive modal, defaulting to the on-chain tab (so a QR is shown
|
||||
* immediately for any external wallet).
|
||||
*/
|
||||
function openQrPay() {
|
||||
payMode.value = 'qr'
|
||||
qrTab.value = 'onchain'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = false
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainError.value = ''
|
||||
loadOnchainQr()
|
||||
}
|
||||
|
||||
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
|
||||
* resuming its payment poll if it was already loaded (so switching back and
|
||||
* forth doesn't silently stop watching for payment). */
|
||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||
if (qrTab.value === tab) return
|
||||
qrTab.value = tab
|
||||
if (tab === 'onchain') {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
if (!onchainData.value && !onchainWaiting.value) {
|
||||
loadOnchainQr()
|
||||
} else if (onchainData.value && !onchainPaying.value) {
|
||||
onchainPaying.value = true
|
||||
pollOnchain(onchainData.value.address)
|
||||
}
|
||||
} else {
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
onchainPaying.value = false
|
||||
if (!invoiceData.value && !invoiceWaiting.value) {
|
||||
payWithInvoice()
|
||||
} else if (invoiceData.value) {
|
||||
scheduleInvoicePoll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-chain QR: ask the seller for a fresh address + amount, render a
|
||||
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
|
||||
* lands, then release the file (the address is the gate token).
|
||||
*/
|
||||
async function loadOnchainQr() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
onchainError.value = ''
|
||||
onchainData.value = null
|
||||
onchainQr.value = ''
|
||||
onchainWaiting.value = true
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
onchainError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
onchainWaiting.value = false
|
||||
return
|
||||
}
|
||||
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
|
||||
const btc = (req.amount_sats / 1e8).toFixed(8)
|
||||
try {
|
||||
onchainQr.value = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${btc}`, { margin: 1, width: 240 })
|
||||
} catch {
|
||||
onchainQr.value = '' // fall back to showing the raw address
|
||||
}
|
||||
onchainWaiting.value = false
|
||||
onchainPaying.value = true // "waiting for payment" — drives the poll loop
|
||||
pollOnchain(req.address)
|
||||
} catch (e: unknown) {
|
||||
onchainError.value = e instanceof Error ? e.message : 'Could not request an on-chain address'
|
||||
onchainWaiting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOnchain() {
|
||||
if (!onchainData.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(onchainData.value.address)
|
||||
onchainCopied.value = true
|
||||
setTimeout(() => { onchainCopied.value = false }, 1500)
|
||||
} catch { /* clipboard denied */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
|
||||
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
|
||||
@@ -695,7 +847,7 @@ async function payOnchain() {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
@@ -805,14 +957,14 @@ async function payWithInvoice() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
|
||||
payMode.value = 'invoice'
|
||||
payMode.value = 'qr'
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!res?.bolt11 || !res?.payment_hash) {
|
||||
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
@@ -849,7 +1001,7 @@ async function payWithLightning() {
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
timeout: 45000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span class="text-white/80 text-sm">FIPS Mesh</span>
|
||||
<span class="text-white/80 text-sm">Fuck IPs Mesh</span>
|
||||
</div>
|
||||
<span class="text-sm" :class="fipsRowTextClass">{{ fipsRowLabel }}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<AccountSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<SystemSection />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Launching overlay — instant tap feedback while the app opens -->
|
||||
<div v-if="launchingId === id" class="app-icon-installing">
|
||||
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Label -->
|
||||
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
|
||||
@@ -114,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
|
||||
@@ -152,6 +159,28 @@ const activePage = ref(0)
|
||||
const longPressTriggered = ref(false)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Per-icon "launching" spinner so a tap is acknowledged instantly even while
|
||||
// the app session/iframe is still spinning up. Cleared when the launcher
|
||||
// overlay opens, with a fallback timeout for the open-in-new-tab path.
|
||||
const launchingId = ref<string | null>(null)
|
||||
let launchClearTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function markLaunching(id: string) {
|
||||
launchingId.value = id
|
||||
if (launchClearTimer) clearTimeout(launchClearTimer)
|
||||
launchClearTimer = setTimeout(() => {
|
||||
if (launchingId.value === id) launchingId.value = null
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
// Clear the spinner as soon as the app overlay actually opens.
|
||||
watch(() => appLauncher.isOpen, (open) => {
|
||||
if (open) {
|
||||
launchingId.value = null
|
||||
if (launchClearTimer) { clearTimeout(launchClearTimer); launchClearTimer = null }
|
||||
}
|
||||
})
|
||||
|
||||
const pages = computed(() => {
|
||||
const result: [string, PackageDataEntry][][] = []
|
||||
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
|
||||
@@ -216,6 +245,7 @@ function openAppOptions(id: string) {
|
||||
}
|
||||
|
||||
function launchNow(id: string, pkg: PackageDataEntry) {
|
||||
markLaunching(id)
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
@@ -305,6 +335,15 @@ function scrollToPage(index: number) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Instant press feedback: the icon scales down the moment it's touched, so the
|
||||
tap is acknowledged even before the app finishes launching. */
|
||||
.app-icon-frame {
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.app-icon-item:active .app-icon-frame {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.sideload-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
<template>
|
||||
<!-- Offline Banner -->
|
||||
<div v-if="isOffline && !store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-yellow-500">
|
||||
<div class="flex items-center gap-2 text-yellow-200">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<!-- Offline Banner -->
|
||||
<Transition name="conn-banner">
|
||||
<div
|
||||
v-if="isOffline && !store.isReconnecting && store.isAuthenticated"
|
||||
class="conn-banner-overlay"
|
||||
>
|
||||
<div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Reconnecting Banner -->
|
||||
<div v-if="store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-blue-500">
|
||||
<div class="flex items-center gap-2 text-blue-200">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span class="font-medium">Reconnecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reconnecting Banner -->
|
||||
<Transition name="conn-banner">
|
||||
<div
|
||||
v-if="store.isReconnecting && store.isAuthenticated"
|
||||
class="conn-banner-overlay"
|
||||
>
|
||||
<div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl">
|
||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span class="font-medium">Reconnecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -32,3 +44,34 @@ const isOffline = computed(() => store.isOffline)
|
||||
const isRestarting = computed(() => store.isRestarting)
|
||||
const isShuttingDown = computed(() => store.isShuttingDown)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Float the connection banners over the UI instead of occupying layout space
|
||||
* (which previously pushed the whole dashboard down when reconnecting).
|
||||
* Pinned top-center, clear of the status bar via the safe-area inset that the
|
||||
* Android companion app injects (--safe-area-top), falling back to env(). */
|
||||
.conn-banner-overlay {
|
||||
position: fixed;
|
||||
top: calc(1rem + var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
left: 50%;
|
||||
z-index: 60;
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100% - 2rem);
|
||||
pointer-events: none; /* purely informational — never intercept taps */
|
||||
}
|
||||
|
||||
.conn-banner-enter-active,
|
||||
.conn-banner-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.conn-banner-enter-from,
|
||||
.conn-banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
}
|
||||
.conn-banner-enter-to,
|
||||
.conn-banner-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
defineProps<{
|
||||
filteredApps: MarketplaceApp[]
|
||||
@@ -181,11 +182,6 @@ defineEmits<{
|
||||
'install': [app: MarketplaceApp]
|
||||
'retry-nostr': []
|
||||
}>()
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
defineProps<{
|
||||
featuredApps: FeaturedApp[]
|
||||
@@ -114,9 +115,4 @@ defineEmits<{
|
||||
'launch': [app: MarketplaceApp]
|
||||
'install': [app: MarketplaceApp]
|
||||
}>()
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -122,9 +122,7 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Cashu">🥜</span>
|
||||
<span class="text-sm text-white/80">Cashu</span>
|
||||
</div>
|
||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||
|
||||
@@ -11,6 +11,23 @@ const enabled = ref(false)
|
||||
const model = ref('') // '' = use the backend's default model
|
||||
const policy = ref<'trusted' | 'anyone'>('trusted')
|
||||
const backend = ref<'claude' | 'ollama'>('claude')
|
||||
const allowedContacts = ref<string[]>([])
|
||||
|
||||
// Preset Claude models offered in the dropdown ('' = backend default = Haiku).
|
||||
const CLAUDE_MODELS: { value: string; label: string }[] = [
|
||||
{ value: '', label: 'Default (Claude Haiku 4.5)' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 — fast & cheap' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8 — most capable' },
|
||||
]
|
||||
// Include any non-preset value the node already has so it isn't silently lost.
|
||||
const claudeModelOptions = computed(() => {
|
||||
const opts = [...CLAUDE_MODELS]
|
||||
if (model.value && !opts.some((o) => o.value === model.value)) {
|
||||
opts.push({ value: model.value, label: `${model.value} (custom)` })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
// Sync local controls from the fetched status.
|
||||
watch(
|
||||
@@ -21,10 +38,72 @@ watch(
|
||||
model.value = s.model ?? ''
|
||||
policy.value = s.trusted_only ? 'trusted' : 'anyone'
|
||||
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
|
||||
allowedContacts.value = [...(s.allowed_contacts ?? [])]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Addressable contacts (have an archipelago/radio pubkey) for the allowlist.
|
||||
const contactOptions = computed(() =>
|
||||
mesh.peers
|
||||
.filter((p) => !!p.pubkey_hex)
|
||||
.map((p) => ({ pubkey: p.pubkey_hex as string, name: p.advert_name || (p.pubkey_hex as string).slice(0, 10) })),
|
||||
)
|
||||
|
||||
function isAllowed(pubkey: string) {
|
||||
return allowedContacts.value.some((k) => k.toLowerCase() === pubkey.toLowerCase())
|
||||
}
|
||||
function toggleAllowed(pubkey: string) {
|
||||
if (isAllowed(pubkey)) {
|
||||
allowedContacts.value = allowedContacts.value.filter((k) => k.toLowerCase() !== pubkey.toLowerCase())
|
||||
} else {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
}
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
|
||||
// Manually pasting a raw ed25519 pubkey (hex) — for an allowed asker that
|
||||
// isn't in the contact list yet (e.g. a phone/meshcore device).
|
||||
const newPubkey = ref('')
|
||||
const pubkeyError = ref('')
|
||||
// Allowlisted keys that aren't one of our known contacts (manually added).
|
||||
const extraAllowed = computed(() =>
|
||||
allowedContacts.value.filter(
|
||||
(k) => !contactOptions.value.some((c) => c.pubkey.toLowerCase() === k.toLowerCase()),
|
||||
),
|
||||
)
|
||||
function addPubkey() {
|
||||
const pk = newPubkey.value.trim().toLowerCase()
|
||||
pubkeyError.value = ''
|
||||
if (!/^[0-9a-f]{64}$/.test(pk)) {
|
||||
pubkeyError.value = 'Enter a 64-character hex ed25519 public key.'
|
||||
return
|
||||
}
|
||||
if (allowedContacts.value.some((k) => k.toLowerCase() === pk)) {
|
||||
newPubkey.value = ''
|
||||
return
|
||||
}
|
||||
allowedContacts.value = [...allowedContacts.value, pk]
|
||||
newPubkey.value = ''
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
|
||||
// Radio/peers who recently tried `!ai` and were turned away by the policy.
|
||||
// Surfaced so the operator can one-click allow them instead of digging through
|
||||
// the journal for the firmware key. Hide any we've since allowed.
|
||||
const deniedAskers = computed(() =>
|
||||
(status.value?.denied_askers ?? []).filter(
|
||||
(d) => !d.pubkey_hex || !isAllowed(d.pubkey_hex),
|
||||
),
|
||||
)
|
||||
function allowDenied(pubkey: string | null) {
|
||||
if (!pubkey) return
|
||||
if (!isAllowed(pubkey)) {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
apply({ allowed_contacts: allowedContacts.value })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mesh.fetchAssistantStatus()
|
||||
})
|
||||
@@ -45,6 +124,7 @@ async function apply(partial: {
|
||||
model?: string | null
|
||||
trusted_only?: boolean
|
||||
backend?: string
|
||||
allowed_contacts?: string[]
|
||||
}) {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -74,7 +154,6 @@ function onPolicy() {
|
||||
<template>
|
||||
<div class="glass-card mesh-assistant-panel">
|
||||
<h3 class="mesh-panel-title">AI Assistant</h3>
|
||||
<p class="mesh-panel-sub">Answer questions over the mesh with AI</p>
|
||||
|
||||
<!-- Backend chooser -->
|
||||
<div class="mesh-assistant-field">
|
||||
@@ -131,7 +210,9 @@ function onPolicy() {
|
||||
</div>
|
||||
<div v-else class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Model</label>
|
||||
<input v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" :placeholder="defaultModel" @change="onModel" />
|
||||
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
|
||||
<option v-for="m in claudeModelOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mesh-assistant-field">
|
||||
@@ -140,13 +221,83 @@ function onPolicy() {
|
||||
<option value="trusted">Trusted nodes only</option>
|
||||
<option value="anyone">Anyone on the mesh</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/40 mt-1">
|
||||
{{ policy === 'anyone'
|
||||
? 'Any peer can spend this node\'s AI budget + airtime.'
|
||||
: 'Only federation-trusted peers may ask.' }}
|
||||
<p v-if="policy === 'anyone'" class="text-xs text-white/40 mt-1">
|
||||
Any peer can spend this node's AI budget + airtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Per-contact allowlist: let specific contacts use !ai even when the
|
||||
policy is "trusted only" and they aren't federation-trusted. -->
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Always allow these contacts</label>
|
||||
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
|
||||
No contacts yet — they appear here once you have mesh/federation contacts.
|
||||
</div>
|
||||
<div v-else class="mesh-assistant-allowlist">
|
||||
<label
|
||||
v-for="c in contactOptions"
|
||||
:key="c.pubkey"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
|
||||
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
|
||||
</label>
|
||||
<!-- Manually-added pubkeys not in the contact list -->
|
||||
<label
|
||||
v-for="pk in extraAllowed"
|
||||
:key="pk"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<input type="checkbox" checked @change="toggleAllowed(pk)" />
|
||||
<span class="mesh-assistant-allow-name" :title="pk">{{ pk.slice(0, 10) }}… (added)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Add an arbitrary pubkey directly -->
|
||||
<div class="mesh-assistant-addkey">
|
||||
<input
|
||||
v-model="newPubkey"
|
||||
class="mesh-bitcoin-input mesh-bitcoin-input-sm"
|
||||
placeholder="Paste an ed25519 pubkey (64 hex) to allow"
|
||||
@keyup.enter="addPubkey"
|
||||
/>
|
||||
<button type="button" class="glass-button mesh-bitcoin-input-sm" @click="addPubkey">Add</button>
|
||||
</div>
|
||||
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Recently denied askers: someone tried !ai but the policy turned them
|
||||
away. Show who, and offer a one-click Allow when we know their key. -->
|
||||
<div v-if="deniedAskers.length > 0" class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Recently denied</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
These tried <code>!ai</code> but the policy turned them away. Allow one to add its key.
|
||||
</p>
|
||||
<div class="mesh-assistant-allowlist">
|
||||
<div
|
||||
v-for="d in deniedAskers"
|
||||
:key="d.contact_id + (d.pubkey_hex || '')"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<span class="mesh-assistant-allow-name" :title="d.pubkey_hex || ''">
|
||||
{{ d.name || ('#' + d.contact_id) }}
|
||||
<span v-if="d.pubkey_hex" class="text-white/30">· {{ d.pubkey_hex.slice(0, 10) }}…</span>
|
||||
</span>
|
||||
<button
|
||||
v-if="d.pubkey_hex"
|
||||
type="button"
|
||||
class="glass-button mesh-bitcoin-input-sm mesh-assistant-allow-btn"
|
||||
@click="allowDenied(d.pubkey_hex)"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<span v-else class="text-xs text-white/30" title="No archipelago key advertised — switch policy to 'Anyone on the mesh' to admit this device.">
|
||||
no key
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||
</p>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
||||
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
||||
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; }
|
||||
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
|
||||
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; overscroll-behavior: contain; }
|
||||
.mesh-tools-wrapper { display: contents; }
|
||||
.mesh-tools-tab-bar { display: none; }
|
||||
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
|
||||
@@ -62,6 +62,10 @@
|
||||
.mesh-status-indicator.connected { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.5); }
|
||||
.mesh-status-indicator.disconnected { background: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-section-title { font-size: 0.95rem; font-weight: 600; color: rgba(255, 255, 255, 0.9); margin: 0; }
|
||||
/* Collapse chevron — only used on mobile (hidden on desktop, where the Device
|
||||
panel is always expanded). Kept small and pushed to the far right. */
|
||||
.mesh-status-chevron { display: none; width: 16px; height: 16px; margin-left: auto; flex-shrink: 0; color: rgba(255, 255, 255, 0.5); transition: transform 0.2s ease; }
|
||||
.mesh-status-card:not(.mesh-status-collapsed) .mesh-status-chevron { transform: rotate(180deg); }
|
||||
.mesh-status-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.mesh-stat { display: flex; flex-direction: column; gap: 1px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 6px; }
|
||||
.mesh-stat-label { font-size: 0.65rem; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
@@ -85,7 +89,16 @@
|
||||
.mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
||||
.mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); }
|
||||
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
|
||||
.mesh-peer-avatar { width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
|
||||
.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
|
||||
.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; }
|
||||
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
|
||||
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
|
||||
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; line-height: 1; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 15px; cursor: pointer; padding: 0; }
|
||||
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
|
||||
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
|
||||
.mesh-peer-reach.is-reachable { background: #34d399; }
|
||||
.mesh-peer-reach.is-unreachable { background: rgba(255,255,255,0.25); }
|
||||
.mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; }
|
||||
.mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; }
|
||||
.mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; }
|
||||
@@ -111,7 +124,9 @@
|
||||
.mesh-chat-empty p { margin: 0; font-size: 0.9rem; }
|
||||
.mesh-chat-empty-sub { font-size: 0.75rem !important; color: rgba(255, 255, 255, 0.2); }
|
||||
.mesh-chat-header { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; }
|
||||
.mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; }
|
||||
/* Floating mobile back button (Teleported to body). Hidden by default; only
|
||||
shown in the single-column mobile mesh layout (see media query below). */
|
||||
.mesh-chat-mobile-back { display: none; }
|
||||
.mesh-chat-header-info { flex: 1; min-width: 0; }
|
||||
.mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; }
|
||||
.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; }
|
||||
@@ -121,7 +136,7 @@
|
||||
.mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; }
|
||||
.mesh-chat-header-status { flex-shrink: 0; }
|
||||
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.mesh-chat-messages { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.mesh-chat-no-messages { flex: 1; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.25); font-size: 0.85rem; }
|
||||
.mesh-chat-bubble-wrapper { display: flex; }
|
||||
.mesh-chat-bubble-wrapper.sent { justify-content: flex-end; }
|
||||
@@ -146,22 +161,115 @@
|
||||
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-mobile-back-btn { display: none; }
|
||||
|
||||
/* Floating mobile mesh tab strip (Teleported to body). Hidden on desktop; the
|
||||
≤1279px block flips it to flex and the placement mirrors the mobile back
|
||||
button (pinned above the global tab bar + audio player). */
|
||||
.mesh-mobile-tabbar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 8px);
|
||||
z-index: 40;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.mesh-mtab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 4px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.mesh-mtab:hover { color: rgba(255, 255, 255, 0.9); }
|
||||
.mesh-mtab.active { background: rgba(251, 146, 60, 0.2); color: #fff; }
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.mesh-view { height: auto; overflow: visible; padding: 0 12px 100px 12px; }
|
||||
.mesh-columns { flex-direction: column; overflow: visible; }
|
||||
.mesh-left { width: 100%; overflow: visible; }
|
||||
.mesh-right { min-height: auto; overflow: visible; }
|
||||
.mesh-chat-card { min-height: 60dvh; max-height: 75dvh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Single-column mobile mesh: one fixed, internally-scrolling pane that
|
||||
fills the space between the top tab strip and the floating mesh tab bar.
|
||||
The page itself never scrolls; each pane scrolls inside its own bounds.
|
||||
Fixed positioning is relative to the full-height perspective container, so
|
||||
the offsets line up with the body-teleported tab bar / back button. ──── */
|
||||
.mesh-left,
|
||||
.mesh-mobile-tools,
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
/* width:auto so left+right govern the box — .mesh-left otherwise carries a
|
||||
fixed 380px width that ignores `right` and overflows the screen. */
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 96px);
|
||||
/* Just above the floating mesh tab bar (tabs sit at +8, ~48px tall). */
|
||||
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px) + 72px);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
z-index: 30;
|
||||
}
|
||||
/* Active conversation: the floating tabs are hidden here and the back button
|
||||
takes the standard spot above the tab bar, so the chat window fills down to
|
||||
just above the back pill (back pill ≈44px at +8, plus a 16px gap). When the
|
||||
keyboard is up it covers the tab bar, so anchor to whichever is taller — the
|
||||
bottom controls or the keyboard — so the window sits right above both. */
|
||||
.mesh-chat-card.mesh-chat-card-active {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 68px);
|
||||
overflow: hidden; /* the messages list inside does the scrolling */
|
||||
}
|
||||
.mesh-tools-wrapper { display: none !important; }
|
||||
.mesh-mobile-tools { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.mesh-mobile-tools .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; }
|
||||
.mesh-mobile-tools :deep(.mesh-bitcoin-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-assistant-panel),
|
||||
.mesh-mobile-tools :deep(.mesh-deadman-panel) { min-height: 320px; max-height: min(68dvh, 620px); overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { min-height: 360px; max-height: min(68dvh, 620px); overflow: hidden; }
|
||||
.mesh-mobile-tools { margin-top: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* The active tool fills the whole fixed pane (no fixed cap that would leave a
|
||||
bottom margin); the panel itself scrolls if its content is taller. */
|
||||
.mesh-mobile-tools > * { flex: 1 1 auto; min-height: 0; max-height: none; }
|
||||
.mesh-mobile-tools .mesh-bitcoin-panel,
|
||||
.mesh-mobile-tools .mesh-assistant-panel,
|
||||
.mesh-mobile-tools .mesh-deadman-panel { overflow-y: auto; }
|
||||
.mesh-mobile-tools .mesh-map-panel { height: 100%; overflow: hidden; }
|
||||
.mesh-status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.mesh-chat-back { display: block; }
|
||||
/* In a conversation the tabs are hidden, so the back pill sits just above the
|
||||
tab bar — or above the keyboard when it's up, whichever is taller. */
|
||||
.mesh-chat-mobile-back { display: flex; }
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
bottom: calc(max(var(--mobile-tab-bar-height, 72px) + var(--audio-player-height, 0px), var(--keyboard-inset, 0px)) + 8px);
|
||||
}
|
||||
/* Floating mesh tab strip — same placement logic as the mobile back button. */
|
||||
.mesh-mobile-tabbar { display: flex; }
|
||||
.mobile-hidden { display: none !important; }
|
||||
/* Device panel is a collapsible/expandable accordion on mobile (starts
|
||||
collapsed). Show the chevron, make the header tappable, and hide the body
|
||||
when collapsed. */
|
||||
.mesh-status-chevron { display: block; }
|
||||
.mesh-status-card .mesh-status-header { cursor: pointer; margin-bottom: 12px; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-header { margin-bottom: 0; }
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-status-grid,
|
||||
.mesh-status-card.mesh-status-collapsed .mesh-detected-devices { display: none; }
|
||||
:deep(.mesh-bitcoin-panel),
|
||||
:deep(.mesh-assistant-panel),
|
||||
:deep(.mesh-deadman-panel) { flex: none; cursor: pointer; flex-shrink: 0; }
|
||||
@@ -172,6 +280,14 @@
|
||||
.mesh-view {
|
||||
padding: 24px;
|
||||
}
|
||||
/* In this range the desktop sidebar (256px) is still shown. The in-pane
|
||||
fixed elements are positioned relative to the main content area (their
|
||||
perspective containing block), so they already clear the sidebar — but the
|
||||
body-teleported floating bars are viewport-relative, so nudge them right. */
|
||||
.mesh-mobile-tabbar,
|
||||
.mesh-chat-mobile-back.mobile-back-btn {
|
||||
left: 268px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
@@ -232,6 +348,12 @@
|
||||
.mesh-assistant-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-assistant-install { padding: 12px; background: rgba(251,146,60,0.08); border: 1px solid rgba(251,146,60,0.25); border-radius: 10px; }
|
||||
.mesh-assistant-install-btn { display: inline-block; text-align: center; padding: 8px 14px; font-size: 0.8rem; }
|
||||
.mesh-assistant-allowlist { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 6px; background: rgba(0,0,0,0.2); }
|
||||
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
|
||||
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
|
||||
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mesh-assistant-addkey { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
|
||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
||||
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 class="text-xl font-semibold text-white">FIPS Mesh</h2>
|
||||
<h2 class="text-xl font-semibold text-white">Fuck IPs Mesh</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2" :title="statusLabel">
|
||||
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
||||
|
||||
@@ -228,6 +228,36 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.00-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.8.00-alpha</span>
|
||||
<span class="text-xs text-white/40">June 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.</p>
|
||||
<p>You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.</p>
|
||||
<p>The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.</p>
|
||||
<p>Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.</p>
|
||||
<p>In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.</p>
|
||||
<p>In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.</p>
|
||||
<p>Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).</p>
|
||||
<p>You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.</p>
|
||||
<p>New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.</p>
|
||||
<p>"Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.</p>
|
||||
<p>The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.</p>
|
||||
<p>Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.</p>
|
||||
<p>You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.</p>
|
||||
<p>Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.</p>
|
||||
<p>You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.</p>
|
||||
<p>Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.</p>
|
||||
<p>The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).</p>
|
||||
<p>Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.</p>
|
||||
<p>You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.</p>
|
||||
<p>Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.</p>
|
||||
<p>For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.99-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
@@ -14,8 +12,6 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<InterfaceModeSection />
|
||||
<ClaudeAuthSection />
|
||||
<AIDataAccessSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<BackupSection />
|
||||
|
||||
@@ -157,8 +157,8 @@
|
||||
|
||||
<div v-if="receiveMethod === 'ecash'">
|
||||
<div class="mb-3">
|
||||
<label class="text-white/60 text-sm block mb-1">Paste ecash token</label>
|
||||
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuSend_..." class="w-full input-glass"></textarea>
|
||||
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
|
||||
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
|
||||
</div>
|
||||
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
||||
</div>
|
||||
@@ -487,11 +487,12 @@ async function unifiedReceive() {
|
||||
unifiedReceiveError.value = t('web5.pasteEcashToken')
|
||||
return
|
||||
}
|
||||
const res = await rpcClient.call<{ received_sats: number }>({
|
||||
const res = await rpcClient.call<{ received_sats: number; kind?: string }>({
|
||||
method: 'wallet.ecash-receive',
|
||||
params: { token: ecashReceiveToken.value.trim() },
|
||||
})
|
||||
ecashReceiveResult.value = `Received ${res.received_sats} sats!`
|
||||
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
|
||||
ecashReceiveToken.value = ''
|
||||
emit('balancesChanged')
|
||||
}
|
||||
|
||||
@@ -39,6 +39,17 @@ detect_environment() {
|
||||
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 16000000) / 1024))
|
||||
LOW_MEM=false
|
||||
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true
|
||||
# Bitcoin UTXO cache (dbcache) sized to host RAM, NOT a fixed value.
|
||||
# A large dbcache on a small box pushes total memory (bitcoind + the ~20 app
|
||||
# containers) past physical RAM and forces system-wide swap thrash: the disk
|
||||
# saturates, bitcoind can't answer its own RPC, and the dashboard backend's
|
||||
# sqlite reads stall — surfacing as fleet-wide /rpc/v1 502s and a blank
|
||||
# Bitcoin UI. The old binary LOW_MEM->2048 toggle still over-committed 8 GB
|
||||
# nodes. Budget ~1/16 of RAM for the cache, leaving the bulk for the OS +
|
||||
# containers; floor 300 MB (bitcoind default is 450), cap 4096 MB.
|
||||
BTC_DBCACHE=$(( TOTAL_MEM_MB / 16 ))
|
||||
[ "$BTC_DBCACHE" -lt 300 ] && BTC_DBCACHE=300
|
||||
[ "$BTC_DBCACHE" -gt 4096 ] && BTC_DBCACHE=4096
|
||||
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
HOST_IP=${HOST_IP:-127.0.0.1}
|
||||
# Stable mDNS hostname for URLs that get baked into federation/consensus data.
|
||||
@@ -175,8 +186,6 @@ load_spec_bitcoin-knots() {
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
|
||||
SPEC_DATA_UID="100101:100101"
|
||||
local btc_dbcache=4096
|
||||
[ "${LOW_MEM:-false}" = "true" ] && btc_dbcache=2048
|
||||
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
|
||||
local btc_txrelay_flags="-rpcwhitelistdefault=0"
|
||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
|
||||
@@ -184,9 +193,9 @@ load_spec_bitcoin-knots() {
|
||||
fi
|
||||
# Dynamic: prune on small disk
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${btc_dbcache} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
else
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Archipelago companion debug APK and stage it as the served download
|
||||
# at neode-ui/public/packages/archipelago-companion.apk.zip.
|
||||
#
|
||||
# Run manually, or automatically via the pre-push hook (.githooks/pre-push).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT"
|
||||
|
||||
JAVA="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
|
||||
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
|
||||
if [ ! -x "$JAVA/bin/java" ] || [ ! -d "$SDK" ]; then
|
||||
echo "publish-companion-apk: JDK or Android SDK not found — skipping." >&2
|
||||
echo " (set JAVA_HOME and ANDROID_HOME to build the companion APK)" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "publish-companion-apk: building debug APK…" >&2
|
||||
( cd Android && JAVA_HOME="$JAVA" ANDROID_HOME="$SDK" ./gradlew -q :app:assembleDebug )
|
||||
|
||||
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
cp "$APK" "$TMP/app-debug.apk"
|
||||
# -X drops platform-specific extra fields for a stabler archive.
|
||||
( cd "$TMP" && zip -q -X archipelago-companion.apk.zip app-debug.apk )
|
||||
cp "$TMP/archipelago-companion.apk.zip" "$DEST"
|
||||
rm -rf "$TMP"
|
||||
|
||||
git add "$DEST"
|
||||
echo "publish-companion-apk: staged $DEST" >&2
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/multinode/meshtastic.sh — two-/three-radio Meshtastic parity harness.
|
||||
#
|
||||
# Validates that Meshtastic radios have the SAME mesh-tab features Meshcore got,
|
||||
# done over the real wire. It drives 2 (optionally 3) archipelago nodes, each
|
||||
# with a Meshtastic radio attached, and exercises the full message pipeline:
|
||||
#
|
||||
# 1. detect — each node reports a connected meshtastic device
|
||||
# 2. discover — A sees B as a peer (NodeInfo discovery), and vice-versa
|
||||
# 3. dm — A → B direct message round-trips (native unicast)
|
||||
# 4. privacy — a third listener C does NOT see the A→B DM (proves the
|
||||
# directed-unicast fix: DMs are not broadcast on the channel)
|
||||
# 5. channel — A's channel broadcast IS seen by both B and C
|
||||
# 6. typed — a typed envelope (reaction) round-trips with message_type set
|
||||
# 7. assistant — (optional) an !ai query gets a PRIVATE reply, not a channel
|
||||
# blast (gated on ASSIST=1 + assistant enabled on B)
|
||||
# 8. reachable — reports each peer's `reachable`/`last_advert` so the ambiguous
|
||||
# Meshtastic reachability semantics can be eyeballed on-air
|
||||
# before anyone "fixes" them
|
||||
#
|
||||
# The privacy test (4) is the on-air proof of the meshtastic.rs send_text_msg
|
||||
# unicast change. Without it, A→B DMs land on every node's channel feed.
|
||||
#
|
||||
# Nodes override via env (each must have a Meshtastic radio on the SAME LoRa
|
||||
# channel/region so they can actually hear each other):
|
||||
# MA_URL MA_PW node A (sender) default .116 http / ThisIsWeb54321@
|
||||
# MB_URL MB_PW node B (receiver) default .228 https / password123
|
||||
# MC_URL MC_PW node C (eavesdrop) OPTIONAL — enables privacy test (4)
|
||||
#
|
||||
# MB_NAME B's mesh node name, if A's peer list is ambiguous (>1 peer)
|
||||
# PROP_WAIT seconds to wait for LoRa propagation per step (default 45)
|
||||
# ASSIST set =1 to run the assistant private-reply test (7)
|
||||
#
|
||||
# Usage:
|
||||
# tests/multinode/meshtastic.sh
|
||||
# MA_URL=http://192.168.1.116 MB_URL=https://192.168.1.228 \
|
||||
# MC_URL=https://192.168.1.198 tests/multinode/meshtastic.sh
|
||||
#
|
||||
# Requires: curl, jq. Exit code = number of failed assertions (0 = all green).
|
||||
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/multinode.bash
|
||||
source "$HERE/lib/multinode.bash"
|
||||
|
||||
# ── node registration ──────────────────────────────────────────────────────
|
||||
MA_URL="${MA_URL:-http://192.168.1.116}"; MA_PW="${MA_PW:-ThisIsWeb54321@}"
|
||||
MB_URL="${MB_URL:-https://192.168.1.228}"; MB_PW="${MB_PW:-password123}"
|
||||
MC_URL="${MC_URL:-}"; MC_PW="${MC_PW:-password123}"
|
||||
PROP_WAIT="${PROP_WAIT:-45}"
|
||||
MB_NAME="${MB_NAME:-}"
|
||||
ASSIST="${ASSIST:-0}"
|
||||
|
||||
node_register A "$MA_URL" "$MA_PW"
|
||||
node_register B "$MB_URL" "$MB_PW"
|
||||
HAVE_C=0
|
||||
if [[ -n "$MC_URL" ]]; then node_register C "$MC_URL" "$MC_PW"; HAVE_C=1; fi
|
||||
|
||||
# ── tiny assert framework (mirrors smoke.sh) ───────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
green() { printf '\033[32m%s\033[0m' "$*"; }
|
||||
red() { printf '\033[31m%s\033[0m' "$*"; }
|
||||
yellow() { printf '\033[33m%s\033[0m' "$*"; }
|
||||
else
|
||||
green() { printf '%s' "$*"; }; red() { printf '%s' "$*"; }; yellow() { printf '%s' "$*"; }
|
||||
fi
|
||||
PASS=0; FAIL=0; SKIP=0; declare -a FAILED_NAMES
|
||||
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
|
||||
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
|
||||
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "${2:-skipped}"; SKIP=$((SKIP+1)); }
|
||||
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
|
||||
section() { printf '\n%s\n' "$(yellow "── $* ──")"; }
|
||||
|
||||
# nonce for this run so message matches can't collide with stale history
|
||||
NONCE="mtparity-$$-${RANDOM}"
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
# mesh_connected HANDLE -> "true" if a meshtastic device is connected
|
||||
mesh_connected() {
|
||||
local s; s=$(node_result "$1" mesh.status 2>/dev/null) || { echo false; return; }
|
||||
local conn type
|
||||
conn=$(echo "$s" | jq -r '.device_connected // false')
|
||||
type=$(echo "$s" | jq -r '.device_type // "unknown"')
|
||||
[[ "$conn" == "true" && "$type" == "meshtastic" ]] && echo true || echo false
|
||||
}
|
||||
|
||||
# self_name HANDLE -> this node's meshtastic long-name (from firmware_version)
|
||||
self_name() {
|
||||
node_result "$1" mesh.status 2>/dev/null | jq -r '.firmware_version // empty'
|
||||
}
|
||||
|
||||
# contact_id_for HANDLE NAME -> the contact_id of the peer whose advert_name
|
||||
# matches NAME (case-insensitive substring); empty if not found / ambiguous.
|
||||
contact_id_for() {
|
||||
local h="$1" want="$2"
|
||||
node_result "$h" mesh.peers 2>/dev/null | jq -r --arg w "$want" '
|
||||
[.peers[] | select((.advert_name // "" | ascii_downcase)
|
||||
| contains($w | ascii_downcase))] as $m
|
||||
| if ($m|length)==1 then ($m[0].contact_id|tostring) else "" end'
|
||||
}
|
||||
|
||||
# peer_count_excl_self HANDLE -> number of peers
|
||||
peer_count() { node_result "$1" mesh.peers 2>/dev/null | jq -r '.count // 0'; }
|
||||
|
||||
# saw_text HANDLE NEEDLE [direction] -> "true" if a message whose plaintext
|
||||
# contains NEEDLE exists (optionally filtered to a direction: sent/received)
|
||||
saw_text() {
|
||||
local h="$1" needle="$2" dir="${3:-}"
|
||||
node_result "$h" mesh.messages '{"limit":200}' 2>/dev/null | jq -r --arg n "$needle" --arg d "$dir" '
|
||||
[.messages[] | select((.plaintext // "") | contains($n))
|
||||
| select($d=="" or (.direction==$d))] | length > 0'
|
||||
}
|
||||
|
||||
# wait_text HANDLE NEEDLE — poll up to PROP_WAIT for a received message
|
||||
wait_text() {
|
||||
local h="$1" needle="$2" waited=0
|
||||
while (( waited < PROP_WAIT )); do
|
||||
[[ "$(saw_text "$h" "$needle" received)" == "true" ]] && return 0
|
||||
sleep 3; waited=$((waited+3))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── login ──────────────────────────────────────────────────────────────────
|
||||
section "login"
|
||||
node_login A && ok "A login ($MA_URL)" || { no "A unreachable ($MA_URL)"; echo; exit 1; }
|
||||
node_login B && ok "B login ($MB_URL)" || { no "B unreachable ($MB_URL)"; echo; exit 1; }
|
||||
if (( HAVE_C )); then
|
||||
node_login C && ok "C login ($MC_URL)" || { skip "C login" "unreachable — privacy test disabled"; HAVE_C=0; }
|
||||
fi
|
||||
|
||||
# ── 1. detect ──────────────────────────────────────────────────────────────
|
||||
section "1. device detection"
|
||||
A_CONN=$(mesh_connected A); B_CONN=$(mesh_connected B)
|
||||
assert_true "A has a connected meshtastic radio" "$A_CONN"
|
||||
assert_true "B has a connected meshtastic radio" "$B_CONN"
|
||||
if [[ "$A_CONN" != "true" || "$B_CONN" != "true" ]]; then
|
||||
printf '\n%s\n' "$(yellow 'Both A and B need a Meshtastic radio attached & mesh enabled.')"
|
||||
printf '%s\n' "$(yellow 'Aborting on-air tests; see mesh.status output above.')"
|
||||
echo; printf 'PASS=%d FAIL=%d SKIP=%d\n' "$PASS" "$FAIL" "$SKIP"; exit "$FAIL"
|
||||
fi
|
||||
A_NAME=$(self_name A); B_NAME=$(self_name B)
|
||||
printf ' A=%s B=%s\n' "${A_NAME:-?}" "${B_NAME:-?}"
|
||||
[[ -n "$MB_NAME" ]] && B_NAME="$MB_NAME"
|
||||
|
||||
# ── 2. peer discovery ──────────────────────────────────────────────────────
|
||||
section "2. peer discovery (NodeInfo)"
|
||||
DISCO=0; waited=0
|
||||
while (( waited < PROP_WAIT )); do
|
||||
CID=$(contact_id_for A "${B_NAME:-Meshtastic}")
|
||||
[[ -n "$CID" ]] && { DISCO=1; break; }
|
||||
# fall back: any single non-channel peer
|
||||
if [[ -z "$MB_NAME" && "$(peer_count A)" == "1" ]]; then
|
||||
CID=$(node_result A mesh.peers | jq -r '.peers[0].contact_id'); DISCO=1; break
|
||||
fi
|
||||
sleep 3; waited=$((waited+3))
|
||||
done
|
||||
if (( DISCO )); then ok "A discovered B as a peer (contact_id=$CID)"
|
||||
else
|
||||
no "A did not discover B within ${PROP_WAIT}s"
|
||||
printf ' A peers: %s\n' "$(node_result A mesh.peers | jq -c '.peers[]? | {contact_id,advert_name}')"
|
||||
fi
|
||||
|
||||
# ── 3. direct message round-trip ───────────────────────────────────────────
|
||||
section "3. direct message (native unicast)"
|
||||
if (( DISCO )); then
|
||||
DM="$NONCE-dm hello-from-A"
|
||||
if node_result A mesh.send "$(jq -nc --argjson c "$CID" --arg m "$DM" '{contact_id:$c,message:$m}')" >/dev/null; then
|
||||
ok "A sent DM to B (contact_id=$CID)"
|
||||
if wait_text B "$NONCE-dm"; then ok "B received the DM"
|
||||
else no "B did not receive the DM within ${PROP_WAIT}s"; fi
|
||||
else no "mesh.send failed on A"; fi
|
||||
else skip "DM round-trip" "B not discovered"; fi
|
||||
|
||||
# ── 4. privacy: third node must NOT see the DM ─────────────────────────────
|
||||
section "4. DM privacy (directed, not broadcast)"
|
||||
if (( HAVE_C )) && (( DISCO )); then
|
||||
C_CONN=$(mesh_connected C)
|
||||
if [[ "$C_CONN" != "true" ]]; then
|
||||
skip "DM privacy" "C has no meshtastic radio"
|
||||
else
|
||||
# Give C the same window the DM had to propagate, then assert absence.
|
||||
sleep "$PROP_WAIT"
|
||||
if [[ "$(saw_text C "$NONCE-dm")" == "true" ]]; then
|
||||
no "C (eavesdropper) saw the A→B DM — it is being BROADCAST, not unicast"
|
||||
else
|
||||
ok "C did NOT see the A→B DM (directed unicast confirmed)"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "DM privacy" "needs MC_URL (third radio) + discovered peer"
|
||||
fi
|
||||
|
||||
# ── 5. channel broadcast reaches everyone ──────────────────────────────────
|
||||
section "5. channel broadcast"
|
||||
CH="$NONCE-chan broadcast-to-all"
|
||||
if node_result A mesh.send-channel "$(jq -nc --arg m "$CH" '{channel:0,message:$m}')" >/dev/null; then
|
||||
ok "A sent a channel broadcast"
|
||||
if wait_text B "$NONCE-chan"; then ok "B received the broadcast"; else no "B missed the broadcast"; fi
|
||||
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
|
||||
if [[ "$(saw_text C "$NONCE-chan")" == "true" ]]; then ok "C also received the broadcast"
|
||||
else no "C missed the broadcast (it should reach all channel members)"; fi
|
||||
fi
|
||||
else no "mesh.send-channel failed on A"; fi
|
||||
|
||||
# ── 6. typed envelope round-trip ───────────────────────────────────────────
|
||||
section "6. typed message (reaction envelope)"
|
||||
if (( DISCO )); then
|
||||
# A reaction is the smallest typed envelope; it should arrive with a
|
||||
# non-"text" message_type, proving the typed pipeline works over Meshtastic.
|
||||
REACT_PARAMS=$(jq -nc --argjson c "$CID" --arg n "$NONCE" \
|
||||
'{contact_id:$c, emoji:"👍", target_seq:0, note:$n}')
|
||||
if node_result A mesh.send-reaction "$REACT_PARAMS" >/dev/null 2>&1; then
|
||||
ok "A sent a reaction (typed envelope)"
|
||||
sleep "$PROP_WAIT"
|
||||
TYPED=$(node_result B mesh.messages '{"limit":200}' 2>/dev/null \
|
||||
| jq -r '[.messages[] | select(.message_type != null and .message_type != "text")] | length > 0')
|
||||
assert_true "B received a non-text typed message" "$TYPED"
|
||||
else
|
||||
skip "typed message" "mesh.send-reaction rejected params (check handler signature)"
|
||||
fi
|
||||
else skip "typed message" "B not discovered"; fi
|
||||
|
||||
# ── 7. assistant private reply (optional) ──────────────────────────────────
|
||||
section "7. AI assistant private reply (optional)"
|
||||
if [[ "$ASSIST" == "1" ]] && (( DISCO )); then
|
||||
AST=$(node_result B mesh.assistant-status 2>/dev/null | jq -r '.enabled // false')
|
||||
if [[ "$AST" != "true" ]]; then
|
||||
skip "assistant reply" "assistant not enabled on B"
|
||||
else
|
||||
Q="$NONCE-ai !ai are you there"
|
||||
node_result A mesh.send-channel "$(jq -nc --arg m "$Q" '{channel:0,message:$m}')" >/dev/null
|
||||
sleep "$PROP_WAIT"
|
||||
# A should get a private DM reply; C (if present) should NOT.
|
||||
if [[ "$(saw_text A "$NONCE-ai-reply")" == "true" || "$(node_result A mesh.messages '{"limit":50}' | jq -r '[.messages[]|select(.direction=="received")]|length>0')" == "true" ]]; then
|
||||
ok "A received an assistant reply"
|
||||
else
|
||||
no "A did not receive an assistant reply within ${PROP_WAIT}s"
|
||||
fi
|
||||
if (( HAVE_C )) && [[ "$(mesh_connected C)" == "true" ]]; then
|
||||
# heuristic: the reply text shouldn't be on C's channel feed
|
||||
skip "assistant reply privacy" "eyeball C's feed — automated check is heuristic"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "assistant reply" "set ASSIST=1 and enable the assistant on B to run"
|
||||
fi
|
||||
|
||||
# ── 8. reachability snapshot (report-only) ─────────────────────────────────
|
||||
section "8. reachability snapshot (report-only)"
|
||||
node_result A mesh.peers 2>/dev/null | jq -r '.peers[]?
|
||||
| " \(.advert_name // "?") reachable=\(.reachable) last_advert=\(.last_advert // 0)"'
|
||||
printf '%s\n' "$(yellow ' NOTE: Meshtastic flood-routes; path_len is always 0xff, so `reachable`')"
|
||||
printf '%s\n' "$(yellow ' may read true even for stale nodes. Confirm desired semantics here')"
|
||||
printf '%s\n' "$(yellow ' before changing the refresh_contacts reachability rule.')"
|
||||
|
||||
# ── summary ────────────────────────────────────────────────────────────────
|
||||
section "summary"
|
||||
printf 'PASS=%s FAIL=%s SKIP=%s\n' "$(green "$PASS")" "$( ((FAIL)) && red "$FAIL" || green 0 )" "$(yellow "$SKIP")"
|
||||
if (( FAIL )); then
|
||||
printf 'failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
|
||||
fi
|
||||
exit "$FAIL"
|
||||
Reference in New Issue
Block a user