Compare commits

...

18 Commits

Author SHA1 Message Date
archipelago
14feb1feb9 chore: release v1.7.118-alpha
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m19s
2026-07-29 08:35:38 -04:00
archipelago
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
archipelago
338bfd43a7 docs: v1.7.118-alpha changelog + What's New
Some checks failed
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
archipelago
3da1d0b0f7 fix(ui): mesh unread badges persist seen-state; more button + animated route modal
Some checks failed
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
archipelago
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
archipelago
04c056acdb chore: release v1.7.117-alpha
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m16s
2026-07-29 07:01:45 -04:00
archipelago
d0463196a3 docs: changelog + What's New — Reticulum relay (transport mode) bullet
Some checks failed
Demo images / Build & push demo images (push) Failing after 59s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:23:05 -04:00
archipelago
8e0939170c feat(mesh): archy nodes run as RNS transport nodes
The Reticulum daemon gains --enable-transport, which writes
enable_transport = yes into the RNS config it regenerates on every
start, and the Rust supervisor always passes it. Archy nodes now relay
RNS traffic and rebroadcast 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 left every
node limited to its own radio horizon. RNS's per-interface airtime
caps bound the extra announce overhead on LoRa.

Verified: config generation with the flag on/off, daemon --selftest
green with transport enabled, mesh test module 116/116.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 06:21:43 -04:00
archipelago
5c19effdd1 style: cargo fmt — clear formatting drift blocking the release gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:53:33 -04:00
archipelago
8e51164321 docs: v1.7.117-alpha changelog covers everything since 1.7.116 + What's New sync
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m4s
Rewrites the prepared 1.7.117 section to include the mesh flash flow,
first-class Reticulum fixes, radio-first routing, mesh chat polish, the
transactions-modal phone fixes, services-vs-apps classification, the
lightning slow-payment fix, cached-resource page loads, load-shedding,
FIPS uptime hardening, and companion 0.5.25. What's New modal block
regenerated from the new bullets (sync-whats-new --check passes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:49:45 -04:00
archipelago
da14c135e4 feat(apps): backend-only services classify as services with no Launch button
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
A published port no longer implies a web UI. The package scanner used to
synthesize interfaces.main.ui="true" for any container with a port or
onion address, so headless backends — including self-deployed compose
stacks like podsteadr — showed up as launchable apps. New ui_detection
module decides instead: a manifest interfaces declaration (catalog
overlay first, disk second) is definitive; undeclared apps get a short
HTTP probe of the launch port (HTML page, redirect, or browser auth
wall = UI; JSON APIs, raw TCP, dead ports = service), with cached
verdicts and probes gated on running containers. Frontend canLaunch
now refuses curated services outright and only treats a bare runtime
address as launchable for curated known apps.

Works identically for manifest apps and containers deployed by hand
outside the orchestrator. ui_detection tests 6/6, frontend suite
696/696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:48:38 -04:00
archipelago
d7c5d39747 feat(ui): transactions modal filter tabs pin to top with blur on scroll
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m59s
Chips become sticky inside the modal's scroll region with a dimmed
blurred band, so the rail filter stays reachable while rows scroll
underneath. Verified in headless chromium: pinned at scroll-region top
after deep scroll, touch swipes starting on the chips still scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 05:01:22 -04:00
archipelago
6ba39041d0 feat(ui): mesh header "Flash LoRa" button opens the in-app flash flow
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m9s
Replaces the external flasher.meshcore.co.uk link with a button that
opens the global device-setup modal directly at its flash step, via a
new manual entry point in the mesh store (flashFlowPath). Manual opens
target the connected radio (else the first detected stick), skip the
read-only probe — the port is held by the live session and a second tty
opener corrupts it; the backend flash job stops the listener itself —
and close the modal instead of stepping back to the detection screen.
Button is disabled with a hint when no radio is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 04:43:38 -04:00
archipelago
c99f1c7b77 fix(ui): transactions modal touch scrolling on phones
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m3s
The tx list kept a vestigial overflow-y-auto from before the modal
contract refactor made BaseModal's slot wrapper the scroller. With a
modal open, modal-scroll-locked applies overscroll-behavior:contain to
every .overflow-y-auto inside the overlay, so touch scrolls latched
onto the non-scrollable inner list and could not chain up to the real
scroller — the modal was unscrollable on any touch device. Wheel input
latches onto the scrollable ancestor directly, which is why desktop
never showed it. Verified with headless-chromium touch synthesis at
360x640/320x568: list scrolls to bottom, background stays contained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 04:32:41 -04:00
archipelago
7326bb9262 fix(mesh): unpinned preferred path must stay an auto-detect candidate
Post-merge regression from combining two individually-correct changes:
main's 2026-07-23 fix makes open_preferred_path bail WITHOUT touching the
port when no device_kind is pinned, while the hw-config branch's skip_path
dedup excludes the preferred path from the auto-detect fallback on the
assumption it was already probed this cycle. Together, on a single-radio
node with no pin (the common fleet state), the only candidate was never
probed at all and the mesh never came up — hit live on archi-dev-box
right after deploying merged main.

Fix: when device_kind is None, skip open_preferred_path entirely and go
straight to auto_detect_and_open with skip_path=None. The pinned path
keeps the existing probe-then-skip fallback.

Verified live on archi-dev-box: radio auto-detected, Reticulum daemon
ready, 5 persisted peers loaded. Mesh tests 116 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 03:34:02 -04:00
archipelago
3589c3a6b9 Merge archy-hwconfig into main — hw-config flash-firmware flow
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m3s
Brings the hw-config branch (radio firmware flashing modal step 3,
flasher packaging + PyInstaller runtime hook, self-update hardening)
onto main, already reconciled with the probe/dedup/name work via
fb1f4bf0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:02:25 -04:00
archipelago
0aa3941c40 feat(ui): mesh chat polish — transport pills in image modal, hop-route modal, reaction dropdown, real read-tracking
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m29s
- Image quality modal: 'Send via' pills (LoRa / FIPS / Tor) when the
  peer is federation-reachable — mesh.transport-advice now returns
  has_fips + last_transport alongside has_tor. Picking FIPS/Tor routes
  the image over the content-ref path instead of the radio.
- Attachment modals (transport chooser, image quality, new hop modal)
  Teleport to body so the backdrop dims the FULL viewport — rendered
  in-place they sat inside a transformed glass panel that trapped
  position:fixed to the right chat panel.
- Click a message's transport pill → route modal: radio hops + live
  SNR/RSSI quality for LoRa transports, overlay/circuit shape for
  FIPS/Tor, delivery + E2E state.
- Reactions move behind a compact 'React ▾' dropdown with a larger
  12-emoji palette.
- Unread badges now clear like a normal chat app: opening a contact
  clears ALL twins of the merged conversation (badge sums every
  contact_id — clearing just the clicked one left it stuck), and only
  once the chat has scrolled to the latest messages; scrolled up into
  history, new arrivals accumulate until you scroll back down.
- Refresh button shows only the spinner while refreshing (text+spinner
  overflowed the fixed button width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:01:36 -04:00
archipelago
e62f911810 fix(mesh): Reticulum resource transfers actually deliver — 4 root causes
E2E-verified both directions dev-box<->x250 over real RF (5KB image ~60s):

1. Sender daemon never called link.identify() — receiver's
   get_remote_identity() was None, so every arrived transfer carried an
   empty source_hash and the Rust side dropped it (now also warns
   instead of silently vanishing it).
2. Receiver treated resource.data as bytes, but RNS hands a concluded
   Resource's data as a file-like BufferedReader — b64encode raised
   TypeError and the transfer was lost even when attributed.
3. Radio twins of merged contacts carry the peer's Archipelago ed25519
   key as pubkey_hex, not an RNS hash — prefix lookup could never match
   ('Unknown Reticulum prefix', observed live). resolve_dest_hash now
   falls back to matching the announce-bound arch_pubkey_hex.
4. The daemon RPC socket kept asyncio's default 64KiB line limit; any
   attachment >~48KB overflowed it and tore down the whole daemon
   connection ('reticulum-daemon is gone'). Raised to 16MiB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:00:44 -04:00
35 changed files with 1194 additions and 259 deletions

View File

@ -1,12 +1,29 @@
# Changelog # Changelog
## v1.7.117-alpha (2026-07-27) ## 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.
- 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.
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started. - FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports. - App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps. - Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core. - Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
## v1.7.116-alpha (2026-07-27) ## v1.7.116-alpha (2026-07-27)

2
core/Cargo.lock generated
View File

@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.7.116-alpha" version = "1.7.117-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.7.116-alpha" version = "1.7.118-alpha"
edition = "2021" edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"] authors = ["Archipelago Team"]

View File

@ -198,8 +198,7 @@ impl RpcHandler {
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.and_then(|arr| { .and_then(|arr| {
arr.iter().find(|p| { arr.iter().find(|p| {
p.get("payment_hash").and_then(|v| v.as_str()) p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
== Some(hash_lower.as_str())
}) })
}); });

View File

@ -9,7 +9,9 @@ fn parse_family(s: &str) -> Result<DeviceType> {
"meshcore" => Ok(DeviceType::Meshcore), "meshcore" => Ok(DeviceType::Meshcore),
"meshtastic" => Ok(DeviceType::Meshtastic), "meshtastic" => Ok(DeviceType::Meshtastic),
"reticulum" | "rnode" => Ok(DeviceType::Reticulum), "reticulum" | "rnode" => Ok(DeviceType::Reticulum),
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"), other => anyhow::bail!(
"Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"
),
} }
} }

View File

@ -575,14 +575,17 @@ impl RpcHandler {
let nodes = crate::federation::load_nodes(&self.config.data_dir) let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let has_tor = peer_pubkey_hex let fed_node = nodes.iter().find(|n| {
.as_ref() peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk)) || peer_did.as_deref() == Some(n.did.as_str())
.unwrap_or(false) });
|| peer_did let has_tor = fed_node.is_some();
.as_ref() // Distinct FIPS capability so the frontend can offer FIPS as its own
.map(|d| nodes.iter().any(|n| &n.did == d)) // pill (not just a generic "Tor") — the dial layer still picks
.unwrap_or(false); // FIPS-first with Tor fallback on the actual send; these are honest
// capability labels, with `last_transport` saying what worked last.
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1); let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
@ -618,6 +621,8 @@ impl RpcHandler {
"tier": tier, "tier": tier,
"est_seconds": est_seconds, "est_seconds": est_seconds,
"has_tor": has_tor, "has_tor": has_tor,
"has_fips": has_fips,
"last_transport": last_transport,
"reason": reason, "reason": reason,
"size": size, "size": size,
"mesh_auto_max": MESH_AUTO_MAX, "mesh_auto_max": MESH_AUTO_MAX,

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 { if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await; let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
if nginx_src.exists() { if nginx_src.exists() {

View File

@ -212,9 +212,21 @@ impl DockerPackageScanner {
website: lan_address.clone(), website: lan_address.clone(),
tier: Some(metadata.tier.to_string()), tier: Some(metadata.tier.to_string()),
interfaces: if lan_address.is_some() || tor_address.is_some() { interfaces: if lan_address.is_some() || tor_address.is_some() {
// `ui` is no longer implied by a published port: a
// headless backend with an exposed port is a service,
// not a launchable app. ui_detection consults the
// manifest declaration first, then HTTP-probes the
// port. Addresses stay present either way so the
// Services tab can still show where a backend lives.
let has_ui = super::ui_detection::has_web_ui(
&app_id,
lan_address.as_deref(),
package_state == PackageState::Running,
)
.await;
Some(Interfaces { Some(Interfaces {
main: Some(MainInterface { main: Some(MainInterface {
ui: Some("true".to_string()), ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(), tor_config: tor_address.clone(),
lan_config: None, lan_config: None,
}), }),
@ -729,7 +741,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently /// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
/// drops a reachable launch URL. Reading digits after the final colon mirrors /// drops a reachable launch URL. Reading digits after the final colon mirrors
/// `port_from_url` in the RPC layer and tolerates a trailing path. /// `port_from_url` in the RPC layer and tolerates a trailing path.
fn launch_url_port(url: &str) -> Option<u16> { pub(crate) fn launch_url_port(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1; let after_colon = url.rsplit_once(':')?.1;
after_colon after_colon
.chars() .chars()

View File

@ -15,6 +15,7 @@ pub mod quadlet;
pub mod registry; pub mod registry;
pub mod secrets; pub mod secrets;
pub mod traits; pub mod traits;
pub mod ui_detection;
pub mod version_config; pub mod version_config;
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL}; pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};

View File

@ -0,0 +1,243 @@
//! Web-UI detection for discovered containers.
//!
//! The packages list used to mark every container that published a port (or
//! carried an onion address) as a UI app, which gave headless backends —
//! databases, media servers, self-deployed compose stacks — a Launch button.
//! UI-ness is decided here instead, for manifest apps and ad-hoc containers
//! alike:
//!
//! 1. A manifest that declares an `interfaces:` block is definitive: any
//! entry of `type: ui` means a browsable UI, a block without one means a
//! backend service. The signed catalog overlay is consulted before disk
//! manifests (catalog supremacy).
//! 2. Manifests without an `interfaces:` block (the overwhelming majority)
//! and manifest-less containers fall through to a short HTTP probe of the
//! launch port: an HTML page, a redirect, or a browser-auth wall means a
//! UI; JSON APIs, raw TCP protocols, and dead ports mean a service.
//!
//! Verdicts are cached — positives longer than negatives, since "no UI yet"
//! is often just an app that hasn't finished starting.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tracing::debug;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// A confirmed UI stays a UI — re-check rarely.
const POSITIVE_TTL: Duration = Duration::from_secs(15 * 60);
/// A "no UI" verdict may be a slow-starting app — re-check sooner.
const NEGATIVE_TTL: Duration = Duration::from_secs(2 * 60);
/// Decide whether `app_id` exposes a browsable web UI. `lan_address` is the
/// launch candidate already computed by the package scanner (host-published),
/// and `running` gates the probe: a stopped container can't answer, and a
/// dead-port verdict against it would poison the cache.
pub async fn has_web_ui(app_id: &str, lan_address: Option<&str>, running: bool) -> bool {
if let Some(declared) = manifest_declares_ui(app_id) {
return declared;
}
let Some(port) = lan_address.and_then(super::docker_packages::launch_url_port) else {
return false;
};
if !running {
// Serve a cached verdict if we have one, but never record one.
return cached_verdict(app_id, port).unwrap_or(false);
}
if let Some(v) = cached_verdict(app_id, port) {
return v;
}
let verdict = probe_port(port).await;
debug!(app_id, port, verdict, "web-UI probe");
cache_verdict(app_id, port, verdict);
verdict
}
// ─── Manifest declarations ───────────────────────────────────────────────
/// `Some(true)`: a manifest covers this app and declares a `type: ui`
/// interface. `Some(false)`: a manifest declares interfaces, none of them UI.
/// `None`: no manifest, or one without an `interfaces:` block — undeclared,
/// let the probe decide.
fn manifest_declares_ui(app_id: &str) -> Option<bool> {
// Catalog overlay first: disk manifests don't apply to catalog-covered
// apps, so the catalog must win where it speaks.
for (id, value) in super::app_catalog::catalog_manifest_values() {
if id == app_id {
if let Some(v) = declared_ui_in_value(&value) {
return Some(v);
}
break;
}
}
for path in disk_manifest_candidates(app_id) {
if !path.exists() {
continue;
}
match archipelago_container::AppManifest::from_file(&path) {
Ok(m) => {
if m.app.interfaces.is_empty() {
return None;
}
return Some(m.app.interfaces.values().any(|i| i.interface_type == "ui"));
}
// Malformed manifests are already reported by the orchestrator's
// loader; here they simply don't count as a declaration.
Err(_) => return None,
}
}
None
}
/// Same declaration logic against a catalog manifest carried as raw JSON.
fn declared_ui_in_value(manifest: &serde_json::Value) -> Option<bool> {
let interfaces = manifest.get("app")?.get("interfaces")?.as_object()?;
if interfaces.is_empty() {
return None;
}
Some(interfaces.values().any(|i| {
// An omitted `type` defaults to "ui", mirroring the YAML schema.
i.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "ui")
.unwrap_or(true)
}))
}
fn disk_manifest_candidates(app_id: &str) -> Vec<std::path::PathBuf> {
let mut roots: Vec<std::path::PathBuf> = Vec::new();
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
let v = v.trim();
if !v.is_empty() {
roots.push(v.into());
}
}
roots.push("/opt/archipelago/apps".into());
roots
.into_iter()
.map(|root| root.join(app_id).join("manifest.yml"))
.collect()
}
// ─── HTTP probe ──────────────────────────────────────────────────────────
async fn probe_port(port: u16) -> bool {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("static probe client")
});
match client.get(format!("http://127.0.0.1:{port}/")).send().await {
Ok(resp) => {
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth_challenge = resp
.headers()
.contains_key(reqwest::header::WWW_AUTHENTICATE);
ui_verdict(resp.status().as_u16(), content_type, auth_challenge)
}
// Connection refused, timeout, or a non-HTTP protocol on the port.
Err(_) => false,
}
}
/// The pure classification rule, split out for tests: a browsable UI is an
/// HTML response (any status — error pages included), a redirect (login
/// flows), or a browser auth prompt. JSON/plaintext APIs are services.
fn ui_verdict(status: u16, content_type: &str, auth_challenge: bool) -> bool {
if content_type.contains("text/html") {
return true;
}
if (300..400).contains(&status) {
return true;
}
if (status == 401 || status == 403) && auth_challenge {
return true;
}
false
}
// ─── Verdict cache ───────────────────────────────────────────────────────
fn cache() -> &'static Mutex<HashMap<String, (bool, Instant)>> {
static CACHE: OnceLock<Mutex<HashMap<String, (bool, Instant)>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn cached_verdict(app_id: &str, port: u16) -> Option<bool> {
let cache = cache().lock().ok()?;
let (verdict, at) = cache.get(&format!("{app_id}:{port}"))?;
let ttl = if *verdict { POSITIVE_TTL } else { NEGATIVE_TTL };
(at.elapsed() < ttl).then_some(*verdict)
}
fn cache_verdict(app_id: &str, port: u16, verdict: bool) {
if let Ok(mut cache) = cache().lock() {
cache.insert(format!("{app_id}:{port}"), (verdict, Instant::now()));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn html_is_ui_regardless_of_status() {
assert!(ui_verdict(200, "text/html; charset=utf-8", false));
assert!(ui_verdict(404, "text/html", false));
assert!(ui_verdict(401, "text/html", false));
}
#[test]
fn redirects_are_ui() {
assert!(ui_verdict(302, "", false));
assert!(ui_verdict(307, "text/plain", false));
}
#[test]
fn auth_walls_are_ui_only_with_challenge() {
assert!(ui_verdict(401, "text/plain", true));
assert!(!ui_verdict(401, "application/json", false));
}
#[test]
fn apis_and_plain_ports_are_services() {
assert!(!ui_verdict(200, "application/json", false));
assert!(!ui_verdict(200, "text/plain", false));
assert!(!ui_verdict(500, "", false));
}
#[test]
fn catalog_value_declaration() {
let ui = json!({"app": {"interfaces": {"main": {"type": "ui", "port": 80}}}});
assert_eq!(declared_ui_in_value(&ui), Some(true));
// Omitted type defaults to "ui", mirroring the YAML schema default.
let default_ty = json!({"app": {"interfaces": {"main": {"port": 80}}}});
assert_eq!(declared_ui_in_value(&default_ty), Some(true));
let api_only = json!({"app": {"interfaces": {"rpc": {"type": "api", "port": 80}}}});
assert_eq!(declared_ui_in_value(&api_only), Some(false));
// No interfaces block ⇒ undeclared, not "no UI".
let none = json!({"app": {"id": "x"}});
assert_eq!(declared_ui_in_value(&none), None);
let empty = json!({"app": {"interfaces": {}}});
assert_eq!(declared_ui_in_value(&empty), None);
}
#[test]
fn verdict_cache_roundtrip() {
cache_verdict("test-app", 1234, true);
assert_eq!(cached_verdict("test-app", 1234), Some(true));
assert_eq!(cached_verdict("test-app", 9999), None);
}
}

View File

@ -6,39 +6,7 @@
//! no listener, so allowing them is inert. //! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[ pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
2342, 8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
3000, 18083, 23000, 32838, 50002,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8088,
8089,
8090,
8096,
8123,
8175,
8176,
8240,
8334,
8888,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
23000,
32838,
50002,
]; ];

View File

@ -287,7 +287,10 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
app_install?; app_install?;
// Make the allowance live immediately; a no-op error when the // Make the allowance live immediately; a no-op error when the
// hardening baseline isn't installed on this node yet. // hardening baseline isn't installed on this node yet.
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) { if tokio::fs::try_exists("/etc/fips/fips.nft")
.await
.unwrap_or(false)
{
match Command::new("sudo") match Command::new("sudo")
.args(["nft", "-f", "/etc/fips/fips.nft"]) .args(["nft", "-f", "/etc/fips/fips.nft"])
.output() .output()

View File

@ -376,13 +376,9 @@ impl<'a> PeerRequest<'a> {
let onion = self.onion_host.to_string(); let onion = self.onion_host.to_string();
let transport = kind.to_string(); let transport = kind.to_string();
tokio::spawn(async move { tokio::spawn(async move {
let _ = crate::federation::record_peer_transport( let _ =
&dir, crate::federation::record_peer_transport(&dir, None, Some(&onion), &transport)
None, .await;
Some(&onion),
&transport,
)
.await;
}); });
} }
} }

View File

@ -104,17 +104,15 @@ pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
.await .await
.unwrap_or_default(); .unwrap_or_default();
let seed = anchors::load(&data_dir).await.unwrap_or_default(); let seed = anchors::load(&data_dir).await.unwrap_or_default();
let mut warm_npubs: std::collections::BTreeSet<String> = nodes let mut warm_npubs: std::collections::BTreeSet<String> =
.iter() nodes.iter().filter_map(|n| n.fips_npub.clone()).collect();
.filter_map(|n| n.fips_npub.clone())
.collect();
warm_npubs.extend(seed.iter().map(|a| a.npub.clone())); warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
let mut handles = Vec::new(); let mut handles = Vec::new();
for npub in warm_npubs { for npub in warm_npubs {
// Service-active was checked once above for the whole batch. // Service-active was checked once above for the whole batch.
handles.push(tokio::spawn( handles.push(tokio::spawn(async move {
async move { dial::warm_path_unchecked(&npub).await }, dial::warm_path_unchecked(&npub).await
)); }));
} }
for h in handles { for h in handles {
let _ = h.await; let _ = h.await;

View File

@ -420,7 +420,9 @@ pub async fn start_flash_job(
match &result { match &result {
Ok(()) => { Ok(()) => {
bg_job.push_log("Flash completed successfully".to_string()).await; bg_job
.push_log("Flash completed successfully".to_string())
.await;
bg_job.finish().await; bg_job.finish().await;
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded"); info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
} }
@ -586,7 +588,8 @@ async fn fetch_meshtastic_image(
if tokio::fs::metadata(&zip_path).await.is_err() { if tokio::fs::metadata(&zip_path).await.is_err() {
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?; download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
} else { } else {
job.push_log(format!("Using cached {}", zip_asset.name)).await; job.push_log(format!("Using cached {}", zip_asset.name))
.await;
} }
// "*.factory.bin" is Meshtastic's full merged image (bootloader + // "*.factory.bin" is Meshtastic's full merged image (bootloader +
@ -594,26 +597,20 @@ async fn fetch_meshtastic_image(
// erased chip — confirmed by inspecting the real zip's contents, as // erased chip — confirmed by inspecting the real zip's contents, as
// opposed to the plain "*.bin" OTA-update image which assumes an // opposed to the plain "*.bin" OTA-update image which assumes an
// existing bootloader/partition table already on the chip. // existing bootloader/partition table already on the chip.
let entry_name = format!( let entry_name = format!("firmware-{}-{}.factory.bin", board.meshtastic_id(), version);
"firmware-{}-{}.factory.bin",
board.meshtastic_id(),
version
);
let out_path = cache.join(&entry_name); let out_path = cache.join(&entry_name);
if tokio::fs::metadata(&out_path).await.is_ok() { if tokio::fs::metadata(&out_path).await.is_ok() {
return Ok(out_path); return Ok(out_path);
} }
job.push_log(format!( job.push_log(format!("Extracting {entry_name} from {}", zip_asset.name))
"Extracting {entry_name} from {}", .await;
zip_asset.name
))
.await;
let zip_path_owned = zip_path.clone(); let zip_path_owned = zip_path.clone();
let entry_name_owned = entry_name.clone(); let entry_name_owned = entry_name.clone();
let out_path_owned = out_path.clone(); let out_path_owned = out_path.clone();
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
let file = std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?; let file =
std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?; let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
let mut entry = archive let mut entry = archive
.by_name(&entry_name_owned) .by_name(&entry_name_owned)
@ -826,6 +823,18 @@ fn rnodeconf_bin() -> String {
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_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 — /// `--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 /// 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 /// given on the command line, rnodeconf can't always tell V3 from V4 apart
@ -880,9 +889,17 @@ async fn rnodeconf_autoinstall(
let bin = rnodeconf_bin(); let bin = rnodeconf_bin();
let mut cmd = if Path::new(&bin).exists() { let mut cmd = if Path::new(&bin).exists() {
Command::new(bin) Command::new(bin)
} else { } else if which_on_path("rnodeconf") {
// Dev fallback if only a plain venv/system rnodeconf is on PATH. // Dev fallback if only a plain venv/system rnodeconf is on PATH.
Command::new("rnodeconf") 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]); cmd.args(["--autoinstall", path]);
let stdin = format!( let stdin = format!(

View File

@ -1012,31 +1012,48 @@ pub(super) async fn run_mesh_session(
) )
.await .await
} else if let Some(path) = preferred_path { } else if let Some(path) = preferred_path {
match open_preferred_path( if device_kind.is_none() {
path, // Unpinned: open_preferred_path bails without ever touching
data_dir, // the port, so the preferred path has NOT been probed this
our_ed_pubkey_hex, // cycle — auto-detect must keep it as a candidate
our_x25519_pubkey_hex, // (skip_path=None). Passing Some(path) here excluded the only
device_kind, // radio on single-device nodes and the mesh never came up.
Some(&desired_advert_name), auto_detect_and_open(
) data_dir,
.await our_ed_pubkey_hex,
{ our_x25519_pubkey_hex,
Ok((dev, info)) => Ok((path.to_string(), dev, info)), device_kind,
Err(e) => { None,
warn!( Some(&desired_advert_name),
"Preferred path {} probe failed: {} — trying auto-detect", )
path, e .await
); } else {
auto_detect_and_open( match open_preferred_path(
data_dir, path,
our_ed_pubkey_hex, data_dir,
our_x25519_pubkey_hex, our_ed_pubkey_hex,
device_kind, our_x25519_pubkey_hex,
Some(path), device_kind,
Some(&desired_advert_name), Some(&desired_advert_name),
) )
.await .await
{
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
Err(e) => {
warn!(
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open(
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
Some(path),
Some(&desired_advert_name),
)
.await
}
} }
} }
} else { } else {

View File

@ -802,7 +802,10 @@ impl MeshService {
// the server name — it existed as write-only config with no reader // the server name — it existed as write-only config with no reader
// until this line, which is why renaming on the Mesh page never // until this line, which is why renaming on the Mesh page never
// changed anything on the air. // changed anything on the air.
self.config.advert_name.clone().or_else(|| self.server_name.clone()), self.config
.advert_name
.clone()
.or_else(|| self.server_name.clone()),
self.config.lora_region.clone(), self.config.lora_region.clone(),
self.config.lora_radio_params, self.config.lora_radio_params,
self.config.channel_name.clone(), self.config.channel_name.clone(),
@ -1333,20 +1336,21 @@ impl MeshService {
// federation branch: the fall-through LoRa path twin-resolves the // federation branch: the fall-through LoRa path twin-resolves the
// routing key via peer_dest_prefix. // routing key via peer_dest_prefix.
let device_connected = self.state.status.read().await.device_connected; let device_connected = self.state.status.read().await.device_connected;
let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && { let radio_twin_reachable =
let peers = self.state.peers.read().await; is_federation_synthetic && !exceeds_lora && device_connected && {
peers let peers = self.state.peers.read().await;
.get(&contact_id) peers
.and_then(|p| p.arch_pubkey_hex.clone()) .get(&contact_id)
.map(|arch| { .and_then(|p| p.arch_pubkey_hex.clone())
peers.values().any(|p| { .map(|arch| {
p.contact_id < FEDERATION_CONTACT_ID_BASE peers.values().any(|p| {
&& p.reachable p.contact_id < FEDERATION_CONTACT_ID_BASE
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str()) && p.reachable
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
})
}) })
}) .unwrap_or(false)
.unwrap_or(false) };
};
let mesh_only_mode = load_config(&self.data_dir) let mesh_only_mode = load_config(&self.data_dir)
.await .await
.ok() .ok()

View File

@ -115,6 +115,59 @@ fn is_loopback_host(host: &str) -> bool {
/// Reticulum-carried identity binds onto the existing Archy contact via the /// Reticulum-carried identity binds onto the existing Archy contact via the
/// existing `parse_identity_broadcast`/`handle_identity_received` path, /// existing `parse_identity_broadcast`/`handle_identity_received` path,
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code. /// 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( fn daemon_command(
socket_path: &Path, socket_path: &Path,
iface: &ReticulumInterface<'_>, iface: &ReticulumInterface<'_>,
@ -122,21 +175,13 @@ fn daemon_command(
archy_ed_pubkey_hex: Option<&str>, archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>,
display_name: Option<&str>, display_name: Option<&str>,
enable_transport: bool,
) -> Command { ) -> Command {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN") let (program, script) = daemon_program();
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string()); let mut cmd = Command::new(program);
let mut cmd = if Path::new(&bin).exists() { if let Some(script) = script {
Command::new(bin) cmd.arg(script);
} 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
};
cmd.arg("--identity-key") cmd.arg("--identity-key")
.arg(identity_key) .arg(identity_key)
.arg("--socket") .arg("--socket")
@ -154,6 +199,15 @@ fn daemon_command(
} }
} }
} }
// Archy nodes are RNS transport nodes: they relay traffic and rebroadcast
// 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. 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) { if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex") cmd.arg("--archy-ed-pubkey-hex")
.arg(ed) .arg(ed)
@ -399,6 +453,7 @@ impl ReticulumLink {
); );
} }
let enable_transport = daemon_supports_enable_transport().await;
let mut cmd = daemon_command( let mut cmd = daemon_command(
&socket_path, &socket_path,
&iface, &iface,
@ -406,6 +461,7 @@ impl ReticulumLink {
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
display_name, display_name,
enable_transport,
); );
cmd.env("TMPDIR", &tmp_dir); cmd.env("TMPDIR", &tmp_dir);
let child = cmd let child = cmd
@ -525,6 +581,28 @@ impl ReticulumLink {
info!(count = self.peers.len(), "Loaded persisted Reticulum peers"); info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
} }
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
/// destination hash. Two shapes arrive here depending on how the contact
/// record was born (observed live 2026-07-28, image-to-merged-contact):
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
/// announce) — direct `prefix_to_hash` hit;
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
/// no dest-hash match possible, so fall back to scanning peers whose
/// announce-bound `arch_pubkey_hex` starts with the prefix.
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
if let Some(hash) = self.prefix_to_hash.get(prefix) {
return Some(*hash);
}
let hex_prefix = hex::encode(prefix);
self.peers.values().find_map(|p| {
p.arch_pubkey_hex
.as_deref()
.filter(|arch| arch.starts_with(&hex_prefix))
.map(|_| p.dest_hash)
})
}
/// Best-effort sync write of the current peer table — called after any /// Best-effort sync write of the current peer table — called after any
/// insert that adds/renames a peer. Infrequent (announces/first-contact, /// insert that adds/renames a peer. Infrequent (announces/first-contact,
/// not per-message) so a blocking write here is a fine trade for keeping /// not per-message) so a blocking write here is a fine trade for keeping
@ -602,9 +680,7 @@ impl ReticulumLink {
payload: &[u8], payload: &[u8],
) -> Result<()> { ) -> Result<()> {
let dest_hash = self let dest_hash = self
.prefix_to_hash .resolve_dest_hash(dest_pubkey_prefix)
.get(dest_pubkey_prefix)
.copied()
.with_context(|| { .with_context(|| {
format!( format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet", "Unknown Reticulum prefix {} — peer hasn't announced yet",
@ -646,9 +722,7 @@ impl ReticulumLink {
) -> Result<()> { ) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self let dest_hash = self
.prefix_to_hash .resolve_dest_hash(dest_pubkey_prefix)
.get(dest_pubkey_prefix)
.copied()
.with_context(|| { .with_context(|| {
format!( format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet", "Unknown Reticulum prefix {} — peer hasn't announced yet",
@ -678,9 +752,7 @@ impl ReticulumLink {
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> { pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self let dest_hash = self
.prefix_to_hash .resolve_dest_hash(dest_pubkey_prefix)
.get(dest_pubkey_prefix)
.copied()
.with_context(|| { .with_context(|| {
format!( format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet", "Unknown Reticulum prefix {} — peer hasn't announced yet",
@ -876,13 +948,15 @@ impl ReticulumLink {
.as_deref() .as_deref()
.and_then(protocol::parse_identity_broadcast); .and_then(protocol::parse_identity_broadcast);
let is_legacy_blob = legacy_identity.is_some(); let is_legacy_blob = legacy_identity.is_some();
let identity_blob_text = explicit_blob.or_else(|| { let identity_blob_text =
app_data_text.clone().filter(|_| is_legacy_blob) explicit_blob.or_else(|| app_data_text.clone().filter(|_| is_legacy_blob));
});
let parsed_identity = identity_blob_text let parsed_identity = identity_blob_text
.as_deref() .as_deref()
.and_then(protocol::parse_identity_broadcast); .and_then(protocol::parse_identity_broadcast);
if let Some(text) = identity_blob_text.as_deref().filter(|_| parsed_identity.is_some()) { if let Some(text) = identity_blob_text
.as_deref()
.filter(|_| parsed_identity.is_some())
{
let mut data = Vec::with_capacity(7 + text.len()); let mut data = Vec::with_capacity(7 + text.len());
data.push(0); // channel index — unused by the identity path data.push(0); // channel index — unused by the identity path
data.extend_from_slice(&prefix); data.extend_from_slice(&prefix);
@ -1023,10 +1097,19 @@ impl ReticulumLink {
.push_back(build_synthetic_frame(&prefix, &content)); .push_back(build_synthetic_frame(&prefix, &content));
} }
Some("resource_recv") => { Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else { let source_hex = ev
return; .get("source_hash")
}; .and_then(Value::as_str)
.unwrap_or_default();
let Ok(source_hash) = parse_hash16(source_hex) else { let Ok(source_hash) = parse_hash16(source_hex) else {
// An empty source_hash means the sender's link wasn't
// identified (pre-identify daemon build on the far end) —
// the blob is undeliverable without attribution, but say
// so instead of vanishing it.
warn!(
source = source_hex,
"Dropping inbound Reticulum resource without valid source identity"
);
return; return;
}; };
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap(); let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
@ -1368,7 +1451,10 @@ mod tests {
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false), pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
None None
); );
assert_eq!(pick_announced_name(None, Some("has\u{1}ctl".into()), false), None); assert_eq!(
pick_announced_name(None, Some("has\u{1}ctl".into()), false),
None
);
// Nothing at all. // Nothing at all.
assert_eq!(pick_announced_name(None, None, false), None); assert_eq!(pick_announced_name(None, None, false), None);
} }
@ -1464,6 +1550,45 @@ mod tests {
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1); 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] #[test]
fn synthetic_frame_matches_meshtastic_layout() { fn synthetic_frame_matches_meshtastic_layout() {
let prefix = [1, 2, 3, 4, 5, 6]; let prefix = [1, 2, 3, 4, 5, 6];

View File

@ -381,21 +381,16 @@ pub async fn send_to_peer(
if let Some(dir) = record_data_dir { if let Some(dir) = record_data_dir {
req = req.record_transport(dir); req = req.record_transport(dir);
} }
let (resp, transport) = req let (resp, transport) = req.send_json(&body).await.map_err(|e| {
.send_json(&body) let msg = e.to_string();
.await if msg.contains("connection refused") || msg.contains("Connection refused") {
.map_err(|e| { anyhow::anyhow!("Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status.")
let msg = e.to_string(); } else if msg.contains("timeout") || msg.contains("timed out") {
if msg.contains("connection refused") || msg.contains("Connection refused") { anyhow::anyhow!("Connection timed out. The peer may be offline.")
anyhow::anyhow!( } else {
"Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status." anyhow::anyhow!("Failed to send: {}", msg)
) }
} else if msg.contains("timeout") || msg.contains("timed out") { })?;
anyhow::anyhow!("Connection timed out. The peer may be offline.")
} else {
anyhow::anyhow!("Failed to send: {}", msg)
}
})?;
if !resp.status().is_success() { if !resp.status().is_success() {
anyhow::bail!( anyhow::bail!(

View File

@ -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" echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded"
fi 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 [ "$BACKEND_CAPTURED" = "0" ]; then
if [ "$BUILD_FROM_SOURCE" != "1" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then
echo " ⚠️ Could not capture from live server, building from source..." echo " ⚠️ Could not capture from live server, building from source..."

View File

@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.115-alpha", "version": "1.7.118-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.115-alpha", "version": "1.7.118-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",

View File

@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.7.116-alpha", "version": "1.7.118-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",

View File

@ -3,8 +3,10 @@
syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. --> syncViewportHeightVar in main.ts) so the tx list doesn't fill the screen. -->
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close"> <BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[calc(var(--visual-viewport-height,100dvh)*0.6)] md:max-h-[90vh] flex flex-col" @close="close">
<!-- Rail filter: instant ecash micro-payments pile up fast and bury <!-- Rail filter: instant ecash micro-payments pile up fast and bury
on-chain/Lightning rows; chips keep the standard txs reachable. --> on-chain/Lightning rows; chips keep the standard txs reachable.
<div v-if="transactions.length > 0" class="flex gap-1.5 mb-3 shrink-0 flex-wrap"> Sticky inside the modal's scroll region so the tabs stay pinned
while rows blur past underneath. -->
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-black/70 backdrop-blur-md">
<button <button
v-for="f in filters" v-for="f in filters"
:key="f.key" :key="f.key"
@ -26,7 +28,12 @@
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p> <p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
</div> </div>
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5"> <!-- Must NOT be its own scroll container: BaseModal's slot wrapper is the
scroller, and modal-scroll-locked gives every .overflow-y-auto inside
the overlay overscroll-behavior:contain a nested scroller with no
overflow then swallows touch scrolling entirely (phones only; wheel
latches onto the scrollable ancestor and never sees the bug). -->
<div v-else class="-mx-2 px-2 divide-y divide-white/5">
<div <div
v-for="tx in filteredTransactions" v-for="tx in filteredTransactions"
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp" :key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"

View File

@ -340,7 +340,7 @@ function stopProbeProgress(done = false) {
if (done) probeProgress.value = 100 if (done) probeProgress.value = 100
} }
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '') const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value) const show = computed(() => !!devicePath.value)
const imageFailed = ref(false) const imageFailed = ref(false)
const deviceImage = computed(() => const deviceImage = computed(() =>
@ -402,6 +402,23 @@ watch([show, devicePath], async ([visible]) => {
stopFlashPoll() stopFlashPoll()
return return
} }
if (mesh.flashFlowPath) {
// Manual "Flash LoRa" entry: jump straight to the flash step. Skip the
// read-only probe the port is usually the live session's, and a second
// opener on the tty corrupts the running connection; status already
// knows the firmware kind for the connected radio.
probe.value = null
probeError.value = ''
probing.value = false
stopProbeProgress()
imageFailed.value = false
openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
flashFamily.value = t as FlashFirmwareFamily
}
return
}
step.value = 1 step.value = 1
error.value = '' error.value = ''
imageFailed.value = false imageFailed.value = false
@ -428,6 +445,10 @@ watch([show, devicePath], async ([visible]) => {
}, { immediate: false }) }, { immediate: false })
function dismiss() { function dismiss() {
if (mesh.flashFlowPath) {
mesh.closeFlashFlow()
return
}
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value) if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
} }
@ -598,6 +619,11 @@ async function cancelFlash() {
function closeFlashStep() { function closeFlashStep() {
stopFlashPoll() stopFlashPoll()
if (mesh.flashFlowPath) {
// Manual entry has no detection step to go back to close the modal.
mesh.closeFlashFlow()
return
}
step.value = 1 step.value = 1
} }
</script> </script>

View File

@ -281,8 +281,35 @@ export const useMeshStore = defineStore('mesh', () => {
// Track unread message counts per peer (contact_id -> count) // Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({}) const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread) // Durable seen-state: highest message id the user has actually seen, per
const viewingChatId = ref<number | null>(null) // 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).
const viewingChatIds = ref<number[]>([])
// Whether the open chat is scrolled to (near) the bottom. New messages only
// auto-clear as read while the latest messages are actually in view —
// standard chat-app semantics; scrolled-up-in-history still counts unread.
const viewingAtBottom = ref(true)
// Total unread count for nav badge // Total unread count for nav badge
const totalUnread = computed(() => const totalUnread = computed(() =>
Object.values(unreadCounts.value).reduce((a, b) => a + b, 0) Object.values(unreadCounts.value).reduce((a, b) => a + b, 0)
@ -371,6 +398,16 @@ export const useMeshStore = defineStore('mesh', () => {
} }
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value)) localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
} }
// Manual "Flash LoRa" entry (Mesh header button): forces the global setup
// modal open at the flash step for this port — including the live session's
// port, which detection-driven opening deliberately excludes.
const flashFlowPath = ref<string | null>(null)
function openFlashFlow(path: string) {
flashFlowPath.value = path
}
function closeFlashFlow() {
flashFlowPath.value = null
}
/** Read-only firmware/config probe of a detected port (hot-swap modal). */ /** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> { async function probeDevice(path: string): Promise<MeshDeviceProbe> {
return rpcClient.call<MeshDeviceProbe>({ return rpcClient.call<MeshDeviceProbe>({
@ -439,16 +476,35 @@ export const useMeshStore = defineStore('mesh', () => {
method: 'mesh.messages', method: 'mesh.messages',
params: limit ? { limit } : {}, 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( 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) { for (const msg of newMsgs) {
// Don't count as unread if we're currently viewing that chat // Don't count as unread if we're currently viewing that chat AND the
if (msg.peer_contact_id !== viewingChatId.value) { // 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) {
seenDirty = advanceLastSeen(msg.peer_contact_id, msg.id) || seenDirty
} else {
unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1 unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1
} }
} }
if (seenDirty) persistLastSeen()
messages.value = res.messages messages.value = res.messages
// Extract node positions from coordinate messages // Extract node positions from coordinate messages
updateNodePositionsFromMessages(res.messages) updateNodePositionsFromMessages(res.messages)
@ -573,13 +629,25 @@ export const useMeshStore = defineStore('mesh', () => {
federatedPositions.value = next federatedPositions.value = next
} }
function markChatRead(contactId: number) { function markChatRead(contactId: number | number[]) {
viewingChatId.value = contactId const ids = Array.isArray(contactId) ? contactId : [contactId]
delete unreadCounts.value[contactId] viewingChatIds.value = ids
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() { function clearViewingChat() {
viewingChatId.value = null viewingChatIds.value = []
viewingAtBottom.value = true
} }
async function sendMessage(contactId: number, message: string) { async function sendMessage(contactId: number, message: string) {
@ -724,6 +792,8 @@ export const useMeshStore = defineStore('mesh', () => {
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible' tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
est_seconds: number est_seconds: number
has_tor: boolean has_tor: boolean
has_fips: boolean
last_transport: string | null
reason: string reason: string
size: number size: number
mesh_auto_max: number mesh_auto_max: number
@ -1042,6 +1112,7 @@ export const useMeshStore = defineStore('mesh', () => {
error, error,
sending, sending,
unreadCounts, unreadCounts,
viewingAtBottom,
totalUnread, totalUnread,
nodePositions, nodePositions,
federatedPositions, federatedPositions,
@ -1052,6 +1123,9 @@ export const useMeshStore = defineStore('mesh', () => {
fetchStatus, fetchStatus,
undismissedDetectedDevices, undismissedDetectedDevices,
dismissDetectedDevice, dismissDetectedDevice,
flashFlowPath,
openFlashFlow,
closeFlashFlow,
probeDevice, probeDevice,
flashListFirmware, flashListFirmware,
flashDevice, flashDevice,

View File

@ -21,6 +21,18 @@ const mesh = useMeshStore()
const transport = useTransportStore() const transport = useTransportStore()
const route = useRoute() const route = useRoute()
// "Flash LoRa" header button target: the connected radio wins, else any
// detected-but-unconnected stick. Opens the global setup modal at its
// flash step (backend stops the listener and frees the port itself).
const flashLoraPath = computed(() =>
(mesh.status?.device_connected && mesh.status.device_path) ||
mesh.status?.detected_devices?.[0] ||
''
)
function openFlashLora() {
if (flashLoraPath.value) mesh.openFlashFlow(flashLoraPath.value)
}
// Responsive layout breakpoints // Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536) const isWideDesktop = ref(window.innerWidth >= 1536)
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200) const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
@ -896,8 +908,14 @@ function openChat(peer: MeshPeer) {
messageText.value = '' messageText.value = ''
activeTab.value = 'chat' activeTab.value = 'chat'
mobileShowChat.value = true mobileShowChat.value = true
mesh.markChatRead(peer.contact_id) nextTick(() => {
nextTick(() => scrollChatToBottom()) // Clear unread only after the chat has rendered and auto-scrolled to the
// latest messages ("you've actually seen them"), and clear ALL twins of
// the merged conversation the badge sums across every contact_id, so
// clearing just the clicked row's id left the badge stuck.
scrollChatToBottom()
mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id)
})
} }
function openChannelChat(channel: { index: number; name: string }) { function openChannelChat(channel: { index: number; name: string }) {
@ -1009,9 +1027,25 @@ async function handleSendMessage() {
function scrollChatToBottom() { function scrollChatToBottom() {
if (chatScrollEl.value) { if (chatScrollEl.value) {
chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight
mesh.viewingAtBottom = true
} }
} }
// Scroll-position read tracking: while the latest messages are in view the
// open conversation stays "read" (new arrivals don't badge); scrolled up into
// history, incoming messages accumulate unread like any normal chat app
// scrolling back down clears them.
function onChatScroll() {
const el = chatScrollEl.value
if (!el) return
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48
mesh.viewingAtBottom = nearBottom
if (nearBottom && activeMergedPeer.value) {
mesh.markChatRead(activeMergedPeer.value.contact_ids)
}
scheduleReadReceipt()
}
// Keep the compose field focused after a send so the user can keep typing // Keep the compose field focused after a send so the user can keep typing
// without re-clicking it (Enter re-focuses natively, but the Send button // without re-clicking it (Enter re-focuses natively, but the Send button
// click otherwise leaves focus on the button). // click otherwise leaves focus on the button).
@ -1136,14 +1170,15 @@ interface PendingReply {
} }
const pendingReply = ref<PendingReply | null>(null) const pendingReply = ref<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(null) const actionMenuForId = ref<number | null>(null)
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏']
function openActionMenu(msgId: number, ev?: Event) { function openActionMenu(msgId: number, ev?: Event) {
ev?.stopPropagation() ev?.stopPropagation()
actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId
reactionPickerForId.value = null
} }
function closeActionMenu() { function closeActionMenu() {
actionMenuForId.value = null actionMenuForId.value = null
reactionPickerForId.value = null
} }
function handleDocClickForMenu(ev: MouseEvent) { function handleDocClickForMenu(ev: MouseEvent) {
if (actionMenuForId.value === null) return if (actionMenuForId.value === null) return
@ -1253,6 +1288,43 @@ function scheduleReadReceipt() {
}, 400) }, 400)
} }
// Hop visualization modal click a message's transport pill to see how it
// traveled: radio hops + signal quality for LoRa transports, overlay/circuit
// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link,
// not stored per-message) labelled as such in the modal.
const hopVizMsg = ref<MeshMessage | null>(null)
const hopVizPeer = computed<MeshPeer | null>(() => {
const m = hopVizMsg.value
if (!m) return null
return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value
})
function hopVizHops(): number | null {
const p = hopVizPeer.value
if (!p || p.hops == null || p.hops === 0xff) return null
return p.hops
}
function signalQualityLabel(snr: number | null, rssi: number | null): string {
if (snr == null && rssi == null) return 'signal unknown'
if (snr != null) {
if (snr > 5) return 'excellent signal'
if (snr > 0) return 'good signal'
if (snr > -10) return 'fair signal'
return 'weak signal'
}
if (rssi != null) {
if (rssi > -80) return 'excellent signal'
if (rssi > -100) return 'good signal'
if (rssi > -115) return 'fair signal'
return 'weak signal'
}
return 'signal unknown'
}
// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so
// the action menu stays compact and a larger emoji palette fits.
const reactionPickerForId = ref<number | null>(null)
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running const reactionInFlight = ref<string | null>(null) // `${msgId}:${emoji}` while RPC is running
async function reactTo(msg: MeshMessage, emoji: string) { async function reactTo(msg: MeshMessage, emoji: string) {
const key = messageKeyFor(msg) const key = messageKeyFor(msg)
@ -1440,6 +1512,14 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
const imageQualityChoice = ref<{ file: File } | null>(null) const imageQualityChoice = ref<{ file: File } | null>(null)
const imageQualityEstimates = ref<Map<string, string>>(new Map()) const imageQualityEstimates = ref<Map<string, string>>(new Map())
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
// Transport pills at the bottom of the image modal: when the peer is also a
// federation node (FIPS overlay and/or Tor), the user can send the image that
// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor'
// route via the blob/content-ref path (the dial layer prefers FIPS with Tor
// fallback the pills are capability labels, defaulted to whichever of the
// two actually worked last for this peer).
const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora')
const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false })
function formatEstSeconds(seconds: number): string { function formatEstSeconds(seconds: number): string {
if (seconds < 60) return `~${seconds}s` if (seconds < 60) return `~${seconds}s`
@ -1449,6 +1529,8 @@ function formatEstSeconds(seconds: number): string {
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> { async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
imageQualityChoice.value = { file } imageQualityChoice.value = { file }
imageQualityEstimates.value = new Map() imageQualityEstimates.value = new Map()
imageSendTransport.value = 'lora'
imageTransportOptions.value = { fips: false, tor: false }
// Fire off estimates for all presets in parallel each preset's nominal // Fire off estimates for all presets in parallel each preset's nominal
// target size (or the real file size for 'original') against the SAME // target size (or the real file size for 'original') against the SAME
// mesh.transport-advice RPC the non-image attach flow already uses. // mesh.transport-advice RPC the non-image attach flow already uses.
@ -1460,6 +1542,7 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis
const label = const label =
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds) advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label) imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor }
} catch { } catch {
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?') imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
} }
@ -1588,12 +1671,18 @@ async function handleAttachFile(ev: Event) {
attaching.value = true attaching.value = true
attachError.value = null attachError.value = null
try { try {
let forcedTransport: 'fips' | 'tor' | null = null
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const preset = await openImageQualityDialog(file, peer.contact_id) const preset = await openImageQualityDialog(file, peer.contact_id)
if (!preset) return // user cancelled if (!preset) return // user cancelled
if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value
file = await compressImage(file, preset) file = await compressImage(file, preset)
} }
if (!(await sendFileViaBestTransport(file, peer))) return if (forcedTransport) {
// User picked a FIPS/Tor pill in the image modal skip the LoRa
// advice/chooser flow entirely and ship via the content-ref path.
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
} else if (!(await sendFileViaBestTransport(file, peer))) return
messageText.value = '' messageText.value = ''
nextTick(() => scrollChatToBottom()) nextTick(() => scrollChatToBottom())
} catch (e) { } catch (e) {
@ -1783,14 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span> <span v-else-if="mesh.status?.device_present" class="mesh-subtitle-badge">Mesh device detected</span>
</p> </p>
</div> </div>
<a <button
href="https://flasher.meshcore.co.uk/"
target="_blank"
rel="noopener noreferrer"
class="glass-button mesh-flasher-btn" class="glass-button mesh-flasher-btn"
:disabled="!flashLoraPath"
:title="flashLoraPath ? '' : 'Plug in a LoRa radio first'"
@click="openFlashLora"
> >
Flash Meshcore <span class="mesh-flasher-sep">|</span> Choose Companion USB Flash LoRa
</a> </button>
</div> </div>
<!-- Error banner --> <!-- Error banner -->
@ -1889,9 +1978,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
> >
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }} {{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
</button> </button>
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh"> <!-- While refreshing show ONLY the spinner spinner + "Refreshing…"
together overflow the button's fixed width. -->
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span> <span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
{{ refreshing ? 'Refreshing…' : 'Refresh' }} <template v-if="!refreshing">Refresh</template>
</button> </button>
</div> </div>
@ -2082,7 +2173,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span> <span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div> </div>
</div> </div>
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel"> <div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages"> <div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello! No messages yet. Say hello!
</div> </div>
@ -2252,7 +2343,8 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Default: plain text --> <!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div> <div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta"> <div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span> <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="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</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> <span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
@ -2283,17 +2375,25 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button> <button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button> <button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
<button <button
v-for="emoji in QUICK_REACTIONS" class="mesh-chat-action-btn"
:key="emoji" :class="{ active: reactionPickerForId === msg.id }"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null" :disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)" @click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
> >😀 React </button>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button> <button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button>
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
<button
v-for="emoji in REACTION_PALETTE"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -2432,7 +2532,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
</Teleport> </Teleport>
<!-- Transport chooser modal: shown when attachment size fits both mesh <!-- Transport chooser modal: shown when attachment size fits both mesh
(inline-chunked) and Tor. User picks which path to send it over. --> (inline-chunked) and Tor. User picks which path to send it over.
Teleported to body so the fixed backdrop covers the FULL viewport
rendered in place it sits inside a transformed/filtered glass panel,
which traps position:fixed to just the right chat panel. -->
<Teleport to="body">
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')"> <div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
<div class="glass-card mesh-transport-modal"> <div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">📎 How should I send this?</h3> <h3 class="mesh-transport-title">📎 How should I send this?</h3>
@ -2461,10 +2565,13 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button> <button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
</div> </div>
</div> </div>
</Teleport>
<!-- Image quality-picker modal: shown before sending an image attachment. <!-- Image quality-picker modal: shown before sending an image attachment.
Each preset shows its nominal size target + a transfer-time estimate Each preset shows its nominal size target + a transfer-time estimate
from the same mesh.transport-advice RPC the file-attach flow uses. --> from the same mesh.transport-advice RPC the file-attach flow uses.
Teleported to body see the transport chooser above. -->
<Teleport to="body">
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)"> <div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
<div class="glass-card mesh-transport-modal"> <div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">🖼 Choose Image Quality</h3> <h3 class="mesh-transport-title">🖼 Choose Image Quality</h3>
@ -2481,12 +2588,94 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
> >
<span class="mesh-transport-icon">🖼</span> <span class="mesh-transport-icon">🖼</span>
<span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span> <span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span>
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span> <span class="mesh-transport-meta">{{
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
}}</span>
</button> </button>
</div> </div>
<!-- Transport pills: LoRa is always available (it's a mesh chat);
FIPS/Tor appear when the peer is a reachable federation node,
letting the user send the image that way instead. -->
<div
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
class="mesh-image-transport-row"
>
<span class="mesh-image-transport-caption">Send via</span>
<button
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'lora' }"
@click="imageSendTransport = 'lora'"
>📡 LoRa</button>
<button
v-if="imageTransportOptions.fips"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'fips' }"
@click="imageSendTransport = 'fips'"
> FIPS</button>
<button
v-if="imageTransportOptions.tor"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'tor' }"
@click="imageSendTransport = 'tor'"
>🧅 Tor</button>
</div>
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button> <button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
</div> </div>
</div> </div>
</Teleport>
<!-- Hop visualization modal: click a message's transport pill to see how
it traveled radio hops + live signal for LoRa transports, overlay/
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
<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) || '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>
<div class="mesh-hopviz-chain">
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
</div>
<template v-if="hopVizMsg.transport === 'tor'">
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
</template>
<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'}` }}
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node"></span>
</span>
</div>
</template>
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
</div>
</div>
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
</div>
<div class="mesh-hopviz-signal">
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
</div>
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
</div>
</div>
</Teleport>
<!-- The "mesh device detected" setup flow is now the global <!-- The "mesh device detected" setup flow is now the global
MeshDeviceSetupModal mounted in App.vue (fires on every page, MeshDeviceSetupModal mounted in App.vue (fires on every page,

View File

@ -98,6 +98,36 @@ describe('appsConfig service filtering', () => {
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false) expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
}) })
it('never offers Launch for an unknown container with a bare exposed port', () => {
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
// has a runtime lan-address — but no manifest-declared or probed UI. It
// must classify as a service and must NOT get a Launch button.
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
expect(canLaunch(selfDeployed)).toBe(false)
})
it('offers Launch for an unknown container once the backend confirms a UI', () => {
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(confirmedUi)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(service)).toBe(false)
})
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
const known = makePkg('jellyfin', 'Jellyfin', 'media')
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(known)).toBe(true)
})
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => { it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
const pkg = makePkg('fedimint', 'Fedimint', 'money') const pkg = makePkg('fedimint', 'Fedimint', 'money')
pkg.state = PackageState.Starting pkg.state = PackageState.Starting

View File

@ -255,9 +255,17 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean { export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true if (isWebOnlyApp(pkg.manifest.id)) return true
// Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address'] const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id) const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl // A bare runtime address is only a launch signal for curated apps: the
// backend now sets interfaces.main.ui strictly for confirmed web UIs
// (manifest declaration or HTTP probe), so an unknown container with an
// exposed non-UI port must not become launchable just for having one.
const hasUI = pkg.manifest.interfaces?.main?.ui
|| hasKnownLaunchUrl
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) { if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
} }

View File

@ -28,6 +28,7 @@
.mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; } .mesh-subtitle { color: rgba(255, 255, 255, 0.5); font-size: 0.85rem; margin: 2px 0 0; display: flex; align-items: center; gap: 8px; }
.mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; } .mesh-subtitle-badge { font-size: 0.65rem; font-weight: 600; color: #4ade80; background: rgba(74, 222, 128, 0.12); padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; } .mesh-flasher-btn { display: inline-flex; align-items: center; gap: 0; padding: 8px 16px; font-size: 0.9rem; text-decoration: none; white-space: nowrap; flex-shrink: 0; }
.mesh-flasher-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); } .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-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-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
@ -581,3 +582,64 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); } .mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; } .mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
.mesh-transport-cancel:hover { color: #fff; } .mesh-transport-cancel:hover { color: #fff; }
/* Transport pills at the bottom of the image quality modal pick LoRa vs
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
/* Hop visualization modal + clickable transport pill */
.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; }
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
.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; }

View File

@ -362,18 +362,41 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.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 --> <!-- v1.7.117-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <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.117-alpha</span> <span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.117-alpha</span>
<span class="text-xs text-white/40">July 27, 2026</span> <span class="text-xs text-white/40">July 29, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.</p>
<p>FIPS startup is more reliable on nodes that have the packaged fips.service instead of Archipelago's archipelago-fips.service. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.</p> <p>FIPS startup is more reliable on nodes that have the packaged fips.service instead of Archipelago's archipelago-fips.service. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.</p>
<p>App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.</p> <p>App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.</p>
<p>Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.</p> <p>Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.</p>
<p>Public-source preparation now includes a Nostr Git hosting plan using ngit, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.</p> <p>Public-source preparation now includes a Nostr Git hosting plan using ngit, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.</p>
<p>Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.</p>
</div> </div>
</div> </div>
<!-- v1.7.116-alpha --> <!-- v1.7.116-alpha -->

View File

@ -1,29 +1,30 @@
{ {
"changelog": [ "changelog": [
"Nodes no longer get stuck on \"server starting up\" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.", "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.",
"Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.", "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.",
"Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts." "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": [ "components": [
{ {
"current_version": "1.7.116-alpha", "current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.7.116-alpha", "new_version": "1.7.118-alpha",
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e", "sha256": "c1a68f72ae832ba2e8668aae4582d4beaf561463a49d19a87881d46a2fb724d0",
"size_bytes": 51787208 "size_bytes": 53336720
}, },
{ {
"current_version": "1.7.116-alpha", "current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz", "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.116-alpha.tar.gz", "name": "archipelago-frontend-1.7.118-alpha.tar.gz",
"new_version": "1.7.116-alpha", "new_version": "1.7.118-alpha",
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58", "sha256": "12bb73af26ebd6bdd314e1c8502c414605cde883b9e91a3792cb235b8faf4f25",
"size_bytes": 178062490 "size_bytes": 210502532
} }
], ],
"release_date": "2026-07-27", "release_date": "2026-07-29",
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e", "signature": "ed18686682ff56613dc7c75ab137b53729d24dfcf7d7298cb4c5f06005c4b57c298b822c8692483aa847496bf09ecdd476f34f30c684cc5b76192ff357ee9609",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.116-alpha" "version": "1.7.118-alpha"
} }

View File

@ -1,29 +1,30 @@
{ {
"changelog": [ "changelog": [
"Nodes no longer get stuck on \"server starting up\" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.", "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.",
"Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.", "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.",
"Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts." "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": [ "components": [
{ {
"current_version": "1.7.116-alpha", "current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago", "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.118-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.7.116-alpha", "new_version": "1.7.118-alpha",
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e", "sha256": "c1a68f72ae832ba2e8668aae4582d4beaf561463a49d19a87881d46a2fb724d0",
"size_bytes": 51787208 "size_bytes": 53336720
}, },
{ {
"current_version": "1.7.116-alpha", "current_version": "1.7.118-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.116-alpha/archipelago-frontend-1.7.116-alpha.tar.gz", "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.116-alpha.tar.gz", "name": "archipelago-frontend-1.7.118-alpha.tar.gz",
"new_version": "1.7.116-alpha", "new_version": "1.7.118-alpha",
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58", "sha256": "12bb73af26ebd6bdd314e1c8502c414605cde883b9e91a3792cb235b8faf4f25",
"size_bytes": 178062490 "size_bytes": 210502532
} }
], ],
"release_date": "2026-07-27", "release_date": "2026-07-29",
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e", "signature": "ed18686682ff56613dc7c75ab137b53729d24dfcf7d7298cb4c5f06005c4b57c298b822c8692483aa847496bf09ecdd476f34f30c684cc5b76192ff357ee9609",
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur", "signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
"version": "1.7.116-alpha" "version": "1.7.118-alpha"
} }

View File

@ -82,6 +82,7 @@ def _write_rns_config(
no_radio: bool, no_radio: bool,
tcp_listen: str | None = None, tcp_listen: str | None = None,
tcp_connect: list[str] | None = None, tcp_connect: list[str] | None = None,
enable_transport: bool = False,
) -> None: ) -> None:
"""Materialise an RNS config file. RNode interface for real radios; plain-TCP """Materialise an RNS config file. RNode interface for real radios; plain-TCP
server/client interface(s) for radio-less dev/verification (e.g. Aurora server/client interface(s) for radio-less dev/verification (e.g. Aurora
@ -136,7 +137,7 @@ def _write_rns_config(
) )
cfg.write_text( cfg.write_text(
"[reticulum]\n" "[reticulum]\n"
" enable_transport = no\n" f" enable_transport = {'yes' if enable_transport else 'no'}\n"
" share_instance = no\n" " share_instance = no\n"
" panic_on_interface_error = no\n\n" " panic_on_interface_error = no\n\n"
"[interfaces]\n" + interfaces "[interfaces]\n" + interfaces
@ -191,6 +192,7 @@ class ReticulumDaemon:
no_radio=self.args.no_radio, no_radio=self.args.no_radio,
tcp_listen=self.args.tcp_listen, tcp_listen=self.args.tcp_listen,
tcp_connect=self.args.tcp_connect, tcp_connect=self.args.tcp_connect,
enable_transport=self.args.enable_transport,
) )
self.reticulum = RNS.Reticulum(configdir=str(configdir)) self.reticulum = RNS.Reticulum(configdir=str(configdir))
self.identity = load_identity(self.seed) self.identity = load_identity(self.seed)
@ -437,13 +439,29 @@ class ReticulumDaemon:
identity, RNS.Destination.OUT, RNS.Destination.SINGLE, identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
"archy", "resource", "archy", "resource",
) )
link = RNS.Link(out_dest) # established_callback identifies us to the peer BEFORE any Resource
# goes out: the receiving daemon attributes an inbound resource via
# link.get_remote_identity() (see _on_resource_received), which is
# None unless the initiator calls identify() — without this every
# transfer arrives with an empty source_hash and the Rust side can't
# route it to a contact (observed live 2026-07-28: 5KB image sent,
# concluded COMPLETE, never surfaced on the receiving node).
link = RNS.Link(
out_dest,
established_callback=lambda lk: self._on_out_link_established(
resource_hash, lk
),
)
link.set_link_closed_callback( link.set_link_closed_callback(
lambda lk: self.links.pop(resource_hash, None) lambda lk: self.links.pop(resource_hash, None)
) )
self.links[resource_hash] = link self.links[resource_hash] = link
return link, resource_hash return link, resource_hash
def _on_out_link_established(self, resource_hash: bytes, link):
link.identify(self.identity)
self._flush_pending_resource_sends(resource_hash, link)
def _send_resource(self, req: dict): def _send_resource(self, req: dict):
import RNS import RNS
req_id = req.get("id", "") req_id = req.get("id", "")
@ -463,14 +481,14 @@ class ReticulumDaemon:
if link.status == RNS.Link.ACTIVE: if link.status == RNS.Link.ACTIVE:
self._start_resource(link, data, req_id) self._start_resource(link, data, req_id)
else: else:
# Link is establishing — queue and flush from the established # Link is establishing — queue; the creation-time established
# callback. set_link_established_callback only fires once per Link # callback (_on_out_link_established) identifies us then flushes
# object (the cache is reused across sends), so re-set it here to # the queue. Re-check afterwards to close the race where the
# make sure THIS send's queue entry gets flushed too. # link went ACTIVE between the status check and the append
# (the callback fires once, on the RNS thread).
self.pending_resource_sends[resource_hash].append((data, req_id)) self.pending_resource_sends[resource_hash].append((data, req_id))
link.set_link_established_callback( if link.status == RNS.Link.ACTIVE:
lambda lk: self._flush_pending_resource_sends(resource_hash, lk) self._flush_pending_resource_sends(resource_hash, link)
)
def _flush_pending_resource_sends(self, resource_hash: bytes, link): def _flush_pending_resource_sends(self, resource_hash: bytes, link):
import RNS import RNS
@ -518,10 +536,17 @@ class ReticulumDaemon:
RNS.Destination.hash(identity, "lxmf", "delivery").hex() RNS.Destination.hash(identity, "lxmf", "delivery").hex()
if identity is not None else "" if identity is not None else ""
) )
# RNS hands a concluded Resource's data as a file-like object
# (BufferedReader over the assembled stream), not bytes —
# observed live 2026-07-28: b64encode raised "a bytes-like
# object is required" and every received transfer was lost.
data = resource.data
if hasattr(data, "read"):
data = data.read()
self._emit_threadsafe({ self._emit_threadsafe({
"event": "resource_recv", "event": "resource_recv",
"source_hash": source_hash, "source_hash": source_hash,
"data_b64": base64.b64encode(resource.data).decode("ascii"), "data_b64": base64.b64encode(data).decode("ascii"),
}) })
except Exception as e: # never let a callback kill the RNS thread except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "resource_recv", self._emit_threadsafe({"event": "error", "where": "resource_recv",
@ -531,7 +556,14 @@ class ReticulumDaemon:
sock_path = self.args.socket sock_path = self.args.socket
if os.path.exists(sock_path): if os.path.exists(sock_path):
os.unlink(sock_path) os.unlink(sock_path)
server = await asyncio.start_unix_server(self._handle_client, path=sock_path) # limit: asyncio's default per-line StreamReader cap is 64KiB, but
# send_resource requests carry the whole payload base64-encoded on one
# JSON line — a ~48KB+ attachment overflows the default and the raised
# LimitOverrunError tears down the RPC connection (the Rust side then
# sees "reticulum-daemon is gone" and restarts the whole session).
server = await asyncio.start_unix_server(
self._handle_client, path=sock_path, limit=16 * 1024 * 1024
)
os.chmod(sock_path, 0o600) os.chmod(sock_path, 0o600)
self.announce() self.announce()
async with server: async with server:
@ -611,6 +643,10 @@ def _parse_args(argv):
p.add_argument("--txpower", type=int, default=17) p.add_argument("--txpower", type=int, default=17)
p.add_argument("--spreadingfactor", type=int, default=8) p.add_argument("--spreadingfactor", type=int, default=8)
p.add_argument("--codingrate", type=int, default=5) p.add_argument("--codingrate", type=int, default=5)
p.add_argument("--enable-transport", action="store_true",
help="run as an RNS transport node: relay traffic and rebroadcast "
"announces so nodes beyond direct RF range discover each other "
"through this one")
p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)") p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)")
p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)") p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)")
p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit") p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit")

View File

@ -115,6 +115,23 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \ cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \
"$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf" "$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf"
fi 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" rm -rf "$RUNTIME_DIR/scripts/resilience/reports"
find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} + 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 find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete

View File

@ -167,6 +167,16 @@ if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
exit 1 exit 1
fi 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..." echo "[5/8] Validating curated changelog..."
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md" CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"