Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c578d3d9 | ||
|
|
6240064acf | ||
|
|
19dbf60f03 | ||
|
|
b49d8f1f8a | ||
|
|
ec36ac7e2c | ||
|
|
7104ba0cbf | ||
|
|
d0b08d2790 | ||
|
|
76288f541e | ||
|
|
b701e125b4 | ||
|
|
837ba63466 | ||
|
|
8191d92bed | ||
|
|
ae8359da4b | ||
|
|
d91b858d9b | ||
|
|
19f2125a4d | ||
|
|
a992abcd06 | ||
|
|
4d6b4f76af | ||
|
|
0a94c0097f | ||
|
|
413d50116e | ||
|
|
daad50325b | ||
|
|
e05e356d64 | ||
|
|
cfb304a001 |
@@ -1,5 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
## v1.7.66-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
|
||||
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
|
||||
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
|
||||
|
||||
## v1.7.65-alpha (2026-05-18)
|
||||
|
||||
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||
|
||||
## v1.7.64-alpha (2026-05-18)
|
||||
|
||||
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||
|
||||
## v1.7.63-alpha (2026-05-18)
|
||||
|
||||
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
|
||||
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
|
||||
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
|
||||
|
||||
## v1.7.62-alpha (2026-05-18)
|
||||
|
||||
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
|
||||
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
|
||||
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
|
||||
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||
|
||||
## v1.7.61-alpha (2026-05-18)
|
||||
|
||||
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
|
||||
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
|
||||
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
|
||||
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
|
||||
|
||||
## v1.7.60-alpha (2026-05-18)
|
||||
|
||||
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
|
||||
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
|
||||
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
|
||||
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
|
||||
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
|
||||
|
||||
## v1.7.59-alpha (2026-05-17)
|
||||
|
||||
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
|
||||
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
|
||||
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
|
||||
- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago.
|
||||
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
|
||||
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
|
||||
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
|
||||
|
||||
## v1.7.58-alpha (2026-05-17)
|
||||
|
||||
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
|
||||
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
|
||||
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
|
||||
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
|
||||
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
|
||||
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
|
||||
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
|
||||
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
|
||||
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
|
||||
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
|
||||
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
|
||||
|
||||
## v1.7.57-alpha (2026-05-17)
|
||||
|
||||
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
|
||||
|
||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.57-alpha"
|
||||
version = "1.7.66-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.57-alpha"
|
||||
version = "1.7.66-alpha"
|
||||
edition = "2021"
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
authors = ["Archipelago Team"]
|
||||
|
||||
@@ -376,7 +376,8 @@ impl RpcHandler {
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
|
||||
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await
|
||||
{
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
|
||||
package_id, e
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||
use super::config::{
|
||||
get_app_capabilities, get_containers_for_app, get_data_dirs_for_app, get_health_check_args,
|
||||
get_memory_limit, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::ordered_containers_for_start;
|
||||
use super::install::install_log;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -665,6 +668,7 @@ async fn do_orchestrator_package_start(
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
@@ -863,11 +867,116 @@ async fn repair_before_package_start(container_name: &str) {
|
||||
repair_nextcloud_dirs().await;
|
||||
cleanup_stale_pasta_port("8085").await;
|
||||
}
|
||||
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
install_log(
|
||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
cleanup_nginx_proxy_manager_ports().await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
}
|
||||
}
|
||||
|
||||
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
if let Ok(output) = podman_control(&["port", "nginx-proxy-manager", "81/tcp"]).await {
|
||||
if output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).lines().any(|line| {
|
||||
line.rsplit(':')
|
||||
.next()
|
||||
.is_some_and(|port| port.trim() == "81")
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(output) = podman_control(&[
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Ports}}",
|
||||
"--filter",
|
||||
"name=^nginx-proxy-manager$",
|
||||
])
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
let ports = String::from_utf8_lossy(&output.stdout);
|
||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to create nginx-proxy-manager data directories")?;
|
||||
|
||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
||||
let mut args = vec![
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
"nginx-proxy-manager".to_string(),
|
||||
"--restart=unless-stopped".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true".to_string(),
|
||||
"--cap-drop=ALL".to_string(),
|
||||
"--security-opt=no-new-privileges:true".to_string(),
|
||||
"--pids-limit=4096".to_string(),
|
||||
];
|
||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||
args.extend([
|
||||
"-p".to_string(),
|
||||
"8081:81".to_string(),
|
||||
"-p".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"-p".to_string(),
|
||||
"8444:443".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
"--memory".to_string(),
|
||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||
"--cpus=2".to_string(),
|
||||
]);
|
||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
||||
args.push(image);
|
||||
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = podman_control(&refs).await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman run nginx-proxy-manager failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
let Some(port) = runtime_required_host_port(container_name) else {
|
||||
return Ok(());
|
||||
@@ -1075,15 +1184,17 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
||||
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
|
||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
||||
"nginx-proxy-manager" => {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
"nginx-proxy-manager" => cleanup_nginx_proxy_manager_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_nginx_proxy_manager_ports() {
|
||||
cleanup_stale_pasta_port("8081").await;
|
||||
cleanup_stale_pasta_port("8084").await;
|
||||
cleanup_stale_pasta_port("8444").await;
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
|
||||
@@ -233,6 +233,24 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
}
|
||||
|
||||
let configs = runtime_dir.join("image-recipe/configs");
|
||||
let nginx_src = configs.join("nginx-archipelago.conf");
|
||||
if nginx_src.exists() {
|
||||
let src_s = nginx_src.to_string_lossy().to_string();
|
||||
let status = host_sudo(&[
|
||||
"install",
|
||||
"-m",
|
||||
"644",
|
||||
&src_s,
|
||||
"/etc/nginx/sites-available/archipelago",
|
||||
])
|
||||
.await
|
||||
.context("install nginx-archipelago.conf")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@@ -250,6 +268,19 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
|
||||
if changed {
|
||||
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
|
||||
if nginx_src.exists() {
|
||||
match host_sudo(&["nginx", "-t"]).await {
|
||||
Ok(status) if status.success() => {
|
||||
let _ = host_sudo(&["systemctl", "reload", "nginx"]).await;
|
||||
}
|
||||
Ok(status) => {
|
||||
tracing::warn!("nginx config test failed after runtime sync: {}", status);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to test nginx config after runtime sync: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
@@ -557,10 +557,37 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
tier: "",
|
||||
},
|
||||
};
|
||||
apply_dynamic_metadata(app_id, &mut meta);
|
||||
meta.tier = get_app_tier(app_id);
|
||||
meta
|
||||
}
|
||||
|
||||
fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
|
||||
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||
let Ok(data) = std::fs::read_to_string(config_path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) else {
|
||||
return;
|
||||
};
|
||||
if let Some(title) = cfg
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 80)
|
||||
{
|
||||
meta.title = title.to_string();
|
||||
}
|
||||
if let Some(description) = cfg
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 240)
|
||||
{
|
||||
meta.description = description.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Map app_id to Tor hidden service directory name.
|
||||
/// "archipelago" is the main web UI (nginx port 80).
|
||||
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
|
||||
|
||||
@@ -1,42 +1,140 @@
|
||||
//! Mesh session lifecycle: connect, initialize, main loop.
|
||||
|
||||
use super::super::meshtastic::MeshtasticDevice;
|
||||
use super::super::serial::MeshcoreDevice;
|
||||
use super::super::types::*;
|
||||
use super::{
|
||||
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Scan all candidate serial ports and open the first Meshcore device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
|
||||
enum MeshRadioDevice {
|
||||
Meshcore(MeshcoreDevice),
|
||||
Meshtastic(MeshtasticDevice),
|
||||
}
|
||||
|
||||
impl MeshRadioDevice {
|
||||
fn device_type(&self) -> DeviceType {
|
||||
match self {
|
||||
Self::Meshcore(_) => DeviceType::Meshcore,
|
||||
Self::Meshtastic(_) => DeviceType::Meshtastic,
|
||||
}
|
||||
}
|
||||
|
||||
fn advert_name(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.advert_name.clone(),
|
||||
Self::Meshtastic(device) => device.advert_name(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.set_advert_name(name).await,
|
||||
Self::Meshtastic(device) => device.set_advert_name(name).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_self_advert(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
Self::Meshtastic(device) => device.send_self_advert().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
Self::Meshtastic(device) => device.get_contacts().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
|
||||
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_messages(&mut self) -> Result<Vec<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.sync_messages().await,
|
||||
Self::Meshtastic(device) => device.sync_messages().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_recv_frame(&mut self) -> Result<Option<super::super::protocol::InboundFrame>> {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.try_recv_frame().await,
|
||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for Meshcore device");
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), dev, info));
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No Meshcore device found on {} candidate ports: {:?}",
|
||||
"No supported mesh radio found on {} candidate ports: {:?}",
|
||||
paths.len(),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII marker for the original DM-via-channel format:
|
||||
/// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire,
|
||||
/// so the receiver has to guess the sender from its contact table — which
|
||||
@@ -90,7 +188,7 @@ fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||
/// with both the recipient and sender prefixes so attribution works on
|
||||
/// the receiver side.
|
||||
async fn send_dm_via_channel(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
@@ -166,7 +264,7 @@ async fn send_dm_via_channel(
|
||||
}
|
||||
|
||||
/// Fetch the contacts list from the device and update the peer cache.
|
||||
async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
@@ -239,7 +337,7 @@ async fn refresh_contacts(device: &mut MeshcoreDevice, state: &Arc<MeshState>) {
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
our_x25519_secret: &[u8; 32],
|
||||
) -> bool {
|
||||
@@ -274,20 +372,11 @@ pub(super) async fn run_mesh_session(
|
||||
) -> Result<()> {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} handshake failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
}
|
||||
},
|
||||
match open_preferred_path(path).await {
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} open failed: {} — trying auto-detect",
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
@@ -301,11 +390,11 @@ pub(super) async fn run_mesh_session(
|
||||
{
|
||||
let mut status = state.status.write().await;
|
||||
status.device_connected = true;
|
||||
status.device_type = DeviceType::Meshcore;
|
||||
status.device_type = device.device_type();
|
||||
status.device_path = Some(device_path.clone());
|
||||
status.firmware_version = Some(device_info.firmware_version.clone());
|
||||
status.self_node_id = Some(device_info.node_id);
|
||||
status.self_advert_name = device.advert_name.clone();
|
||||
status.self_advert_name = device.advert_name();
|
||||
}
|
||||
|
||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||
@@ -434,7 +523,7 @@ pub(super) async fn run_mesh_session(
|
||||
/// Process a single outbound command from MeshService.
|
||||
async fn handle_send_command(
|
||||
cmd: MeshCommand,
|
||||
device: &mut MeshcoreDevice,
|
||||
device: &mut MeshRadioDevice,
|
||||
state: &Arc<MeshState>,
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Async serial driver for Meshtastic devices.
|
||||
//!
|
||||
//! Meshtastic uses protobuf payloads over a SLIP-like serial stream. This
|
||||
//! module implements only the small subset Archipelago needs: connect,
|
||||
//! discover the local node, send/receive text packets, and provide synthetic
|
||||
//! contacts to the existing mesh listener.
|
||||
|
||||
use super::protocol::{InboundFrame, ParsedContact};
|
||||
use super::types::{DeviceInfo, DeviceType};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const BAUD_RATE: u32 = 115200;
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const READ_BUF_SIZE: usize = 512;
|
||||
|
||||
const START1: u8 = 0x94;
|
||||
const START2: u8 = 0xc3;
|
||||
const TO_RADIO_MAX: usize = 512;
|
||||
const BROADCAST_NUM: u32 = 0xffff_ffff;
|
||||
const TEXT_MESSAGE_APP: u32 = 1;
|
||||
|
||||
const TO_RADIO_PACKET: u64 = 1;
|
||||
const TO_RADIO_WANT_CONFIG_ID: u64 = 3;
|
||||
const TO_RADIO_HEARTBEAT: u64 = 7;
|
||||
|
||||
const FROM_RADIO_PACKET: u64 = 2;
|
||||
const FROM_RADIO_MY_INFO: u64 = 3;
|
||||
const FROM_RADIO_NODE_INFO: u64 = 4;
|
||||
const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7;
|
||||
const FROM_RADIO_REBOOTED: u64 = 8;
|
||||
|
||||
/// Async Meshtastic device handle.
|
||||
pub struct MeshtasticDevice {
|
||||
port: serial2_tokio::SerialPort,
|
||||
read_buf: Vec<u8>,
|
||||
node_num: Option<u32>,
|
||||
user_id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
contacts: HashMap<u32, ParsedContact>,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl MeshtasticDevice {
|
||||
pub async fn open(path: &str) -> Result<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
debug!(path = %path, permissions = ?meta.permissions(), "Device node exists")
|
||||
}
|
||||
Err(e) => anyhow::bail!("Serial device {} not accessible: {}", path, e),
|
||||
}
|
||||
|
||||
let port = serial2_tokio::SerialPort::open(path, BAUD_RATE).context(format!(
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
path
|
||||
))?;
|
||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
read_buf: Vec::with_capacity(READ_BUF_SIZE),
|
||||
node_num: None,
|
||||
user_id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
contacts: HashMap::new(),
|
||||
device_path: path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
|
||||
info!(path = %self.device_path, "Starting Meshtastic handshake");
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + READ_TIMEOUT;
|
||||
let mut saw_meshtastic_frame = false;
|
||||
let mut saw_config_complete = false;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
remaining.min(Duration::from_millis(250)),
|
||||
self.read_from_radio(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(frame))) => {
|
||||
saw_meshtastic_frame = true;
|
||||
if matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
) {
|
||||
saw_config_complete = true;
|
||||
}
|
||||
self.handle_from_radio(&frame);
|
||||
if saw_config_complete && self.node_num.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Ok(None)) | Err(_) => {}
|
||||
Ok(Err(e)) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_meshtastic_frame {
|
||||
anyhow::bail!("No Meshtastic serial API response");
|
||||
}
|
||||
|
||||
let node_id = self
|
||||
.node_num
|
||||
.ok_or_else(|| anyhow::anyhow!("Meshtastic serial API did not provide MyInfo"))?;
|
||||
if self.user_id.is_none() && self.long_name.is_none() && self.short_name.is_none() {
|
||||
anyhow::bail!("Meshtastic serial API did not provide node identity");
|
||||
}
|
||||
let firmware_version = self
|
||||
.long_name
|
||||
.clone()
|
||||
.or_else(|| self.user_id.clone())
|
||||
.unwrap_or_else(|| "Meshtastic".to_string());
|
||||
|
||||
info!(node_id, name = %firmware_version, "Meshtastic identity");
|
||||
Ok(DeviceInfo {
|
||||
firmware_version,
|
||||
node_id,
|
||||
max_contacts: 200,
|
||||
device_type: DeviceType::Meshtastic,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||
self.long_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_self_advert(&mut self) -> Result<()> {
|
||||
self.send_to_radio(&encode_heartbeat()).await
|
||||
}
|
||||
|
||||
pub async fn send_channel_text(&mut self, _channel: u8, msg: &[u8]) -> Result<()> {
|
||||
let text = String::from_utf8_lossy(msg);
|
||||
let packet = encode_mesh_packet(BROADCAST_NUM, TEXT_MESSAGE_APP, text.as_bytes());
|
||||
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||
if self.contacts.is_empty() {
|
||||
self.send_to_radio(&encode_want_config()).await?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match self.read_from_radio().await? {
|
||||
Some(frame) => {
|
||||
let config_complete = matches!(
|
||||
decode_top_level_variant(&frame),
|
||||
Some((FROM_RADIO_CONFIG_COMPLETE_ID, _))
|
||||
);
|
||||
self.handle_from_radio(&frame);
|
||||
if config_complete || !self.contacts.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => tokio::time::sleep(Duration::from_millis(50)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(self.contacts.values().cloned().collect())
|
||||
}
|
||||
|
||||
pub async fn reset_contact_path(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
let Some(frame) = self.read_from_radio().await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.handle_from_radio(&frame))
|
||||
}
|
||||
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
self.long_name
|
||||
.clone()
|
||||
.or_else(|| self.short_name.clone())
|
||||
.or_else(|| self.user_id.clone())
|
||||
}
|
||||
|
||||
async fn send_to_radio(&mut self, payload: &[u8]) -> Result<()> {
|
||||
if payload.len() > TO_RADIO_MAX {
|
||||
anyhow::bail!("Meshtastic payload too large: {} bytes", payload.len());
|
||||
}
|
||||
let mut frame = Vec::with_capacity(4 + payload.len());
|
||||
frame.push(START1);
|
||||
frame.push(START2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
tokio::time::timeout(WRITE_TIMEOUT, self.port.write_all(&frame))
|
||||
.await
|
||||
.context("Meshtastic serial write timed out")?
|
||||
.context("Meshtastic serial write failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_from_radio(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if let Some(frame) = decode_serial_frame(&mut self.read_buf) {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
|
||||
let mut tmp = [0u8; READ_BUF_SIZE];
|
||||
match tokio::time::timeout(Duration::from_millis(50), self.port.read(&mut tmp)).await {
|
||||
Ok(Ok(0)) => anyhow::bail!("Meshtastic serial port closed"),
|
||||
Ok(Ok(n)) => self.read_buf.extend_from_slice(&tmp[..n]),
|
||||
Ok(Err(e)) => return Err(e).context("Meshtastic serial read error"),
|
||||
Err(_) => return Ok(None),
|
||||
}
|
||||
|
||||
Ok(decode_serial_frame(&mut self.read_buf))
|
||||
}
|
||||
|
||||
fn handle_from_radio(&mut self, frame: &[u8]) -> Option<InboundFrame> {
|
||||
let Some((field, value)) = decode_top_level_variant(frame) else {
|
||||
return None;
|
||||
};
|
||||
match field {
|
||||
FROM_RADIO_MY_INFO => {
|
||||
if let Some((node_num, user_id)) = parse_my_info(value) {
|
||||
self.node_num = Some(node_num);
|
||||
if let Some(user_id) = user_id {
|
||||
self.user_id = Some(user_id);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
FROM_RADIO_NODE_INFO => {
|
||||
self.update_node_info(value);
|
||||
None
|
||||
}
|
||||
FROM_RADIO_PACKET => self.packet_to_inbound_frame(value),
|
||||
FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None,
|
||||
other => {
|
||||
debug!(
|
||||
field = other,
|
||||
len = value.len(),
|
||||
"Unhandled Meshtastic FromRadio field"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_node_info(&mut self, data: &[u8]) {
|
||||
if let Some(node) = parse_node_info(data) {
|
||||
let key = synthetic_pubkey(node.num);
|
||||
let name = node
|
||||
.long_name
|
||||
.or(node.short_name)
|
||||
.or(node.id)
|
||||
.unwrap_or_else(|| format!("Meshtastic !{:08x}", node.num));
|
||||
if Some(node.num) == self.node_num {
|
||||
self.long_name = Some(name.clone());
|
||||
}
|
||||
self.contacts.insert(
|
||||
node.num,
|
||||
ParsedContact {
|
||||
public_key_hex: hex::encode(key),
|
||||
advert_name: name,
|
||||
last_advert: node.last_heard.unwrap_or_default(),
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option<InboundFrame> {
|
||||
let packet = parse_mesh_packet(data)?;
|
||||
if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let from = packet.from.unwrap_or(0);
|
||||
if Some(from) == self.node_num {
|
||||
return None;
|
||||
}
|
||||
let from_key = synthetic_pubkey(from);
|
||||
self.contacts.entry(from).or_insert_with(|| ParsedContact {
|
||||
public_key_hex: hex::encode(synthetic_pubkey(from)),
|
||||
advert_name: format!("Meshtastic !{:08x}", from),
|
||||
last_advert: 0,
|
||||
contact_type: 1,
|
||||
path_len: 0xff,
|
||||
flags: 0,
|
||||
});
|
||||
|
||||
let mut payload = Vec::with_capacity(15 + packet.payload.len());
|
||||
payload.push(0); // SNR unknown
|
||||
payload.extend_from_slice(&[0, 0]); // reserved
|
||||
payload.extend_from_slice(&from_key[..6]);
|
||||
payload.push(0xff); // unknown/flood path
|
||||
payload.push(0); // text type
|
||||
payload.extend_from_slice(&0u32.to_le_bytes());
|
||||
payload.extend_from_slice(&packet.payload);
|
||||
Some(InboundFrame {
|
||||
code: super::protocol::RESP_CONTACT_MSG_V3,
|
||||
data: payload,
|
||||
bytes_consumed: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_serial_frame(buf: &mut Vec<u8>) -> Option<Vec<u8>> {
|
||||
let start = buf.windows(2).position(|w| w == [START1, START2])?;
|
||||
if start > 0 {
|
||||
buf.drain(..start);
|
||||
}
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let len = u16::from_be_bytes([buf[2], buf[3]]) as usize;
|
||||
if buf.len() < 4 + len {
|
||||
return None;
|
||||
}
|
||||
let payload = buf[4..4 + len].to_vec();
|
||||
buf.drain(..4 + len);
|
||||
Some(payload)
|
||||
}
|
||||
|
||||
fn encode_want_config() -> Vec<u8> {
|
||||
encode_varint_field(TO_RADIO_WANT_CONFIG_ID, 1)
|
||||
}
|
||||
|
||||
fn encode_heartbeat() -> Vec<u8> {
|
||||
encode_to_radio_variant(TO_RADIO_HEARTBEAT, &[])
|
||||
}
|
||||
|
||||
fn encode_to_radio_variant(field: u64, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_len_field(field, bytes, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec<u8> {
|
||||
let mut decoded = Vec::new();
|
||||
encode_varint_field_into(1, portnum as u64, &mut decoded);
|
||||
encode_len_field(2, payload, &mut decoded);
|
||||
|
||||
let mut packet = Vec::new();
|
||||
encode_fixed32_field(2, to, &mut packet);
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
packet
|
||||
}
|
||||
|
||||
fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> {
|
||||
let mut idx = 0;
|
||||
while idx < buf.len() {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let field = key >> 3;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (_, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
if matches!(field, FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED) {
|
||||
return Some((field, &[]));
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[idx..])?;
|
||||
idx += n;
|
||||
let end = idx.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
field,
|
||||
FROM_RADIO_PACKET | FROM_RADIO_MY_INFO | FROM_RADIO_NODE_INFO
|
||||
) {
|
||||
return Some((field, &buf[idx..end]));
|
||||
}
|
||||
idx = end;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_my_info(data: &[u8]) -> Option<(u32, Option<String>)> {
|
||||
let mut idx = 0;
|
||||
let mut node_num = None;
|
||||
let mut user_id = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node_num = Some(v as u32),
|
||||
(1, FieldValue::Fixed32(v)) => node_num = Some(v),
|
||||
(3, FieldValue::Bytes(b)) => user_id = parse_user(b).and_then(|u| u.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
node_num.map(|n| (n, user_id))
|
||||
}
|
||||
|
||||
struct ParsedNode {
|
||||
num: u32,
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
last_heard: Option<u32>,
|
||||
}
|
||||
|
||||
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
|
||||
let mut idx = 0;
|
||||
let mut node = ParsedNode {
|
||||
num: 0,
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
last_heard: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => node.num = v as u32,
|
||||
(1, FieldValue::Fixed32(v)) => node.num = v,
|
||||
(2, FieldValue::Bytes(b)) => {
|
||||
if let Some(user) = parse_user(b) {
|
||||
node.id = user.id;
|
||||
node.long_name = user.long_name;
|
||||
node.short_name = user.short_name;
|
||||
}
|
||||
}
|
||||
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if node.num == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(node)
|
||||
}
|
||||
}
|
||||
|
||||
struct ParsedUser {
|
||||
id: Option<String>,
|
||||
long_name: Option<String>,
|
||||
short_name: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
|
||||
let mut idx = 0;
|
||||
let mut user = ParsedUser {
|
||||
id: None,
|
||||
long_name: None,
|
||||
short_name: None,
|
||||
};
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
|
||||
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
|
||||
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(user)
|
||||
}
|
||||
|
||||
struct ParsedPacket {
|
||||
from: Option<u32>,
|
||||
portnum: u32,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
|
||||
let mut idx = 0;
|
||||
let mut from = None;
|
||||
let mut decoded = None;
|
||||
while idx < data.len() {
|
||||
let (field, value, next) = next_field(data, idx)?;
|
||||
idx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Fixed32(v)) => from = Some(v),
|
||||
(4, FieldValue::Bytes(b)) => decoded = Some(b),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let decoded = decoded?;
|
||||
let mut didx = 0;
|
||||
let mut portnum = 0;
|
||||
let mut payload = Vec::new();
|
||||
while didx < decoded.len() {
|
||||
let (field, value, next) = next_field(decoded, didx)?;
|
||||
didx = next;
|
||||
match (field, value) {
|
||||
(1, FieldValue::Varint(v)) => portnum = v as u32,
|
||||
(2, FieldValue::Bytes(b)) => payload = b.to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(ParsedPacket {
|
||||
from,
|
||||
portnum,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
enum FieldValue<'a> {
|
||||
Varint(u64),
|
||||
Fixed32(u32),
|
||||
Bytes(&'a [u8]),
|
||||
}
|
||||
|
||||
fn next_field(buf: &[u8], idx: usize) -> Option<(u64, FieldValue<'_>, usize)> {
|
||||
let (key, n) = read_varint(&buf[idx..])?;
|
||||
let field = key >> 3;
|
||||
let mut pos = idx + n;
|
||||
match key & 0x07 {
|
||||
0 => {
|
||||
let (v, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
Some((field, FieldValue::Varint(v), pos))
|
||||
}
|
||||
2 => {
|
||||
let (len, n) = read_varint(&buf[pos..])?;
|
||||
pos += n;
|
||||
let end = pos.checked_add(len as usize)?;
|
||||
if end > buf.len() {
|
||||
return None;
|
||||
}
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
5 => {
|
||||
let end = pos.checked_add(4)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
let value =
|
||||
u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
|
||||
Some((field, FieldValue::Fixed32(value), end))
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let end = pos.checked_add(8)?;
|
||||
if end > buf.len() {
|
||||
None
|
||||
} else {
|
||||
Some((field, FieldValue::Bytes(&buf[pos..end]), end))
|
||||
}
|
||||
}
|
||||
wire => {
|
||||
warn!(wire, "Unsupported Meshtastic protobuf wire type");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_varint(buf: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut out = 0u64;
|
||||
for (i, b) in buf.iter().copied().enumerate().take(10) {
|
||||
out |= ((b & 0x7f) as u64) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
return Some((out, i + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn encode_varint_field(field: u64, value: u64) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
encode_varint_field_into(field, value, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn encode_varint_field_into(field: u64, value: u64, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 0, out);
|
||||
write_varint(value, out);
|
||||
}
|
||||
|
||||
fn encode_len_field(field: u64, bytes: &[u8], out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 2, out);
|
||||
write_varint(bytes.len() as u64, out);
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn encode_fixed32_field(field: u64, value: u32, out: &mut Vec<u8>) {
|
||||
write_varint((field << 3) | 5, out);
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_varint(mut value: u64, out: &mut Vec<u8>) {
|
||||
while value >= 0x80 {
|
||||
out.push((value as u8 & 0x7f) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
out.push(value as u8);
|
||||
}
|
||||
|
||||
fn string_field(bytes: &[u8]) -> Option<String> {
|
||||
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn synthetic_pubkey(node_num: u32) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
out[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||
out[4..15].copy_from_slice(b"meshtastic:");
|
||||
out
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Mesh networking: Meshcore LoRa radio integration for offline peer discovery
|
||||
//! Mesh networking: LoRa radio integration for offline peer discovery
|
||||
//! and encrypted messaging between Archipelago nodes.
|
||||
//!
|
||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode).
|
||||
//! Supports Meshcore firmware via Companion USB and Meshtastic firmware via
|
||||
//! the Meshtastic serial API on Heltec, T-Beam, RAK WisBlock, Station G2,
|
||||
//! and other ESP32/nRF52-based LoRa boards.
|
||||
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
|
||||
@@ -323,6 +323,7 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
}
|
||||
|
||||
/// Parsed contact from RESP_CONTACT (0x03).
|
||||
#[derive(Clone)]
|
||||
pub struct ParsedContact {
|
||||
pub public_key_hex: String,
|
||||
pub advert_name: String,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use super::protocol::{self, InboundFrame};
|
||||
use super::types::DeviceInfo;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
@@ -400,12 +401,43 @@ const SERIAL_CANDIDATES: &[&str] = &[
|
||||
"/dev/ttyACM2",
|
||||
];
|
||||
|
||||
const SKIP_SERIAL_MODEL_SUBSTRINGS: &[&str] = &["Sierra_Wireless", "Z-Wave", "Zooz"];
|
||||
|
||||
fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
let Some(name) = Path::new(path).file_name().and_then(|s| s.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let by_id = Path::new("/dev/serial/by-id");
|
||||
let Ok(entries) = std::fs::read_dir(by_id) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
if !SKIP_SERIAL_MODEL_SUBSTRINGS
|
||||
.iter()
|
||||
.any(|needle| file_name.contains(needle))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(target) = std::fs::read_link(entry.path()) {
|
||||
if target.file_name().and_then(|s| s.to_str()) == Some(name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
|
||||
limits.insert("backup.upload-s3".to_string(), (3, 600));
|
||||
limits.insert("backup.download-s3".to_string(), (3, 600));
|
||||
// System operations
|
||||
limits.insert("update.apply".to_string(), (2, 600));
|
||||
// Update apply is an authenticated local admin action. Keep a guard
|
||||
// against accidental button storms without locking operators out for
|
||||
// ten minutes during OTA troubleshooting.
|
||||
limits.insert("update.apply".to_string(), (10, 60));
|
||||
limits.insert("system.reboot".to_string(), (2, 300));
|
||||
limits.insert("system.shutdown".to_string(), (2, 300));
|
||||
// Password and TOTP changes
|
||||
|
||||
@@ -840,6 +840,20 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
|
||||
/// scanner's authoritative view. Applies to all transitional variants.
|
||||
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Multi-container installs can legitimately spend several minutes before the
|
||||
/// primary user-facing container exists. BTCPay, for example, pulls/starts
|
||||
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
|
||||
/// card just because the primary container is absent during that setup window.
|
||||
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
|
||||
|
||||
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
|
||||
if *state == crate::data_model::PackageState::Installing {
|
||||
INSTALLING_STUCK_TIMEOUT
|
||||
} else {
|
||||
TRANSITIONAL_STUCK_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `state` is one of the transitional variants that a
|
||||
/// `spawn_transitional`-style background task owns. While such a state is
|
||||
/// set, the package scanner must not overwrite it with whatever podman
|
||||
@@ -961,13 +975,14 @@ async fn scan_and_update_packages(
|
||||
let overwrite = match existing {
|
||||
Some(existing_entry) if is_transitional(&existing_entry.state) => {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
|
||||
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
||||
let stuck = now.duration_since(entered) > timeout;
|
||||
if stuck {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
|
||||
id,
|
||||
existing_entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
|
||||
timeout.as_secs(),
|
||||
pkg.state
|
||||
);
|
||||
transitional_since.remove(id);
|
||||
@@ -1015,12 +1030,13 @@ async fn scan_and_update_packages(
|
||||
if let Some(entry) = merged.get(&id) {
|
||||
if is_transitional(&entry.state) {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
|
||||
let timeout = transitional_stuck_timeout(&entry.state);
|
||||
if now.duration_since(entered) > timeout {
|
||||
warn!(
|
||||
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
|
||||
id,
|
||||
entry.state,
|
||||
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
|
||||
timeout.as_secs()
|
||||
);
|
||||
merged.remove(&id);
|
||||
transitional_since.remove(&id);
|
||||
@@ -1247,4 +1263,13 @@ mod merge_tests {
|
||||
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_uses_longer_stale_timeout_than_other_transitions() {
|
||||
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
|
||||
assert_eq!(
|
||||
transitional_stuck_timeout(&PackageState::Stopping),
|
||||
TRANSITIONAL_STUCK_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Release Notes Backlog
|
||||
|
||||
## Next Release Required Work
|
||||
|
||||
- Backfill missing or thin historical release notes before cutting the next release.
|
||||
- Audit every `CHANGELOG.md` section from `v1.7.44-alpha` through the current release.
|
||||
- Replace raw commit-hash entries with user/operator-facing bullets that explain behavior changes, operational impact, validation, and known limitations.
|
||||
- Ensure `releases/manifest.json` changelog entries come from curated `CHANGELOG.md` notes only.
|
||||
|
||||
## Release Note Policy
|
||||
|
||||
- Every release must have at least three curated bullets.
|
||||
- Raw `git log --oneline` output is not acceptable release documentation.
|
||||
- Notes should answer what changed, why it matters, what operators should expect, and any known limitations.
|
||||
- `scripts/check-release-manifest.sh` is the enforcement gate before publishing artifacts.
|
||||
@@ -1,6 +1,9 @@
|
||||
# Stable symlink for USB serial adapters used as mesh radios.
|
||||
# Creates /dev/mesh-radio pointing to the underlying ttyUSB device.
|
||||
# Supports: CP2102 (Heltec V3), CH340 (T-Beam), FTDI (RAK WisBlock).
|
||||
# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3),
|
||||
# CH340 (T-Beam), FTDI (RAK WisBlock), and known USB CDC ACM radios.
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
|
||||
|
||||
@@ -105,9 +105,25 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Versioned Vite assets must never fall through to index.html. During OTA
|
||||
# a browser can keep an old HTML shell that references now-removed hashed
|
||||
# chunks; returning HTML for /assets/*.js triggers strict MIME failures.
|
||||
# A real 404 plus immutable/no-cache split lets the app/browser recover on
|
||||
# refresh without caching the wrong content type.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
location ~* ^/(registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Serve static files (Vue.js SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Peer-to-peer node messaging (receives from other nodes over Tor)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.66-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.66-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.57-alpha",
|
||||
"version": "1.7.66-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -112,6 +112,22 @@ describe('useAppLauncherStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes desktop new-tab apps into app session on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
@@ -219,6 +235,35 @@ describe('useAppLauncherStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('opens known prepackaged websites in new tab on desktop when requested', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://nwnn.l484.com', title: 'Next Web News Network', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://nwnn.l484.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('routes prepackaged websites into app session on mobile', () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
value: 390,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://present.l484.com', title: 'Arch Presentation', openInNewTab: true })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
|
||||
})
|
||||
|
||||
it('routes HTTPS same-host apps via session view', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
|
||||
|
||||
@@ -31,6 +31,10 @@ function mustOpenInNewTab(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
return typeof window !== 'undefined' && window.innerWidth < 768
|
||||
}
|
||||
|
||||
function inferAppIdFromTitle(title?: string): string | null {
|
||||
const t = (title || '').toLowerCase()
|
||||
if (!t) return null
|
||||
@@ -149,8 +153,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
|
||||
function openSession(appId: string) {
|
||||
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (mode === 'panel' && !isMobile) {
|
||||
if (mode === 'panel' && !isMobileViewport()) {
|
||||
panelAppId.value = appId
|
||||
} else {
|
||||
panelAppId.value = null
|
||||
@@ -168,8 +171,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Force selected apps to open directly in new tab
|
||||
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
if (!isMobileViewport() && payload.openInNewTab) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Force selected apps to open directly in new tab on desktop only. On
|
||||
// phones, route through the app session/webview so app icons behave like
|
||||
// native launchers and keep the user inside Archipelago.
|
||||
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
@@ -181,7 +191,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
}
|
||||
|
||||
// Unknown apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
|
||||
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -288,16 +288,19 @@ function goBack() {
|
||||
function launchApp() {
|
||||
if (!pkg.value) return
|
||||
const id = appId.value
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title })
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebsitePackage(id, pkg.value)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg.value)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.value.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ const appId = computed(() => {
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
const mustOpenNewTab = computed(() => !isMobile && NEW_TAB_APPS.has(appId.value))
|
||||
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
||||
const screensaverSuppressedApps = new Set([
|
||||
'indeedhub',
|
||||
@@ -530,6 +530,8 @@ onBeforeUnmount(() => {
|
||||
.app-session-fullscreen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
||||
background: black;
|
||||
}
|
||||
.app-session-panel.glass-card {
|
||||
border: none !important;
|
||||
@@ -543,8 +545,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.app-session-frame-safe {
|
||||
flex: none !important;
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
|
||||
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+258
-15
@@ -18,24 +18,48 @@
|
||||
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
|
||||
>{{ category.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="min-w-0 flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search only (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden">
|
||||
<!-- Mobile: search + sideload button (tabs handled by Dashboard.vue header) -->
|
||||
<div class="md:hidden flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="min-w-0 flex-1 px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||
aria-label="Sideload app"
|
||||
title="Sideload app"
|
||||
@click="showSideload = true"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +149,63 @@
|
||||
@confirm="onConfirmUninstall"
|
||||
/>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showSideload"
|
||||
class="fixed inset-0 z-[2600] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
|
||||
@click.self="closeSideload"
|
||||
>
|
||||
<form class="sideload-modal" @submit.prevent="submitSideload">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Sideload app</h2>
|
||||
<p class="text-sm text-white/55 mt-1">Install a trusted Docker image with a simple web UI.</p>
|
||||
</div>
|
||||
<button type="button" class="sideload-close-btn" aria-label="Close" @click="closeSideload">×</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="sideload-label">App ID</span>
|
||||
<input v-model.trim="sideloadForm.id" class="sideload-input" placeholder="excalidraw" pattern="[a-z0-9][a-z0-9-]{0,63}" required />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Docker image</span>
|
||||
<input v-model.trim="sideloadForm.image" class="sideload-input" placeholder="docker.io/excalidraw/excalidraw:latest" required />
|
||||
</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="sideload-label">Title</span>
|
||||
<input v-model.trim="sideloadForm.title" class="sideload-input" placeholder="Excalidraw" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Port mapping</span>
|
||||
<input v-model.trim="sideloadForm.port" class="sideload-input" placeholder="3009:80" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="sideload-label">Description</span>
|
||||
<input v-model.trim="sideloadForm.description" class="sideload-input" placeholder="Collaborative whiteboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="sideloadError" class="alert-error mt-4 text-sm">{{ sideloadError }}</div>
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-3">
|
||||
<button type="button" class="flex-1 glass-button px-4 py-3 rounded-lg" @click="closeSideload">Cancel</button>
|
||||
<button type="submit" class="flex-1 glass-button px-4 py-3 rounded-lg font-semibold" :disabled="sideloading">
|
||||
{{ sideloading ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actions.actionError.value" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
@@ -149,6 +230,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { type PackageDataEntry, type PackageState } from '@/types/api'
|
||||
import AppCard from './apps/AppCard.vue'
|
||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
@@ -156,7 +238,7 @@ import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import {
|
||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
|
||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||
} from './apps/appsConfig'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -165,6 +247,16 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
const serverStore = useServerStore()
|
||||
const actions = useAppsActions()
|
||||
const showSideload = ref(false)
|
||||
const sideloading = ref(false)
|
||||
const sideloadError = ref('')
|
||||
const sideloadForm = ref({
|
||||
id: '',
|
||||
image: '',
|
||||
title: '',
|
||||
port: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// Only stagger-animate on first mount
|
||||
const showStagger = !appsAnimationDone
|
||||
@@ -286,12 +378,20 @@ function goToApp(id: string) {
|
||||
|
||||
function launchApp(id: string) {
|
||||
const pkg = packages.value[id]
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (pkg && webOnlyUrl) {
|
||||
useAppLauncherStore().open({ url: webOnlyUrl, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (pkg && opensInTab(id)) {
|
||||
if (pkg && isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
useAppLauncherStore().open({ url, title: pkg.manifest.title, openInNewTab: !isMobile })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isMobile && pkg && opensInTab(id)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
@@ -308,4 +408,147 @@ async function updateApp(id: string) {
|
||||
actions.actionError.value = `Failed to update ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
|
||||
function closeSideload() {
|
||||
if (sideloading.value) return
|
||||
showSideload.value = false
|
||||
sideloadError.value = ''
|
||||
}
|
||||
|
||||
function inferPortMapping(image: string): string {
|
||||
const lower = image.toLowerCase()
|
||||
if (lower.includes('excalidraw')) return '3009:80'
|
||||
if (lower.includes('stirling')) return '3011:8080'
|
||||
if (lower.includes('freshrss')) return '3012:80'
|
||||
if (lower.includes('wallabag')) return '3013:80'
|
||||
if (lower.includes('hedgedoc')) return '3014:3000'
|
||||
if (lower.includes('cyberchef')) return '3015:80'
|
||||
if (lower.includes('mealie')) return '3016:9000'
|
||||
if (lower.includes('pairdrop')) return '3017:3000'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function submitSideload() {
|
||||
const id = sideloadForm.value.id.trim().toLowerCase()
|
||||
const image = sideloadForm.value.image.trim()
|
||||
const title = sideloadForm.value.title.trim() || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
const port = sideloadForm.value.port.trim() || inferPortMapping(image)
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) {
|
||||
sideloadError.value = 'Use lowercase letters, numbers, and hyphens only.'
|
||||
return
|
||||
}
|
||||
if (!image || !image.includes('/')) {
|
||||
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
|
||||
return
|
||||
}
|
||||
sideloading.value = true
|
||||
sideloadError.value = ''
|
||||
const containerConfig: Record<string, unknown> = {}
|
||||
containerConfig.title = title
|
||||
if (sideloadForm.value.description.trim()) containerConfig.description = sideloadForm.value.description.trim()
|
||||
if (port) containerConfig.ports = [port]
|
||||
try {
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'downloading',
|
||||
progress: 2,
|
||||
message: 'Sideload queued...',
|
||||
attempt: 0,
|
||||
})
|
||||
await rpcClient.call({
|
||||
method: 'package.install',
|
||||
params: {
|
||||
id,
|
||||
dockerImage: image,
|
||||
version: 'sideload',
|
||||
containerConfig,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
closeSideload()
|
||||
sideloadForm.value = { id: '', image: '', title: '', port: '', description: '' }
|
||||
} catch (err) {
|
||||
sideloadError.value = err instanceof Error ? err.message : 'Install failed'
|
||||
serverStore.setInstallProgress(id, {
|
||||
id,
|
||||
title,
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: sideloadError.value,
|
||||
attempt: 0,
|
||||
})
|
||||
} finally {
|
||||
sideloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sideload-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.sideload-icon-btn:hover,
|
||||
.sideload-icon-btn:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
}
|
||||
.sideload-icon-btn-mobile {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.sideload-modal {
|
||||
width: 100%;
|
||||
max-width: 34rem;
|
||||
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 12px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1.5rem 1.5rem 0 0;
|
||||
background: rgba(8, 10, 18, 0.94);
|
||||
padding: 1.25rem;
|
||||
padding-bottom: calc(1.25rem + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
|
||||
box-shadow: 0 -24px 70px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.sideload-close-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sideload-label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.sideload-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: white;
|
||||
outline: none;
|
||||
}
|
||||
.sideload-input::placeholder { color: rgba(255, 255, 255, 0.38); }
|
||||
.sideload-input:focus { border-color: rgba(255, 255, 255, 0.38); }
|
||||
@media (min-width: 768px) {
|
||||
.sideload-modal {
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,6 +62,7 @@ export const EXTERNAL_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
'nostrudel': 'https://nostrudel.ninja',
|
||||
|
||||
@@ -79,7 +79,7 @@ import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl } from './appsConfig'
|
||||
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
@@ -120,14 +120,20 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (canLaunch(pkg)) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
|
||||
if (webOnlyUrl) {
|
||||
appLauncher.open({ url: webOnlyUrl, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
if (isWebsitePackage(id, pkg)) {
|
||||
const url = resolveRuntimeLaunchUrl(pkg)
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
appLauncher.open({ url, title: getTitle(id, pkg), openInNewTab: !isMobile })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (opensInTab(id)) {
|
||||
if (!isMobile && opensInTab(id)) {
|
||||
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
|
||||
if (appUrl) {
|
||||
window.open(appUrl, '_blank', 'noopener,noreferrer')
|
||||
|
||||
@@ -107,6 +107,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
'call-the-operator': 'https://cta.tx1138.com',
|
||||
'arch-presentation': 'https://present.l484.com',
|
||||
'syntropy-institute': 'https://syntropy.institute',
|
||||
't-zero': 'https://teeminuszero.net',
|
||||
}
|
||||
@@ -132,6 +133,11 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
manifest: { id: 'call-the-operator', title: 'Call the Operator', version: '1.0.0', description: { short: 'Escape the Matrix — explore decentralized alternatives', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/call-the-operator.png' },
|
||||
},
|
||||
'arch-presentation': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'arch-presentation', title: 'Arch Presentation', version: '1.0.0', description: { short: 'Archipelago: The Future of Decentralized Infrastructure', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/arch-presentation.png' },
|
||||
},
|
||||
'syntropy-institute': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'syntropy-institute', title: 'Syntropy Institute', version: '1.0.0', description: { short: 'Medicine Reimagined — frequency analysis-therapy', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
|
||||
+16
-18
@@ -1,29 +1,27 @@
|
||||
{
|
||||
"version": "1.7.57-alpha",
|
||||
"release_date": "2026-05-17",
|
||||
"version": "1.7.66-alpha",
|
||||
"release_date": "2026-05-18",
|
||||
"changelog": [
|
||||
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
|
||||
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
|
||||
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
|
||||
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
|
||||
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
|
||||
"Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.",
|
||||
"Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.",
|
||||
"Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
|
||||
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
|
||||
"size_bytes": 42647224
|
||||
"current_version": "1.7.66-alpha",
|
||||
"new_version": "1.7.66-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.66-alpha/archipelago",
|
||||
"sha256": "d410efd1acb6ae2f5e1270b585da8ff90c9de454fbf8b2648c37ddc4c1136794",
|
||||
"size_bytes": 42947056
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
|
||||
"size_bytes": 78322316
|
||||
"name": "archipelago-frontend-1.7.66-alpha.tar.gz",
|
||||
"current_version": "1.7.66-alpha",
|
||||
"new_version": "1.7.66-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.66-alpha/archipelago-frontend-1.7.66-alpha.tar.gz",
|
||||
"sha256": "35ca430accfb3b669c288800b59b899585435133d6d7e5da70c5066ae36a0308",
|
||||
"size_bytes": 166469699
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+16
-18
@@ -1,29 +1,27 @@
|
||||
{
|
||||
"version": "1.7.57-alpha",
|
||||
"release_date": "2026-05-17",
|
||||
"version": "1.7.66-alpha",
|
||||
"release_date": "2026-05-18",
|
||||
"changelog": [
|
||||
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
|
||||
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
|
||||
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
|
||||
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
|
||||
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
|
||||
"Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.",
|
||||
"Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.",
|
||||
"Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
|
||||
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
|
||||
"size_bytes": 42647224
|
||||
"current_version": "1.7.66-alpha",
|
||||
"new_version": "1.7.66-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.66-alpha/archipelago",
|
||||
"sha256": "d410efd1acb6ae2f5e1270b585da8ff90c9de454fbf8b2648c37ddc4c1136794",
|
||||
"size_bytes": 42947056
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"current_version": "1.7.57-alpha",
|
||||
"new_version": "1.7.57-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
|
||||
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
|
||||
"size_bytes": 78322316
|
||||
"name": "archipelago-frontend-1.7.66-alpha.tar.gz",
|
||||
"current_version": "1.7.66-alpha",
|
||||
"new_version": "1.7.66-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.66-alpha/archipelago-frontend-1.7.66-alpha.tar.gz",
|
||||
"sha256": "35ca430accfb3b669c288800b59b899585435133d6d7e5da70c5066ae36a0308",
|
||||
"size_bytes": 166469699
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Validate releases/manifest.json:
|
||||
# - version matches core/archipelago/Cargo.toml
|
||||
# - changelog is non-empty (release notes are mandatory per product policy)
|
||||
# - changelog contains curated release notes, not raw git log output
|
||||
# - every component's download_url exists on disk and matches sha256/size
|
||||
#
|
||||
# Run on every push from CI, and also locally before publishing a release:
|
||||
@@ -30,12 +30,42 @@ if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then
|
||||
fi
|
||||
ok "version matches: $MANIFEST_VERSION"
|
||||
|
||||
# Release notes mandatory — ships stuff nobody can read otherwise.
|
||||
CHANGELOG_COUNT=$(python3 -c "import json; print(len(json.load(open('$MANIFEST'))['changelog']))")
|
||||
if [ "$CHANGELOG_COUNT" -eq 0 ]; then
|
||||
fail "changelog is empty — every release MUST have release notes"
|
||||
fi
|
||||
ok "changelog has $CHANGELOG_COUNT lines"
|
||||
# Release notes mandatory — ships stuff nobody can read otherwise. Require
|
||||
# curated user/operator-facing notes and reject raw `git log --oneline` output.
|
||||
NOTES_CHECK=$(python3 - "$MANIFEST" <<'PY'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
manifest = sys.argv[1]
|
||||
notes = json.load(open(manifest)).get("changelog", [])
|
||||
if len(notes) < 3:
|
||||
print(f"FAIL: changelog has {len(notes)} lines; need at least 3 curated release-note bullets")
|
||||
sys.exit(0)
|
||||
|
||||
bad = []
|
||||
for note in notes:
|
||||
text = str(note).strip()
|
||||
if not text:
|
||||
bad.append("empty release-note entry")
|
||||
if len(text) < 40:
|
||||
bad.append(f"too short: {text!r}")
|
||||
if re.match(r"^[0-9a-f]{7,40}\s+", text):
|
||||
bad.append(f"raw commit hash entry: {text!r}")
|
||||
if re.match(r"^(feat|fix|chore|docs|test|refactor|build|ci|perf)(\([^)]+\))?:\s", text):
|
||||
bad.append(f"raw conventional-commit entry: {text!r}")
|
||||
|
||||
if bad:
|
||||
print("FAIL: release notes must be curated user/operator-facing bullets, not raw git log lines:\n" + "\n".join(bad))
|
||||
else:
|
||||
print(f"OK: changelog has {len(notes)} curated lines")
|
||||
PY
|
||||
)
|
||||
case "$NOTES_CHECK" in
|
||||
OK:*) ok "${NOTES_CHECK#OK: }" ;;
|
||||
FAIL:*) fail "${NOTES_CHECK#FAIL: }" ;;
|
||||
*) fail "unexpected release-note validation output: $NOTES_CHECK" ;;
|
||||
esac
|
||||
|
||||
# Each component: the artifact on disk under releases/v<version>/ must match
|
||||
# the declared sha256 and size_bytes.
|
||||
|
||||
+32
-41
@@ -28,11 +28,12 @@ for arg in "$@"; do
|
||||
echo "Steps performed:"
|
||||
echo " 1. Validate version format (SemVer)"
|
||||
echo " 2. Bump version in Cargo.toml and package.json"
|
||||
echo " 3. Build frontend"
|
||||
echo " 4. Generate changelog from git log"
|
||||
echo " 5. Create release manifest"
|
||||
echo " 6. Commit version bump"
|
||||
echo " 7. Create git tag v{VERSION}"
|
||||
echo " 3. Build backend"
|
||||
echo " 4. Build frontend"
|
||||
echo " 5. Generate changelog from git log"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit version bump"
|
||||
echo " 8. Create git tag v{VERSION}"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Show what would be done without making changes"
|
||||
@@ -95,11 +96,12 @@ if $DRY_RUN; then
|
||||
echo "[DRY RUN] Would perform the following:"
|
||||
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
|
||||
echo " 2. Update neode-ui/package.json version to $VERSION"
|
||||
echo " 3. Build frontend (npm run build)"
|
||||
echo " 4. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 5. Create release manifest"
|
||||
echo " 6. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 7. Tag: v${VERSION}"
|
||||
echo " 3. Build backend (cargo build --release -p archipelago)"
|
||||
echo " 4. Build frontend (npm run build)"
|
||||
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
|
||||
echo " 6. Create release manifest"
|
||||
echo " 7. Commit: 'chore: release v${VERSION}'"
|
||||
echo " 8. Tag: v${VERSION}"
|
||||
echo ""
|
||||
echo "After this script, you would:"
|
||||
echo " - Push: git push && git push --tags"
|
||||
@@ -123,49 +125,38 @@ cd "$PROJECT_ROOT/neode-ui"
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[3/7] Building frontend..."
|
||||
echo "[3/8] Building backend..."
|
||||
cd "$PROJECT_ROOT/core"
|
||||
cargo build --release -p archipelago
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[4/8] Building frontend..."
|
||||
cd "$PROJECT_ROOT/neode-ui"
|
||||
npm run build 2>&1 | tail -3
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "[4/7] Generating changelog..."
|
||||
# Get commits since last tag (or last 50 if no tags)
|
||||
LAST_TAG=$(git -C "$PROJECT_ROOT" describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
if [ -n "$LAST_TAG" ]; then
|
||||
GIT_LOG=$(git -C "$PROJECT_ROOT" log "$LAST_TAG"..HEAD --oneline --no-merges 2>/dev/null || echo "")
|
||||
else
|
||||
GIT_LOG=$(git -C "$PROJECT_ROOT" log --oneline --no-merges -50 2>/dev/null || echo "")
|
||||
fi
|
||||
echo "[5/8] Validating curated changelog..."
|
||||
|
||||
# Create/update CHANGELOG.md entry
|
||||
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
CHANGELOG_ENTRY="## v${VERSION} (${RELEASE_DATE})
|
||||
|
||||
$GIT_LOG
|
||||
"
|
||||
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Prepend new entry after the first line (title)
|
||||
EXISTING=$(cat "$CHANGELOG_FILE")
|
||||
FIRST_LINE=$(head -1 "$CHANGELOG_FILE")
|
||||
REST=$(tail -n +2 "$CHANGELOG_FILE")
|
||||
echo "$FIRST_LINE" > "$CHANGELOG_FILE"
|
||||
echo "" >> "$CHANGELOG_FILE"
|
||||
echo "$CHANGELOG_ENTRY" >> "$CHANGELOG_FILE"
|
||||
echo "$REST" >> "$CHANGELOG_FILE"
|
||||
else
|
||||
echo "# Changelog" > "$CHANGELOG_FILE"
|
||||
echo "" >> "$CHANGELOG_FILE"
|
||||
echo "$CHANGELOG_ENTRY" >> "$CHANGELOG_FILE"
|
||||
if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"; then
|
||||
echo "Error: CHANGELOG.md must already contain curated notes for v${VERSION}."
|
||||
echo "Add a section like:"
|
||||
echo ""
|
||||
echo "## v${VERSION} (${RELEASE_DATE})"
|
||||
echo ""
|
||||
echo "- User/operator-facing change ..."
|
||||
echo "- Another concrete change ..."
|
||||
echo "- Validation or operational note ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[5/7] Creating release manifest..."
|
||||
echo "[6/8] Creating release manifest..."
|
||||
mkdir -p "$PROJECT_ROOT/releases"
|
||||
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
|
||||
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
|
||||
|
||||
echo "[6/7] Committing version bump..."
|
||||
echo "[7/8] Committing version bump..."
|
||||
git -C "$PROJECT_ROOT" add \
|
||||
core/archipelago/Cargo.toml \
|
||||
neode-ui/package.json \
|
||||
@@ -177,7 +168,7 @@ git -C "$PROJECT_ROOT" add \
|
||||
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
||||
|
||||
echo "[7/7] Creating git tag..."
|
||||
echo "[8/8] Creating git tag..."
|
||||
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user