Compare commits

..
Author SHA1 Message Date
archipelago 14feb1feb9 chore: release v1.7.118-alpha
Demo images / Build & push demo images (push) Failing after 2m19s
2026-07-29 08:35:38 -04:00
archipelagoandClaude Fable 5 d2642856c1 chore: fold Cargo.lock version bump from v1.7.117 release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:54:32 -04:00
archipelagoandClaude Fable 5 338bfd43a7 docs: v1.7.118-alpha changelog + What's New
Demo images / Build & push demo images (push) Failing after 2m9s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:53:44 -04:00
archipelagoandClaude Fable 5 3da1d0b0f7 fix(ui): mesh unread badges persist seen-state; more button + animated route modal
Demo images / Build & push demo images (push) Has been cancelled
Unread badges came back on every visit (framework showed a phantom "2"
with nothing new): unreadCounts was memory-only, so each page load
replayed the entire message history as "new". Seen-state now persists
as a per-contact highest-seen-message-id watermark in localStorage
(ids are backend-monotonic across restarts); first run after this
ships seeds the watermark from history so nobody gets a wall of stale
badges. Opening a chat advances and persists the watermark for all
twins of the merged conversation.

The hop-route modal the user asked for is now reachable from a visible
per-message "⋯" button (the transport pill remains clickable too), has
a fallback title/branch for messages that predate transport tracking,
and animates: endpoints and link reveal in sequence, a pulse travels
the link, and relay dots blink in order — all disabled under
prefers-reduced-motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:52:56 -04:00
archipelagoandClaude Fable 5 500aebb3e2 fix(mesh): radio tools ship via OTA + ISO; transport flag gated on daemon support
v1.7.117 broke Reticulum mesh on OTA-only fleet nodes two ways: the
update swaps only the backend binary and frontend tarball, so nodes
kept a stale archy-reticulum-daemon whose argparse exits on the new
--enable-transport flag (mesh session died on every spawn — confirmed
on framework-pt), and they never had archy-rnodeconf at all, so the
in-app Flash LoRa flow failed with a bare "No such file or directory".

Four-part fix:
- The Rust supervisor probes `daemon --help` and only passes
  --enable-transport when the daemon advertises it; unsupported daemons
  run edge-only exactly as pre-1.7.117 (tested against stub daemons
  both ways + missing-binary fail-safe).
- Both PyInstaller tools ride the frontend tarball's runtime payload
  (radio-tools/) and bootstrap.rs promotes them to /usr/local/bin on
  startup when bytes differ — the first OTA path that ever updates
  them. create-release.sh now rebuilds them every release and the
  manifest script hard-fails if they're missing.
- The ISO bundles archy-rnodeconf alongside the daemon (it never did).
- Flash LoRa reports "tool not installed — update the node" instead of
  the bare spawn error when rnodeconf is absent everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 07:51:22 -04:00
17 changed files with 354 additions and 73 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## v1.7.118-alpha (2026-07-29)
- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.
- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.
- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.
- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)
## v1.7.117-alpha (2026-07-29)
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.116-alpha"
version = "1.7.117-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.117-alpha"
version = "1.7.118-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+36
View File
@@ -373,6 +373,42 @@ async fn run_runtime_assets() -> Result<bool> {
}
}
// Packaged radio tools (v1.7.118+): OTA updates only apply the backend
// binary and frontend tarball, so these PyInstaller binaries ride the
// runtime payload and get promoted here. Without this, fleet nodes keep
// a stale archy-reticulum-daemon (which exits on flags it doesn't know —
// the v1.7.117 --enable-transport rollout killed their mesh sessions)
// and never receive archy-rnodeconf at all (Flash LoRa: "No such file
// or directory"). Skipped when byte-identical; a running daemon is
// unaffected (install replaces the inode) and picks the new binary up
// on its next spawn.
for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] {
let src = runtime_dir.join("radio-tools").join(tool);
if !src.exists() {
continue;
}
let dest = format!("/usr/local/bin/{}", tool);
let same = match (fs::read(&src).await, fs::read(&dest).await) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
if same {
continue;
}
let src_s = src.to_string_lossy().to_string();
let status = host_sudo(&["install", "-m", "755", &src_s, &dest])
.await
.with_context(|| format!("install {}", tool))?;
if !status.success() {
anyhow::bail!("install {} exited with {}", tool, status);
}
info!(
tool,
"Promoted packaged radio tool from OTA runtime payload"
);
changed = true;
}
if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
if nginx_src.exists() {
+21 -1
View File
@@ -823,6 +823,18 @@ fn rnodeconf_bin() -> String {
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
}
/// True when `name` resolves to an executable on PATH.
fn which_on_path(name: &str) -> bool {
std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths).any(|dir| {
let candidate = dir.join(name);
candidate.is_file()
})
})
.unwrap_or(false)
}
/// `--autoinstall`'s "which board is this" step is interactive by design —
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
@@ -877,9 +889,17 @@ async fn rnodeconf_autoinstall(
let bin = rnodeconf_bin();
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
} else if which_on_path("rnodeconf") {
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command::new("rnodeconf")
} else {
// Older ISOs/OTAs never shipped the tool — say so instead of the
// bare "No such file or directory" the spawn would produce.
anyhow::bail!(
"{bin} is not installed on this node — RNode flashing needs the packaged \
archy-rnodeconf tool, which ships with the v1.7.118+ update (or can be \
sideloaded from a dev box). Update the node, then retry."
);
};
cmd.args(["--autoinstall", path]);
let stdin = format!(
+105 -16
View File
@@ -115,6 +115,59 @@ fn is_loopback_host(host: &str) -> bool {
/// Reticulum-carried identity binds onto the existing Archy contact via the
/// existing `parse_identity_broadcast`/`handle_identity_received` path,
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
/// Resolve the daemon invocation: packaged binary, else the dev venv script.
/// `(program, Some(script_arg))` for the venv fallback.
fn daemon_program() -> (String, Option<String>) {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
if Path::new(&bin).exists() {
return (bin, None);
}
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
(py, Some(script))
}
/// Whether the installed daemon understands `--enable-transport`. OTA updates
/// ship the archipelago binary ahead of the packaged daemon tools, so a fleet
/// node can run a new binary against an old daemon — whose argparse EXITS on
/// an unknown flag, killing the mesh session on every spawn (live regression,
/// framework-pt on v1.7.117). Probe `--help` and only pass the flag when the
/// daemon advertises it; an old daemon then runs edge-only exactly as before.
async fn daemon_supports_enable_transport() -> bool {
let (program, script) = daemon_program();
let mut cmd = Command::new(&program);
if let Some(script) = &script {
cmd.arg(script);
}
cmd.arg("--help")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
let out = tokio::time::timeout(Duration::from_secs(10), async { cmd.output().await }).await;
match out {
Ok(Ok(out)) => {
let supported = String::from_utf8_lossy(&out.stdout).contains("--enable-transport");
if !supported {
warn!(
program,
"installed reticulum-daemon predates --enable-transport — running edge-only until the daemon tools update"
);
}
supported
}
_ => {
warn!(
program,
"reticulum-daemon --help probe failed — not passing --enable-transport"
);
false
}
}
}
fn daemon_command(
socket_path: &Path,
iface: &ReticulumInterface<'_>,
@@ -122,21 +175,13 @@ fn daemon_command(
archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>,
enable_transport: bool,
) -> Command {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
// Dev fallback: run the script through its venv interpreter.
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
let mut c = Command::new(py);
c.arg(script);
c
};
let (program, script) = daemon_program();
let mut cmd = Command::new(program);
if let Some(script) = script {
cmd.arg(script);
}
cmd.arg("--identity-key")
.arg(identity_key)
.arg("--socket")
@@ -158,8 +203,11 @@ fn daemon_command(
// announces, so archy nodes (and Sideband/NomadNet peers) beyond direct RF
// range discover and reach each other through any archy node in between.
// Edge-only operation (`enable_transport = no`) left every node an island
// limited to its own radio horizon.
cmd.arg("--enable-transport");
// limited to its own radio horizon. Gated on the installed daemon actually
// supporting the flag — see `daemon_supports_enable_transport`.
if enable_transport {
cmd.arg("--enable-transport");
}
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex")
.arg(ed)
@@ -405,6 +453,7 @@ impl ReticulumLink {
);
}
let enable_transport = daemon_supports_enable_transport().await;
let mut cmd = daemon_command(
&socket_path,
&iface,
@@ -412,6 +461,7 @@ impl ReticulumLink {
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
display_name,
enable_transport,
);
cmd.env("TMPDIR", &tmp_dir);
let child = cmd
@@ -1500,6 +1550,45 @@ mod tests {
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1);
}
// One test covers both directions to avoid two tests racing on the same
// process-global env var under the parallel test runner.
#[tokio::test]
async fn transport_flag_gated_on_daemon_support() {
let dir = std::env::temp_dir().join(format!("archy-daemon-stub-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let old = dir.join("old-daemon");
std::fs::write(&old, "#!/bin/sh\necho 'usage: --serial-port --no-radio'\n").unwrap();
let new = dir.join("new-daemon");
std::fs::write(
&new,
"#!/bin/sh\necho 'usage: --serial-port --enable-transport --no-radio'\n",
)
.unwrap();
for p in [&old, &new] {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
}
// An old daemon whose --help doesn't advertise the flag: never pass it
// (its argparse would exit and kill the mesh session — the v1.7.117
// fleet regression this guards against).
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &old);
assert!(!daemon_supports_enable_transport().await);
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &new);
assert!(daemon_supports_enable_transport().await);
// A missing binary must also fail safe (probe error → no flag).
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", dir.join("missing"));
// (falls through to the venv dev path, which doesn't exist in the
// test environment either → probe fails → false)
assert!(!daemon_supports_enable_transport().await);
std::env::remove_var("ARCHY_RETICULUM_DAEMON_BIN");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn synthetic_frame_matches_meshtastic_layout() {
let prefix = [1, 2, 3, 4, 5, 6];
@@ -1184,6 +1184,19 @@ else
echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
fi
# archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware
# (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node
# imaged without it fails every RNode flash with "No such file or directory"
# (framework-pt, 2026-07-29, v1.7.117).
RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}"
if [ -f "$RNODECONF" ]; then
cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf"
chmod +x "$ARCH_DIR/bin/archy-rnodeconf"
echo " ✅ rnodeconf bundled ($(du -h "$ARCH_DIR/bin/archy-rnodeconf" | cut -f1))"
else
echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded"
fi
if [ "$BACKEND_CAPTURED" = "0" ]; then
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
echo " ⚠️ Could not capture from live server, building from source..."
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.117-alpha",
"version": "1.7.118-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.117-alpha",
"version": "1.7.118-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.117-alpha",
"version": "1.7.118-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+51 -4
View File
@@ -281,6 +281,27 @@ export const useMeshStore = defineStore('mesh', () => {
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Durable seen-state: highest message id the user has actually seen, per
// contact. Without this, every page load replayed ALL historical received
// messages into unreadCounts (the in-memory store starts empty, so every
// old message looked "new") — badges came back on every visit. Message ids
// are safe watermarks: the backend allocates them monotonically and
// restores the counter as max(persisted)+1 across restarts.
const LAST_SEEN_KEY = 'archipelago.mesh.last-seen.v1'
const lastSeenId = ref<Record<number, number>>(
JSON.parse(localStorage.getItem(LAST_SEEN_KEY) || '{}') as Record<number, number>
)
// First run after this feature ships: treat existing history as seen so
// nobody gets a wall of phantom badges for months-old messages.
let seedLastSeenFromHistory = localStorage.getItem(LAST_SEEN_KEY) === null
function persistLastSeen() {
localStorage.setItem(LAST_SEEN_KEY, JSON.stringify(lastSeenId.value))
}
function advanceLastSeen(contactId: number, msgId: number): boolean {
if ((lastSeenId.value[contactId] ?? 0) >= msgId) return false
lastSeenId.value[contactId] = msgId
return true
}
// Contact ids of the chat currently on screen — ALL twins of the merged
// conversation, not just the clicked row's id, since the unread badge sums
// across every underlying contact_id (a message can land on any twin).
@@ -455,19 +476,35 @@ export const useMeshStore = defineStore('mesh', () => {
method: 'mesh.messages',
params: limit ? { limit } : {},
})
// Detect new incoming messages and increment unread counts
if (seedLastSeenFromHistory && res.messages.length > 0) {
for (const m of res.messages) {
if (m.direction === 'received') advanceLastSeen(m.peer_contact_id, m.id)
}
persistLastSeen()
seedLastSeenFromHistory = false
}
// Detect new incoming messages and increment unread counts. "New" means
// past the durable seen watermark, not merely absent from the in-memory
// store — otherwise a page reload re-badges the whole history.
const newMsgs = res.messages.filter(
m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id)
m =>
m.direction === 'received' &&
m.id > (lastSeenId.value[m.peer_contact_id] ?? 0) &&
!messages.value.some(existing => existing.id === m.id)
)
let seenDirty = false
for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat AND the
// bottom (latest messages) is in view — i.e. the user actually sees it.
const seenLive =
viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value
if (!seenLive) {
if (seenLive) {
seenDirty = advanceLastSeen(msg.peer_contact_id, msg.id) || seenDirty
} else {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
}
}
if (seenDirty) persistLastSeen()
messages.value = res.messages
// Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages)
@@ -595,7 +632,17 @@ export const useMeshStore = defineStore('mesh', () => {
function markChatRead(contactId: number | number[]) {
const ids = Array.isArray(contactId) ? contactId : [contactId]
viewingChatIds.value = ids
for (const id of ids) delete unreadCounts.value[id]
let seenDirty = false
for (const id of ids) {
delete unreadCounts.value[id]
// Persist the watermark so the seen-state survives reloads.
for (const m of messages.value) {
if (m.direction === 'received' && m.peer_contact_id === id) {
seenDirty = advanceLastSeen(id, m.id) || seenDirty
}
}
}
if (seenDirty) persistLastSeen()
}
function clearViewingChat() {
+5 -1
View File
@@ -2344,6 +2344,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
<button class="mesh-chat-more-btn" title="How this message traveled" aria-label="Show message route" @click.stop="hopVizMsg = msg"></button>
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
@@ -2629,7 +2630,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<Teleport to="body">
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) }} route</h3>
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) || 'Message' }} route</h3>
<p class="mesh-transport-sub">
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
</p>
@@ -2644,6 +2645,9 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<template v-else-if="hopVizMsg.transport === 'fips'">
<div class="mesh-hopviz-link"> FIPS overlay · direct peer-to-peer</div>
</template>
<template v-else-if="!hopVizMsg.transport">
<div class="mesh-hopviz-link">🛰 transport wasn't recorded for this message</div>
</template>
<template v-else>
<div class="mesh-hopviz-link">
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
+37
View File
@@ -595,6 +595,38 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-chat-transport-clickable { cursor: pointer; }
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
/* Animated route reveal: endpoints and link fade/slide in sequence, then a
pulse travels the link continuously to show the direction of travel. */
.mesh-hopviz-chain > * { opacity: 0; animation: mesh-hopviz-appear 0.4s ease forwards; }
.mesh-hopviz-chain > *:nth-child(1) { animation-delay: 0.05s; }
.mesh-hopviz-chain > *:nth-child(2) { animation-delay: 0.35s; }
.mesh-hopviz-chain > *:nth-child(3) { animation-delay: 0.65s; }
@keyframes mesh-hopviz-appear { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.mesh-hopviz-link { position: relative; overflow: hidden; }
.mesh-hopviz-link::before {
content: '';
position: absolute;
top: -2px;
left: -18%;
width: 18%;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(251,146,60,0.95), transparent);
animation: mesh-hopviz-travel 1.6s linear infinite;
animation-delay: 1s;
}
@keyframes mesh-hopviz-travel { from { left: -18%; } to { left: 100%; } }
.mesh-hopviz-dot { animation: mesh-hopviz-blink 1.6s ease-in-out infinite; }
.mesh-hopviz-dot:nth-child(2) { animation-delay: 0.25s; }
.mesh-hopviz-dot:nth-child(3) { animation-delay: 0.5s; }
.mesh-hopviz-dot:nth-child(4) { animation-delay: 0.75s; }
.mesh-hopviz-dot:nth-child(5) { animation-delay: 1s; }
.mesh-hopviz-dot:nth-child(6) { animation-delay: 1.25s; }
@keyframes mesh-hopviz-blink { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.mesh-hopviz-chain > * { animation: none; opacity: 1; }
.mesh-hopviz-link::before { animation: none; display: none; }
.mesh-hopviz-dot { animation: none; }
}
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
.mesh-hopviz-icon { font-size: 1.8rem; }
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -604,5 +636,10 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
/* Per-message "more" button opens the route/hops modal (same target as the
transport pill, but always visible and discoverable). */
.mesh-chat-more-btn { background: none; border: none; color: rgba(255,255,255,0.45); font-size: 0.9rem; line-height: 1; padding: 0 4px; cursor: pointer; border-radius: 6px; }
.mesh-chat-more-btn:hover { color: #fff; background: rgba(255,255,255,0.1); }
/* Reaction dropdown inside the message action menu */
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }
@@ -362,6 +362,19 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.118-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.118-alpha</span>
<span class="text-xs text-white/40">July 29, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.</p>
<p>The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.</p>
<p>Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.</p>
<p>Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate sender and receiver appear, a pulse travels the link, and each relay hop lights up in order with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)</p>
</div>
</div>
<!-- v1.7.117-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+17 -23
View File
@@ -1,36 +1,30 @@
{
"changelog": [
"Flash your LoRa radio from inside the app. The Mesh page now has a \"Flash LoRa\" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.",
"Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.",
"Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.",
"Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.",
"Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.",
"The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.",
"Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.",
"Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest \"pending\" state, and reports the true final outcome.",
"Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.",
"The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks."
"Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing \"device not connected\" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.",
"The in-app \"Flash LoRa\" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic \"No such file or directory\". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.",
"Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.",
"Every mesh message now has a visible \"⋯\" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)"
],
"components": [
{
"current_version": "1.7.117-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago",
"current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.117-alpha",
"sha256": "a63bf41ac00e0b77da7bf3796e55a95e2973d4fc335580c5157bd7a506b4a59b",
"size_bytes": 53577760
"new_version": "1.7.118-alpha",
"sha256": "c1a68f72ae832ba2e8668aae4582d4beaf561463a49d19a87881d46a2fb724d0",
"size_bytes": 53336720
},
{
"current_version": "1.7.117-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago-frontend-1.7.117-alpha.tar.gz",
"name": "archipelago-frontend-1.7.117-alpha.tar.gz",
"new_version": "1.7.117-alpha",
"sha256": "10d409308736a664ede4415d086e1fd9b9b8c25aed4649c3b89562df97d169a0",
"size_bytes": 178025421
"current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago-frontend-1.7.118-alpha.tar.gz",
"name": "archipelago-frontend-1.7.118-alpha.tar.gz",
"new_version": "1.7.118-alpha",
"sha256": "12bb73af26ebd6bdd314e1c8502c414605cde883b9e91a3792cb235b8faf4f25",
"size_bytes": 210502532
}
],
"release_date": "2026-07-29",
"signature": "d1a00037db19cdadf28950e43037dea4b3316d606001593f59afadf13c8fc43502a1404732eb9701f2f2ad9b1803f023a81bbd30d884432499276aeca0711a09",
"signature": "ed18686682ff56613dc7c75ab137b53729d24dfcf7d7298cb4c5f06005c4b57c298b822c8692483aa847496bf09ecdd476f34f30c684cc5b76192ff357ee9609",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.117-alpha"
"version": "1.7.118-alpha"
}
+17 -23
View File
@@ -1,36 +1,30 @@
{
"changelog": [
"Flash your LoRa radio from inside the app. The Mesh page now has a \"Flash LoRa\" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.",
"Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.",
"Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.",
"Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.",
"Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.",
"The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.",
"Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.",
"Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest \"pending\" state, and reports the true final outcome.",
"Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.",
"The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks."
"Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing \"device not connected\" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.",
"The in-app \"Flash LoRa\" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic \"No such file or directory\". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead.",
"Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload.",
"Every mesh message now has a visible \"⋯\" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.)"
],
"components": [
{
"current_version": "1.7.117-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago",
"current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.117-alpha",
"sha256": "a63bf41ac00e0b77da7bf3796e55a95e2973d4fc335580c5157bd7a506b4a59b",
"size_bytes": 53577760
"new_version": "1.7.118-alpha",
"sha256": "c1a68f72ae832ba2e8668aae4582d4beaf561463a49d19a87881d46a2fb724d0",
"size_bytes": 53336720
},
{
"current_version": "1.7.117-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.117-alpha/archipelago-frontend-1.7.117-alpha.tar.gz",
"name": "archipelago-frontend-1.7.117-alpha.tar.gz",
"new_version": "1.7.117-alpha",
"sha256": "10d409308736a664ede4415d086e1fd9b9b8c25aed4649c3b89562df97d169a0",
"size_bytes": 178025421
"current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago-frontend-1.7.118-alpha.tar.gz",
"name": "archipelago-frontend-1.7.118-alpha.tar.gz",
"new_version": "1.7.118-alpha",
"sha256": "12bb73af26ebd6bdd314e1c8502c414605cde883b9e91a3792cb235b8faf4f25",
"size_bytes": 210502532
}
],
"release_date": "2026-07-29",
"signature": "d1a00037db19cdadf28950e43037dea4b3316d606001593f59afadf13c8fc43502a1404732eb9701f2f2ad9b1803f023a81bbd30d884432499276aeca0711a09",
"signature": "ed18686682ff56613dc7c75ab137b53729d24dfcf7d7298cb4c5f06005c4b57c298b822c8692483aa847496bf09ecdd476f34f30c684cc5b76192ff357ee9609",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.117-alpha"
"version": "1.7.118-alpha"
}
+17
View File
@@ -115,6 +115,23 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
fi
# Packaged radio tools ride the runtime payload: OTA-only nodes never
# get them any other way — the v1.7.117 rollout left fleet nodes with
# a stale archy-reticulum-daemon (exits on the new --enable-transport
# flag → mesh dead) and no archy-rnodeconf at all (Flash LoRa fails
# with "No such file or directory"). bootstrap.rs promotes these to
# /usr/local/bin on first startup after the update.
for tool in archy-reticulum-daemon archy-rnodeconf; do
if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then
mkdir -p "$RUNTIME_DIR/radio-tools"
echo " Including radio tool $tool"
cp "$PROJECT_ROOT/reticulum-daemon/dist/$tool" "$RUNTIME_DIR/radio-tools/$tool"
else
echo " ERROR: reticulum-daemon/dist/$tool missing — run reticulum-daemon/build.sh first" >&2
rm -rf "$STAGING_DIR"
exit 1
fi
done
rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete
+10
View File
@@ -167,6 +167,16 @@ if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
exit 1
fi
echo "[4b/8] Building packaged radio tools (archy-reticulum-daemon, archy-rnodeconf)..."
# These ride the frontend tarball's runtime payload (radio-tools/) and are
# promoted to /usr/local/bin by bootstrap.rs — the ONLY path that updates them
# on OTA-only nodes. Stale-dist releases re-broke fleet mesh once (v1.7.117),
# so always rebuild here; the manifest script hard-fails if they're missing.
(cd "$PROJECT_ROOT/reticulum-daemon" && ./build.sh) || {
echo "Error: reticulum-daemon/build.sh failed — radio tools are release-critical" >&2
exit 1
}
echo "[5/8] Validating curated changelog..."
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"