Compare commits
17 Commits
archy-hwco
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2642856c1 | ||
|
|
338bfd43a7 | ||
|
|
3da1d0b0f7 | ||
|
|
500aebb3e2 | ||
|
|
04c056acdb | ||
|
|
d0463196a3 | ||
|
|
8e0939170c | ||
|
|
5c19effdd1 | ||
|
|
8e51164321 | ||
|
|
da14c135e4 | ||
|
|
d7c5d39747 | ||
|
|
6ba39041d0 | ||
|
|
c99f1c7b77 | ||
|
|
7326bb9262 | ||
|
|
3589c3a6b9 | ||
|
|
0aa3941c40 | ||
|
|
e62f911810 |
23
CHANGELOG.md
23
CHANGELOG.md
@ -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
2
core/Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.116-alpha"
|
version = "1.7.117-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"]
|
||||||
|
|||||||
@ -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())
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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)"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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};
|
||||||
|
|||||||
243
core/archipelago/src/container/ui_detection.rs
Normal file
243
core/archipelago/src/container/ui_detection.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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!(
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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!(
|
||||||
|
|||||||
@ -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..."
|
||||||
|
|||||||
4
neode-ui/package-lock.json
generated
4
neode-ui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.7.115-alpha",
|
"version": "1.7.117-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.117-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.116-alpha",
|
"version": "1.7.117-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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">✓✓</span>
|
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">✓✓</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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
@ -1,29 +1,36 @@
|
|||||||
{
|
{
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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.",
|
||||||
"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."
|
"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."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.7.116-alpha",
|
"current_version": "1.7.117-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.117-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.7.116-alpha",
|
"new_version": "1.7.117-alpha",
|
||||||
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
|
"sha256": "a63bf41ac00e0b77da7bf3796e55a95e2973d4fc335580c5157bd7a506b4a59b",
|
||||||
"size_bytes": 51787208
|
"size_bytes": 53577760
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.7.116-alpha",
|
"current_version": "1.7.117-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.117-alpha/archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
|
"name": "archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||||
"new_version": "1.7.116-alpha",
|
"new_version": "1.7.117-alpha",
|
||||||
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
|
"sha256": "10d409308736a664ede4415d086e1fd9b9b8c25aed4649c3b89562df97d169a0",
|
||||||
"size_bytes": 178062490
|
"size_bytes": 178025421
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-07-27",
|
"release_date": "2026-07-29",
|
||||||
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
|
"signature": "d1a00037db19cdadf28950e43037dea4b3316d606001593f59afadf13c8fc43502a1404732eb9701f2f2ad9b1803f023a81bbd30d884432499276aeca0711a09",
|
||||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||||
"version": "1.7.116-alpha"
|
"version": "1.7.117-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,36 @@
|
|||||||
{
|
{
|
||||||
"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.",
|
"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.",
|
||||||
"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.",
|
"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.",
|
||||||
"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."
|
"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."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.7.116-alpha",
|
"current_version": "1.7.117-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.117-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.7.116-alpha",
|
"new_version": "1.7.117-alpha",
|
||||||
"sha256": "376cde3ff9691c7141e67b1b87e746634c45a20167c578938c4a33801eefe46e",
|
"sha256": "a63bf41ac00e0b77da7bf3796e55a95e2973d4fc335580c5157bd7a506b4a59b",
|
||||||
"size_bytes": 51787208
|
"size_bytes": 53577760
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.7.116-alpha",
|
"current_version": "1.7.117-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.117-alpha/archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.7.116-alpha.tar.gz",
|
"name": "archipelago-frontend-1.7.117-alpha.tar.gz",
|
||||||
"new_version": "1.7.116-alpha",
|
"new_version": "1.7.117-alpha",
|
||||||
"sha256": "7008f26d16c7b7d5dacc8edf55bd30b58c02602d3419359516bbf2497591db58",
|
"sha256": "10d409308736a664ede4415d086e1fd9b9b8c25aed4649c3b89562df97d169a0",
|
||||||
"size_bytes": 178062490
|
"size_bytes": 178025421
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-07-27",
|
"release_date": "2026-07-29",
|
||||||
"signature": "455e0ce176a0f8c9adf3bbfd7bf949739ad0e5e23c40af9ed6c89b367a42f5d52dbfe97f5ef53f838f3cd17122087b2081c1e5549065504f1f9ea101c8ffc30e",
|
"signature": "d1a00037db19cdadf28950e43037dea4b3316d606001593f59afadf13c8fc43502a1404732eb9701f2f2ad9b1803f023a81bbd30d884432499276aeca0711a09",
|
||||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||||
"version": "1.7.116-alpha"
|
"version": "1.7.117-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user