Compare commits

...
Author SHA1 Message Date
archipelago 5b0b35401b Merge #a7abeb2c: Clear recovered monitoring alerts
nostr:nevent1qqs202lt93tr40nh6ttlc06682a7qwkjwulntv822crzy785rtgxchspz3mhxue69uhhyetvv9ujumn8d96zuer9wcyxh3c7

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Reconcile notifications on empty metric-alert snapshots, remove resolved disk/RAM/CPU/latency notifications immediately, and preserve unrelated app/health notices. Isolated regression test running.
2026-10-09 16:09:52 -04:00
archipelago e1506a07b2 fix: clear recovered monitoring alerts promptly 2026-10-09 15:59:33 -04:00
archipelago a5d4781ef1 Merge #7dcf5456: Record dual-node IndeeHub UAT gates
nostr:nevent1qqs8mn6526xrlkdwfvcf3jq3rfyu2f8cquvf7m873yvya8leweehzxgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcxqeu9g

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Document tested Yaya and Framework UAT overrides, protected poster access, outstanding live acceptance, and stale disk-alert recovery.
2026-10-09 15:55:37 -04:00
archipelago 8d9f4c9ff0 docs: record dual-node IndeeHub UAT and disk alert gate 2026-10-09 15:54:54 -04:00
archipelago 7becb21376 Merge #08638143: Record UAT ngit merge dispositions
nostr:nevent1qqsqscupgwkuxcek7g62swtz2eevsp9k63jpwjc34ck74rrsmh55x5spz3mhxue69uhhyetvv9ujumn8d96zuer9wcfsa3h4

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record the reviewed hardware, terminal/security, author, and IndeeHub UAT checklist PR outcomes and main-only mirror verification in the release ledger.
2026-10-09 15:46:40 -04:00
archipelago 711db6b711 docs: record ngit UAT proposal dispositions 2026-10-09 15:46:31 -04:00
archipelago 4b65879b6c Merge #f9914a73: Track outstanding IndeeHub UAT acceptance
Demo images / Build & push demo images (push) Failing after 1m8s
nostr:nevent1qqs0ny22wd7hy0xu9yq95sl55cphpvwreak3dqwp599jjhnd86yay2qpz3mhxue69uhhyetvv9ujumn8d96zuer9wcnuv3tu

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Retain the operator-reported signer, upload, poster, Cloud picker, publishing, payment-mode, and durable-deployment failures as open acceptance gates.
2026-10-09 15:27:18 -04:00
archipelago 56cdfcafcc docs: retain IndeeHub UAT acceptance failures 2026-10-09 15:26:48 -04:00
archipelago 3667105b25 Merge #924f78e1: fix: report an installed app's declared author
nostr:nevent1qqsfynmcuy5fruzzxmvz7jdw47hf07nr8kjmuh2s0f8v8e9etr00n2qpz3mhxue69uhhyetvv9ujumn8d96zuer9wczsgytw

PR-Author: TheCryptoDonkey
nostr:npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2

PR description:

The scanner credits every installed app to Archipelago, whatever metadata.author its manifest declares. apply_manifest_value already reads icon, tier and licence from metadata; this reads author too, keeping the scanner's default when none is declared. One test added; docker_packages tests pass (27/27).
2026-10-09 15:21:57 -04:00
archipelago bb79612772 Merge #2e6577db: fix: restrict owner terminal to dashboard origin
nostr:nevent1qqszuethmwphvtkd9p8u4h22c9da3v49mjym8uk5g24tklfkdtytnqgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcrhpskm

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Review follow-up to terminal proposal c1156d20. Require the exact dashboard Origin and Host on list, create and WebSocket routes so same-host apps on other ports cannot access the owner shell; add cross-port and missing-Origin regressions.
2026-10-09 15:21:51 -04:00
archipelago 8a7cfffd46 fix: restrict owner terminal to dashboard origin 2026-10-09 15:10:11 -04:00
archipelago 02671438d8 Merge #c1156d20: Terminal developer environment, resumable TUI, and Arc…
Terminal developer environment, resumable TUI, and Archipelago skills

nostr:nevent1qqsvz9tdypdv9cmtjmsz6azv8yujk0xezwv0j4wpa57qnk60nfyj28gpz3mhxue69uhhyetvv9ujumn8d96zuer9wcffu24e

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Adds the authenticated resumable terminal session backend, xterm-based terminal UI with bounded scrolling and app-style controls, tmux key translation, Codex/node bootstrap, preloaded Archipelago skills, developer app starter, and UAT runbook.

Validation: npm run build:production; cargo check -p archipelago; scripts/tests/archy-developer-setup-test.sh. Framework UAT health and unauthenticated terminal endpoint checks pass.
2026-10-09 15:08:54 -04:00
archipelago a5637d9350 Merge #a9165a5e: 3D hardware models, NIP-99 discovery, and private phon…
Demo images / Build & push demo images (push) Failing after 41s
3D hardware models, NIP-99 discovery, and private phone goal

nostr:nevent1qqs2j9j6tc3kpsdp3jhwty3wt8yy3un7r93e4d4smjpza4nvt033ctgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcq28amm

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Adds refined 3D hardware illustrations, OpenWrt and private-phone marketplace discovery, and the Setup goal for a non-surveillance phone. Production queries enabled Archipelago relays for NIP-99 kind-30402 listings; development mock cards are used only when DEV is enabled. Tested with production build plus Chromium, Firefox, and WebKit desktop/mobile browser checks. File-browser goal completion now opens the native Cloud files view.
2026-10-09 14:59:38 -04:00
yaya 19540c0578 Open file-browser goal in native Cloud view 2026-10-09 19:36:11 +01:00
yaya 52fb27fd43 fix(ui): use neutral loader before device models mount 2026-10-09 17:40:54 +01:00
archipelago 78e8b1d44a Merge #23778b8a: Record IndeeHub managed-service profile repair accepta…
Record IndeeHub managed-service profile repair acceptance

nostr:nevent1qqszxaut3ggsrgmxy6syc0xm55z30regcyurrt5lsgjwth70qqkgffqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcxnt75m

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Direct Podman restart fought systemd supervision and discarded API corrections. Record the read-only per-node Quadlet UAT mount, preserved original image and identities, two managed recreations, real native login acceptance after health, rollback, and image/upgrade hold. The isolated registration regression now passes.
2026-10-09 12:36:58 -04:00
archipelago ab6601db06 Record managed-service cause and persistent IndeeHub profile UAT repair 2026-10-09 12:36:33 -04:00
yaya ecac80dfcb fix(ui): restrict private phone discovery to pixel keyword 2026-10-09 17:30:04 +01:00
archipelago 0fb8ee7e5e Merge #0fb6debb: Record verified IndeeHub native login and registration…
Record verified IndeeHub native login and registration UAT repair

nostr:nevent1qqsqldk7hw5rhldq75z0jgx59wdr0e36qnglz7x28a2pgrfgr8sazmspz3mhxue69uhhyetvv9ujumn8d96zuer9wcq6p86p

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record accepted ngit merge 83feb180 and exact scope of successful real Home Serve authentication and registration readiness. Retain pending isolated Rust test, durable image packaging and full upload/publication acceptance.
2026-10-09 11:35:30 -04:00
archipelago 4ca0772db3 Record ngit merge and live IndeeHub authentication acceptance 2026-10-09 11:35:11 -04:00
archipelago 83feb18063 Merge #9d6de783: Fix IndeeHub registration for managed stack installati…
Fix IndeeHub registration for managed stack installations

nostr:nevent1qqsf6m08s06sy2emsk0qk8e73q0r38dyfqkathg4lu56gz6wyhd0ewqpz3mhxue69uhhyetvv9ujumn8d96zuer9wc4mpaz6

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Managed IndeeHub has one installed package record; its API runs as an internal stack component. Require the installed running parent and existing API identity pin, while retaining validation of legacy standalone API records. Includes regression coverage for parent-only state and records reopened identity/upload UAT. Focused isolated backend tests and production build are in progress.
2026-10-09 11:34:19 -04:00
archipelago 1d6ac1a6c5 Record verified Yaya native login and registration readiness repairs 2026-10-09 11:28:10 -04:00
archipelago 96b6ff9972 Record native autosign regression correction and live UAT limits 2026-10-09 11:09:45 -04:00
archipelago 424070d215 Fix native media registration for managed IndeeHub stacks 2026-10-09 10:45:38 -04:00
yaya dd909040f8 feat(ui): refine black Pixel model with official GrapheneOS boot artwork 2026-10-09 15:02:25 +01:00
archipelago 5ba52f1d21 Merge #479a4caf: docs: record Yaya IndeeHub UAT acceptance
nostr:nevent1qqsy0xjv4adzmz09rsfm06qlujc75fjaw35avdmshlsq4mqrja97gmgpz3mhxue69uhhyetvv9ujumn8d96zuer9wcchzfuk

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Record the resolved enabled-UAT maintenance guard, exact reviewed/mirrored commits and artifact hashes, the single committed supervised Yaya operation, preservation proofs, and disposable Chromium stale-session acceptance. Retain the non-authorizing secondary-cache limitation and the IndeeHub repository mirror blocker explicitly.
2026-10-09 09:51:09 -04:00
archipelago 8d5410566f docs: record Yaya IndeeHub UAT acceptance 2026-10-09 09:50:40 -04:00
archipelago 47f8aff3f9 Merge #a962fe38: fix: allow async-trait must-use compatibility lint
Demo images / Build & push demo images (push) Failing after 47s
nostr:nevent1qqs2jch78pdxt098826ghl6vx3pqxy9rsrn3u6rqawp9mptle7fv3sspz3mhxue69uhhyetvv9ujumn8d96zuer9wcrme3nq

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
2026-10-09 09:31:40 -04:00
yaya cfde1343a4 feat(ui): add private phone setup goal and shared hardware listings 2026-10-09 14:31:24 +01:00
archipelago daa4b55b4b test: allow quiet idempotent companion reconciliation 2026-10-09 09:20:18 -04:00
archipelago 846b316d72 fix: keep isolated backend tests hermetic and disk-backed 2026-10-09 09:09:16 -04:00
archipelago e2d926f5e0 fix: use explicit OS entropy for assistant and wallet IDs 2026-10-09 08:56:29 -04:00
yaya 03ee3c0162 fix(ui): keep marketplace cards within showcase height 2026-10-09 13:44:24 +01:00
archipelago 088eee55b8 fix: update atomic monitoring API for Rust 1.99 2026-10-09 08:43:56 -04:00
archipelago 48c13adaca fix: allow async-trait must-use compatibility lint 2026-10-09 08:43:56 -04:00
archipelago c6f8308e9a ci: add isolated ARM proposal test lane 2026-10-09 08:43:56 -04:00
archipelago 76141ffd2f fix: restore green source validation baseline 2026-10-09 08:43:56 -04:00
yaya e3d2f0b827 feat(ui): improve router product card layout 2026-10-09 13:43:31 +01:00
yaya c5cec95108 fix(ui): reserve visible viewport gutter below router 2026-10-09 13:42:57 +01:00
yaya 5ca345162d fix(ui): render dev router cards immediately 2026-10-09 13:42:28 +01:00
yaya 963dd03f83 fix(ui): add explicit bottom spacer after router container 2026-10-09 13:42:10 +01:00
yaya cc35b06ce6 fix(ui): show dev router card mocks without relays 2026-10-09 13:41:52 +01:00
yaya 3ca567c04e fix(ui): reserve bottom space below router container 2026-10-09 13:41:27 +01:00
yaya 7fde60126a fix(ui): align router card with page margins 2026-10-09 13:41:00 +01:00
yaya f7377b0ac8 feat(ui): add silent router lookup and dev listing mocks 2026-10-09 13:40:49 +01:00
yaya 9d1970a37d fix(ui): add page spacing around router showcase 2026-10-09 13:40:07 +01:00
yaya 2c7ba923cf fix(ui): query configured Archipelago Nostr relays 2026-10-09 13:39:08 +01:00
yaya f4cb5e2c74 fix(ui): preserve showcase height and broaden NIP-99 lookup 2026-10-09 13:38:15 +01:00
yaya 7c23a331ee fix(ui): remove overflowing fixed router height 2026-10-09 13:37:41 +01:00
yaya ced5342111 fix(ui): preserve router container height for marketplace 2026-10-09 13:37:22 +01:00
yaya d4d6a655e7 feat(ui): expand real NIP-99 router listings view 2026-10-09 13:37:00 +01:00
yaya 70cd80c3e8 feat(ui): add responsive router listing carousel 2026-10-09 13:35:51 +01:00
yaya 6dcaa41040 fix(ui): reserve bottom margin below router card 2026-10-09 13:34:50 +01:00
yaya 057ac1c68b fix(ui): cap router showcase to viewport height 2026-10-09 13:34:23 +01:00
yaya 1291ea1508 fix(ui): enlarge router model and hide router controls 2026-10-09 13:34:01 +01:00
yaya 6b97505a59 fix(ui): keep model browser for LoRa dev previews only 2026-10-09 13:33:46 +01:00
yaya 6d19cf27c3 fix(ui): make mobile router purchase action full width 2026-10-09 13:33:09 +01:00
yaya ca47c9fcb9 fix(ui): pin router purchase panel to showcase bottom 2026-10-09 13:32:45 +01:00
yaya df15a9c890 fix(ui): fill router showcase height on desktop 2026-10-09 13:32:20 +01:00
yaya 9936bda9ae fix(ui): nest router purchase panel beside model 2026-10-09 13:31:51 +01:00
yaya f9bfceb80e feat(ui): add router marketplace glass panel 2026-10-09 13:31:10 +01:00
yaya a6725c1b69 feat(ui): add dev model browser and soften hardware lighting 2026-10-09 13:26:41 +01:00
yaya 5d64991203 feat(ui): add interactive 3D LoRa devices and OpenWrt router 2026-10-09 13:15:59 +01:00
TheCryptoDonkey cb6be9edb5 fix: report an installed app's declared author
The scanner gives every installed app author "Archipelago", and the
app details sidebar shows it, so an app whose manifest declares
metadata.author (Wildbloom: ForgeSworn) is credited to the node OS.
apply_manifest_value already reads icon, tier and licence from the
manifest's metadata; it now reads author too. Without one the scanner's
default stays.
2026-10-09 12:49:05 +01:00
archipelago 5812f53c7c Merge #ecac4574: fix(indeehub): allow verified enabled UAT shutdown
nostr:nevent1qqswetz9wn77lnd30wgnkusdraa5gmdkekdx7crr779z944h4aknyrqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcw2g8t3

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Accept a money-free IndeeHub UAT instance when registration and publication are both exactly enabled or both exactly disabled. Continue rejecting missing, ambiguous, or mixed modes, and retain all zero-work, binary-hash, provider, and connection safeguards.\n\nValidation: 63 focused maintenance-controller tests passed. Isolated backend suite: 2,066 passed, 5 opt-in ignored, 0 failed.
2026-10-09 07:07:08 -04:00
archipelago 5e6260864e fix(indeehub): allow verified enabled UAT shutdown 2026-10-09 06:56:17 -04:00
112 changed files with 3282 additions and 5840 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn}; use tracing::{debug, info};
const CDP_HTTP: &str = "http://127.0.0.1:9222"; const CDP_HTTP: &str = "http://127.0.0.1:9222";
/// Marker whose presence means this node drives a local kiosk display. /// Marker whose presence means this node drives a local kiosk display.
+11 -9
View File
@@ -583,15 +583,17 @@ impl ApiHandler {
paid = true; paid = true;
} }
Ok(false) => {} Ok(false) => {}
Err(_) => return Ok(build_response( Err(_) => {
StatusCode::OK, return Ok(build_response(
"application/json", StatusCode::OK,
hyper::Body::from(serde_json::to_vec(&serde_json::json!({ "application/json",
"paid": false, hyper::Body::from(serde_json::to_vec(&serde_json::json!({
"status": "unknown", "paid": false,
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again." "status": "unknown",
}))?), "error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
)), }))?),
))
}
} }
} }
let body = serde_json::json!({ "paid": paid }); let body = serde_json::json!({ "paid": paid });
@@ -193,16 +193,6 @@ impl ApiHandler {
let data = self.config.data_dir.clone(); let data = self.config.data_dir.clone();
let id = binding.content_id.clone(); let id = binding.content_id.clone();
let retained = source.clone(); let retained = source.clone();
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl Drop for CancelCopy {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
false,
)));
let cancelled = cancel_copy.0.clone();
let snapshot = tokio::task::spawn_blocking(move || { let snapshot = tokio::task::spawn_blocking(move || {
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size) crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
}) })
+73 -2
View File
@@ -318,6 +318,26 @@ impl ApiHandler {
} }
} }
// A node app on another port shares host-only owner cookies. Never grant
// that app access to the owner shell, even though other app APIs accept
// same-host cross-port origins. Browser terminal requests must originate
// from the exact dashboard authority (Host includes its port).
fn has_terminal_origin(headers: &hyper::HeaderMap) -> bool {
let Some(origin) = headers
.get(hyper::header::ORIGIN)
.and_then(|v| v.to_str().ok())
else {
return false;
};
let Some(host) = headers
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
else {
return false;
};
origin == format!("http://{host}") || origin == format!("https://{host}")
}
/// Permissive origin check for the share-to-mesh iframe intent: any scheme /// Permissive origin check for the share-to-mesh iframe intent: any scheme
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps /// http(s):// followed by the configured host_ip, optionally `:port`. Apps
/// proxied under other ports (APP_PORTS) call this from within the same /// proxied under other ports (APP_PORTS) call this from within the same
@@ -434,6 +454,13 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing"); tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
return Ok(Self::unauthorized()); return Ok(Self::unauthorized());
} }
if !Self::has_terminal_origin(req.headers()) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
));
}
return Self::handle_terminal_websocket(req).await; return Self::handle_terminal_websocket(req).await;
} }
@@ -556,11 +583,29 @@ impl ApiHandler {
} }
(Method::GET, "/api/terminal/sessions") => { (Method::GET, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); } if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
));
}
terminal::list_response().await terminal::list_response().await
} }
(Method::POST, "/api/terminal/sessions") => { (Method::POST, "/api/terminal/sessions") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); } if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Terminal origin denied"}"#),
));
}
terminal::create(&body_bytes).await terminal::create(&body_bytes).await
} }
@@ -879,3 +924,29 @@ fn sanitize_html(s: &str) -> String {
.replace('"', "&quot;") .replace('"', "&quot;")
.replace('\'', "&#x27;") .replace('\'', "&#x27;")
} }
#[cfg(test)]
mod terminal_origin_tests {
use super::ApiHandler;
use hyper::header::{HeaderMap, HeaderValue, HOST, ORIGIN};
fn allowed(host: &str, origin: Option<&str>) -> bool {
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
ApiHandler::has_terminal_origin(&headers)
}
#[test]
fn terminal_accepts_only_the_exact_dashboard_authority() {
assert!(allowed("node.local", Some("https://node.local")));
assert!(allowed("192.168.1.2:7778", Some("http://192.168.1.2:7778")));
assert!(!allowed("node.local", Some("https://node.local:7778")));
assert!(!allowed("node.local:7778", Some("https://node.local")));
assert!(!allowed("node.local", Some("https://evil.example")));
assert!(!allowed("node.local", Some("null")));
assert!(!allowed("node.local", None));
}
}
@@ -257,6 +257,12 @@ impl RpcHandler {
} }
} }
// Unit tests deliberately construct a handler without an orchestrator.
// Never let that mock cross into a real Podman command on the host.
if cfg!(test) && self.orchestrator.is_none() {
return Ok(serde_json::json!([]));
}
let output = tokio::process::Command::new("podman") let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "json"]) .args(["ps", "-a", "--format", "json"])
.output() .output()
+21 -12
View File
@@ -1013,19 +1013,28 @@ impl RpcHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/onchain-status/{}", content_id, address); let path = format!("/content/{}/onchain-status/{}", content_id, address);
let (response, _transport) = let (response, _transport) = match crate::fips::dial::PeerRequest::new(
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) fips_npub.as_deref(),
.service(crate::settings::transport::PeerService::PeerFiles) onion,
.timeout(std::time::Duration::from_secs(15)) &path,
.fips_timeout(std::time::Duration::from_secs(6)) )
.send_content_get(&self.config.data_dir) .service(crate::settings::transport::PeerService::PeerFiles)
.await .timeout(std::time::Duration::from_secs(15))
{ .fips_timeout(std::time::Duration::from_secs(6))
Ok(v) => v, .send_content_get(&self.config.data_dir)
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." })), .await
}; {
Ok(v) => v,
Err(_) => {
return Ok(
serde_json::json!({ "paid": false, "unreachable": true, "status": "unknown", "error": "Payment verification is unavailable. Keep the original address and do not pay again." }),
)
}
};
if !response.status().is_success() { if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." })); return Ok(
serde_json::json!({ "paid": false, "status": "unknown", "error": "The seller could not verify this payment. Keep the original address and do not pay again." }),
);
} }
let body: serde_json::Value = response let body: serde_json::Value = response
.json() .json()
@@ -114,17 +114,34 @@ impl RpcHandler {
/// Explicit owner-key import into a separate native business identity. /// Explicit owner-key import into a separate native business identity.
pub(in crate::api::rpc) async fn handle_identity_import_nostr( pub(in crate::api::rpc) async fn handle_identity_import_nostr(
&self, params: Option<serde_json::Value>, &self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let params = params.unwrap_or_default(); let params = params.unwrap_or_default();
let password = params.get("password").and_then(|v| v.as_str()).unwrap_or(""); let password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("");
if !self.auth_manager.verify_password(password).await? { if !self.auth_manager.verify_password(password).await? {
anyhow::bail!("Invalid node password"); anyhow::bail!("Invalid node password");
} }
let name = params.get("name").and_then(|v| v.as_str()).unwrap_or("Just Works"); let name = params
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name"); .get("name")
let nsec = params.get("nsec").and_then(|v| v.as_str()).unwrap_or("").trim(); .and_then(|v| v.as_str())
let npub = params.get("expected_npub").and_then(|v| v.as_str()).unwrap_or(""); .unwrap_or("Just Works");
anyhow::ensure!(
!name.trim().is_empty() && name.len() <= 100,
"Invalid identity name"
);
let nsec = params
.get("nsec")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let npub = params
.get("expected_npub")
.and_then(|v| v.as_str())
.unwrap_or("");
let manager = IdentityManager::new(&self.config.data_dir).await?; let manager = IdentityManager::new(&self.config.data_dir).await?;
let record = manager.import_nostr(name.to_string(), nsec, npub).await?; let record = manager.import_nostr(name.to_string(), nsec, npub).await?;
Ok(serde_json::json!({"id":record.id, "name":record.name, Ok(serde_json::json!({"id":record.id, "name":record.name,
@@ -61,8 +61,8 @@ fn verified_resolution(input: serde_json::Value, now: u64) -> Result<ResolvePara
let encoded = serde_json::to_value(event)?; let encoded = serde_json::to_value(event)?;
anyhow::ensure!( anyhow::ensure!(
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]]) encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30) && event.created_at.as_secs() >= params.intent.created_at.saturating_sub(30)
&& event.created_at.as_u64() <= now.saturating_add(30), && event.created_at.as_secs() <= now.saturating_add(30),
"Invalid resolution signature time or scope" "Invalid resolution signature time or scope"
); );
let content: serde_json::Value = serde_json::from_str(&event.content)?; let content: serde_json::Value = serde_json::from_str(&event.content)?;
@@ -103,7 +103,7 @@ fn verified_producer(params: &Params, now: u64) -> Result<String> {
producer == params.intent.producer, producer == params.intent.producer,
"The signing identity differs from the project producer" "The signing identity differs from the project producer"
); );
let created = event.created_at.as_u64(); let created = event.created_at.as_secs();
anyhow::ensure!( anyhow::ensure!(
created >= params.intent.created_at.saturating_sub(30) created >= params.intent.created_at.saturating_sub(30)
&& created < params.intent.expires_at && created < params.intent.expires_at
@@ -201,12 +201,12 @@ impl RpcHandler {
anyhow::ensure!( anyhow::ensure!(
state state
.package_data .package_data
.get("indeedhub-api") .get("indeedhub")
.is_some_and(|entry| matches!( .is_some_and(|entry| matches!(
entry.state, entry.state,
crate::data_model::PackageState::Running crate::data_model::PackageState::Running
)), )),
"The installed IndeeHub API must be running to register its media" "The installed IndeeHub app must be running to register its media"
); );
let identity = let identity =
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
+2 -3
View File
@@ -18,10 +18,10 @@ mod handshake;
mod identity; mod identity;
mod interfaces; mod interfaces;
mod lightning_purchase; mod lightning_purchase;
mod onchain_purchase;
pub(crate) mod lnd; pub(crate) mod lnd;
mod marketplace; mod marketplace;
mod media_registration; mod media_registration;
mod onchain_purchase;
mod playback; mod playback;
mod purchase; mod purchase;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses // pub(crate): 13-10's `assistant::backends::select_backend` reuses
@@ -34,12 +34,12 @@ mod monitoring;
mod music; mod music;
mod names; mod names;
mod network; mod network;
mod publishing;
mod node; mod node;
mod nostr; mod nostr;
mod onboarding_gate; mod onboarding_gate;
mod openwrt; mod openwrt;
mod package; mod package;
mod publishing;
pub(crate) use package::patch_indeedhub_nostr_provider; pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper; pub(crate) use package::wyoming_satellite_keeper;
mod peers; mod peers;
@@ -112,7 +112,6 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
| "media.registration.context" | "media.registration.context"
| "media.registration.resolve" | "media.registration.resolve"
| "content.rental-purchase" | "content.rental-purchase"
| "content.onchain-cancel" | "content.onchain-cancel"
| "content.onchain-attempt" | "content.onchain-attempt"
| "content.onchain-create" | "content.onchain-create"
@@ -442,7 +442,7 @@ impl RpcHandler {
if record.quote.is_none() { if record.quote.is_none() {
engine::mark_address_allocation(&journal, true)?; engine::mark_address_allocation(&journal, true)?;
let status = self.request_onchain_allocation(&record, &fips).await?; let status = self.request_onchain_allocation(&record, &fips).await?;
record = engine::accept_quote( engine::accept_quote(
&journal, &journal,
status.quote()?.context( status.quote()?.context(
"Original seller allocation is unresolved; recover this operation", "Original seller allocation is unresolved; recover this operation",
@@ -883,7 +883,8 @@ async fn do_orchestrator_package_start(
if i > 0 { if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
} }
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); let managed =
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
if !managed { if !managed {
repair_before_package_start(name).await; repair_before_package_start(name).await;
wait_before_package_start(name).await; wait_before_package_start(name).await;
@@ -1145,7 +1146,8 @@ async fn do_orchestrator_package_stop(
) -> Result<()> { ) -> Result<()> {
let mut errors = Vec::new(); let mut errors = Vec::new();
for name in containers { for name in containers {
let managed = crate::container::supervised_update::installed_unit(data_dir, name)?.is_some(); let managed =
crate::container::supervised_update::installed_unit(data_dir, name)?.is_some();
match orchestrator.stop(name).await { match orchestrator.stop(name).await {
Ok(()) => {} Ok(()) => {}
Err(e) if !managed && is_unknown_app_id_error(&e) => { Err(e) if !managed && is_unknown_app_id_error(&e) => {
@@ -1977,9 +1979,9 @@ pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") { if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into()); dirs.push(root.into());
} }
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { // Cargo exposes this at compile time, not when an already-built isolated
dirs.push(Path::new(&manifest_dir).join("../../apps")); // test executable is launched from a different working directory.
} dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
dirs.extend([ dirs.extend([
Path::new("apps").to_path_buf(), Path::new("apps").to_path_buf(),
Path::new("/opt/archipelago/apps").to_path_buf(), Path::new("/opt/archipelago/apps").to_path_buf(),
+3 -3
View File
@@ -119,9 +119,9 @@ fn apps_dirs() -> Vec<PathBuf> {
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") { if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into()); dirs.push(root.into());
} }
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { // Preserve source discovery after the prebuilt test binary moves into its
dirs.push(PathBuf::from(manifest_dir).join("../../apps")); // networkless execution container.
} dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../apps"));
dirs.extend([ dirs.extend([
PathBuf::from("apps"), PathBuf::from("apps"),
PathBuf::from("/opt/archipelago/apps"), PathBuf::from("/opt/archipelago/apps"),
+3 -2
View File
@@ -23,7 +23,6 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use rand::RngCore;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -227,7 +226,9 @@ pub fn global() -> Arc<ConfirmGate> {
/// nonce for the same action never matches today's pending entry). /// nonce for the same action never matches today's pending entry).
pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String { pub fn mint_nonce(tool_name: &str, validated_args: &str) -> String {
let mut salt = [0u8; 16]; let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt); crate::entropy::draw_key_bytes(&mut rand::rngs::OsRng, &mut salt).unwrap_or_else(|e| {
panic!("refusing to mint a confirmation nonce from degenerate entropy: {e} (KEY-05)")
});
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(salt); hasher.update(salt);
hasher.update(tool_name.as_bytes()); hasher.update(tool_name.as_bytes());
+1 -1
View File
@@ -30,7 +30,7 @@ pub const TOKEN_LEN: usize = 8;
/// wrapped. Called exactly once per [`UntrustedBlock::new`] / /// wrapped. Called exactly once per [`UntrustedBlock::new`] /
/// [`wrap_untrusted`] invocation. /// [`wrap_untrusted`] invocation.
fn fresh_token() -> String { fn fresh_token() -> String {
rand::thread_rng() rand::rngs::OsRng
.sample_iter(Alphanumeric) .sample_iter(Alphanumeric)
.take(TOKEN_LEN) .take(TOKEN_LEN)
.map(char::from) .map(char::from)
@@ -553,6 +553,24 @@ mod lifecycle_regression_tests {
assert_eq!(entry.static_files.license, ""); assert_eq!(entry.static_files.license, "");
} }
#[test]
fn manifest_presentation_reports_the_declared_author() {
let mut entry = installing_fixture();
entry.manifest.author = Some("Archipelago".to_owned());
for (metadata, expected) in [
(serde_json::json!({}), "Archipelago"),
(serde_json::json!({"author":" "}), "Archipelago"),
(serde_json::json!({"author":3}), "Archipelago"),
(serde_json::json!({"author":" ForgeSworn "}), "ForgeSworn"),
] {
apply_manifest_value(
&serde_json::json!({"app":{"metadata":metadata}}),
&mut entry,
);
assert_eq!(entry.manifest.author.as_deref(), Some(expected));
}
}
#[test] #[test]
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() { fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
let mut entry = installing_fixture(); let mut entry = installing_fixture();
@@ -882,6 +900,10 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
if let Some(icon) = text(metadata.get("icon")) { if let Some(icon) = text(metadata.get("icon")) {
entry.static_files.icon = icon; entry.static_files.icon = icon;
} }
// The scanner's default author is the node OS; a declared author wins.
if let Some(author) = text(metadata.get("author")) {
entry.manifest.author = Some(author);
}
if let Some(tier) = text(metadata.get("tier")) { if let Some(tier) = text(metadata.get("tier")) {
entry.manifest.tier = Some(tier); entry.manifest.tier = Some(tier);
} }
@@ -2402,7 +2402,10 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Left("user-uninstalled".into())); return Ok(ReconcileAction::Left("user-uninstalled".into()));
} }
self.sync_quadlet_unit(lm, &managed_name).await?; self.sync_quadlet_unit(lm, &managed_name).await?;
let status = self.runtime.get_container_status(&managed_name).await let status = self
.runtime
.get_container_status(&managed_name)
.await
.context("Reviewed managed runtime is missing; explicit recovery required")?; .context("Reviewed managed runtime is missing; explicit recovery required")?;
anyhow::ensure!(matches!(status.state, ContainerState::Running), anyhow::ensure!(matches!(status.state, ContainerState::Running),
"Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog"); "Reviewed managed runtime is not running; recover its saved systemd unit explicitly instead of recreating from the catalog");
@@ -4019,36 +4022,38 @@ impl ProdContainerOrchestrator {
.clone() .clone()
.unwrap_or_else(|| "bitcoin-knots".to_string()); .unwrap_or_else(|| "bitcoin-knots".to_string());
} }
#[allow(unreachable_code)] #[cfg(not(test))]
// The known Bitcoin node containers, preferred in order. Any archy {
// Bitcoin distribution runs as a container named `bitcoin-<distro>` // The known Bitcoin node containers, preferred in order. Any archy
// (or bare `bitcoin`), all reachable on archy-net by name. // Bitcoin distribution runs as a container named `bitcoin-<distro>`
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"]; // (or bare `bitcoin`), all reachable on archy-net by name.
let names = tokio::process::Command::new("podman") const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
.args(["ps", "--format", "{{.Names}}"]) let names = tokio::process::Command::new("podman")
.output() .args(["ps", "--format", "{{.Names}}"])
.await .output()
.ok() .await
.filter(|o| o.status.success()) .ok()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) .filter(|o| o.status.success())
.unwrap_or_default(); .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
let running: Vec<&str> = names.lines().map(|l| l.trim()).collect(); .unwrap_or_default();
// Prefer a known name in priority order… let running: Vec<&str> = names.lines().map(|l| l.trim()).collect();
if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) { // Prefer a known name in priority order…
return hit.to_string(); if let Some(hit) = BITCOIN_NAMES.iter().find(|n| running.contains(n)) {
return hit.to_string();
}
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
} }
// …else accept ANY running `bitcoin-*` / `bitcoin` container, so a
// future Bitcoin distribution archy ships works without editing this
// list (user req 2026-07-22). Excludes companions/sidecars like
// `bitcoin-ui` and `archy-*`.
if let Some(other) = running.iter().find(|n| {
(**n == "bitcoin" || n.starts_with("bitcoin-"))
&& !n.ends_with("-ui")
&& !n.starts_with("archy-")
}) {
return other.to_string();
}
"bitcoin-knots".to_string()
} }
#[cfg(test)] #[cfg(test)]
@@ -5227,8 +5232,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
}; };
let name = compute_container_name(&lm.manifest); let name = compute_container_name(&lm.manifest);
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() { if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?, anyhow::ensure!(
"Reviewed managed runtime is held for update recovery"); !super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery"
);
self.sync_quadlet_unit(&lm, &name).await?; self.sync_quadlet_unit(&lm, &name).await?;
self.ensure_resolved_source_available(&lm).await?; self.ensure_resolved_source_available(&lm).await?;
} }
@@ -5328,13 +5335,18 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let _guard = lock.lock().await; let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest); let name = compute_container_name(&lm.manifest);
if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() { if super::supervised_update::installed_unit(&self.data_dir, &name)?.is_some() {
anyhow::ensure!(!super::update_transaction::is_held(&self.data_dir, &name)?, anyhow::ensure!(
"Reviewed managed runtime is held for update recovery"); !super::update_transaction::is_held(&self.data_dir, &name)?,
"Reviewed managed runtime is held for update recovery"
);
self.sync_quadlet_unit(&lm, &name).await?; self.sync_quadlet_unit(&lm, &name).await?;
quadlet::stop_service(&format!("{name}.service")).await?; quadlet::stop_service(&format!("{name}.service")).await?;
if let Ok(status) = self.runtime.get_container_status(&name).await { if let Ok(status) = self.runtime.get_container_status(&name).await {
anyhow::ensure!( anyhow::ensure!(
matches!(status.state, ContainerState::Stopped | ContainerState::Exited | ContainerState::Created), matches!(
status.state,
ContainerState::Stopped | ContainerState::Exited | ContainerState::Created
),
"Reviewed managed runtime is still active after systemd stop" "Reviewed managed runtime is still active after systemd stop"
); );
} }
@@ -5391,7 +5403,12 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn restart(&self, app_id: &str) -> Result<()> { async fn restart(&self, app_id: &str) -> Result<()> {
if let Ok(lm) = self.loaded(app_id).await { if let Ok(lm) = self.loaded(app_id).await {
if super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_some() { if super::supervised_update::installed_unit(
&self.data_dir,
&compute_container_name(&lm.manifest),
)?
.is_some()
{
self.validate_start(app_id).await?; self.validate_start(app_id).await?;
self.stop(app_id).await?; self.stop(app_id).await?;
return self.start(app_id).await; return self.start(app_id).await;
@@ -7966,27 +7983,59 @@ app:
#[tokio::test] #[tokio::test]
async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() { async fn reviewed_runtime_survives_catalog_drift_and_refuses_repairs_before_mutation() {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
for case in ["running", "stopped", "missing", "changed-unit", "missing-unit", "user-stopped", "user-uninstalled"] { for case in [
"running",
"stopped",
"missing",
"changed-unit",
"missing-unit",
"user-stopped",
"user-uninstalled",
] {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await; let orch = orch_with(rt.clone()).await;
let name = format!("managed-{}", uuid::Uuid::new_v4().simple()); let name = format!("managed-{}", uuid::Uuid::new_v4().simple());
let mut manifest = pull_manifest(&name, "catalog:new"); let mut manifest = pull_manifest(&name, "catalog:new");
manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()]; manifest.app.environment = vec!["NEW_CATALOG_ENV=changed".into()];
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift")).await; orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/catalog-drift"))
.await;
let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n"; let body = "[Container]\nImage=original:retained\nEnvironment=OLD_ENV=preserved\nPublishPort=127.0.0.1:1234:80\n";
let records = orch.data_dir.join("update-transactions/installed-units"); let records = orch.data_dir.join("update-transactions/installed-units");
std::fs::create_dir_all(&records).unwrap(); std::fs::create_dir_all(&records).unwrap();
std::fs::write(records.join(format!("{name}.json")), serde_json::to_vec(&serde_json::json!({ std::fs::write(
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(), records.join(format!("{name}.json")),
"name": name, "body": body, "mode": 0o600 serde_json::to_vec(&serde_json::json!({
})).unwrap()).unwrap(); "schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
let unit = quadlet::unit_dir().await.unwrap().join(format!("{name}.container")); "name": name, "body": body, "mode": 0o600
}))
.unwrap(),
)
.unwrap();
let unit = quadlet::unit_dir()
.await
.unwrap()
.join(format!("{name}.container"));
if case != "missing-unit" { if case != "missing-unit" {
std::fs::write(&unit, if case == "changed-unit" { "operator changed" } else { body }).unwrap(); std::fs::write(
&unit,
if case == "changed-unit" {
"operator changed"
} else {
body
},
)
.unwrap();
std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap(); std::fs::set_permissions(&unit, std::fs::Permissions::from_mode(0o600)).unwrap();
} }
if case != "missing" { if case != "missing" {
rt.set_state(&name, if case == "stopped" { ContainerState::Stopped } else { ContainerState::Running }); rt.set_state(
&name,
if case == "stopped" {
ContainerState::Stopped
} else {
ContainerState::Running
},
);
} }
if case == "user-stopped" { if case == "user-stopped" {
crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await; crate::crash_recovery::mark_user_stopped(&orch.data_dir, &name).await;
@@ -8010,7 +8059,13 @@ app:
assert!(result.is_err(), "{case} must refuse before mutation"); assert!(result.is_err(), "{case} must refuse before mutation");
} }
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}"); assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:")), "{case}: {:?}", rt.calls()); assert!(
rt.calls()
.iter()
.all(|call| call.starts_with("get_container_status:")),
"{case}: {:?}",
rt.calls()
);
if case == "running" { if case == "running" {
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body); assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
} }
@@ -8026,8 +8081,14 @@ app:
orch.validate_start(&name).await.unwrap(); orch.validate_start(&name).await.unwrap();
orch.start(&name).await.unwrap(); orch.start(&name).await.unwrap();
assert_eq!(std::fs::read_to_string(&unit).unwrap(), body); assert_eq!(std::fs::read_to_string(&unit).unwrap(), body);
assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir).await.contains(&name)); assert!(!crate::crash_recovery::load_user_stopped(&orch.data_dir)
assert!(!crate::crash_recovery::load_user_uninstalled(&orch.data_dir).await.contains(&name)); .await
.contains(&name));
assert!(
!crate::crash_recovery::load_user_uninstalled(&orch.data_dir)
.await
.contains(&name)
);
} else { } else {
// Missing images/units and modified units refuse an explicit // Missing images/units and modified units refuse an explicit
// start before service mutation; no catalog pull is attempted. // start before service mutation; no catalog pull is attempted.
@@ -8037,7 +8098,14 @@ app:
assert!(orch.stop(&name).await.is_err()); assert!(orch.stop(&name).await.is_err());
} }
} }
assert!(rt.calls().iter().all(|call| call.starts_with("get_container_status:") || call.starts_with("image_exists:")), "{case}: {:?}", rt.calls()); assert!(
rt.calls()
.iter()
.all(|call| call.starts_with("get_container_status:")
|| call.starts_with("image_exists:")),
"{case}: {:?}",
rt.calls()
);
assert_eq!(*rt.containers.lock().unwrap(), before, "{case}"); assert_eq!(*rt.containers.lock().unwrap(), before, "{case}");
let _ = std::fs::remove_file(unit); let _ = std::fs::remove_file(unit);
} }
@@ -8664,9 +8732,11 @@ app:
let refs: Vec<&str> = names.iter().map(String::as_str).collect(); let refs: Vec<&str> = names.iter().map(String::as_str).collect();
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await; crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
// Repeated passes must leave lifecycle ownership with companion.rs. // Repeated passes must leave lifecycle ownership with companion.rs.
for _ in 0..3 { for pass in 0..3 {
let report = orch.reconcile_existing().await; let report = orch.reconcile_existing().await;
assert_eq!(report.actions.len(), companions.len()); if pass == 0 {
assert_eq!(report.actions.len(), companions.len());
}
assert!(report assert!(report
.actions .actions
.iter() .iter()
+45 -34
View File
@@ -742,14 +742,17 @@ pub async fn unit_dir() -> Result<PathBuf> {
.get_or_init(|| tempfile::tempdir().unwrap().keep()) .get_or_init(|| tempfile::tempdir().unwrap().keep())
.clone()); .clone());
} }
let home = std::env::var_os("HOME") #[cfg(not(test))]
.map(PathBuf::from) {
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; let home = std::env::var_os("HOME")
let dir = home.join(DEFAULT_REL_UNIT_DIR); .map(PathBuf::from)
fs::create_dir_all(&dir) .ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
.await let dir = home.join(DEFAULT_REL_UNIT_DIR);
.with_context(|| format!("create_dir_all {}", dir.display()))?; fs::create_dir_all(&dir)
Ok(dir) .await
.with_context(|| format!("create_dir_all {}", dir.display()))?;
Ok(dir)
}
} }
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once /// The early same-node Portainer repair used a managed Quadlet drop-in. Once
@@ -944,21 +947,25 @@ async fn systemctl_user_status(
#[cfg(test)] #[cfg(test)]
{ {
use std::os::unix::process::ExitStatusExt; use std::os::unix::process::ExitStatusExt;
let _ = (args, timeout);
return Ok(std::process::ExitStatus::from_raw(0)); return Ok(std::process::ExitStatus::from_raw(0));
} }
let mut cmd = Command::new("systemctl"); #[cfg(not(test))]
cmd.arg("--user").args(args); {
cmd.kill_on_drop(true); let mut cmd = Command::new("systemctl");
tokio::time::timeout(timeout, cmd.status()) cmd.arg("--user").args(args);
.await cmd.kill_on_drop(true);
.with_context(|| { tokio::time::timeout(timeout, cmd.status())
format!( .await
"systemctl --user {} timed out after {}s", .with_context(|| {
args.join(" "), format!(
timeout.as_secs() "systemctl --user {} timed out after {}s",
) args.join(" "),
})? timeout.as_secs()
.with_context(|| format!("spawn systemctl --user {}", args.join(" "))) )
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
} }
async fn kill_and_reset_service(service: &str) -> Result<()> { async fn kill_and_reset_service(service: &str) -> Result<()> {
@@ -994,21 +1001,25 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> { async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
#[cfg(test)] #[cfg(test)]
{ {
let _ = (args, timeout);
anyhow::bail!("Unit tests have no real user service manager"); anyhow::bail!("Unit tests have no real user service manager");
} }
let mut cmd = Command::new("systemctl"); #[cfg(not(test))]
cmd.arg("--user").args(args); {
cmd.kill_on_drop(true); let mut cmd = Command::new("systemctl");
tokio::time::timeout(timeout, cmd.output()) cmd.arg("--user").args(args);
.await cmd.kill_on_drop(true);
.with_context(|| { tokio::time::timeout(timeout, cmd.output())
format!( .await
"systemctl --user {} timed out after {}s", .with_context(|| {
args.join(" "), format!(
timeout.as_secs() "systemctl --user {} timed out after {}s",
) args.join(" "),
})? timeout.as_secs()
.with_context(|| format!("spawn systemctl --user {}", args.join(" "))) )
})?
.with_context(|| format!("spawn systemctl --user {}", args.join(" ")))
}
} }
pub fn contains_stale_health_gate(unit_body: &str) -> bool { pub fn contains_stale_health_gate(unit_body: &str) -> bool {
@@ -48,6 +48,12 @@ pub(crate) fn installed_context(
state: &crate::data_model::DataModel, state: &crate::data_model::DataModel,
) -> Result<InstalledAppContext> { ) -> Result<InstalledAppContext> {
for id in ["indeedhub", "indeedhub-api"] { for id in ["indeedhub", "indeedhub-api"] {
// Stack components are not separate package records on managed installs.
// Keep validating legacy standalone API records when they exist; the
// installed parent and its existing API identity pin are mandatory.
if id == "indeedhub-api" && !state.package_data.contains_key(id) {
continue;
}
let entry = state let entry = state
.package_data .package_data
.get(id) .get(id)
@@ -613,6 +619,12 @@ mod tests {
assert!(installed_context(root.path(), &identity, &state).is_err()); assert!(installed_context(root.path(), &identity, &state).is_err());
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap(); let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
let first = installed_context(root.path(), &identity, &state).unwrap(); let first = installed_context(root.path(), &identity, &state).unwrap();
let api_entry = state.package_data.remove("indeedhub-api").unwrap();
assert_eq!(
installed_context(root.path(), &identity, &state).unwrap(),
first
);
state.package_data.insert("indeedhub-api".into(), api_entry);
assert_eq!(first.app_audience, pin.app_audience); assert_eq!(first.app_audience, pin.app_audience);
assert_eq!(first.app_origins, vec!["https://localhost:7778"]); assert_eq!(first.app_origins, vec!["https://localhost:7778"]);
state.package_data.get_mut("indeedhub-api").unwrap().state = state.package_data.get_mut("indeedhub-api").unwrap().state =
+1 -4
View File
@@ -171,10 +171,7 @@ pub(crate) fn prepare(
Err(error) Err(error)
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
{
()
}
Err(error) => return Err(error), Err(error) => return Err(error),
} }
let reservation = crate::snapshot_budget::reserve( let reservation = crate::snapshot_budget::reserve(
+1 -1
View File
@@ -24,7 +24,7 @@ pub const FIPS_IFACE: &str = "fips0";
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we /// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to. /// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
pub fn fips0_ula() -> Option<Ipv6Addr> { pub fn fips0_ula() -> Option<Ipv6Addr> {
addresses_on(FIPS_IFACE).into_iter().find(|a| is_ula(a)) addresses_on(FIPS_IFACE).into_iter().find(is_ula)
} }
/// List every IPv6 address bound to a given interface from /// List every IPv6 address bound to a given interface from
+17 -7
View File
@@ -946,7 +946,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
} }
if matches!( if matches!(
pkg.state, pkg.state,
PackageState::Starting | PackageState::Stopping | PackageState::Restarting | PackageState::Updating PackageState::Starting
| PackageState::Stopping
| PackageState::Restarting
| PackageState::Updating
) { ) {
debug!( debug!(
"Skipping container during package lifecycle transition: {} ({:?})", "Skipping container during package lifecycle transition: {} ({:?})",
@@ -1069,7 +1072,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
app_id: Some(container.app_id.clone()), app_id: Some(container.app_id.clone()),
}); });
if data.notifications.len() > 20 { if data.notifications.len() > 20 {
data.notifications = data.notifications.split_off(data.notifications.len() - 20); data.notifications =
data.notifications.split_off(data.notifications.len() - 20);
} }
state_changed = true; state_changed = true;
} }
@@ -1164,7 +1168,8 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
// the restart resyncs cleanly instead of crash-looping. // the restart resyncs cleanly instead of crash-looping.
maybe_recover_corrupt_electrumx(&container.name, attempt).await; maybe_recover_corrupt_electrumx(&container.name, attempt).await;
let restarted = restart_container(&container.name, &container.state, &data_dir).await; let restarted =
restart_container(&container.name, &container.state, &data_dir).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS { if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
let notification = Notification { let notification = Notification {
@@ -1249,10 +1254,15 @@ mod tests {
let installed = root.path().join("update-transactions/installed-units"); let installed = root.path().join("update-transactions/installed-units");
std::fs::create_dir_all(&installed).unwrap(); std::fs::create_dir_all(&installed).unwrap();
let record = installed.join(format!("{name}.json")); let record = installed.join(format!("{name}.json"));
std::fs::write(&record, serde_json::to_vec(&serde_json::json!({ std::fs::write(
"schema": 1, "operation": uuid::Uuid::new_v4().to_string(), &record,
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600 serde_json::to_vec(&serde_json::json!({
})).unwrap()).unwrap(); "schema": 1, "operation": uuid::Uuid::new_v4().to_string(),
"name": name, "body": "[Container]\nImage=original:retained\n", "mode": 0o600
}))
.unwrap(),
)
.unwrap();
assert!(!automatic_recovery_allowed(root.path(), name)); assert!(!automatic_recovery_allowed(root.path(), name));
assert!(!restart_container(name, "running", root.path()).await); assert!(!restart_container(name, "running", root.path()).await);
std::fs::write(&record, b"damaged").unwrap(); std::fs::write(&record, b"damaged").unwrap();
+70 -21
View File
@@ -205,14 +205,22 @@ impl IdentityManager {
nsec: &str, nsec: &str,
expected_npub: &str, expected_npub: &str,
) -> Result<IdentityRecord> { ) -> Result<IdentityRecord> {
anyhow::ensure!(!name.trim().is_empty() && name.len() <= 100, "Invalid identity name"); anyhow::ensure!(
anyhow::ensure!(nsec.starts_with("nsec1") && nsec.len() == 63, "Enter a plain nsec owner key"); !name.trim().is_empty() && name.len() <= 100,
let secret = nostr_sdk::SecretKey::parse(nsec) "Invalid identity name"
.map_err(|_| anyhow::anyhow!("Invalid owner key"))?; );
anyhow::ensure!(
nsec.starts_with("nsec1") && nsec.len() == 63,
"Enter a plain nsec owner key"
);
let secret =
nostr_sdk::SecretKey::parse(nsec).map_err(|_| anyhow::anyhow!("Invalid owner key"))?;
let keys = nostr_sdk::Keys::new(secret); let keys = nostr_sdk::Keys::new(secret);
let nostr_pubkey = keys.public_key().to_hex(); let nostr_pubkey = keys.public_key().to_hex();
anyhow::ensure!(keys.public_key().to_bech32()? == expected_npub, anyhow::ensure!(
"Owner key does not match this website"); keys.public_key().to_bech32()? == expected_npub,
"Owner key does not match this website"
);
// Serializes imports only; mature creation/signing paths are untouched. // Serializes imports only; mature creation/signing paths are untouched.
static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); static IMPORT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -260,7 +268,8 @@ impl IdentityManager {
// Atomic publication, and unlike rename this cannot replace a file. // Atomic publication, and unlike rename this cannot replace a file.
fs::hard_link(&staging, &destination).await?; fs::hard_link(&staging, &destination).await?;
Ok(()) Ok(())
}.await; }
.await;
let _ = fs::remove_file(&staging).await; let _ = fs::remove_file(&staging).await;
write_result.context("Could not save imported identity")?; write_result.context("Could not save imported identity")?;
self.get(&id).await self.get(&id).await
@@ -974,23 +983,53 @@ mod tests {
async fn import_nostr_preserves_identities_and_rejects_mismatches() { async fn import_nostr_preserves_identities_and_rejects_mismatches() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let manager = IdentityManager::new(dir.path()).await.unwrap(); let manager = IdentityManager::new(dir.path()).await.unwrap();
let original = manager.create("Personal".into(), IdentityPurpose::Personal).await.unwrap(); let original = manager
.create("Personal".into(), IdentityPurpose::Personal)
.await
.unwrap();
let keys = nostr_sdk::Keys::generate(); let keys = nostr_sdk::Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap(); let nsec = keys.secret_key().to_bech32().unwrap();
let npub = keys.public_key().to_bech32().unwrap(); let npub = keys.public_key().to_bech32().unwrap();
assert!(manager.import_nostr("Wrong".into(), &nsec, "npub1wrong").await.is_err()); assert!(manager
.import_nostr("Wrong".into(), &nsec, "npub1wrong")
.await
.is_err());
assert_eq!(manager.list().await.unwrap().0.len(), 1); assert_eq!(manager.list().await.unwrap().0.len(), 1);
let imported = manager.import_nostr("Website".into(), &nsec, &npub).await.unwrap(); let imported = manager
.import_nostr("Website".into(), &nsec, &npub)
.await
.unwrap();
assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str())); assert_eq!(imported.nostr_npub.as_deref(), Some(npub.as_str()));
assert_eq!(manager.import_nostr("Again".into(), &nsec, &npub).await.unwrap().id, imported.id); assert_eq!(
manager
.import_nostr("Again".into(), &nsec, &npub)
.await
.unwrap()
.id,
imported.id
);
let (records, default) = manager.list().await.unwrap(); let (records, default) = manager.list().await.unwrap();
assert_eq!(records.len(), 2); assert_eq!(records.len(), 2);
assert_eq!(default.as_deref(), Some(original.id.as_str())); assert_eq!(default.as_deref(), Some(original.id.as_str()));
assert_eq!(manager.get(&original.id).await.unwrap().nostr_pubkey, original.nostr_pubkey); assert_eq!(
assert_eq!(manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"], nsec); manager.get(&original.id).await.unwrap().nostr_pubkey,
#[cfg(unix)] { original.nostr_pubkey
);
assert_eq!(
manager.export_keys(&imported.id).await.unwrap()["nostr_nsec"],
nsec
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join("identities").join(format!("{}.json", imported.id))).unwrap().permissions().mode(); let mode = std::fs::metadata(
dir.path()
.join("identities")
.join(format!("{}.json", imported.id)),
)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600); assert_eq!(mode & 0o777, 0o600);
} }
} }
@@ -1012,14 +1051,24 @@ mod tests {
assert_eq!(records.len(), 1); assert_eq!(records.len(), 1);
assert!(default.is_none()); assert!(default.is_none());
let hash = [7u8; 32]; let hash = [7u8; 32];
let signature = manager.nostr_sign(&first.id, &hex::encode(hash)).await.unwrap(); let signature = manager
.nostr_sign(&first.id, &hex::encode(hash))
.await
.unwrap();
let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap(); let signature: nostr_sdk::secp256k1::schnorr::Signature = signature.parse().unwrap();
let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey = keys.public_key().to_hex().parse().unwrap(); let pubkey: nostr_sdk::secp256k1::XOnlyPublicKey =
nostr_sdk::secp256k1::Secp256k1::verification_only().verify_schnorr( keys.public_key().to_hex().parse().unwrap();
&signature, &nostr_sdk::secp256k1::Message::from_digest(hash), &pubkey, nostr_sdk::secp256k1::Secp256k1::verification_only()
).unwrap(); .verify_schnorr(
&signature,
&nostr_sdk::secp256k1::Message::from_digest(hash),
&pubkey,
)
.unwrap();
let entries = std::fs::read_dir(dir.path().join("identities")).unwrap(); let entries = std::fs::read_dir(dir.path().join("identities")).unwrap();
assert!(entries.map(|entry| entry.unwrap().file_name()).all(|name| !name.to_string_lossy().ends_with(".tmp"))); assert!(entries
.map(|entry| entry.unwrap().file_name())
.all(|name| !name.to_string_lossy().ends_with(".tmp")));
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -77,7 +77,6 @@ mod monitoring;
mod music; mod music;
mod names; mod names;
mod network; mod network;
mod publishing;
mod node_message; mod node_message;
mod nostr_discovery; mod nostr_discovery;
mod nostr_handshake; mod nostr_handshake;
@@ -87,6 +86,7 @@ mod nostr_security_tests;
mod peers; mod peers;
mod port_allocator; mod port_allocator;
mod prepared_media; mod prepared_media;
mod publishing;
mod rate_limit; mod rate_limit;
mod registered_media; mod registered_media;
mod rental_chunk_index; mod rental_chunk_index;
+1 -4
View File
@@ -683,10 +683,7 @@ pub fn resolve(
Err(error) Err(error)
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
{
()
}
Err(error) => return Err(error), Err(error) => return Err(error),
} }
} }
+7 -10
View File
@@ -113,22 +113,19 @@ const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
/// resource is gone yet). /// resource is gone yet).
async fn wait_for_port_free(path: &str) -> Result<()> { async fn wait_for_port_free(path: &str) -> Result<()> {
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT; let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
let mut last_err = None;
loop { loop {
match serial2_tokio::SerialPort::open(path, 115200) { match serial2_tokio::SerialPort::open(path, 115200) {
Ok(_) => return Ok(()), Ok(_) => return Ok(()),
Err(e) => last_err = Some(e), Err(error) if tokio::time::Instant::now() >= deadline => {
} return Err(anyhow::anyhow!(
if tokio::time::Instant::now() >= deadline { "{path} is still held open by something else after {}s (last error: {error}) — refusing to start the flasher against a contended port",
break; PORT_FREE_TIMEOUT.as_secs(),
));
}
Err(_) => {}
} }
tokio::time::sleep(std::time::Duration::from_millis(500)).await; tokio::time::sleep(std::time::Duration::from_millis(500)).await;
} }
Err(anyhow::anyhow!(
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
PORT_FREE_TIMEOUT.as_secs(),
last_err.map(|e| e.to_string()).unwrap_or_default()
))
} }
/// Live state for the one flash job that can run at a time. A single global /// Live state for the one flash job that can run at a time. A single global
+1 -1
View File
@@ -15,7 +15,7 @@ mod frames;
mod node_cmd; mod node_cmd;
mod session; mod session;
pub(crate) use session::{probe_device, DeviceProbe}; pub(crate) use session::probe_device;
use super::types::*; use super::types::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+20 -7
View File
@@ -122,7 +122,8 @@ async fn read_disk_usage() -> Result<(u64, u64)> {
}; };
let mut command = tokio::process::Command::new("df"); let mut command = tokio::process::Command::new("df");
command.args(["--block-size=1", "--output=used,size", target]); command.args(["--block-size=1", "--output=used,size", target]);
let output = bounded_output(command, std::time::Duration::from_secs(3)).await let output = bounded_output(command, std::time::Duration::from_secs(3))
.await
.context("Failed to run df")?; .context("Failed to run df")?;
if !output.status.success() { if !output.status.success() {
@@ -222,7 +223,8 @@ async fn bounded_output(
) -> Result<std::process::Output> { ) -> Result<std::process::Output> {
command.kill_on_drop(true); command.kill_on_drop(true);
tokio::time::timeout(timeout, command.output()) tokio::time::timeout(timeout, command.output())
.await.context("Metrics subprocess timed out")? .await
.context("Metrics subprocess timed out")?
.context("Metrics subprocess failed") .context("Metrics subprocess failed")
} }
@@ -230,7 +232,8 @@ async fn bounded_output(
async fn read_container_stats() -> Result<Vec<ContainerMetrics>> { async fn read_container_stats() -> Result<Vec<ContainerMetrics>> {
let mut command = tokio::process::Command::new("podman"); let mut command = tokio::process::Command::new("podman");
command.args(["stats", "--no-stream", "--format", "json"]); command.args(["stats", "--no-stream", "--format", "json"]);
let output = bounded_output(command, std::time::Duration::from_secs(8)).await let output = bounded_output(command, std::time::Duration::from_secs(8))
.await
.context("Failed to run podman stats")?; .context("Failed to run podman stats")?;
if !output.status.success() { if !output.status.success() {
@@ -411,14 +414,22 @@ mod subprocess_deadline_tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("pid"); let pid_file = dir.path().join("pid");
let mut command = tokio::process::Command::new("sh"); let mut command = tokio::process::Command::new("sh");
command.arg("-c").arg("echo $$ > \"$1\"; exec sleep 30").arg("metrics-test").arg(&pid_file); command
.arg("-c")
.arg("echo $$ > \"$1\"; exec sleep 30")
.arg("metrics-test")
.arg(&pid_file);
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let error = bounded_output(command, std::time::Duration::from_millis(500)).await.unwrap_err(); let error = bounded_output(command, std::time::Duration::from_millis(500))
.await
.unwrap_err();
assert!(error.to_string().contains("timed out")); assert!(error.to_string().contains("timed out"));
assert!(start.elapsed() < std::time::Duration::from_secs(3)); assert!(start.elapsed() < std::time::Duration::from_secs(3));
let pid = tokio::fs::read_to_string(pid_file).await.unwrap(); let pid = tokio::fs::read_to_string(pid_file).await.unwrap();
for _ in 0..40 { for _ in 0..40 {
if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() { return; } if !std::path::Path::new(&format!("/proc/{}", pid.trim())).exists() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await; tokio::time::sleep(std::time::Duration::from_millis(25)).await;
} }
panic!("Timed-out metrics subprocess was not reaped"); panic!("Timed-out metrics subprocess was not reaped");
@@ -428,7 +439,9 @@ mod subprocess_deadline_tests {
async fn successful_metrics_output_is_preserved() { async fn successful_metrics_output_is_preserved() {
let mut command = tokio::process::Command::new("printf"); let mut command = tokio::process::Command::new("printf");
command.arg("metrics-ok"); command.arg("metrics-ok");
let output = bounded_output(command, std::time::Duration::from_secs(1)).await.unwrap(); let output = bounded_output(command, std::time::Duration::from_secs(1))
.await
.unwrap();
assert!(output.status.success()); assert!(output.status.success());
assert_eq!(output.stdout, b"metrics-ok"); assert_eq!(output.stdout, b"metrics-ok");
} }
+5 -3
View File
@@ -40,10 +40,12 @@ pub fn spawn_metrics_collector(
store.push(snapshot).await; store.push(snapshot).await;
debug!("Metrics snapshot collected"); debug!("Metrics snapshot collected");
// Reconcile even when all thresholds have recovered, so
// the previous disk/RAM/CPU warning disappears promptly.
if let Some(ref state_mgr) = state {
notifications::push_alert_notifications(state_mgr, &alerts).await;
}
if !alerts.is_empty() { if !alerts.is_empty() {
if let Some(ref state_mgr) = state {
notifications::push_alert_notifications(state_mgr, &alerts).await;
}
if let Some(ref dir) = data_dir { if let Some(ref dir) = data_dir {
notifications::deliver_alert_webhooks(dir, &alerts).await; notifications::deliver_alert_webhooks(dir, &alerts).await;
} }
@@ -14,7 +14,11 @@ pub(crate) async fn push_alert_notifications(
alerts: &[FiredAlert], alerts: &[FiredAlert],
) { ) {
let (mut data, _rev) = state_mgr.get_snapshot().await; let (mut data, _rev) = state_mgr.get_snapshot().await;
let previous_count = data.notifications.len();
prune_stale_alert_notifications(&mut data.notifications, alerts); prune_stale_alert_notifications(&mut data.notifications, alerts);
if alerts.is_empty() && data.notifications.len() == previous_count {
return;
}
for alert in alerts { for alert in alerts {
let level = match alert.kind { let level = match alert.kind {
AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => { AlertRuleKind::DiskUsage | AlertRuleKind::RamUsage => {
@@ -42,7 +46,17 @@ pub(crate) async fn push_alert_notifications(
data.notifications.remove(0); data.notifications.remove(0);
} }
state_mgr.update_data(data).await; state_mgr.update_data(data).await;
info!("Fired {} alert(s)", alerts.len()); if !alerts.is_empty() {
info!("Fired {} alert(s)", alerts.len());
}
}
fn is_metrics_alert_id(id: &str) -> bool {
id.split_once('-').is_some_and(|(kind, timestamp)| {
matches!(kind, "disk" | "ram" | "cpu" | "latency")
&& !timestamp.is_empty()
&& timestamp.bytes().all(|byte| byte.is_ascii_digit())
})
} }
fn prune_stale_alert_notifications( fn prune_stale_alert_notifications(
@@ -55,6 +69,11 @@ fn prune_stale_alert_notifications(
if active_ids.contains(notification.id.as_str()) { if active_ids.contains(notification.id.as_str()) {
return false; return false;
} }
// A fresh timestamp is not evidence that an earlier threshold is
// still exceeded. Remove resolved monitoring alerts immediately.
if is_metrics_alert_id(&notification.id) {
return false;
}
if notification.app_id.is_some() || notification.id.starts_with("health-") { if notification.app_id.is_some() || notification.id.starts_with("health-") {
return true; return true;
} }
@@ -127,6 +146,8 @@ mod tests {
notification("alert-active", fresh_timestamp.clone(), None), notification("alert-active", fresh_timestamp.clone(), None),
notification("alert-old", old_timestamp, None), notification("alert-old", old_timestamp, None),
notification("alert-fresh", fresh_timestamp.clone(), None), notification("alert-fresh", fresh_timestamp.clone(), None),
notification("disk-123", fresh_timestamp.clone(), None),
notification("ram-123", fresh_timestamp.clone(), None),
notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")), notification("health-indeedhub-1", fresh_timestamp, Some("indeedhub")),
]; ];
@@ -135,4 +156,22 @@ mod tests {
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect(); let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]); assert_eq!(ids, vec!["alert-fresh", "health-indeedhub-1"]);
} }
#[test]
fn clears_resolved_metrics_alerts_without_touching_other_notifications() {
let fresh = Utc::now().to_rfc3339();
let mut notifications = vec![
notification("disk-123", fresh.clone(), None),
notification("latency-456", fresh.clone(), None),
notification("disk-not-a-timestamp", fresh.clone(), None),
notification("health-indeedhub-1", fresh.clone(), Some("indeedhub")),
notification("app-notice", fresh, Some("indeedhub")),
];
prune_stale_alert_notifications(&mut notifications, &[]);
let ids: Vec<&str> = notifications.iter().map(|n| n.id.as_str()).collect();
assert_eq!(
ids,
vec!["disk-not-a-timestamp", "health-indeedhub-1", "app-notice"]
);
}
} }
+1 -1
View File
@@ -102,7 +102,7 @@ impl MetricsStore {
// Use saturating semantics to avoid underflow // Use saturating semantics to avoid underflow
let _ = self let _ = self
.ws_connections .ws_connections
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| { .try_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v > 0 { if v > 0 {
Some(v - 1) Some(v - 1)
} else { } else {
+4 -3
View File
@@ -227,7 +227,7 @@ pub async fn publish_presence(
// NIP-40 expiration: relays that honour it garbage-collect the event if // NIP-40 expiration: relays that honour it garbage-collect the event if
// this node stops heartbeating (reinstall, decommission, long outage). // this node stops heartbeating (reinstall, decommission, long outage).
// `discover` enforces the same window client-side for relays that don't. // `discover` enforces the same window client-side for relays that don't.
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), content) let builder = EventBuilder::new(Kind::Custom(30078), content)
.tag(Tag::identifier("archipelago-node")) .tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires)); .tag(Tag::expiration(expires));
@@ -268,7 +268,7 @@ pub async fn publish_tombstone(
} }
// Tombstone also expires: after TTL the relay may drop it entirely, // Tombstone also expires: after TTL the relay may drop it entirely,
// which is the desired end state (nothing left to list). // which is the desired end state (nothing left to list).
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS); let expires = Timestamp::from(Timestamp::now().as_secs() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), "{}") let builder = EventBuilder::new(Kind::Custom(30078), "{}")
.tag(Tag::identifier("archipelago-node")) .tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires)); .tag(Tag::expiration(expires));
@@ -326,7 +326,8 @@ pub async fn discover_nodes(
client.disconnect().await; client.disconnect().await;
let mut nodes = Vec::new(); let mut nodes = Vec::new();
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS)); let stale_cutoff =
Timestamp::from(Timestamp::now().as_secs().saturating_sub(PRESENCE_TTL_SECS));
for event in events { for event in events {
// Client-side staleness enforcement: pre-TTL events (and events from // Client-side staleness enforcement: pre-TTL events (and events from
// relays that ignore NIP-40) would otherwise list dead installs // relays that ignore NIP-40) would otherwise list dead installs
+4 -2
View File
@@ -1194,11 +1194,13 @@ impl Server {
// Podman needs and can restart-loop apps that publish those ports. // Podman needs and can restart-loop apps that publish those ports.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe())); let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let publishing_task = tokio::spawn(crate::publishing::serving::run( let publishing_task = tokio::spawn(crate::publishing::serving::run(
self._config.data_dir.clone(), tx.subscribe(), self._config.data_dir.clone(),
tx.subscribe(),
)); ));
let publishing_tor_task = tokio::spawn(crate::publishing::tor::run( let publishing_tor_task = tokio::spawn(crate::publishing::tor::run(
self._config.data_dir.clone(), tx.subscribe(), self._config.data_dir.clone(),
tx.subscribe(),
)); ));
// The app gate: authentication in front of every app port, on every // The app gate: authentication in front of every app port, on every
+1 -4
View File
@@ -145,10 +145,7 @@ pub(crate) fn reserve_until(
Err(error) Err(error)
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => {}
{
()
}
Err(error) => return Err(error), Err(error) => return Err(error),
} }
} }
+40 -34
View File
@@ -1496,25 +1496,28 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
.context("isolated test command failed"); .context("isolated test command failed");
} }
let mut full: Vec<&str> = vec![ #[cfg(not(test))]
"systemd-run", {
"--wait", let mut full: Vec<&str> = vec![
"--quiet", "systemd-run",
"--collect", "--wait",
"--pipe", "--quiet",
// Shell snippets passed as one argument must reach the child intact. "--collect",
// systemd-run otherwise expands $VAR/${VAR} against the manager's "--pipe",
// environment before `sh -lc` can see them (and usually replaces them // Shell snippets passed as one argument must reach the child intact.
// with empty strings). // systemd-run otherwise expands $VAR/${VAR} against the manager's
"--expand-environment=no", // environment before `sh -lc` can see them (and usually replaces them
"--", // with empty strings).
]; "--expand-environment=no",
full.extend_from_slice(args); "--",
tokio::process::Command::new("sudo") ];
.args(&full) full.extend_from_slice(args);
.status() tokio::process::Command::new("sudo")
.await .args(&full)
.context("sudo systemd-run spawn failed") .status()
.await
.context("sudo systemd-run spawn failed")
}
} }
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes /// Same mechanism as `host_sudo` but captures stdout — for read-only probes
@@ -1535,21 +1538,24 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
.context("isolated test command failed"); .context("isolated test command failed");
} }
let mut full: Vec<&str> = vec![ #[cfg(not(test))]
"systemd-run", {
"--wait", let mut full: Vec<&str> = vec![
"--quiet", "systemd-run",
"--collect", "--wait",
"--pipe", "--quiet",
"--expand-environment=no", "--collect",
"--", "--pipe",
]; "--expand-environment=no",
full.extend_from_slice(args); "--",
tokio::process::Command::new("sudo") ];
.args(&full) full.extend_from_slice(args);
.output() tokio::process::Command::new("sudo")
.await .args(&full)
.context("sudo systemd-run output spawn failed") .output()
.await
.context("sudo systemd-run output spawn failed")
}
} }
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions. /// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
+1 -1
View File
@@ -474,7 +474,7 @@ async fn ensure_token(
/// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number. /// Draw a fresh readable wallet name, Minibits-style: adjective + noun + number.
fn generate_wallet_id() -> String { fn generate_wallet_id() -> String {
let mut rng = rand::thread_rng(); let mut rng = rand::rngs::OsRng;
let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet"); let adj = ADJECTIVES.choose(&mut rng).copied().unwrap_or("quiet");
let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor"); let noun = NOUNS.choose(&mut rng).copied().unwrap_or("harbor");
let num = rand::Rng::gen_range(&mut rng, 1..=999); let num = rand::Rng::gen_range(&mut rng, 1..=999);
+20 -8
View File
@@ -16,7 +16,7 @@ use super::nut13::RecoverySource;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bitcoin::secp256k1; use bitcoin::secp256k1;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, warn}; use tracing::debug;
/// Default timeout for mint API calls. /// Default timeout for mint API calls.
const MINT_TIMEOUT_SECS: u64 = 10; const MINT_TIMEOUT_SECS: u64 = 10;
@@ -83,13 +83,25 @@ impl std::fmt::Debug for PreparedSwap {
} }
impl PreparedSwap { impl PreparedSwap {
// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed. // Add inside impl PreparedSwap; no mutability or proof/output secrets exposed.
pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id } pub(super) fn payment_keyset_id(&self) -> &str {
pub(super) fn input_fee_sats(&self) -> Result<u64> { &self.keyset.id
let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?; }
let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?; pub(super) fn input_fee_sats(&self) -> Result<u64> {
inputs.checked_sub(outputs).context("Prepared outputs exceed input value") let inputs = self
} .inputs
.iter()
.try_fold(0u64, |sum, proof| sum.checked_add(proof.amount))
.context("Prepared input sum overflow")?;
let outputs = self
.outputs
.iter()
.try_fold(0u64, |sum, output| sum.checked_add(output.amount))
.context("Prepared output sum overflow")?;
inputs
.checked_sub(outputs)
.context("Prepared outputs exceed input value")
}
pub(super) fn inputs(&self) -> &[Proof] { pub(super) fn inputs(&self) -> &[Proof] {
&self.inputs &self.inputs
+1 -1
View File
@@ -11,8 +11,8 @@ pub mod mint_client;
pub(crate) mod mutation; pub(crate) mod mutation;
pub mod nut13; pub mod nut13;
pub mod profits; pub mod profits;
mod send_journal;
mod receive_journal; mod receive_journal;
mod send_journal;
pub(crate) mod purchase_fee_plan; pub(crate) mod purchase_fee_plan;
+1 -1
View File
@@ -2682,7 +2682,7 @@ async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() {
#[tokio::test] #[tokio::test]
async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() { async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() {
use crate::content_purchase_caller::{purchase, ReadyPurchase}; use crate::content_purchase_caller::{purchase, ReadyPurchase};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::AtomicBool;
let mint = Mint::start(0, None).await; let mint = Mint::start(0, None).await;
let buyer = tempfile::tempdir().unwrap(); let buyer = tempfile::tempdir().unwrap();
let seller = mint.wallet().await; let seller = mint.wallet().await;
+7 -1
View File
@@ -1795,8 +1795,10 @@ app:
} }
} }
exempt.sort(); exempt.sort();
// Reviewed 2026-09-30: lightning-stack's three retired endpoints // Reviewed 2026-10-09: lightning-stack's three retired endpoints
// disappeared; Cuprate restricted RPC moved from none to gate-open. // disappeared; Cuprate restricted RPC moved from none to gate-open.
// DATUM's Stratum port is a raw public mining protocol; its separate
// administration interface remains gated and loopback-bound.
// Compare exact endpoints, not just a count that can hide substitutions. // Compare exact endpoints, not just a count that can hide substitutions.
let expected = [ let expected = [
("bitcoin-core", 8333), ("bitcoin-core", 8333),
@@ -1804,6 +1806,7 @@ app:
("core-lightning", 9736), ("core-lightning", 9736),
("core-lightning", 9835), ("core-lightning", 9835),
("cuprate", 18183), ("cuprate", 18183),
("datum", 23334),
("electrumx", 50001), ("electrumx", 50001),
("fedimint", 8173), ("fedimint", 8173),
("fedimint", 8174), ("fedimint", 8174),
@@ -1870,6 +1873,8 @@ app:
// Angor's indexer exposes public chain data/transaction broadcast; // Angor's indexer exposes public chain data/transaction broadcast;
// its optional standalone relay accepts signed public Nostr events. // its optional standalone relay accepts signed public Nostr events.
// Neither mounts credentials or the node's internal relay database. // Neither mounts credentials or the node's internal relay database.
// Gashboard performs its own NIP-98/access-list authentication on
// every data route before issuing or accepting a session.
assert_eq!( assert_eq!(
open, open,
vec![ vec![
@@ -1877,6 +1882,7 @@ app:
("angor-relay".to_string(), 8091u16), ("angor-relay".to_string(), 8091u16),
("btcpay-server".to_string(), 23000u16), ("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16), ("cuprate".to_string(), 18090u16),
("gashboard".to_string(), 1337u16),
("gitea".to_string(), 3001u16), ("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16), ("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16), ("tailscale".to_string(), 8240u16),
+4 -1
View File
@@ -1002,7 +1002,10 @@ fn manifest_container_name(manifest: &AppManifest) -> String {
} }
fn manifest_apps_dirs() -> Vec<PathBuf> { fn manifest_apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new(); // Keep source-tree discovery independent of the caller's working
// directory. Isolated test runners deliberately start in `core/`, while
// production uses one of the installed paths below.
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps")];
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") { if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps")); dirs.push(Path::new(&manifest_dir).join("../../apps"));
} }
+4
View File
@@ -38,6 +38,10 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
} }
} }
// Rust 1.99 treats the boxed Future generated by async-trait as must-use and
// also sees the macro's own must-use marker. Keep the compatibility allowance
// scoped to this generated trait surface; callers still cannot ignore Result.
#[allow(clippy::double_must_use)]
#[async_trait] #[async_trait]
pub trait ContainerRuntime: Send + Sync { pub trait ContainerRuntime: Send + Sync {
/// CLI used for offline app provisioning in this runtime's storage scope. /// CLI used for offline app provisioning in this runtime's storage scope.
+93
View File
@@ -0,0 +1,93 @@
# Interactive hardware illustrations
The radio detection modal and unconfigured OpenWrt gateway use original
procedural Three.js models. The renderer follows the studio lighting,
rounded geometry, material finishes and instancing approach used for the
computer illustrations in `archipelago-website-deck`.
## Coverage
`neode-ui/src/graphics/deviceModels.ts` contains 25 radio illustrations,
including the generic fallback and three optional enclosure variants, plus
the Beryl AX (GL-MT3000) and a black Pixel 9 Pro. All existing `meshDeviceImages.ts` detection results
resolve to a procedural model key. USB chip identification remains a
best-effort visual match; it does not select firmware or claim exact hardware.
The radios include modeled rear faces, enclosure panels or populated boards,
and appropriate battery holders, connectors and antennas. These are stylized
recognition illustrations, not CAD models. Fine component placement and rear
details are representative; not every board revision or enclosure has been
verified against a rear photograph. The Beryl AX is the example router from
`docs/openwrt-gateway-setup.md`, not an assertion about the connected router.
Reference material:
- Beryl AX official front/rear/side hardware drawing:
https://static.gl-inet.com/docs/router/en/4/user_guide/gl-mt3000/hardware_info/mt3000_interface.jpg
- Beryl AX guide:
https://docs.gl-inet.com/router/en/4/user_guide/gl-mt3000/
- T-Echo manufacturer board and enclosure reference:
https://github.com/Xinyuan-LilyGO/T-Echo
https://raw.githubusercontent.com/Xinyuan-LilyGO/T-Echo/main/image/T-ECHO.jpg
- T-Deck manufacturer documentation:
https://wiki.lilygo.cc/products/t-deck-series/t-deck/
Manufacturer photographs are references only; none are copied into the app.
Geometry, grain and device labels are generated locally without CDN assets.
The black Pixel 9 Pro illustration uses an unmodified, locally bundled GrapheneOS
boot logo mask from its framework source. Its rounded outline, glass panels,
camera island, layered lenses, side buttons and ports are procedural geometry.
The display shows a static phase of the OS boot animation, distinct from Google's
bootloader notice. Source and attribution: `neode-ui/src/graphics/assets/ATTRIBUTION.md`.
## Interaction and lifecycle
`DeviceModel.vue` loads the renderer on intersection. It turns once per minute,
capped at 30 rendered frames per second. Drag horizontally to rotate; a mouse
can also tilt vertically. Touch preserves vertical page scrolling. Arrow keys
rotate/tilt, Home shows the front and End shows the back. Keyboard interaction
pauses automatic turning. The Pause/Rotate button controls autoplay.
Reduced motion disables autoplay and hides its control while preserving manual
rotation. Hidden tabs and offscreen models stop scheduling frames. Model changes
reuse the renderer/environment and dispose the previous geometry, textures and
materials. Unmount releases the renderer and context. Unsupported browser APIs,
unavailable WebGL, import failures or lost contexts leave a static fallback.
The router figure uses the remaining viewport height below the connection form
on desktop (minimum 260px); mobile layouts get a fixed 290px illustration. Small desktop
windows can scroll normally rather than collapsing the model to zero height.
## Hardware discovery
The router gateway and private-phone Setup goal share `HardwareMarketplace.vue`.
Buy router / Buy phone replaces the model with a horizontal product carousel,
preserving the outer container's height. Discovery queries enabled relays from
`nostr.list-relays` for NIP-99 kind-30402 events. Phone discovery uses the `pixel`
keyword. Development builds show clearly
labeled sample listings when no matching live listings are found. Checkout and
the product View/Buy actions remain unimplemented.
Browser scenarios and reports stay outside this repository, under
`/home/yaya/.local/share/browser-check/projects/archy-devices/`.
## Validation (2026-10-09)
- Production typecheck/build passes; the lazy renderer chunk is 136 KB gzipped.
- Existing gateway/cache tests (10) and radio setup tests (2) pass.
- Chromium 153, Firefox 155 and WebKit 26.6 pass at desktop 1440×900 and
phone 390×844: all 26 models, front/back rotation, drag, autoplay/pause,
reduced motion, zero offscreen/idle draws, model replacement, unmount/remount
and context-loss fallback. Reports: `/tmp/archy-models-final/`.
- Actual gateway and detection-modal components pass the same six-case matrix
with mocked RPC data. Reports: `/tmp/archy-device-pages-matrix/`.
- Delayed renderer loading, storage denial, reload and unavailable WebGL pass
the same matrix. Reports: `/tmp/archy-device-fallbacks/`.
- These are Linux browser tests with phone viewport emulation, not physical
iPhone/Android GPU or touch-scroll certification. No live router was changed.
Branch: `feat/3d-mesh-router`, based on ngit main
`5812f53c7c05027848a1007f818e9d33117b62e0`. The ngit helper could read relay
state but failed Git server authentication; main's published kind-30618 ref
was checked on relay.ngit.dev and that exact commit fetched from the existing
Git mirror. No remote proposal, merge or deployment was performed.
+150
View File
@@ -42,6 +42,156 @@ acceptance; this is a new paid-file incident.
## Current tasks ## Current tasks
- 2026-10-09 recurrence: the operator again receives the profile-identity refusal.
Live reproduction shows the signed session still matches the selected native
identity, but the API's original DTO is back and omits `nostrPubkey`. Do not
weaken the frontend identity check. The earlier direct `podman restart` was
incorrect for this systemd-managed app: the attached service exits, then its
`Restart=always` cleanup/recreation returns to the original image. On this
repeat, the API disappears briefly and systemd recreates it at 16:30:53 UTC;
journal evidence and changed container ID confirm that lifecycle. The brief
earlier successful login was therefore insufficient deployment acceptance.
A per-node UAT correction now uses Quadlet drop-in
`~/.config/containers/systemd/indeedhub-api.container.d/90-profile-uat.conf`
to mount `/var/lib/archipelago/uat-overrides/indeehub-profile-08586e3/user.dto.js`
read-only at `/app/dist/users/dto/response/user.dto.js`. Artifact SHA256:
`4450f45def6c68396d9f4fa597a24ca74cec447d1cb3c8772af29e406d929e6d`.
The original image, source Quadlet, credentials, databases and identities are
unchanged. Restart via `systemctl --user restart indeedhub-api`, never direct
Podman. The module checksum survives two managed recreations. Real native
login after the first passes. The immediate second-start login obtained no
session; after explicitly verifying API health 200, the repeated real native
login passes with matching node selection, JWT subject and profile public key.
The service remains active/running with zero automatic restarts. This is a persistent **local UAT
override**, not a new signed image/catalog. Native upgrades remain held until
the reviewed image includes the fix and this exact override is retired; do not
bypass external-override/installation ownership checks. Recovery is to move
this specific drop-in into the existing private repair backup, daemon-reload
and restart the managed API; that restores the old image's known login defect.
No node identity or personal media was replaced. The previously pending
isolated registration regression has now passed: one test, zero failures.
- 2026-10-09 live repair checkpoint (supersedes the pending deployment notes
immediately below): Yaya now runs management backend SHA256
`938a90c7d9ecbdb179b1cf2886b5ed03e4d78c0d454f2cec42c0d9de3d447eb9`,
built from the registration correction in `424070d2`. Optimized build passed.
Management health, unchanged running container IDs, unchanged node identity key
and unchanged registration pin/marker checks passed. The old binary is retained
under `/var/lib/archipelago/support/indee-registration-424070d2/`.
The first context probe during inventory startup still failed; after inventory
recovery the authenticated `media.registration.context` probe succeeds and
returns all installation bindings. No catalog or app-data mutation was used.
The probe initially treated JSON-RPC `error: null` as failure; corrected it and
repeated the successful live request. Isolated backend regression compilation
remains pending after severe build-host swapping; do not claim its test passed.
A real native Home Serve login then exposed another defect: `UserDTO` omitted
`nostrPubkey`, so the strict frontend profile check correctly refused the
otherwise matching signed session. IndeeHub `08586e3` adds this public field;
two DTO regressions and backend production build pass. Deployed only the built
DTO module with its previous copy saved under the private
`indee-profile-08586e3` artifact directory, then restarted only `indeedhub-api`.
API health passes. A clean real browser selected native **Home Serve**, approved
only its kind-27235 authentication request, and verified that the native choice,
session subject and API profile key match. The signer closes normally. No
identity was created/replaced and no payment or publication was submitted.
Frontend `7b18912` and API DTO remain running-container patches; durable image/
managed deployment, complete video upload/publication and cached-account-switch
acceptance remain open. Preserve the operator's native identities throughout.
- 2026-10-09 UI checkpoint: IndeeHub commit `40b3798` is served on Yaya
(index SHA256 `d921a88448f89674809dbe623ec045849012b1fb0330e3abf1d2d6836e9b3c96`).
Publishing now appears only in its own editor tab; Assets remains mounted
across tab switches. Copy distinguishes computer upload from Cloud-backed
catalog publication; saved-registration recovery is under a details control.
Production build and 26 focused frontend tests pass. Browser checks verify
native signer availability, no automatic login, removal of local account
controls and clearing of legacy app-local accounts. Real selected-identity
sign-in and video upload/publication remain unverified. This is a running
container frontend patch, not a durable pinned-image/catalog deployment.
Backend correction `424070d2` has ngit proposal
`9d6de783f5022b3b859e0b1f3e881e389da4482dd5dd15ff29a40b4e25dafcb8`;
isolated regression and optimized build are still running, not passed or
deployed. Preserve this distinction when resuming the backend work.
- 2026-10-09 follow-up: operator reports missing autosign and the same generic
registration error. `40b3798` disabled provider auto-authentication without
replacing it with app authentication on native selection; this was a regression.
IndeeHub `7b18912` adds a single shared automatic/manual login path driven by
the trusted provider's `onIdentitySelected`. It rejects mismatching signer keys
and changed selections, checks the backend profile, and never falls back to a
cached app identity. Twenty-one focused tests and the production build pass.
Yaya serves index SHA256
`1c7fee64430b0d993288408dbe493d0f4f55a49fe2422a3507c7204c12343632`;
the native provider bytes are unchanged. A disposable served-app browser test
verifies selection triggers exactly one kind-27235 signature request without
clicking login; it intentionally stops before signing with a stub. This is not
real-identity login acceptance. The first browser run raced deployment and
failed against the previous build; the post-deployment rerun passes.
Authenticated read-only registration RPC still reproduces the generic failure,
with server cause `IndeeHub is not installed`; backend build/test remain pending.
- [ ] **2026-10-09 IndeeHub UAT remains failed:** operator still reports wrong
Nostr identity, upload failure and confusing always-visible publishing UI.
Preserve native identities; remove only app-local generated/imported accounts.
Require explicit Archipelago selection and matching API profile. On Yaya,
`media.registration.context` logs `IndeeHub is not installed` while all seven
containers run. Authenticated state includes only the parent `indeedhub`
package; the registration code incorrectly requires a separate
`indeedhub-api` package record. Candidate correction retains parent running/
installed checks and the existing API installation pin; it checks a legacy
API package when present. Source tests/build and live registration acceptance
must complete before closing. Move publishing to its own editor tab, preserve
computer-upload state and existing pending registration requests.
Retained operator UAT follow-up (2026-10-09; none of these are closed by an
isolated build or a running-container patch):
- [ ] Keep only the selected native Archipelago signer identity; reject any
cached or mismatching IndeeHub identity, and verify Home Serve and
Archipelago sign-in with real selected identities after reload.
- [ ] Verify computer uploads for every asset field, byte-level progress,
project Save/Publish persistence, and reloaded poster/video/trailer URLs.
The poster URL currently omits the slash after `/storage` on Yaya.
- [ ] Restore the authenticated, mobile-friendly Cloud file modal, with an
actual attach action beside computer upload for every required asset
field. The current Cloud iframe is not an attachment workflow.
- [ ] Verify that any producer can register and publish Cloud video, including
exact-project/video/terms signing, resumable registration, and playback.
- [ ] Deliver and UAT both metered ecash playback and fixed-period rental,
retaining Lightning payment/rental support where promised. A price in
sats plus one-time Cashu rental is not metered ecash streaming.
- [ ] Deploy pinned, durable IndeeHub frontend/API images and test their
recreation without losing native identities, uploaded assets, projects,
payments, or publication state. Running-container file patches do not
satisfy this gate.
Dual-node UAT checkpoint, 2026-10-09: Yaya and Framework serve the same
IndeeHub frontend build through restart-surviving UAT Quadlet overrides;
their existing native-provider bytes were preserved. Both app endpoints
return HTTP 200 and protected API endpoints reject unauthenticated requests.
Narrow API overrides add the profile field required by the strict signer
check and correct `/storage/projects/...` poster URLs. Yaya's saved project
still has a poster and video key; its poster now returns HTTP 200 while the
private video returns HTTP 403 without authorization. A poster-only anonymous
`GetObject` rule was added to the public bucket; no private bucket read rule
was added. Framework's two missing IndeeHub buckets were created without
replacing other storage. Source backend tests/build, frontend build, and 12
focused identity/upload tests pass. Real selected-identity sign-in, upload
completion and Save/reload on both physical nodes still require operator UAT.
The Cloud browser is view-only and is **not** an attachment picker. These
overrides are not pinned managed images or release acceptance.
- [ ] **Yaya disk threshold notification recovery:** after safely reclaiming
only BuildKit cache, actual data-volume use is 62% (127,440,146,432 of
219,181,039,616 bytes on 2026-10-09). The previous 164 GiB / 194 GiB
display reflected real usage before cleanup, not merely stale UI.
Source monitoring pushes notifications only when current alerts are
nonempty, so a previously fired threshold notice may remain after usage
recovers. Fix notification clearing and verify live recovery after app
removal/cache cleanup without deleting wallets or persistent app data.
- [x] Yaya App Store component/alias regression (reported 2026-10-02): one - [x] Yaya App Store component/alias regression (reported 2026-10-02): one
Cuprate entry (hide Cuprate UI companion), one BTCPay Server in Commerce Cuprate entry (hide Cuprate UI companion), one BTCPay Server in Commerce
with its icon (merge legacy btcpay pins), one NetBird entry (hide server with its icon (merge legacy btcpay pins), one NetBird entry (hide server
+173
View File
@@ -1,7 +1,137 @@
# Archipelago 1.9.0-alpha release acceptance # Archipelago 1.9.0-alpha release acceptance
## Yaya alpha UAT continuation — 9 October 2026
Operator renewed deployment authorization for `yaya-server` for combined UAT,
including IndeeHub, JustWorks and the merged external-access, Gashboard and
DATUM work. OTA/ISO publication still requires subsequent operator acceptance.
Fresh read-only inspection found the prior operation `36ac4803` Restored with
cleanup complete, no lifecycle holds, and all 28 current containers running.
The API is already the shutdown-hook-aware version (main.js SHA256
`6ff3d4fa5d6a17c530a8e69b2b8b65ec8214c03e71f9a8cf3a27dd53702f1120`).
Its one project, one content item and one shareholder caused the legacy
empty-business gate to refuse. All 11 checked payment/rental/publication history
and intent tables are empty; pending shareholder revenue is zero, and BTCPay/
Strike credentials and endpoints are absent. These observations do not qualify
updates of nodes with monetary history or configured payment providers.
Correction `1e11c93e` adds a bounded money-free UAT path: exact known API entry
point, disabled registration/publication, absent providers and HTTP connections,
closed ingress, stopped frontend/worker, paused empty queue, zero monetary state,
and exact database commitments before and after the existing child SIGTERM path.
Changed data refuses forward cutover and retains live data for native recovery.
All 63 maintenance-controller tests passed. A matching optimized backend build
and live deployment/verification remain pending at this checkpoint.
The README server-install instructions and verified published 1.9.0-alpha ISO
link are commit `31ea755c`, including alpha/funds-at-own-risk and planned
pre-production hardening wording. Both commits were reviewed and merged through
ngit proposal `5bd850d3` (status applied), merge `ecc8cc80`. Main and the existing
`v1.9.0-alpha` tag passed local/ngit/Gitea parity. The ISO asset returned HTTP 200
and its published SHA-256 is
`aad5f0350428976969043079a63b0e7a8264dcee2574526bd34719c798c750af`.
This documentation update does not publish a new installer.
### IndeeHub sign-out UAT deployment — PASS with retained limitation
The dashboard/provider sign-out change was reviewed as ngit proposal
`8648b7362c3ddb2195a7f5466cab9c8ad224ca289996fec303f4a7f8091e7c42`
and merged at `2cb1bae5`. A first supervised Yaya attempt
`74ef081b-0e7a-49c0-8fd8-959ebcf4b2ec` correctly refused before target startup
because the maintenance controller recognized only coherently disabled
registration/publication flags, while this money-free UAT installation has both
flags enabled. Native recovery completed `Restored`, cleanup complete and
maintenance released; PostgreSQL, Redis, MinIO, relay and API identities were
preserved, and only the frontend/worker recovery containers were recreated. The
failed attempt and its private receipts remain retained; it was not retried or
reused.
The narrow controller correction accepts either coherently enabled or coherently
disabled UAT flags while continuing to reject missing, ambiguous or mixed flags
and preserving the exact API binary, zero monetary/business/outbox/entitlement/
revenue, absent-provider and absent-connection requirements. Sixty-three focused
controller tests passed. The required isolated backend suite passed 2,066 tests,
with five explicit opt-in ignores and zero failures. It was reviewed as ngit
proposal `ecac4574fdefcdb17b913b720d1f7b446db6cd9a6f6063f78a22d6b7af6d320c`,
merged at `5812f53c`, mirrored exactly to ngit and Gitea, and passed the local
main-ref parity check.
Yaya now runs optimized backend SHA256
`6a1389c8945f95183ea918b653f813e3125e4254ec3078e8daa1773d896573df`
with helper SHA256
`4e086c7b8ef985944f1a370d932d9107ec87d2164b004f130522d25a81184c23`.
The signed 69-app combined catalog remains SHA256
`88888f6a541e2b13b70e2ab57ae6c355420313d61fd47c152b4341b2cda3ab1a`.
After native recovery, a fresh seven-unit plan was bound to the actual recovered
frontend/worker recipes; its SHA256 is
`a6d26db482c86ff8dda6b2c2816473f2cdcbd5def98c9cf640b97e42d53206bd`.
Offline preparation preserved the exact seven originals without changing any
runtime. Dashboard index and provider SHA256 values are respectively
`f3cff3f8a27ae94318c34ef61638e22a73bb06fc076634ebe981309ee3c5008e`
and `ad4c93b3b25545dca1b391c5add75e5bc618c865290805f7ac5d1ad0fd18b469`.
Exactly one new authenticated `package.update` was submitted. Operation
`309f3d74-74d4-478a-a02e-cf077bab547d` completed Prepared → Editing → Starting →
**Committed**, cleanup complete; maintenance is **Released/committed**, and both
fresh database and four-volume restore proofs pass. Post-commit verification
passed all seven exact target units/images and healthy new runtimes, exact
database commitments, all 110 migration rows including the three reviewed
additions, 21 unchanged unrelated runtimes, 16 unchanged identity/operator
files, 18 unchanged unrelated units, cleared holds/fence, coherent enabled UAT
flags, nginx configuration, and matching host/container provider bytes.
A disposable live Chromium test injected valid-looking stale login hints without
using a real identity. The production API did not grant authentication; the
primary login credentials and active-account selection cleared, and the app
remained signed out after both normal and cache-bypassing hard reloads. Three
secondary API-client hints (`indeehub_api_token`, `indeehub_api_refresh`, and
`indeehub_api_expires`) remained in that synthetic profile but did not restore
authentication. Treat complete removal of those non-authorizing hints as retained
hardening rather than claiming every cache key was erased. The IndeeHub frontend
commits `0d6434e` and `71cf546a` remain local because that repository has no ngit
coordinate; do not publish them Gitea-only under the mirror policy.
### IndeeHub selected-identity regression — UAT reopened 9 October 2026
Operator UAT disproved the earlier sign-out acceptance: choosing the homeserver
identity could continue with a previously active saved account and display a
different npub. `loginWithExtension` caught selection/provider failures without
rethrowing, after which the modal read `accountManager.active`; that pointer could
still name the old account. IndeeHub commits `a4d3946` and `86256ee` now clear the
active pointer before an explicit native switch, propagate failure, bind the
authentication call to the exact returned signer account, and retain the
Archipelago provider hook in the source index. A regression starts with a stale
saved account and proves a failed selection cannot fall back to it while the
saved key itself remains preserved.
All 155 frontend tests, type checking and the production build pass. A live Yaya
frontend-only correction is installed with rollback under the operator's private
artifact directory. The served index references the provider, provider SHA256 is
still `ad4c93b3b25545dca1b391c5add75e5bc618c865290805f7ac5d1ad0fd18b469`,
the service worker was regenerated to invalidate the brief provider-less cache,
and `/health` passes. Automated Chromium confirms the provider loads and no stale
active account is restored. Real homeserver selection and displayed-npub matching
remain pending operator UAT; the current live filesystem correction is not yet a
new signed catalog/image activation and must not be described as durable managed
deployment acceptance.
## Combined Yaya UAT candidate — 9 October 2026 ## Combined Yaya UAT candidate — 9 October 2026
Follow-up operator request: retain all node-native identities; remove IndeeHub's
Create Account/private-key import and reset only its old browser accounts.
IndeeHub commit `7fbdc74` removes generation/import handlers and screens, performs
a one-time removal of `indeedhub-accounts`, `indeedhub-active-account` and app
session credentials, and corrects the default API base from browser localhost
to `/api`. The previous live hotfix was built without explicit API settings,
explaining its network error; its earlier success claims were premature.
Twenty-one focused authentication/reset tests pass and the production build
passes with `/api`, `/storage`, `/relay`, mock off and publication enabled.
This updated static bundle is deployed to Yaya's existing frontend container;
its index SHA256 is `a14834acab5fc9b42f55a92c540b9b765334ac7d2d2628ddc82bc1c5725d53ad`.
Node identity storage was not modified. Signed-image recreation and real native
identity sign-in acceptance remain outstanding.
The combined source is published on ngit and mirrored byte-for-byte to Gitea at The combined source is published on ngit and mirrored byte-for-byte to Gitea at
main `bb933d409187a8181e284a5a91a81b4e036b3036`, based on accepted main main `bb933d409187a8181e284a5a91a81b4e036b3036`, based on accepted main
`aff408cc`. Its integration commits retain each proposal head as a parent: `aff408cc`. Its integration commits retain each proposal head as a parent:
@@ -1197,3 +1327,46 @@ Public assets: [1.9.0-alpha release](https://source.archipelago-foundation.org/l
The separate follow-up branch now passes1,683 backend tests (four existing ignored) The separate follow-up branch now passes1,683 backend tests (four existing ignored)
and all1,222 frontend tests after correcting the storage classifier. These are and all1,222 frontend tests after correcting the storage classifier. These are
source test results, not inclusion in the published artifacts or live acceptance. source test results, not inclusion in the published artifacts or live acceptance.
## IndeeHub native login and registration repair — 2026-10-09 follow-up
ngit proposal `9d6de783f5022b3b859e0b1f3e881e389da4482dd5dd15ff29a40b4e25dafcb8`
was reviewed and merged once as
`83feb1806336a1204ea0d6232de5220d4f6656ad`. It retains installed/running
parent, existing node identity/pin, installed origins and legacy API component
checks while accepting managed stacks without a separate API package record.
See the latest checkpoint in `post-1.8.22-regressions-20261001.md` for exact
deployed hashes, preserved-state checks, backups and the failed earlier probes.
Yaya's authenticated registration-context RPC now passes on the optimized
candidate backend. Real native **Home Serve** authentication passes after
IndeeHub frontend `7b18912` and API profile correction `08586e3`: chosen node
key, signed session subject and API profile key match, with native consent and
normal signer dismissal. Frontend 21 focused tests, API two DTO tests, and both
production builds pass. The isolated Rust regression build is still running;
no unit-test pass is inferred from the live check.
The frontend/API module changes are running-container UAT patches, not a new
durable pinned app image. Full upload/publication and cached-identity-switch
acceptance remain open. No payment, public listing, identity replacement, fleet
catalog, OTA or ISO publication was performed by this repair.
## ngit UAT contribution disposition — 2026-10-09
The overlapping hardware proposals `a9165a5e`, `5a210008`, `a11eb1da`,
and `39c7c335` were reviewed as one contribution and merged once at
`a5637d9350aa05fa52136e736a64a5bccd1539c6`. ngit marked all four
applied after main publication; no duplicate Gitea merge was created.
The hardware frontend production build and six focused UI tests passed.
Terminal proposal `c1156d20` merged at `02671438d`; its dashboard-origin
security correction was separately reviewed as ngit proposal `2e6577db`
and merged at `bb7961277`. The terminal frontend build, two shell suites,
and isolated backend `terminal_origin_tests` passed. Declared-author proposal
`924f78e1` merged at `3667105b2`; its isolated backend regression passed.
The IndeeHub open-UAT checklist proposal `f9914a73` merged at `4b65879b6`.
After publishing main at `4b65879b6`, ngit showed no open/draft proposals.
`python3 scripts/check-git-mirrors.py --local` passed for local/ngit/Gitea
main at `4b65879b6`. This is a main-ref check only, not an `--all` historical
branch/tag audit, live-node acceptance, or permission to publish OTA/ISO.
+2
View File
@@ -33,6 +33,8 @@ planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
## Vendored ## Vendored
- `src/graphics/assets/grapheneos-boot-mask.png` — GrapheneOS boot artwork,
Apache-2.0; see the adjacent ATTRIBUTION.md for the pinned source and credits.
- `public/assets/icon/` — see ATTRIBUTION.md in that directory - `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT). (game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0; - `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
+6
View File
@@ -4470,6 +4470,12 @@ app.post('/rpc/v1', (req, res) => {
// ── OpenWRT / TollGate gateway (demo: a thoroughly-used gateway) ── // ── OpenWRT / TollGate gateway (demo: a thoroughly-used gateway) ──
case 'openwrt.get-status': { case 'openwrt.get-status': {
// Keep the local preview in the unconnected state so the router model
// is immediately visible. Set MOCK_OPENWRT_CONNECTED=1 to preview the
// connected dashboard instead.
if (!process.env.MOCK_OPENWRT_CONNECTED) {
return res.json({ error: { code: -32004, message: 'No router configured' } })
}
return res.json({ return res.json({
result: { result: {
host: '192.168.8.1', host: '192.168.8.1',
+946 -1032
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -41,6 +41,7 @@
"qr-scanner": "^1.4.2", "qr-scanner": "^1.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"qrloop": "^1.4.1", "qrloop": "^1.4.1",
"three": "^0.185.0",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.3.0",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
@@ -51,16 +52,17 @@
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/three": "^0.185.0",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^5.0.3",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dockerode": "^4.0.9", "dockerode": "^5.0.1",
"express": "^4.21.2", "express": "^4.21.2",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@@ -68,7 +70,7 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.2", "vite": "^7.2.2",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vitest": "^3.1.1", "vitest": "^5.0.3",
"vue-tsc": "^3.1.3", "vue-tsc": "^3.1.3",
"ws": "^8.18.0" "ws": "^8.18.0"
} }
@@ -1,13 +0,0 @@
# Device Artwork Attribution
The device illustrations in this directory are from the
**Meshtastic® project** — https://meshtastic.org
(https://github.com/meshtastic), © Meshtastic contributors,
licensed under GPL-3.0.
Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
identify compatible hardware. No endorsement by the Meshtastic project is
implied.
Any modifications to these images made for Archipelago are likewise available
under GPL-3.0 as part of this public repository.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 96 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="795.27 277.13 409.46 1319.35"><defs><style>.cls-1{fill:#353535;}.cls-2{fill:#1e1e1d;}.cls-3{fill:#b1a368;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8,.cls-9{fill:none;}.cls-4,.cls-6{stroke:#050606;}.cls-10,.cls-11,.cls-4,.cls-6,.cls-8{stroke-miterlimit:10;}.cls-4{stroke-width:2.41px;}.cls-5{fill:#30c2db;}.cls-6{stroke-width:3.91px;}.cls-7{fill:#dcf0f2;}.cls-10,.cls-11,.cls-8{stroke:#dcf0f2;}.cls-8{stroke-width:1.81px;}.cls-9{stroke:#17afbf;stroke-linecap:round;stroke-linejoin:round;stroke-width:7.23px;}.cls-10{stroke-width:1.78px;}.cls-11{stroke-width:1.81px;}</style></defs><g id="Layer_7" data-name="Layer 7"><path class="cls-1" fill="#353535" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-2" fill="#1e1e1d" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-2" fill="#1e1e1d" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-3" fill="#b1a368" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-3" fill="#b1a368" x="885.8" y="715.74" width="84.14" height="7.28"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="844.3" width="84.14" height="19.02"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="819.07" width="92.65" height="25.23"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="790.65" width="84.14" height="28.41"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="723.02" width="92.65" height="67.63"></rect><rect class="cls-4" stroke-width="2.41px" x="885.8" y="715.74" width="84.14" height="7.28"></rect><path class="cls-4" stroke-width="2.41px" d="M915.62,278.34h22.61a35,35,0,0,1,35,35V715.74a0,0,0,0,1,0,0H880.6a0,0,0,0,1,0,0V313.36A35,35,0,0,1,915.62,278.34Z"></path><rect class="cls-4" stroke-width="2.41px" x="880.6" y="340.15" width="92.65" height="7.54"></rect><rect class="cls-4" stroke-width="2.41px" x="880.6" y="356.68" width="92.65" height="7.54"></rect><rect class="cls-5" fill="#30c2db" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><rect class="cls-1" fill="#353535" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-6" stroke-width="3.91px" x="900.05" y="973.19" width="202.03" height="354.65" rx="16.4"></rect><rect class="cls-7" fill="#dcf0f2" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-7" fill="#dcf0f2" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="871.51" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><rect class="cls-4" stroke-width="2.41px" x="1070.16" y="890.41" width="55.42" height="31.12" rx="15.56"></rect><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="1537.01" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="841.7" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="913.26" r="16.25"></circle><circle class="cls-8" stroke-width="1.81px" cx="1157.32" cy="1504.51" r="16.25"></circle><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="942.51" y1="1592.42" x2="942.51" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="966.52" y1="1592.42" x2="966.52" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="990.57" y1="1592.42" x2="990.57" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1014.59" y1="1592.42" x2="1014.59" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1038.63" y1="1592.42" x2="1038.63" y2="1381.55"></line><line class="cls-9" stroke="#17afbf" stroke-width="7.23px" stroke-linecap="round" stroke-linejoin="round" x1="1062.65" y1="1592.42" x2="1062.65" y2="1381.55"></line><rect class="cls-4" stroke-width="2.41px" x="796.48" y="856.3" width="407.05" height="738.98" rx="47.74"></rect><path class="cls-10" stroke-width="1.78px" d="M1040.1,947.74H960.65A13.93,13.93,0,0,1,947,936.64l-10.23-49.2a13.93,13.93,0,0,1,13.64-16.77h97.72a13.93,13.93,0,0,1,13.75,16.18l-8,49.2A13.94,13.94,0,0,1,1040.1,947.74Z"></path><rect class="cls-11" stroke-width="1.81px" x="816.35" y="870.67" width="365.51" height="703.12" rx="32.37"></rect><rect class="cls-11" stroke-width="1.81px" x="888.77" y="963.84" width="223.2" height="374.66" rx="25.21"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="404.68 390.65 1217.15 959.26"><defs><style>.cls-1{fill:#dfeaf7;}.cls-2{fill:#17907f;}.cls-3{fill:#2b2b2b;}.cls-4,.cls-5{fill:none;stroke:#050606;stroke-miterlimit:10;}.cls-4{stroke-width:2.25px;}.cls-5{stroke-width:4px;}.cls-6{fill:#050606;}</style></defs><g id="Layer_5" data-name="Layer 5"><path class="cls-1" fill="#dfeaf7" d="M1517.73,392.65h0a102.1,102.1,0,0,0-102.1,102.1V770.37A40.62,40.62,0,0,1,1375,811H455.16a48.49,48.49,0,0,0-48.48,48.48v126a11.85,11.85,0,0,0,3.46,8.37l15.34,15.34a11.81,11.81,0,0,1,3.47,8.37v137.16a11.81,11.81,0,0,1-3.47,8.37l-15.34,15.34a11.85,11.85,0,0,0-3.46,8.37v112.67a48.49,48.49,0,0,0,48.48,48.49H1571.34a48.51,48.51,0,0,0,48.49-48.5V494.75A102.1,102.1,0,0,0,1517.73,392.65Zm-110.61,815V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><path class="cls-2" fill="#17907f" d="M1516,439.16c-30.23.91-53.92,26.54-53.92,56.79V770.37A87.11,87.11,0,0,1,1375,857.48H732.31A27.51,27.51,0,0,0,704.8,885v388.93a27.51,27.51,0,0,0,27.51,27.51h828.38a12.7,12.7,0,0,0,12.65-12.65v-794A55.69,55.69,0,0,0,1516,439.16Zm-108.9,768.47V954a33.14,33.14,0,0,1,66.27,0v253.65a33.14,33.14,0,0,1-66.27,0Z"></path><rect class="cls-3" fill="#2b2b2b" x="787.14" y="943.38" width="429.45" height="224.42"></rect><path class="cls-1" fill="#dfeaf7" d="M1478.6,915.35A54.23,54.23,0,0,0,1386,953.69v254.23a54.23,54.23,0,1,0,108.45,0V953.69A54,54,0,0,0,1478.6,915.35Zm-5.21,292.28a33.14,33.14,0,0,1-66.27,0V954a33.14,33.14,0,0,1,66.27,0Z"></path></g><g id="Layer_2" data-name="Layer 2"><path class="cls-4" stroke-width="2.25px" d="M1573.34,494.75v794a12.68,12.68,0,0,1-12.65,12.65H732.31a27.51,27.51,0,0,1-27.51-27.51V885a27.51,27.51,0,0,1,27.51-27.51H1375a87.11,87.11,0,0,0,87.11-87.11V496c0-30.25,23.69-55.88,53.92-56.79A55.69,55.69,0,0,1,1573.34,494.75Z"></path><path class="cls-5" stroke-width="4px" d="M410.14,1178.39,425.49,1163a11.78,11.78,0,0,0,3.46-8.35V1017.5a11.8,11.8,0,0,0-3.46-8.35l-15.35-15.36a11.77,11.77,0,0,1-3.46-8.35v-126A48.47,48.47,0,0,1,455.16,811H1375a40.63,40.63,0,0,0,40.63-40.63V494.75a102.1,102.1,0,0,1,102.1-102.1h0a102.1,102.1,0,0,1,102.1,102.1v804.66a48.51,48.51,0,0,1-48.49,48.5H455.16a48.48,48.48,0,0,1-48.48-48.49V1186.74A11.78,11.78,0,0,1,410.14,1178.39Z"></path><rect class="cls-4" stroke-width="2.25px" x="1407.12" y="920.85" width="66.26" height="319.9" rx="33.13"></rect><rect class="cls-4" stroke-width="2.25px" x="1386.03" y="899.46" width="108.46" height="362.69" rx="54.23"></rect><path class="cls-6" fill="#050606" d="M639.76,1070.55a2.91,2.91,0,0,1-2.91-2.91v-30.53a5.42,5.42,0,0,0-1.6-3.86l-32.44-32.44a11.86,11.86,0,0,1-3.5-8.44V901a12.52,12.52,0,0,0-12.51-12.51H483.92a12.7,12.7,0,0,0-12.68,12.69v76.78a24.13,24.13,0,0,0,7.11,17.18l14.33,14.33a24.13,24.13,0,0,0,17.18,7.11h50.75a11.86,11.86,0,0,1,8.44,3.5l24.26,24.26a12.47,12.47,0,0,1,3.68,8.88v14.46a2.91,2.91,0,1,1-5.81,0v-14.46a6.72,6.72,0,0,0-2-4.77l-24.26-24.26a6.09,6.09,0,0,0-4.33-1.8H509.86a29.91,29.91,0,0,1-21.29-8.81l-14.33-14.33a29.87,29.87,0,0,1-8.81-21.29V901.14a18.51,18.51,0,0,1,18.49-18.5H586.8A18.34,18.34,0,0,1,605.12,901v91.41a6.09,6.09,0,0,0,1.8,4.33l32.44,32.44a11.19,11.19,0,0,1,3.3,8v30.53A2.9,2.9,0,0,1,639.76,1070.55Z"></path><path class="cls-6" fill="#050606" d="M586.8,1289.46H483.92a18.51,18.51,0,0,1-18.49-18.5v-76.78a29.87,29.87,0,0,1,8.81-21.29l14.33-14.34a29.91,29.91,0,0,1,21.29-8.81h50.75a6.09,6.09,0,0,0,4.33-1.8l24.26-24.25a6.74,6.74,0,0,0,2-4.78v-14.46a2.91,2.91,0,0,1,5.81,0v14.46a12.51,12.51,0,0,1-3.68,8.89l-24.26,24.25a11.86,11.86,0,0,1-8.44,3.5H509.86a24.17,24.17,0,0,0-17.18,7.11L478.35,1177a24.09,24.09,0,0,0-7.11,17.18V1271a12.71,12.71,0,0,0,12.68,12.69H586.8a12.53,12.53,0,0,0,12.51-12.52v-91.4a11.83,11.83,0,0,1,3.5-8.44l32.44-32.44a5.46,5.46,0,0,0,1.6-3.87v-30.53a2.91,2.91,0,0,1,5.81,0V1135a11.19,11.19,0,0,1-3.3,8l-32.44,32.45a6.06,6.06,0,0,0-1.8,4.33v91.4A18.35,18.35,0,0,1,586.8,1289.46Z"></path><rect class="cls-4" stroke-width="2.25px" x="787.14" y="943.38" width="429.45" height="224.42"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 137 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.6 KiB

@@ -1,502 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 293.56501 470.885"
version="1.1"
id="svg207"
sodipodi:docname="buyer benmeshtastic 2-03.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
width="293.565"
height="470.88501"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview207"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.96807266"
inkscape:cx="186.96944"
inkscape:cy="370.32344"
inkscape:window-width="1728"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_2" />
<defs
id="defs1">
<style
id="style1">.cls-1{fill:#474746;}.cls-2{fill:#3a3a3a;}.cls-3{fill:#202121;}.cls-4{fill:#dbdcdd;}.cls-5{fill:#cbcbcc;}.cls-11,.cls-16,.cls-18,.cls-6{fill:none;}.cls-11,.cls-16,.cls-6{stroke:#1d1d1b;stroke-linecap:round;stroke-linejoin:round;}.cls-6{stroke-width:0.95px;}.cls-7{fill:#e5e2e5;}.cls-8{fill:#444e85;}.cls-21,.cls-22,.cls-9{fill:#1d1d1b;}.cls-10{fill:#d1b066;}.cls-11{stroke-width:1.3px;}.cls-12{fill:#d8b258;}.cls-13{fill:#b28e45;}.cls-14{fill:#a6a9af;}.cls-15{fill:#d1c854;}.cls-16{stroke-width:1.01px;}.cls-17{fill:#fff;}.cls-18{stroke:#fff;stroke-miterlimit:10;}.cls-19{fill:#ba9c61;}.cls-20{fill:#715c34;}.cls-21{font-size:13.29px;}.cls-21,.cls-22{font-family:LucidaConsole, Lucida Console;}.cls-22{font-size:8.44px;}</style>
</defs>
<g
id="Layer_2"
data-name="Layer 2"
transform="translate(-106.20269,-157.045)">
<path
class="cls-1" fill="#474746"
d="m 396.14,293.93 v 311.36 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 v -225.7 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.6 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,21.94 z"
id="path1" />
<path
class="cls-2" fill="#3a3a3a"
d="m 358.92,384 a 23.67,23.67 0 0 1 23.64,23.64 v 176.6 a 23.67,23.67 0 0 1 -23.64,23.64 H 147.31 A 23.67,23.67 0 0 1 123.67,584.24 V 407.61 A 23.67,23.67 0 0 1 147.31,384 h 211.61 m 0,-5 H 147.31 a 28.64,28.64 0 0 0 -28.64,28.64 v 176.6 a 28.65,28.65 0 0 0 28.64,28.64 h 211.61 a 28.64,28.64 0 0 0 28.64,-28.64 V 407.61 A 28.64,28.64 0 0 0 358.92,379 Z"
id="path2" />
<rect
class="cls-2" fill="#3a3a3a"
x="237.61"
y="284.09"
width="139.52"
height="79.300003"
rx="8.3900003"
id="rect2" />
<rect
class="cls-3" fill="#202121"
x="244.67999"
y="289.66"
width="125.32"
height="67.370003"
rx="2"
id="rect3" />
<path
class="cls-2" fill="#3a3a3a"
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
id="path3" />
<path
class="cls-1" fill="#474746"
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61 L 240.25,272"
id="path4" />
<path
class="cls-4" fill="#dbdcdd"
d="m 353.11,172.61 a 54.23,54.23 0 0 0 -32.38,-10.67 h -49 a 54.49,54.49 0 0 0 -54.49,54.48 V 282 h -5.21 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.24,54.24 0 0 1 37.59,15.09 z"
id="path5" />
<path
class="cls-5" fill="#cbcbcc"
d="m 370,212 v 51.84 h -21.49 l -9.24,-46.17 V 209 A 19.5,19.5 0 0 0 319.79,189.52 H 260.87 A 19.48,19.48 0 0 0 241.39,209 v 60.74 c -1.23,0.56 -0.86,2.13 -0.86,2.13 l -6.91,1.75 c -5.66,1.43 -7.63,4.78 -9.44,7 -1.81,2.22 -2,1.41 -2,1.41 h -4.89 v -65.61 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 a 54.23,54.23 0 0 1 32.38,10.67 v 0 A 54.3,54.3 0 0 1 370,212 Z"
id="path6" />
<path
class="cls-6" stroke-width="0.95px"
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
id="path7" />
<path
class="cls-2" fill="#3a3a3a"
d="M 329.33,586.79 H 198 A 24.51,24.51 0 0 1 173.52,562.31 V 429 A 24.5,24.5 0 0 1 198,404.51 H 329.33 A 24.49,24.49 0 0 1 353.8,429 v 133.31 a 24.5,24.5 0 0 1 -24.47,24.48 z m -128.81,-27 H 326.8 V 431.51 H 200.52 Z"
id="path8" />
<rect
class="cls-7" fill="#e5e2e5"
x="187.02"
y="418.01001"
width="153.28"
height="155.27"
rx="10.97"
id="rect8" />
<path
class="cls-3" fill="#202121"
d="m 166.62,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path9" />
<path
class="cls-2" fill="#3a3a3a"
d="m 174.77,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
id="path10" />
<path
class="cls-3" fill="#202121"
d="m 359.9,386.56 a 12.85,12.85 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path11" />
<path
class="cls-2" fill="#3a3a3a"
d="m 368.05,399.41 a 8.15,8.15 0 1 1 -8.15,-8.15 8.15,8.15 0 0 1 8.15,8.15 z"
id="path12" />
<path
class="cls-3" fill="#202121"
d="m 359.9,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.85,12.85 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path13" />
<path
class="cls-2" fill="#3a3a3a"
d="m 368.05,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
id="path14" />
<path
class="cls-3" fill="#202121"
d="m 166.62,579.56 a 12.86,12.86 0 1 0 12.86,12.85 12.86,12.86 0 0 0 -12.86,-12.85 z m 0,21 a 8.15,8.15 0 1 1 8.15,-8.15 8.14,8.14 0 0 1 -8.15,8.15 z"
id="path15" />
<path
class="cls-2" fill="#3a3a3a"
d="m 174.77,592.41 a 8.15,8.15 0 1 1 -8.15,-8.14 8.15,8.15 0 0 1 8.15,8.14 z"
id="path16" />
<path
class="cls-8" fill="#444e85"
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
id="path17" />
<path
class="cls-8" fill="#444e85"
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
id="path18" />
<path
class="cls-8" fill="#444e85"
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
id="path19" />
<path
class="cls-8" fill="#444e85"
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
id="path20" />
<path
class="cls-8" fill="#444e85"
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
id="path21" />
<path
class="cls-8" fill="#444e85"
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
id="path22" />
<path
class="cls-8" fill="#444e85"
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
id="path23" />
<path
class="cls-8" fill="#444e85"
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
id="path24" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path25" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
id="path26" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path27" />
<path
class="cls-9" fill="#1d1d1b"
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
id="path28" />
<path
class="cls-9" fill="#1d1d1b"
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
id="path29" />
<path
class="cls-9" fill="#1d1d1b"
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
id="path30" />
<path
class="cls-1" fill="#474746"
d="m 384.27,274.39 v -3.8 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31 c 0,0 74.61,0 76.35,0 0.32,0 6.89,-0.2 10.16,2.49 z"
id="path67" />
<path
class="cls-15" fill="#d1c854"
d="m 223.32,282 a 21.84,21.84 0 0 0 -3.5,11.91 v 41.6 a 21.88,21.88 0 0 1 -3,11 L 204.7,339.3 a 8,8 0 0 1 -3.9,-6.86 V 290 a 8,8 0 0 1 8,-8 z"
id="path68" />
<path
class="cls-2" fill="#3a3a3a"
d="m 374.11,275.9 a 18,18 0 0 1 18,18 v 311.39 a 18.05,18.05 0 0 1 -18,18 H 128.65 a 18.05,18.05 0 0 1 -18,-18 v -225.7 a 18,18 0 0 1 18,-18 h 69.14 a 26.05,26.05 0 0 0 26,-26 v -41.6 a 18,18 0 0 1 18,-18 h 132.32 m 0,-4 H 241.85 a 22,22 0 0 0 -22,22 v 41.6 a 22,22 0 0 1 -22,22 h -69.2 a 22,22 0 0 0 -22,22 v 225.7 a 22,22 0 0 0 22,22 h 245.46 a 22,22 0 0 0 22,-22 V 293.93 a 22,22 0 0 0 -22,-22 z"
id="path69" />
</g>
<g
id="Layer_7"
data-name="Layer 7"
transform="translate(-106,-157.045)">
<path
class="cls-11" stroke-width="1.3px"
d="m 396.14,293.93 v 311.35 a 22,22 0 0 1 -22,22 H 128.65 a 22,22 0 0 1 -22,-22 V 379.59 a 22,22 0 0 1 22,-22 h 69.14 a 22,22 0 0 0 22,-22 v -41.66 a 22,22 0 0 1 22,-22 h 132.32 a 22,22 0 0 1 22.03,22 z"
id="path70" />
<rect
class="cls-11" stroke-width="1.3px"
x="118.67"
y="378.97"
width="268.88"
height="233.91"
rx="28.639999"
id="rect70" />
<rect
class="cls-11" stroke-width="1.3px"
x="237.61"
y="284.09"
width="139.52"
height="79.300003"
rx="8.3900003"
id="rect71" />
<rect
class="cls-11" stroke-width="1.3px"
x="244.67999"
y="289.66"
width="125.32"
height="67.370003"
rx="2"
id="rect72" />
<path
class="cls-11" stroke-width="1.3px"
d="M 151.55,352.34 H 194 a 3.83,3.83 0 0 1 3.83,3.83 v 1.39 h -50.11 v -1.39 a 3.83,3.83 0 0 1 3.83,-3.83 z"
id="path72" />
<path
class="cls-16" stroke-width="1.01px"
d="m 240,272 a 3,3 0 0 1 3,-2.69 h 24.48 a 3,3 0 0 1 3,2.61"
id="path73" />
<rect
class="cls-10" fill="#d1b066"
x="322.39001"
y="253.42999"
width="37.77"
height="10.45"
id="rect73" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path74" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path75" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path76" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path77" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path78" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path79" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path80" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path81" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path82" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path83" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path84" />
<path
class="cls-10" fill="#d1b066"
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path85" />
<rect
class="cls-10" fill="#d1b066"
x="328.20001"
y="217.71001"
width="26.27"
height="4.9099998"
id="rect85" />
<rect
class="cls-11" stroke-width="1.3px"
x="322.63"
y="253.42999"
width="18.77"
height="10.45"
id="rect86" />
<rect
class="cls-11" stroke-width="1.3px"
x="341.39999"
y="253.42999"
width="18.77"
height="10.45"
id="rect87" />
<rect
class="cls-6" stroke-width="0.95px"
x="328.20001"
y="217.71001"
width="26.27"
height="5.29"
id="rect88" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,250.81 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path88" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,248.28 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path89" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,245.75 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path90" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,243.22 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path91" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,240.69 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path92" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,238.17 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path93" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,235.64 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path94" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,233.11 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.63 26.39,0.1 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path95" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,230.58 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path96" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,228.05 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path97" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,225.52 -1,0.64 a 0.68,0.68 0 0 0 0,1.16 l 1,0.64 26.39,0.09 1,-0.64 a 0.68,0.68 0 0 0 0,-1.15 l -1,-0.64"
id="path98" />
<path
class="cls-6" stroke-width="0.95px"
d="m 328.2,223 -1,0.64 a 0.67,0.67 0 0 0 0,1.15 l 1,0.64 26.39,0.09 1,-0.63 a 0.69,0.69 0 0 0 0,-1.16 l -1,-0.64"
id="path99" />
<path
class="cls-11" stroke-width="1.3px"
d="m 384.27,274.39 v 0 -3.79 a 6.7,6.7 0 0 0 -6.71,-6.71 h -73.09 a 6.7,6.7 0 0 0 -6.71,6.71 v 1.31"
id="path100" />
<path
class="cls-11" stroke-width="1.3px"
d="m 223.32,282 h -14.53 a 8,8 0 0 0 -8,8 v 42.43 a 8,8 0 0 0 3.9,6.86 l 12.16,7.25"
id="path101" />
<path
class="cls-6" stroke-width="0.95px"
d="m 212.06,282 v -70 a 54.49,54.49 0 0 1 54.49,-54.48 h 49 A 54.48,54.48 0 0 1 370,212 v 51.84"
id="path102" />
<path
class="cls-6" stroke-width="0.95px"
d="M 241.39,269.77 V 209 a 19.48,19.48 0 0 1 19.48,-19.48 h 58.92 A 19.48,19.48 0 0 1 339.27,209 v 8.68"
id="path103" />
<path
class="cls-6" stroke-width="0.95px"
d="m 396.42,323.74 h 1.21 a 1.46,1.46 0 0 1 1.46,1.46 v 2.17 a 1.46,1.46 0 0 1 -1.46,1.46 h -1.21 z"
id="path104" />
<rect
class="cls-6" stroke-width="0.95px"
x="187.02"
y="418.01001"
width="153.28"
height="155.27"
rx="10.97"
id="rect104" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="399.41"
r="8.1499996"
id="circle140" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="399.41"
r="12.85"
id="circle141" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="399.41"
r="8.1499996"
id="circle142" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="399.41"
r="12.85"
id="circle143" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="592.40997"
r="8.1499996"
id="circle144" />
<circle
class="cls-6" stroke-width="0.95px"
cx="359.89999"
cy="592.40997"
r="12.85"
id="circle145" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="592.40997"
r="8.1499996"
id="circle146" />
<circle
class="cls-6" stroke-width="0.95px"
cx="166.62"
cy="592.40997"
r="12.85"
id="circle147" />
<path
class="cls-8" fill="#444e85"
d="m 253.12,315.37 h 5.46 v 15.27 h 8.51 v 5 h -14 z"
id="path147" />
<path
class="cls-8" fill="#444e85"
d="m 269.79,315.37 h 5.48 v 20.25 h -5.48 z"
id="path148" />
<path
class="cls-8" fill="#444e85"
d="m 279.18,315.37 h 5.46 v 15.27 h 8.52 v 5 h -14 z"
id="path149" />
<path
class="cls-8" fill="#444e85"
d="m 292,315.37 h 6 l 3.56,6.84 3.56,-6.84 h 6 l -6.87,11.77 v 8.48 h -5.47 v -8.48 z"
id="path150" />
<path
class="cls-8" fill="#444e85"
d="m 320.33,328.29 v -4.21 h 8.44 v 8.63 a 16.91,16.91 0 0 1 -4.28,2.58 13,13 0 0 1 -4.42,0.68 9.6,9.6 0 0 1 -5.13,-1.23 8,8 0 0 1 -3.07,-3.66 13.45,13.45 0 0 1 -1.09,-5.58 13.15,13.15 0 0 1 1.19,-5.77 8.3,8.3 0 0 1 3.5,-3.72 10,10 0 0 1 4.83,-1 11.86,11.86 0 0 1 4.38,0.61 5.69,5.69 0 0 1 2.41,1.88 8.8,8.8 0 0 1 1.44,3.24 l -5.27,1.08 a 3.24,3.24 0 0 0 -1.1,-1.76 3.15,3.15 0 0 0 -2,-0.6 3.36,3.36 0 0 0 -2.86,1.43 7.54,7.54 0 0 0 -1.07,4.52 c 0,2.19 0.36,3.76 1.08,4.7 a 3.57,3.57 0 0 0 3,1.41 5.11,5.11 0 0 0 1.75,-0.31 9.16,9.16 0 0 0 1.91,-1 v -1.91 z"
id="path151" />
<path
class="cls-8" fill="#444e85"
d="m 331.32,325.51 c 0,-3.3 0.81,-5.88 2.41,-7.72 a 8.46,8.46 0 0 1 6.72,-2.76 8.55,8.55 0 0 1 6.79,2.71 q 2.38,2.72 2.39,7.61 a 14.13,14.13 0 0 1 -1,5.82 8.19,8.19 0 0 1 -3,3.54 8.93,8.93 0 0 1 -4.91,1.26 10,10 0 0 1 -4.94,-1.09 8,8 0 0 1 -3.18,-3.45 12.89,12.89 0 0 1 -1.28,-5.92 z m 5.46,0 a 7.49,7.49 0 0 0 1,4.41 3.44,3.44 0 0 0 5.43,0 q 1,-1.31 1,-4.71 a 6.89,6.89 0 0 0 -1,-4.17 3.25,3.25 0 0 0 -2.73,-1.32 3.13,3.13 0 0 0 -2.65,1.34 7.6,7.6 0 0 0 -1.05,4.48 z"
id="path152" />
<path
class="cls-8" fill="#444e85"
d="m 356,322.64 v -4.46 h 1.89 a 3.22,3.22 0 0 1 1,0.12 1,1 0 0 1 0.52,0.43 1.3,1.3 0 0 1 0.2,0.7 1.18,1.18 0 0 1 -0.3,0.83 1.44,1.44 0 0 1 -0.88,0.42 2,2 0 0 1 0.48,0.37 5,5 0 0 1 0.51,0.72 l 0.55,0.87 h -1.08 l -0.65,-1 a 8,8 0 0 0 -0.47,-0.65 0.82,0.82 0 0 0 -0.28,-0.19 1.54,1.54 0 0 0 -0.45,-0.05 h -0.18 v 1.86 z m 0.9,-2.57 h 0.66 a 3.91,3.91 0 0 0 0.81,-0.05 0.51,0.51 0 0 0 0.26,-0.19 0.59,0.59 0 0 0 0.09,-0.34 0.52,0.52 0 0 0 -0.12,-0.36 0.62,0.62 0 0 0 -0.34,-0.18 h -1.36 z"
id="path153" />
<path
class="cls-8" fill="#444e85"
d="m 357.66,326.28 a 5.71,5.71 0 1 1 5.71,-5.71 5.71,5.71 0 0 1 -5.71,5.71 z m 0,-10.12 a 4.42,4.42 0 1 0 4.41,4.41 4.41,4.41 0 0 0 -4.41,-4.41 z"
id="path154" />
<path
class="cls-3" fill="#202121"
d="m 135.34,431.15 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path155" />
<path
class="cls-3" fill="#202121"
d="m 135.34,455 h 26.1 v 5.73 h -26.1 z"
id="path156" />
<path
class="cls-3" fill="#202121"
d="m 135.34,466.39 h 25.89 v 5.73 h -21.49 v 14.25 h -4.4 z"
id="path157" />
<path
class="cls-3" fill="#202121"
d="m 135.34,494.27 h 11 l 15.11,-10.4 v 6.72 l -10.32,6.68 10.32,6.55 v 6.6 L 146.29,500 h -10.95 z"
id="path158" />
<path
class="cls-3" fill="#202121"
d="m 144.94,526.48 h 4.4 v 12.35 h -10.4 a 15.78,15.78 0 0 1 -2.82,-5.22 20.31,20.31 0 0 1 -1.22,-6.92 16.61,16.61 0 0 1 1.72,-7.77 11.34,11.34 0 0 1 4.91,-5 14.83,14.83 0 0 1 6.95,-1.67 14,14 0 0 1 7.25,1.86 12.12,12.12 0 0 1 4.86,5.44 15.74,15.74 0 0 1 1.3,6.8 q 0,5.28 -2,8.26 a 9.16,9.16 0 0 1 -5.64,3.82 l -1,-5.69 a 5.51,5.51 0 0 0 3,-2.26 7.25,7.25 0 0 0 1.11,-4.13 8.14,8.14 0 0 0 -2.19,-6 q -2.19,-2.21 -6.49,-2.21 c -3.1,0 -5.43,0.75 -7,2.24 a 7.82,7.82 0 0 0 -2.33,5.89 10.72,10.72 0 0 0 0.65,3.61 12.93,12.93 0 0 0 1.58,3.11 h 3.31 z"
id="path159" />
<path
class="cls-3" fill="#202121"
d="m 148.23,543 a 15.54,15.54 0 0 1 6.7,1.3 12.53,12.53 0 0 1 3.58,2.64 11.41,11.41 0 0 1 2.35,3.67 17,17 0 0 1 1,6.12 q 0,6.27 -3.58,10 -3.58,3.73 -9.95,3.77 -6.33,0 -9.89,-3.74 -3.56,-3.74 -3.57,-10 0,-6.33 3.55,-10.07 3.55,-3.74 9.81,-3.69 z m 0.18,5.91 c -2.95,0 -5.2,0.74 -6.72,2.23 a 8.12,8.12 0 0 0 0,11.28 q 2.28,2.19 6.81,2.19 4.53,0 6.7,-2.14 a 8.43,8.43 0 0 0 0,-11.39 q -2.28,-2.22 -6.79,-2.22 z"
id="path160" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 123 KiB

@@ -1,109 +0,0 @@
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg105" sodipodi:docname="thinknode_m1.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="397.31 77.24 361 863.17">
<sodipodi:namedview id="namedview105" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="4.066786" inkscape:cx="564.94244" inkscape:cy="741.49463" inkscape:window-width="1472" inkscape:window-height="890" inkscape:window-x="0" inkscape:window-y="38" inkscape:window-maximized="1" inkscape:current-layer="Layer_3"/>
<defs id="defs1">
<style id="style1">.cls-1{fill:#353535;}.cls-2{fill:#262626;}.cls-3{fill:#cccccb;}.cls-4{fill:#2b2b2b;}.cls-5{fill:#f05043;}.cls-6{fill:#3d3d3d;}.cls-7{fill:#231f20;}.cls-8{fill:none;stroke:#000;stroke-miterlimit:10;}</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path class="cls-1" fill="#353535" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path1"/>
<rect class="cls-2" fill="#262626" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect1"/>
<rect class="cls-1" fill="#353535" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect2"/>
<rect class="cls-3" fill="#cccccb" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect3"/>
<path class="cls-1" fill="#353535" d="M507.38,77.74H472.16a7,7,0,0,0-7,7V359.93L452.2,396.26v39.91H561V396.26l-13.3-36V84.15a6.41,6.41,0,0,0-6.41-6.41Z" id="path3"/>
<rect class="cls-2" fill="#262626" x="454.25" y="436.17" width="104.38" height="3.65" id="rect4"/>
<polygon class="cls-1" fill="#353535" points="442.68 449.91 448.16 440.69 562.98 440.69 570.51 449.91 442.68 449.91" id="polygon4"/>
<rect class="cls-1" fill="#353535" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.8" id="rect5"/>
<path class="cls-2" fill="#262626" d="M611.2,356v-5.48a3.13,3.13,0,0,1,3.13-3.13h86.35a3.13,3.13,0,0,1,3.13,3.13V356Z" id="path5"/>
<rect class="cls-2" fill="#262626" x="611.07" y="416.61" width="92.74" height="23.22" id="rect6"/>
<polygon class="cls-1" fill="#353535" points="592.99 449.91 598.47 440.69 713.42 440.69 720.82 449.91 592.99 449.91" id="polygon6"/>
<rect class="cls-2" fill="#262626" x="751.94" y="555.13" width="5.87" height="47.48" id="rect7"/>
<path class="cls-2" fill="#262626" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path7"/>
<path class="cls-2" fill="#262626" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path8"/>
<path class="cls-4" fill="#2b2b2b" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path9"/>
<rect class="cls-2" fill="#262626" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect9"/>
<circle class="cls-5" fill="#f05043" cx="518.72" cy="853.89" r="5.48" id="circle9"/>
<circle class="cls-1" fill="#353535" cx="640.14" cy="853.89" r="5.48" id="circle10"/>
<circle class="cls-1" fill="#353535" cx="541.83" cy="853.89" r="5.48" id="circle11"/>
<circle class="cls-1" fill="#353535" cx="567.67" cy="853.89" r="5.48" id="circle12"/>
<circle class="cls-1" fill="#353535" cx="593.51" cy="853.89" r="5.48" id="circle13"/>
<circle class="cls-1" fill="#353535" cx="616.82" cy="853.89" r="5.48" id="circle14"/>
<path class="cls-4" fill="#2b2b2b" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path14"/>
<path class="cls-4" fill="#2b2b2b" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path15"/>
<path class="cls-6" fill="#3d3d3d" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path16"/>
<path class="cls-6" fill="#3d3d3d" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path17"/>
<path class="cls-6" fill="#3d3d3d" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path18"/>
<path class="cls-6" fill="#3d3d3d" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path19"/>
<path class="cls-6" fill="#3d3d3d" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path20"/>
<path class="cls-6" fill="#3d3d3d" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path21"/>
<path class="cls-1" fill="#353535" d="M477,924.32h-3V903.09h-7.46v-2.65h17.9v2.65H477Z" id="path51"/>
<path class="cls-1" fill="#353535" d="M490.59,906.36c0,.43,0,.86,0,1.31s-.07.85-.12,1.2h.2a5.17,5.17,0,0,1,1.44-1.54,7.08,7.08,0,0,1,1.94-.92,7.81,7.81,0,0,1,2.21-.31,8.61,8.61,0,0,1,3.63.68,4.62,4.62,0,0,1,2.19,2.13,8.22,8.22,0,0,1,.73,3.74v11.67h-2.9V912.85a4.72,4.72,0,0,0-1-3.24,3.92,3.92,0,0,0-3.05-1.07,5.65,5.65,0,0,0-3.14.75,4.07,4.07,0,0,0-1.62,2.21,11.17,11.17,0,0,0-.49,3.56v9.26h-2.94V898.91h2.94Z" id="path52"/>
<path class="cls-1" fill="#353535" d="M509.82,899.67a1.73,1.73,0,0,1,1.19.46,2.17,2.17,0,0,1,0,2.82,1.74,1.74,0,0,1-1.19.47,1.77,1.77,0,0,1-1.24-.47,2.24,2.24,0,0,1,0-2.82A1.76,1.76,0,0,1,509.82,899.67Zm1.44,6.73v17.92h-2.94V906.4Z" id="path53"/>
<path class="cls-1" fill="#353535" d="M525.58,906.06a6.8,6.8,0,0,1,4.85,1.56c1.09,1,1.63,2.71,1.63,5v11.67h-2.91V912.85a4.67,4.67,0,0,0-1-3.24,3.88,3.88,0,0,0-3-1.07c-2,0-3.36.56-4.11,1.67a8.54,8.54,0,0,0-1.14,4.82v9.29H517V906.4h2.37l.44,2.44h.16a5.68,5.68,0,0,1,1.49-1.56,6.41,6.41,0,0,1,2-.92A8.13,8.13,0,0,1,525.58,906.06Z" id="path54"/>
<path class="cls-1" fill="#353535" d="M540.53,912.18c0,.36,0,.83,0,1.41s-.07,1.08-.09,1.5h.14l.6-.77.82-1c.28-.34.52-.63.72-.85l5.72-6.05h3.44l-7.26,7.66,7.76,10.26h-3.54L542.57,916l-2,1.78v6.58h-2.91V898.91h2.91Z" id="path55"/>
<path class="cls-1" fill="#353535" d="M574.81,924.32H571.3l-12.78-19.83h-.13c0,.51.08,1.12.11,1.82s.07,1.45.1,2.24,0,1.61,0,2.43v13.34h-2.77V900.44h3.48l12.74,19.77h.13c0-.36-.05-.89-.08-1.6s-.07-1.5-.1-2.35,0-1.62,0-2.34V900.44h2.81Z" id="path56"/>
<path class="cls-1" fill="#353535" d="M596.48,915.33a12.32,12.32,0,0,1-.58,4,8.32,8.32,0,0,1-1.67,2.93,7,7,0,0,1-2.65,1.82,9.31,9.31,0,0,1-3.46.62,8.63,8.63,0,0,1-3.28-.62,7.29,7.29,0,0,1-2.61-1.82,8.57,8.57,0,0,1-1.72-2.93,11.76,11.76,0,0,1-.62-4,11.43,11.43,0,0,1,1-5,7.12,7.12,0,0,1,2.87-3.14,8.76,8.76,0,0,1,4.45-1.09,8.4,8.4,0,0,1,4.3,1.09,7.45,7.45,0,0,1,2.91,3.14A11,11,0,0,1,596.48,915.33Zm-13.54,0a10.93,10.93,0,0,0,.55,3.66,4.82,4.82,0,0,0,1.72,2.39,5.69,5.69,0,0,0,5.95,0,4.78,4.78,0,0,0,1.73-2.39,10.93,10.93,0,0,0,.55-3.66,10.36,10.36,0,0,0-.57-3.65,4.84,4.84,0,0,0-1.72-2.32,5,5,0,0,0-3-.82,4.5,4.5,0,0,0-4,1.8A8.79,8.79,0,0,0,582.94,915.33Z" id="path57"/>
<path class="cls-1" fill="#353535" d="M607.49,924.66a6.67,6.67,0,0,1-5.35-2.33c-1.34-1.54-2-3.86-2-6.94s.67-5.4,2-7a6.74,6.74,0,0,1,5.37-2.36,7.71,7.71,0,0,1,2.44.35,6.37,6.37,0,0,1,1.81,1,6.58,6.58,0,0,1,1.3,1.34h.2c0-.29-.06-.72-.11-1.29s-.09-1-.09-1.36v-7.15H616v25.41h-2.38l-.43-2.4h-.14a6.51,6.51,0,0,1-1.3,1.38,5.9,5.9,0,0,1-1.82,1A7.4,7.4,0,0,1,607.49,924.66Zm.46-2.44c1.9,0,3.23-.52,4-1.56a7.84,7.84,0,0,0,1.16-4.7v-.53a9.84,9.84,0,0,0-1.11-5.14c-.73-1.19-2.09-1.79-4.08-1.79a4,4,0,0,0-3.56,1.89,9.46,9.46,0,0,0-1.19,5.07,9,9,0,0,0,1.19,5A4,4,0,0,0,608,922.22Z" id="path58"/>
<path class="cls-1" fill="#353535" d="M628.62,906.06a7.53,7.53,0,0,1,4,1,6.62,6.62,0,0,1,2.54,2.82,9.69,9.69,0,0,1,.89,4.27v1.77H623.74a6.75,6.75,0,0,0,1.56,4.63,5.42,5.42,0,0,0,4.16,1.59,12.58,12.58,0,0,0,3-.32,16.78,16.78,0,0,0,2.72-.92v2.58a13.84,13.84,0,0,1-2.71.89,16.15,16.15,0,0,1-3.17.28,9.46,9.46,0,0,1-4.5-1,7.15,7.15,0,0,1-3-3.09,10.61,10.61,0,0,1-1.09-5,12,12,0,0,1,1-5,7.33,7.33,0,0,1,6.94-4.38Zm0,2.41a4.26,4.26,0,0,0-3.33,1.36,6.34,6.34,0,0,0-1.45,3.76h9.13a7.05,7.05,0,0,0-.47-2.68,3.94,3.94,0,0,0-1.42-1.79A4.33,4.33,0,0,0,628.59,908.47Z" id="path59"/>
<path class="cls-1" fill="#353535" d="M639.36,913h8.1v2.74h-8.1Z" id="path60"/>
<path class="cls-1" fill="#353535" d="M662.87,924.32,655,903.39h-.13c0,.44.08,1,.12,1.7s.06,1.45.08,2.26,0,1.65,0,2.49v14.48h-2.77V900.44h4.45L664.15,920h.13l7.49-19.57h4.42v23.88h-3V909.64c0-.78,0-1.55,0-2.32s.06-1.5.1-2.18.08-1.25.1-1.72h-.13l-8,20.9Z" id="path61"/>
<path class="cls-1" fill="#353535" d="M688.16,924.32V909.41c0-.63,0-1.3,0-2s0-1.42.07-2.1,0-1.27,0-1.76c-.35.38-.66.69-.92.92s-.6.54-1,.92l-2.41,2-1.57-2,6.26-4.89H691v23.88Z" id="path62"/>
<path class="cls-1" fill="#353535" d="M502.55,894.19l-2.22-2.37a14.1,14.1,0,0,1,18.94-.33l-2.13,2.44a10.9,10.9,0,0,0-7.16-2.69A10.78,10.78,0,0,0,502.55,894.19Z" id="path63"/>
<path class="cls-1" fill="#353535" d="M506.38,897.85l-2.4-2.18a8.08,8.08,0,0,1,11.61-.39l-2.24,2.33a4.85,4.85,0,0,0-7,.24Z" id="path64"/>
</g>
<g id="Layer_2" data-name="Layer 2">
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M472.55,77.74h68.09a7.43,7.43,0,0,1,7.43,7.43V360.26a0,0,0,0,1,0,0h-83a0,0,0,0,1,0,0V85.17A7.43,7.43,0,0,1,472.55,77.74Z" id="path65"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="123.91" x2="548.07" y2="123.91" id="line65"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="465.12" y1="149.74" x2="548.07" y2="149.74" id="line66"/>
<polyline class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" points="465.12 360.26 452.2 396.26 452.2 436.17 560.99 436.17 560.99 396.26 548.07 360.26" id="polyline66"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="452.2" y1="396.26" x2="560.98" y2="396.26" id="line67"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M449.69,440.17H562a3.26,3.26,0,0,1,2.56,1.55l5.93,8.19H442.68l4-7.49A3.65,3.65,0,0,1,449.69,440.17Z" id="path67"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M600,440.17H712.33a3.24,3.24,0,0,1,2.56,1.55l5.93,8.19H593l4-7.49A3.65,3.65,0,0,1,600,440.17Z" id="path68"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="454.45" y1="436.17" x2="454.45" y2="439.83" id="line68"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="558.64" y1="436.17" x2="558.64" y2="439.83" id="line69"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="604.37" y="355.96" width="105.26" height="60.65" rx="4.87" id="rect69"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="611.07" y1="416.61" x2="611.07" y2="439.83" id="line70"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="703.81" y1="416.61" x2="703.81" y2="439.83" id="line71"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M614.2,347.35h86.48a3.13,3.13,0,0,1,3.13,3.13V356a0,0,0,0,1,0,0H611.07a0,0,0,0,1,0,0v-5.48A3.13,3.13,0,0,1,614.2,347.35Z" id="path71"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="570.51" y1="449.91" x2="592.99" y2="449.91" id="line72"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M720.82,449.91h11.45a19.68,19.68,0,0,1,19.67,19.68V905.12a28.48,28.48,0,0,1-28.47,28.48H425.72A27.77,27.77,0,0,1,397.81,906V470.82a21,21,0,0,1,21.13-20.91h23.74" id="path72"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="447.12" y="523.83" width="266.09" height="266.09" rx="22.7" id="rect72"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="465.51" y="542.22" width="229.3" height="229.3" rx="12.91" id="rect73"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="476.07" y="552.78" width="208.17" height="208.17" rx="7.83" id="rect74"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M494.46,449.91v5.59a12.22,12.22,0,0,0,1.05,4.95l8.6,19.42a9.43,9.43,0,0,0,8.62,5.61h3.61a9.43,9.43,0,0,0,9.43-9.43V449.91" id="path74"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M672.56,449.91v5.59a12.22,12.22,0,0,1-1,4.95l-8.6,19.42a9.43,9.43,0,0,1-8.62,5.61h-3.61a9.43,9.43,0,0,1-9.43-9.43V449.91" id="path75"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M532.42,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,532.42,449.91Z" id="path76"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M559.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,559.81,449.91Z" id="path77"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M587.2,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,587.2,449.91Z" id="path78"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M613.81,449.91h20.35a0,0,0,0,1,0,0v28.72a6.85,6.85,0,0,1-6.85,6.85h-6.65a6.85,6.85,0,0,1-6.85-6.85V449.91A0,0,0,0,1,613.81,449.91Z" id="path79"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="751.94" y="555.13" width="5.87" height="47.48" id="rect79"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,683.87h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V683.87A0,0,0,0,1,751.94,683.87Z" id="path80"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M751.94,781.43h2.72a3.15,3.15,0,0,1,3.15,3.15v49.17a3.15,3.15,0,0,1-3.15,3.15h-2.72a0,0,0,0,1,0,0V781.43A0,0,0,0,1,751.94,781.43Z" id="path81"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M425.72,933.6l17.46-41.05a15.2,15.2,0,0,1,14-9.25H702.88A15.19,15.19,0,0,1,717,892.9l15.52,39.22" id="path82"/>
<rect class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x="505.03" y="841.57" width="147.52" height="24.65" rx="12.33" id="rect82"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="518.72" cy="853.89" r="5.48" id="circle82"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="640.14" cy="853.89" r="5.48" id="circle83"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="541.83" cy="853.89" r="5.48" id="circle84"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="567.67" cy="853.89" r="5.48" id="circle85"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="593.51" cy="853.89" r="5.48" id="circle86"/>
<circle class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" cx="616.82" cy="853.89" r="5.48" id="circle87"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="572.74" x2="430.68" y2="602.61" id="line87"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="595.43" x2="424.42" y2="578.11" id="line88"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="595.43" x2="438.21" y2="578.11" id="line89"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="644.74" x2="430.68" y2="674.61" id="line90"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="667.43" x2="424.42" y2="650.11" id="line91"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="667.43" x2="438.21" y2="650.11" id="line92"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="430.68" y1="716.74" x2="430.68" y2="746.61" id="line93"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="424.42" y1="739.43" x2="424.42" y2="722.11" id="line94"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="438.21" y1="739.43" x2="438.21" y2="722.11" id="line95"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="572.74" x2="730.03" y2="602.61" id="line96"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="595.43" x2="723.77" y2="578.11" id="line97"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="595.43" x2="737.56" y2="578.11" id="line98"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="644.74" x2="730.03" y2="674.61" id="line99"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="667.43" x2="723.77" y2="650.11" id="line100"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="667.43" x2="737.56" y2="650.11" id="line101"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="730.03" y1="716.74" x2="730.03" y2="746.61" id="line102"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="723.77" y1="739.43" x2="723.77" y2="722.11" id="line103"/>
<line class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" x1="737.56" y1="739.43" x2="737.56" y2="722.11" id="line104"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M428.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path104"/>
<path class="cls-8" fill="none" stroke="#000" stroke-miterlimit="10" d="M713.2,933.6v4.1a2.21,2.21,0,0,0,2.22,2.21h11.22a2.21,2.21,0,0,0,2.21-2.21v-4.1" id="path105"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.9 KiB

@@ -1,264 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 233.57587 210"
fill="none"
version="1.1"
id="svg22"
sodipodi:docname="unknown-new.svg"
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
width="233.57587"
height="210"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.6448247"
inkscape:cx="116.45384"
inkscape:cy="98.116143"
inkscape:window-width="1728"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="svg22" />
<!-- Antenna -->
<!-- Radio body - main housing with gradient -->
<defs
id="defs6">
<linearGradient
id="bodyGrad"
x1="29.580399"
y1="34.651325"
x2="207.06279"
y2="212.13371"
gradientTransform="matrix(0.84515425,0,0,1.183216,16.289544,-41)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
style="stop-color:#22222a"
id="stop2" />
<stop
offset="100%"
style="stop-color:#16161b"
id="stop3" />
</linearGradient>
<linearGradient
id="screenGrad"
x1="36.829224"
y1="105.71089"
x2="36.829224"
y2="201.38222"
gradientTransform="matrix(1.1811273,0,0,0.84664878,16.289544,-41)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
style="stop-color:#0c1a12"
id="stop4" />
<stop
offset="100%"
style="stop-color:#061008"
id="stop5" />
</linearGradient>
<filter
id="glow"
x="-0.8"
y="-0.8"
width="2.6"
height="2.6">
<feGaussianBlur
stdDeviation="2"
result="coloredBlur"
id="feGaussianBlur5" />
<feMerge
id="feMerge6">
<feMergeNode
in="coloredBlur"
id="feMergeNode5" />
<feMergeNode
in="SourceGraphic"
id="feMergeNode6" />
</feMerge>
</filter>
</defs>
<rect
x="46.289543"
y="5"
width="140"
height="200"
rx="14"
fill="url(#bodyGrad)"
stroke="#67ea94"
stroke-width="10"
stroke-linecap="round"
stroke-linejoin="round"
id="rect6"
style="fill:url(#bodyGrad)" />
<!-- Inner bezel -->
<rect
x="54.289543"
y="13"
width="124"
height="184"
rx="10"
fill="none"
stroke="#2a2a32"
stroke-width="1"
id="rect7" />
<!-- Top speaker grille -->
<rect
x="71.289543"
y="21"
width="90"
height="20"
rx="3"
fill="#0a0a0f"
id="rect8" />
<g
stroke="#333340"
stroke-width="1"
id="g10"
transform="translate(16.289544,-41)">
<line
x1="62"
y1="68"
x2="138"
y2="68"
id="line8" />
<line
x1="62"
y1="72"
x2="138"
y2="72"
id="line9" />
<line
x1="62"
y1="76"
x2="138"
y2="76"
id="line10" />
</g>
<!-- Screen area with glow effect -->
<rect
x="60.289543"
y="49"
width="112"
height="80"
rx="6"
fill="url(#screenGrad)"
stroke="#67ea94"
stroke-width="1"
opacity="0.8"
id="rect10"
style="fill:url(#screenGrad)" />
<!-- Meshtastic logo centered in screen - using actual logo paths -->
<g
transform="matrix(0.95,0,0,0.95,69.289544,64)"
filter="url(#glow)"
id="g14">
<g
transform="matrix(0.802386,0,0,0.460028,-421.748,-122.127)"
id="g13">
<g
transform="matrix(0.579082,0,0,1.01004,460.975,-39.6867)"
id="g11">
<path
d="m 250.908,330.267 -57.782,84.738 -12.188,-8.311 63.864,-93.657 c 1.372,-2.013 3.651,-3.218 6.087,-3.221 2.437,-0.002 4.717,1.199 6.093,3.21 l 64.012,93.51 -12.173,8.333 z"
fill="#67ea94"
id="path10" />
</g>
<g
transform="matrix(0.582378,0,0,1.01579,485.019,-211.182)"
id="g12">
<path
d="m 87.642,581.398 67.115,-98.421 -12.119,-8.264 -67.115,98.421 z"
fill="#67ea94"
id="path11" />
</g>
</g>
</g>
<!-- Question mark with glow -->
<!-- Control area background -->
<rect
x="60.289543"
y="137"
width="112"
height="60"
rx="6"
fill="#0f0f14"
id="rect14" />
<!-- D-pad style navigation -->
<g
transform="translate(88.289544,167)"
id="g17">
<circle
cx="0"
cy="0"
r="22"
fill="#1a1a20"
stroke="#2a2a32"
stroke-width="1"
id="circle14" />
<path
d="M -6,-16 H 6 V -6 H -6 Z"
fill="#2a2a32"
rx="1"
id="path14" />
<path
d="M -6,6 H 6 V 16 H -6 Z"
fill="#2a2a32"
rx="1"
id="path15" />
<path
d="M -16,-6 H -6 V 6 h -10 z"
fill="#2a2a32"
rx="1"
id="path16" />
<path
d="M 6,-6 H 16 V 6 H 6 Z"
fill="#2a2a32"
rx="1"
id="path17" />
<circle
cx="0"
cy="0"
r="5"
fill="#3a3a42"
id="circle17" />
</g>
<!-- Action button (glowing green) -->
<circle
cx="144.28955"
cy="167"
r="14"
fill="#1a2a1f"
stroke="#67ea94"
stroke-width="2"
id="circle18" />
<circle
cx="144.28955"
cy="167"
r="8"
fill="#67ea94"
filter="url(#glow)"
id="circle19" />
<!-- Status LED -->
<circle
cx="170.26186"
cy="25.547974"
r="3"
fill="#67ea94"
opacity="0.8"
filter="url(#glow)"
id="circle20" />
<!-- Side buttons -->
<!-- USB port indication at bottom -->
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+202
View File
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { createDeviceScene } from '@/graphics/deviceScene'
import { deviceSpecs } from '@/graphics/deviceModels'
const props = withDefaults(defineProps<{
model: string
label: string
fallback?: string
fillViewport?: boolean
glassContainer?: boolean
controls?: boolean
/** Development-only gallery controls. Never rendered in production builds. */
devBrowse?: boolean
}>(), { fallback: '', fillViewport: false, glassContainer: false, controls: true, devBrowse: false })
const host = ref<HTMLElement>()
const frame = ref<HTMLElement>()
const ready = ref(false)
const failed = ref(false)
const paused = ref(false)
const reducedMotion = ref(false)
const currentModel = ref(props.model)
const devModelIds = Object.keys(deviceSpecs)
const devBrowseEnabled = import.meta.env.DEV && props.devBrowse
const dragging = ref(false)
const remainingHeight = ref('360px')
let view: ReturnType<typeof createDeviceScene> | undefined
let observer: IntersectionObserver | undefined
let resizeObserver: ResizeObserver | undefined
let motion: MediaQueryList | undefined
let visible = false
let mounted = false
let generation = 0
let raf = 0
let previous = 0
let lastDraw = 0
let pointer: number | undefined
let lastX = 0
let lastY = 0
function devModelLabel(id: string) { return deviceSpecs[id]?.name ?? id }
function browseModel(offset: number) {
if (!devBrowseEnabled || !view) return
const index = devModelIds.indexOf(currentModel.value)
const next = devModelIds[(index + offset + devModelIds.length) % devModelIds.length]!
currentModel.value = next
view.setModel(next)
view.render()
}
function stop() { cancelAnimationFrame(raf); raf = 0; previous = 0; lastDraw = 0 }
function canSpin() { return view && visible && !document.hidden && !paused.value && !motion?.matches && !dragging.value }
function animate(now: number) {
raf = 0
if (!canSpin()) { previous = 0; return }
// Cap to 30fps; complete a turn in about a minute, independent of refresh rate.
if (now - lastDraw >= 32) {
view!.rotation.y += previous ? Math.min((now - previous) / 1000, .1) * .105 : 0
previous = now; lastDraw = now; view!.render()
}
raf = requestAnimationFrame(animate)
}
function sync() { reducedMotion.value = !!motion?.matches; stop(); if (canSpin()) raf = requestAnimationFrame(animate) }
function layout() {
if (props.fillViewport && frame.value) {
// Use the layout position, not its changing scrolled screen position.
let scroll = 0
for (let el = frame.value.parentElement; el; el = el.parentElement) scroll += el.scrollTop
const top = frame.value.getBoundingClientRect().top + scroll
// Keep a visible page gutter below the showcase; the frame sits inside a
// glass card whose bottom margin must remain visible in the viewport.
remainingHeight.value = `${Math.max(260, window.innerHeight - top - 88)}px`
}
view?.resize()
}
function disposeScene() {
stop(); ready.value = false
view?.canvas.removeEventListener('webglcontextlost', contextLost)
view?.dispose(); view = undefined
}
function contextLost(event: Event) {
event.preventDefault(); failed.value = true; disposeScene()
}
async function init() {
if (!mounted || !visible || view || failed.value) return
const token = ++generation
try {
const { createDeviceScene } = await import('@/graphics/deviceScene')
if (!mounted || token !== generation || !host.value) return
view = createDeviceScene(host.value, props.model)
view.canvas.addEventListener('webglcontextlost', contextLost)
ready.value = true; layout(); sync()
} catch {
if (token === generation) { failed.value = true; disposeScene() }
}
}
function down(event: PointerEvent) {
if (!view || (event.pointerType === 'mouse' && event.button !== 0) || pointer !== undefined) return
pointer = event.pointerId; lastX = event.clientX; lastY = event.clientY
dragging.value = true; stop(); host.value?.setPointerCapture(pointer)
}
function move(event: PointerEvent) {
if (pointer !== event.pointerId || !view) return
view.rotation.y += (event.clientX - lastX) * .012
// Touch preserves vertical page scrolling; mouse can also tilt the model.
if (event.pointerType === 'mouse') view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.clientY - lastY) * .008))
lastX = event.clientX; lastY = event.clientY; view.render()
}
function up() { pointer = undefined; dragging.value = false; sync() }
function key(event: KeyboardEvent) {
if (!view || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault(); paused.value = true; stop()
if (event.key === 'Home') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, -.4, 0)
else if (event.key === 'End') view.rotation.set(props.model === 'beryl-ax' ? .27 : -.08, Math.PI - .4, 0)
else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') view.rotation.y += event.key === 'ArrowLeft' ? -.2 : .2
else view.rotation.x = Math.max(-.65, Math.min(.65, view.rotation.x + (event.key === 'ArrowUp' ? -.12 : .12)))
view.render()
}
watch(paused, sync)
watch(() => props.model, () => {
generation++; up()
currentModel.value = props.model
if (view) {
try { view.setModel(props.model) } catch { failed.value = true; disposeScene() }
} else { failed.value = false; void init() }
})
onMounted(() => {
mounted = true
if (!window.matchMedia || !window.IntersectionObserver || !window.ResizeObserver) {
failed.value = true
return
}
motion = window.matchMedia('(prefers-reduced-motion: reduce)')
motion.addEventListener('change', sync)
document.addEventListener('visibilitychange', sync)
window.addEventListener('resize', layout)
resizeObserver = new ResizeObserver(layout)
if (host.value) resizeObserver.observe(host.value)
if (frame.value?.parentElement) resizeObserver.observe(frame.value.parentElement)
observer = new IntersectionObserver(entries => {
visible = entries.some(entry => entry.isIntersecting)
if (visible) void init()
sync()
})
if (frame.value) observer.observe(frame.value)
layout()
})
onBeforeUnmount(() => {
mounted = false; generation++; observer?.disconnect(); resizeObserver?.disconnect()
motion?.removeEventListener('change', sync)
document.removeEventListener('visibilitychange', sync); window.removeEventListener('resize', layout)
disposeScene()
})
</script>
<template>
<figure ref="frame" class="device-model" :class="{ 'device-model--viewport': fillViewport, 'device-model--glass': glassContainer }"
:style="{ '--device-height': remainingHeight }" :data-model="model" :data-ready="ready">
<div ref="host" class="device-model__stage" :class="{ 'is-dragging': dragging }"
:tabindex="ready ? 0 : -1" role="group" :aria-label="`${label}. Drag or use arrow keys to rotate. Home shows the front; End shows the back.`"
@pointerdown="down" @pointermove="move" @pointerup="up" @pointercancel="up" @lostpointercapture="up" @keydown="key">
<img v-if="!ready && fallback" :src="fallback" :alt="label" class="device-model__fallback" />
<div v-else-if="!ready" class="device-model__placeholder" role="status" aria-label="Loading 3D illustration">
<span class="device-model__loader" aria-hidden="true"><i></i><i></i><i></i></span>
</div>
</div>
<figcaption v-if="controls || devBrowseEnabled" class="device-model__caption">
<span>{{ failed ? label : (devBrowseEnabled ? devModelLabel(currentModel) : 'Drag to explore') }}</span>
<template v-if="devBrowseEnabled">
<button type="button" aria-label="Previous 3D model" @click="browseModel(-1)">Back</button>
<span class="device-model__counter">{{ devModelIds.indexOf(currentModel) + 1 }} / {{ devModelIds.length }}</span>
<button type="button" aria-label="Next 3D model" @click="browseModel(1)">Next</button>
</template>
<button v-if="ready && !reducedMotion" type="button" :aria-label="paused ? 'Resume automatic rotation' : 'Pause automatic rotation'"
:aria-pressed="paused" @click="paused = !paused">{{ paused ? 'Rotate' : 'Pause' }}</button>
</figcaption>
</figure>
</template>
<style scoped>
.device-model { position: relative; width: 100%; height: 240px; margin: 0; isolation: isolate; }
.device-model--glass { padding: 16px 18px 12px; border: 1px solid rgba(255,255,255,.13); border-radius: 14px; background: linear-gradient(135deg, rgba(255,255,255,.075), rgba(255,255,255,.025)); box-shadow: inset 0 1px 0 rgba(255,255,255,.045), 0 18px 55px rgba(0,0,0,.14); }
.device-model--glass::after { content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: linear-gradient(120deg, rgba(255,255,255,.1), transparent 34%, transparent 72%, rgba(255,255,255,.035)); opacity: .45; }
.device-model::before { content: ''; position: absolute; inset: 14% 9%; background: radial-gradient(ellipse, rgba(110,167,180,.09), transparent 68%); pointer-events: none; z-index: -1; }
.device-model__stage { position: absolute; inset: 0 0 28px; cursor: grab; touch-action: pan-y; border-radius: 16px; }
.device-model__stage.is-dragging { cursor: grabbing; }
.device-model__stage:focus-visible { outline: 1px solid rgba(255,255,255,.5); outline-offset: 2px; }
.device-model__stage :deep(canvas) { display: block; width: 100%; height: 100%; }
.device-model__fallback { width: 100%; height: 100%; object-fit: contain; padding: 22px; }
.device-model__placeholder { height: 100%; display: grid; place-items: center; }
.device-model__loader { display: flex; align-items: center; gap: 8px; opacity: .7; }
.device-model__loader i { display: block; width: 7px; height: 7px; border-radius: 999px; background: rgba(140,228,213,.75); animation: device-model-pulse 1.1s ease-in-out infinite; }
.device-model__loader i:nth-child(2) { animation-delay: .14s; }
.device-model__loader i:nth-child(3) { animation-delay: .28s; }
@keyframes device-model-pulse { 0%, 60%, 100% { transform: scale(.65); opacity: .35; } 30% { transform: scale(1); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .device-model__loader i { animation: none; opacity: .65; } }
.device-model__caption { position: absolute; inset: auto 0 0; display: flex; justify-content: center; align-items: center; gap: 14px; color: rgba(255,255,255,.4); font-size: 11px; }
.device-model__caption button { padding: 4px 9px; color: rgba(255,255,255,.65); border: 1px solid rgba(255,255,255,.12); border-radius: 20px; background: transparent; cursor: pointer; }
.device-model__counter { color: rgba(255,255,255,.3); font-variant-numeric: tabular-nums; }
.device-model--viewport { height: 290px; }
@media (min-width: 1024px) { .device-model--viewport { height: var(--device-height); min-height: 260px; } }
</style>
+1
View File
@@ -94,6 +94,7 @@ function goalIcon(icon: string): string {
lightning: '⚡', lightning: '⚡',
identity: '🔑', identity: '🔑',
backup: '💾', backup: '💾',
phone: '📱',
} }
return icons[icon] || '📦' return icons[icon] || '📦'
} }
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
const props = withDefaults(defineProps<{ expanded?: boolean; kind?: 'router' | 'phone' }>(), { expanded: false, kind: 'router' })
const emit = defineEmits<{ open: []; close: [] }>()
interface HardwareListing {
id: string
pubkey: string
title: string
summary: string
content: string
location: string
status: string
image: string
price?: { amount: string; currency: string; frequency?: string }
relay: string
}
const relayUrls = ref<string[]>([])
const DEV_ROUTER_LISTINGS: HardwareListing[] = [
{ id: 'dev-beryl-ax', pubkey: 'archipelago-demo-seller', title: 'GL.iNet Beryl AX (GL-MT3000)', summary: 'Wi‑Fi 6 travel router with OpenWrt, WireGuard and mesh-ready firmware.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/router-mockups/beryl-ax.webp', price: { amount: '89', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-flint-2', pubkey: 'openwrt-demo-seller', title: 'GL.iNet Flint 2', summary: 'Dual 2.5G Ethernet OpenWrt router for a home gateway or node controller.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/router-mockups/flint-2.webp', price: { amount: '159', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-nano-pi', pubkey: 'community-demo-seller', title: 'NanoPi R5S OpenWrt gateway', summary: 'Compact RK3568 gateway with three Ethernet ports and community support.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/router-mockups/nanopi-r5s.webp', price: { amount: '74', currency: 'EUR' }, relay: 'development mock' },
]
const DEV_PHONE_LISTINGS: HardwareListing[] = [
{ id: 'dev-pixel-9', pubkey: 'privacy-demo-seller', title: 'Pixel 9 · GrapheneOS ready', summary: 'Unlocked Pixel with a long update runway and a fresh install path.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/phone-mockups/pixel-9.webp', price: { amount: '499', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-pixel-9a', pubkey: 'mobile-demo-seller', title: 'Pixel 9a · unlocked', summary: 'Compact everyday Pixel. Check the bootloader can be unlocked before buying.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/phone-mockups/pixel-9a.webp', price: { amount: '339', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-pixel-10', pubkey: 'phone-demo-seller', title: 'Pixel 10 · GrapheneOS ready', summary: 'A newer Pixel for private, independent mobile computing.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/phone-mockups/pixel-10.webp', price: { amount: '699', currency: 'EUR' }, relay: 'development mock' },
]
const devListings = computed(() => props.kind === 'phone' ? DEV_PHONE_LISTINGS : DEV_ROUTER_LISTINGS)
const topicTags = computed(() => props.kind === 'phone' ? ['pixel'] : ['openwrt', 'router', 'gl-inet', 'beryl', 'flint'])
const open = ref(false)
const carousel = ref<HTMLElement>()
const listings = ref<HardwareListing[]>([])
const loading = ref(false)
const error = ref('')
let requestId = 0
let sockets: WebSocket[] = []
function tag(event: { tags?: string[][] }, name: string): string {
return event.tags?.find(item => item[0] === name)?.[1]?.trim() ?? ''
}
function imageTag(event: { tags?: string[][] }): string {
return event.tags?.find(item => item[0] === 'image')?.[1]?.trim() ?? ''
}
function matchesHardware(item: { title: string; summary: string; content: string; tags?: string[][] }) {
const tags = item.tags?.flat().join(' ') ?? ''
const text = `${item.title} ${item.summary} ${item.content} ${tags}`
return props.kind === 'phone'
? /\bpixel\b/i.test(text)
: /openwrt|gl[- .]?inet|beryl|flint|nanopi|gl-mt3000|gl-mt6000/i.test(text)
}
function parse(event: {
id?: string; pubkey?: string; content?: string; tags?: string[][]
}, relay: string): HardwareListing | null {
if (!event.id || !event.pubkey || !matchesHardware({ title: tag(event, 'title'), summary: tag(event, 'summary'), content: event.content ?? '', tags: event.tags })) return null
const price = event.tags?.find(item => item[0] === 'price')
return {
id: event.id,
pubkey: event.pubkey,
title: tag(event, 'title') || (props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'),
summary: tag(event, 'summary') || (event.content ?? '').split('\n')[0]?.slice(0, 150) || 'Hardware listing',
content: event.content ?? '',
location: tag(event, 'location'),
status: tag(event, 'status') || 'active',
image: imageTag(event),
price: price?.[1] && price[2] ? { amount: price[1], currency: price[2], frequency: price[3] } : undefined,
relay,
}
}
function closeSockets() {
sockets.forEach(socket => socket.close())
sockets = []
}
async function search() {
const run = ++requestId
closeSockets(); loading.value = true; error.value = ''
try {
const configured = await rpcClient.call<{ relays?: Array<{ url?: string; enabled?: boolean }> }>({ method: 'nostr.list-relays', params: {} })
relayUrls.value = (configured.relays ?? [])
.filter(relay => relay.enabled !== false && typeof relay.url === 'string' && /^wss?:\/\//.test(relay.url))
.map(relay => relay.url!.replace(/\/$/, ''))
} catch {
relayUrls.value = []
}
if (run !== requestId) return
if (!relayUrls.value.length) {
if (import.meta.env.DEV) {
listings.value = devListings.value
loading.value = false
return
}
loading.value = false
error.value = 'No Archipelago Nostr relays are configured. Add a relay in Settings → Nostr Relays and try again.'
return
}
const found = new Map<string, HardwareListing>()
await Promise.all(relayUrls.value.map(relay => new Promise<void>(resolve => {
let settled = false
const finish = () => { if (!settled) { settled = true; resolve() } }
let socket: WebSocket
try { socket = new WebSocket(relay) } catch { finish(); return }
sockets.push(socket)
const timer = window.setTimeout(() => { socket.close(); finish() }, 8000)
socket.addEventListener('open', () => {
socket.send(JSON.stringify(['REQ', `archy-${props.kind}-${run}`, {
kinds: [30402],
'#t': topicTags.value,
limit: 100,
}, {
kinds: [30402],
limit: 200,
}]))
})
socket.addEventListener('message', message => {
try {
const packet = JSON.parse(String(message.data)) as [string, string, { id?: string; pubkey?: string; content?: string; tags?: string[][] }]
if (packet[0] === 'EVENT') {
const item = parse(packet[2], relay)
if (item && item.status !== 'sold') found.set(item.id, item)
}
if (packet[0] === 'EOSE') { window.clearTimeout(timer); socket.close(); finish() }
} catch { /* Ignore malformed relay frames. */ }
})
socket.addEventListener('error', () => { window.clearTimeout(timer); finish() })
socket.addEventListener('close', () => { window.clearTimeout(timer); finish() })
})))
if (run !== requestId) return
listings.value = found.size ? [...found.values()] : import.meta.env.DEV ? devListings.value : []
loading.value = false
if (!listings.value.length) error.value = `No active ${props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'} listings found on your configured relays.`
}
function openShop() {
open.value = true
if (import.meta.env.DEV) listings.value = devListings.value
emit('open')
void search()
}
function closeShop() { requestId++; open.value = false; closeSockets(); emit('close') }
function moveCarousel(direction: number) {
const list = carousel.value
const card = list?.querySelector<HTMLElement>('.router-marketplace__card')
if (list && card) list.scrollBy({ left: direction * (card.offsetWidth + 16), behavior: 'smooth' })
}
function formatPrice(item: HardwareListing) {
if (!item.price) return 'Price on request'
return `${item.price.amount} ${item.price.currency.toUpperCase()}${item.price.frequency ? ` / ${item.price.frequency}` : ''}`
}
onBeforeUnmount(() => { requestId++; closeSockets() })
</script>
<template>
<section class="router-marketplace lg:mt-0" :class="{ 'router-marketplace--expanded': props.expanded }" aria-labelledby="router-marketplace-title">
<div class="glass-card p-5 md:p-6">
<template v-if="!open">
<p class="text-xs uppercase tracking-[.18em] text-white/40">{{ props.kind === 'phone' ? 'Private mobile' : 'OpenWrt gateway' }}</p>
<h2 id="router-marketplace-title" class="text-base font-semibold text-white mt-1">{{ props.kind === 'phone' ? 'Ready for a phone that answers to you?' : 'Not got an OpenWrt router?' }}</h2>
<p class="text-sm text-white/55 mt-2 max-w-2xl">{{ props.kind === 'phone' ? 'Choose an unlockable Pixel. Install GrapheneOS. Lock the bootloader for verified boot.' : 'Browse community-published router listings from Nostr marketplaces. We’ll add Archipelago checkout after discovery is in place.' }}</p>
<button type="button" class="glass-button router-marketplace__cta mt-4" @click="openShop">
{{ props.kind === 'phone' ? 'Buy phone' : 'Buy router' }}
<span aria-hidden="true">→</span>
</button>
</template>
<template v-else>
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
<div>
<h2 id="router-marketplace-title" class="text-base font-semibold text-white">{{ props.kind === 'phone' ? 'Pixel phones for sale' : 'OpenWrt routers for sale' }}</h2>
<p class="text-xs text-white/50 mt-1">{{ listings[0]?.relay === 'development mock' ? 'Development preview · conceptual images and sample prices' : 'Live NIP-99 listings from Archipelago relays.' }}</p>
</div>
<div class="flex items-center gap-2">
<button type="button" class="router-marketplace__nav" :aria-label="`Previous ${props.kind} listing`" @click="moveCarousel(-1)">‹</button>
<button type="button" class="router-marketplace__nav" :aria-label="`Next ${props.kind} listing`" @click="moveCarousel(1)">›</button>
<button type="button" class="text-xs text-white/45 hover:text-white ml-2" @click="closeShop">Close</button>
</div>
</div>
<div v-if="loading && !listings.length" class="sr-only" aria-live="polite">Loading {{ props.kind }} listings</div>
<div v-else-if="error && !listings.length" class="text-sm text-white/50 py-5 text-center">{{ error }}</div>
<div v-else ref="carousel" class="router-marketplace__results" tabindex="0" :aria-label="`${props.kind === 'phone' ? 'Phone' : 'Router'} listings carousel`">
<article v-for="item in listings" :key="item.id" class="router-marketplace__card">
<img v-if="item.image" :src="item.image" :alt="item.title" class="router-marketplace__image" loading="lazy" />
<div v-else class="router-marketplace__image router-marketplace__image--empty" aria-hidden="true">⌁</div>
<div class="router-marketplace__details">
<h3 class="text-sm font-medium text-white truncate">{{ item.title }}</h3>
<p class="text-xs text-white/55 mt-1 line-clamp-1">{{ item.summary }}</p>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-3 text-[11px] text-white/40">
<span v-if="item.location">{{ item.location }}</span>
<span>seller {{ item.pubkey.slice(0, 8) }}…</span>
</div>
<div class="router-marketplace__footer">
<strong class="router-marketplace__price">{{ formatPrice(item) }}</strong>
<div class="router-marketplace__actions">
<button type="button" class="router-marketplace__action" disabled title="Listing details are next">View</button>
<button type="button" class="router-marketplace__action router-marketplace__action--buy" disabled title="Checkout is the next step">Buy</button>
</div>
</div>
</div>
</article>
</div>
</template>
</div>
</section>
</template>
<style scoped>
.router-marketplace { margin-top: 16px; }
.router-marketplace--expanded { grid-column: 1 / -1; width: 100%; min-width: 0; height: 100%; min-height: 0; margin-top: 0; }
.router-marketplace--expanded > .glass-card { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 14px; }
.router-marketplace--expanded .router-marketplace__results { flex: 1 1 0; min-height: 0; }
.router-marketplace__cta { display: inline-flex; align-items: center; gap: 10px; }
.router-marketplace__nav { display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; color: rgba(255,255,255,.72); font-size: 18px; line-height: 1; }
.router-marketplace__nav:hover { background: rgba(255,255,255,.09); }
@media (max-width: 1023px) {
.router-marketplace__cta { width: 100%; justify-content: center; }
/* The dashboard's fixed mobile Back bar must not cover card actions. */
.router-marketplace--expanded > .glass-card { padding-bottom: 64px; }
}
.router-marketplace__results { display: flex; align-items: stretch; gap: 16px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 52px), transparent 100%); }
.router-marketplace__results::-webkit-scrollbar { display: none; }
.router-marketplace__card { display: flex; flex: 0 0 88%; scroll-snap-align: start; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; padding: 12px; border: 1px solid rgba(255,255,255,.09); border-radius: 11px; background: rgba(255,255,255,.035); }
.router-marketplace__details { display: flex; flex: 0 0 auto; min-width: 0; flex-direction: column; }
.router-marketplace__footer { margin-top: auto; padding-top: 8px; }
.router-marketplace__price { display: block; color: rgba(180,244,235,.9); font-size: 18px; }
.router-marketplace__actions { display: flex; gap: 8px; margin-top: 8px; }
.router-marketplace__action { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: rgba(255,255,255,.55); font-size: 12px; }
.router-marketplace__action--buy { border-color: rgba(118,230,191,.35); color: rgba(180,244,235,.9); }
@media (min-width: 768px) {
.router-marketplace__card { flex-basis: min(360px, 44%); }
}
.router-marketplace__image { width: 100%; height: 0; min-height: 0; flex: 1 1 0; object-fit: contain; border-radius: 8px; background: linear-gradient(135deg, rgba(112,214,205,.18), rgba(255,255,255,.04)); }
.router-marketplace__image--empty { display: grid; place-items: center; color: rgba(121,205,215,.6); font-size: 34px; }
</style>
@@ -48,14 +48,14 @@ describe('MeshMap', () => {
return 7 return 7
}) })
const clearWatch = vi.fn() const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({ class ResizeObserverMock {
observe: vi.fn(), observe = vi.fn()
disconnect: vi.fn(), disconnect = vi.fn()
})) }
vi.stubGlobal('navigator', { vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch }, geolocation: { watchPosition, clearWatch },
}) })
vi.stubGlobal('ResizeObserver', resizeObserver) vi.stubGlobal('ResizeObserver', ResizeObserverMock)
const wrapper = mount(MeshMap) const wrapper = mount(MeshMap)
@@ -79,11 +79,15 @@ describe('Mesh graphics lifecycle (Task 2): Leaflet map (MeshMap.vue)', () => {
vi.useFakeTimers() vi.useFakeTimers()
mapInstances.length = 0 mapInstances.length = 0
resizeObserverInstances = [] resizeObserverInstances = []
vi.stubGlobal('ResizeObserver', vi.fn(() => { vi.stubGlobal('ResizeObserver', class {
const inst = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() } observe = vi.fn()
resizeObserverInstances.push(inst) disconnect = vi.fn()
return inst unobserve = vi.fn()
}))
constructor() {
resizeObserverInstances.push(this)
}
})
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined, height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined,
} as DOMRect) } as DOMRect)
@@ -8,25 +8,11 @@
> >
<!-- Step 1: detection graphic + what's currently on the radio --> <!-- Step 1: detection graphic + what's currently on the radio -->
<div v-if="step === 1" class="text-center"> <div v-if="step === 1" class="text-center">
<div class="mx-auto my-2 w-40 h-40 relative"> <DeviceModel
<!-- pulsing signal waves behind the board image --> :model="deviceImage.model"
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true"> :label="deviceImage.label"
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round"> dev-browse
<path d="M130 44 a30 30 0 0 1 0 28" /> />
<path d="M138 36 a42 42 0 0 1 0 44" />
<path d="M146 28 a54 54 0 0 1 0 60" />
</g>
</svg>
<!-- actual board image (vendored from the Meshtastic web flasher) -->
<img
:src="deviceImage.image"
:alt="deviceImage.label"
class="relative w-full h-full object-contain drop-shadow-[0_8px_24px_rgba(251,146,60,0.25)]"
@error="imageFailed = true"
v-if="!imageFailed"
/>
<div v-else class="relative w-full h-full flex items-center justify-center text-5xl">📡</div>
</div>
<p class="text-white text-base font-medium">{{ deviceImage.label }}</p> <p class="text-white text-base font-medium">{{ deviceImage.label }}</p>
<p class="text-white/60 text-xs mt-1"> <p class="text-white/60 text-xs mt-1">
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on {{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
@@ -306,6 +292,7 @@ import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type Flas
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions' import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions'
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages' import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
import DeviceModel from '@/components/DeviceModel.vue'
const mesh = useMeshStore() const mesh = useMeshStore()
const appStore = useAppStore() const appStore = useAppStore()
@@ -344,7 +331,6 @@ function stopProbeProgress(done = false) {
const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '') const devicePath = computed(() => mesh.flashFlowPath ?? mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value) const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
const deviceImage = computed(() => const deviceImage = computed(() =>
resolveMeshDeviceImage( resolveMeshDeviceImage(
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value) mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
@@ -417,7 +403,6 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = '' probeError.value = ''
probing.value = false probing.value = false
stopProbeProgress() stopProbeProgress()
imageFailed.value = false
openFlashStep() openFlashStep()
const t = (mesh.status?.device_type ?? '').toLowerCase() const t = (mesh.status?.device_type ?? '').toLowerCase()
if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') { if (t === 'meshcore' || t === 'meshtastic' || t === 'reticulum') {
@@ -427,7 +412,6 @@ watch([show, devicePath], async ([visible]) => {
} }
step.value = 1 step.value = 1
error.value = '' error.value = ''
imageFailed.value = false
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? '' form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? '' form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
form.value.channel = mesh.status?.channel_name || 'archipelago' form.value.channel = mesh.status?.channel_name || 'archipelago'
@@ -629,17 +613,3 @@ function closeFlashStep() {
step.value = 1 step.value = 1
} }
</script> </script>
<style scoped>
.mesh-detect-waves path {
animation: mesh-wave-pulse 2.2s ease-out infinite;
opacity: 0;
}
.mesh-detect-waves path:nth-child(2) { animation-delay: 0.35s; }
.mesh-detect-waves path:nth-child(3) { animation-delay: 0.7s; }
@keyframes mesh-wave-pulse {
0% { opacity: 0; }
25% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -32,10 +32,10 @@ function createTestComponent(onCloseFn: () => void) {
} }
describe('useModalKeyboard', () => { describe('useModalKeyboard', () => {
let closeFn: ReturnType<typeof vi.fn> let closeFn: ReturnType<typeof vi.fn<() => void>>
beforeEach(() => { beforeEach(() => {
closeFn = vi.fn() closeFn = vi.fn<() => void>()
}) })
it('calls onClose when Escape is pressed and modal is open', async () => { it('calls onClose when Escape is pressed and modal is open', async () => {
+7
View File
@@ -67,6 +67,13 @@ export const GOALS: GoalDefinition[] = [
route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner', route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner',
steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }], steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }],
}, },
{
id: 'non-surveillance-phone', title: 'Get a non surveillance phone',
subtitle: 'Choose an unlockable Pixel and take control with GrapheneOS',
icon: 'phone', category: 'identity', requiredApps: [],
route: '/dashboard/setup/non-surveillance-phone', estimatedTime: 'Browse phones', difficulty: 'beginner',
steps: [{ id: 'choose-private-phone', title: 'Choose a compatible Pixel', description: 'Check that the bootloader can be unlocked, then install GrapheneOS and lock it again for verified boot.', action: 'info', isAutomatic: false }],
},
{ {
id: 'publish-website', title: 'Publish a website', id: 'publish-website', title: 'Publish a website',
subtitle: 'Create a website on your node and choose where to publish it', subtitle: 'Create a website on your node and choose where to publish it',
@@ -0,0 +1,15 @@
# GrapheneOS boot artwork
`grapheneos-boot-mask.png` is the unmodified boot logo mask from GrapheneOS's
Android framework, commit `0f505137b78efaee3058434e88974e2a06e0917c`:
https://github.com/GrapheneOS/platform_frameworks_base/blob/0f505137b78efaee3058434e88974e2a06e0917c/core/res/assets/images/android-logo-mask.png
The source commit credits Daniel Micay and smdyv. The framework is distributed
under Apache-2.0; the license text is also shipped at
`public/assets/fonts/Open_Sans/LICENSE.txt` in this frontend.
The phone displays a static white phase of the actual GrapheneOS OS boot logo.
It does not reproduce Google's separate bootloader / yellow alternate-OS notice.
GrapheneOS branding identifies the illustrated operating system; it does not
imply endorsement. The phone geometry and materials are original procedural art.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+406
View File
@@ -0,0 +1,406 @@
import * as THREE from 'three'
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'
import grapheneBootMaskUrl from './assets/grapheneos-boot-mask.png'
// Original, procedural display models. Dimensions/layouts follow the existing
// device illustrations and manufacturer references listed in device-models.md.
// These are recognition aids, not mechanical CAD or revision-specific pinouts.
type Kind = 'board' | 'echo' | 'deck' | 'card' | 'case' | 'router' | 'phone'
interface DeviceSpec {
kind: Kind; name: string; w: number; h: number; d: number; color: number
screen?: 'oled' | 'paper'; antenna?: boolean; battery?: boolean; pins?: number
}
export const deviceSpecs: Record<string, DeviceSpec> = {
'heltec-v3': { kind: 'board', name: 'HELTEC V3', w: 2.9, h: 1.25, d: .07, color: 0xe8e7df, screen: 'oled', pins: 18 },
'heltec_v4': { kind: 'board', name: 'HELTEC V4', w: 2.85, h: 1.2, d: .07, color: 0x181d20, screen: 'oled', pins: 18 },
'heltec-wsl-v3': { kind: 'board', name: 'HELTEC WSL', w: 2.85, h: 1, d: .07, color: 0x182225, pins: 18 },
'heltec-mesh-node-t114': { kind: 'board', name: 'HELTEC T114', w: 2.65, h: 1.25, d: .08, color: 0xe6e3dc, screen: 'oled' },
'tlora-t3s3-v1': { kind: 'board', name: 'LILYGO T3-S3', w: 2.8, h: 1.25, d: .07, color: 0x1b2024, screen: 'oled', antenna: true, pins: 16 },
'tlora-v2-1-1_6': { kind: 'board', name: 'LILYGO T-LoRa', w: 2.8, h: 1.2, d: .07, color: 0x25272a, screen: 'oled', antenna: true, pins: 16 },
'tbeam': { kind: 'board', name: 'LILYGO T-Beam', w: 3.7, h: 1.2, d: .08, color: 0x242a2d, screen: 'oled', antenna: true, battery: true, pins: 16 },
'tbeam-s3-core': { kind: 'board', name: 'T-Beam S3', w: 3.35, h: 1.05, d: .08, color: 0x22272a, screen: 'oled', antenna: true, battery: true },
'rak4631': { kind: 'board', name: 'RAK4631', w: 1.4, h: 2.8, d: .08, color: 0x202629, pins: 12 },
'seeed-xiao-s3': { kind: 'board', name: 'XIAO ESP32S3', w: 1.25, h: 1.5, d: .08, color: 0x152c28, pins: 7 },
'wio-tracker-wm1110': { kind: 'board', name: 'Wio WM1110', w: 1.85, h: 2.7, d: .08, color: 0xb32130, pins: 12 },
'rpipicow': { kind: 'board', name: 'Raspberry Pi Pico', w: 3.1, h: 1.2, d: .07, color: 0x16754d, pins: 20 },
'diy': { kind: 'board', name: 'RNode', w: 2.8, h: 1.3, d: .07, color: 0x24272b, pins: 19 },
't-deck': { kind: 'deck', name: 'LILYGO T-Deck', w: 2, h: 3.2, d: .28, color: 0x25262b, screen: 'oled' },
't-echo': { kind: 'echo', name: 'LILYGO', w: 1.7, h: 1.9, d: .5, color: 0xe3e4dc, screen: 'paper', antenna: true },
't-echo_plus': { kind: 'echo', name: 'LILYGO', w: 1.9, h: 2.15, d: .65, color: 0x343839, screen: 'paper', antenna: true },
'tracker-t1000-e': { kind: 'card', name: 'SenseCAP T1000-E', w: 1.8, h: 2.9, d: .17, color: 0xc6c9c5 },
'rak-wismeshtap': { kind: 'case', name: 'WisMesh Tap', w: 2.2, h: 1.7, d: .52, color: 0x33363a, screen: 'oled', antenna: true },
'station-g2': { kind: 'case', name: 'Station G2', w: 1.7, h: 2.85, d: .65, color: 0x32363b, screen: 'oled' },
'nano-g2-ultra': { kind: 'case', name: 'Nano G2 Ultra', w: 1.7, h: 2.8, d: .5, color: 0x36383d, screen: 'oled' },
'thinknode_m1': { kind: 'case', name: 'ThinkNode M1', w: 1.7, h: 2.3, d: .65, color: 0x252c2c, screen: 'paper', antenna: true },
'heltec-v3-case': { kind: 'case', name: 'HELTEC V3', w: 2.8, h: 1.25, d: .48, color: 0x13857e, screen: 'oled', antenna: true },
'heltec-mesh-node-t114-case': { kind: 'case', name: 'HELTEC T114', w: 1.25, h: 2.4, d: .5, color: 0x10a9c4, screen: 'oled', antenna: true },
'rak4631_case': { kind: 'case', name: 'RAK WisBlock', w: 1.4, h: 2.3, d: .55, color: 0xd5dad5, screen: 'oled', antenna: true },
'unknown-new': { kind: 'case', name: 'LoRa mesh radio', w: 1.8, h: 2.6, d: .5, color: 0x182623, screen: 'oled' },
'beryl-ax': { kind: 'router', name: 'Beryl AX · GL-MT3000', w: 3, h: .8, d: 2.05, color: 0x171b20 },
'pixel-graphene': { kind: 'phone', name: 'Pixel 9 Pro · GrapheneOS', w: 1.8, h: 3.82, d: .213, color: 0x08090b },
}
export function createDeviceModel(id: string, invalidate: () => void = () => {}) {
const spec = deviceSpecs[id] ?? deviceSpecs['unknown-new']!
const root = new THREE.Group()
const geometries = new Map<string, THREE.BufferGeometry>()
const materials: THREE.Material[] = []
const textures: THREE.Texture[] = []
let disposed = false
const material = (color: number, roughness = .5, metalness = .1) => {
const m = new THREE.MeshStandardMaterial({ color, roughness, metalness })
materials.push(m); return m
}
const body = material(spec.color, .43)
const black = material(0x10151a, .58)
const dark = material(0x03070a, .72)
const silver = material(0x9ca9af, .3, .86)
const gold = material(0xc9a454, .3, .82)
const white = material(0xdce2de, .66)
const pcb = material(0x193a30, .6)
const glass = material(0x091923, .17, .26)
const accent = material(0x76e6bf, .35)
accent.emissive.set(0x1b704a); accent.emissiveIntensity = .5
const grainCanvas = document.createElement('canvas'); grainCanvas.width = grainCanvas.height = 64
const grainContext = grainCanvas.getContext('2d')!
const pixels = grainContext.createImageData(64, 64)
let seed = 173
for (let i = 0; i < pixels.data.length; i += 4) {
seed = (seed * 1664525 + 1013904223) >>> 0
const value = 200 + seed % 45; pixels.data.set([value, value, value, 255], i)
}
grainContext.putImageData(pixels, 0, 0)
const grain = new THREE.CanvasTexture(grainCanvas)
grain.wrapS = grain.wrapT = THREE.RepeatWrapping; grain.repeat.set(7, 7); textures.push(grain)
body.roughnessMap = grain; body.bumpMap = grain; body.bumpScale = .0018
const geometry = (key: string, build: () => THREE.BufferGeometry) => {
if (!geometries.has(key)) geometries.set(key, build())
return geometries.get(key)!
}
function box(w: number, h: number, d: number, x: number, y: number, z: number, m = body, radius = .025, parent = root) {
const r = Math.min(radius, w / 3, h / 3, d / 3)
const g = geometry(`b${w},${h},${d},${r}`, () => new RoundedBoxGeometry(w, h, d, 2, r))
const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); parent.add(mesh); return mesh
}
function cylinder(r: number, length: number, x: number, y: number, z: number, m = silver, parent = root) {
const g = geometry(`c${r},${length}`, () => new THREE.CylinderGeometry(r, r, length, 20))
const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); parent.add(mesh); return mesh
}
function disk(r: number, x: number, y: number, z: number, m = silver) {
const mesh = cylinder(r, .018, x, y, z, m); mesh.rotation.x = Math.PI / 2; return mesh
}
function label(text: string, w: number, h: number, x: number, y: number, z: number, back = false, color = '#dbe4e3') {
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 96
const ctx = canvas.getContext('2d')!
ctx.fillStyle = color; ctx.font = '500 40px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
ctx.fillText(text, 256, 48, 500)
const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; textures.push(texture)
const m = new THREE.MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false }); materials.push(m)
const mesh = new THREE.Mesh(geometry(`p${w},${h}`, () => new THREE.PlaneGeometry(w, h)), m)
mesh.position.set(x, y, z); if (back) mesh.rotation.y = Math.PI; root.add(mesh); return mesh
}
function screws(w: number, h: number, z: number) {
for (const x of [-w / 2 + .12, w / 2 - .12]) for (const y of [-h / 2 + .12, h / 2 - .12]) {
disk(.055, x, y, z, silver)
box(.058, .012, .012, x, y, z + Math.sign(z) * .013, dark, .001)
}
}
function antenna(x: number, y: number, length = 1.3) {
cylinder(.085, .2, x, y + .1, 0, gold)
cylinder(.1, .18, x, y + .24, 0, black)
cylinder(.068, length, x, y + .3 + length / 2, 0, black)
for (let i = 0; i < 3; i++) cylinder(.074, .02, x, y + length + .1 + i * .05, 0, body)
}
function screen(w: number, h: number, x: number, y: number, z: number, paper = false) {
box(w + .12, h + .12, .065, x, y, z, black)
box(w, h, .02, x, y, z + .038, paper ? white : glass, .015)
const ink = paper ? '#263834' : '#8ce4d5'
label('MESH', w * .65, h * .28, x, y + h * .14, z + .052, false, ink)
for (let i = 0; i < 4; i++) box(.028, .045 + .035 * i, .008, x + w * .3 + i * .045, y - h * .32, z + .053, paper ? black : accent, .002)
label('LoRa', w * .48, h * .18, x - w * .08, y - h * .2, z + .054, false, ink)
}
function usb(x: number, y: number, z: number, back = false) {
box(.28, .115, .14, x, y, z, silver)
box(.22, .063, .015, x, y, z + (back ? -.077 : .077), dark)
box(.15, .015, .018, x, y, z + (back ? -.084 : .084), black)
}
const { w, h, d } = spec
if (spec.kind === 'router') {
// Beryl AX: two hinged paddles, rear USB-C / WAN / LAN / vertical USB-A.
box(w, h, d, 0, 0, 0, body, .25)
for (const z of [-1.023, 1.023]) box(2.56, .013, .008, 0, -.12, z, black, .002)
label('GL.iNet', .62, .14, 0, .423, .08, false, '#d5e9ed').rotation.x = -Math.PI / 2
box(.22, .032, .022, 0, -.115, d / 2 + .012, white)
for (const side of [-1, 1]) {
const hinge = cylinder(.16, .12, side * 1.57, -.04, .36, body); hinge.rotation.z = Math.PI / 2
const paddle = new THREE.Group(); paddle.position.set(side * 1.62, 0, .36); paddle.rotation.z = side * -.1; root.add(paddle)
box(.27, 1.93, .19, 0, .86, 0, body, .085, paddle)
box(.18, 1.65, .015, 0, .88, .102, body, .005, paddle)
for (let i = 0; i < 24; i++) box(.014, .37, .028, side * 1.497, -.035, -.85 + i * .069, dark, .003)
for (const z of [-.72, .72]) {
cylinder(.14, .08, side * 1.12, -.43, z, black)
for (let i = 0; i < 14; i++) box(.038, .012, .45, side * (.35 + i * .055), -.405, z * .6, dark, .002)
}
}
box(2.46, .55, .022, 0, -.015, -1.031, body, .008)
usb(1, .04, -1.035, true)
label('POWER', .3, .075, 1, .2, -1.057, true)
for (const [x, text] of [[.38, '2.5G WAN'], [-.27, 'LAN']] as const) {
box(.51, .4, .03, x, -.025, -1.055, silver)
box(.45, .32, .03, x, -.025, -1.074, dark)
box(.22, .06, .04, x, -.19, -1.08, dark)
for (let i = 0; i < 8; i++) box(.019, .08, .014, x - .15 + i * .043, .062, -1.095, gold, .001)
label(text, .53, .075, x, .24, -1.06, true)
}
box(.22, .4, .035, -.93, -.04, -1.06, silver)
box(.145, .32, .02, -.93, -.04, -1.086, dark)
box(.038, .26, .022, -.92, -.04, -1.1, material(0x287ecc))
label('USB 3.0', .42, .075, -.93, .24, -1.06, true)
box(.018, .085, .18, -1.508, .14, -.5, black)
box(.02, .05, .05, -1.51, .14, -.77, dark)
const underside = label('GL-MT3000 / BERYL AX', 1.25, .15, 0, -.413, 0)
underside.rotation.x = Math.PI / 2
} else if (spec.kind === 'phone') {
// Rounded outlines are independent of thickness: a thin RoundedBox clamps
// the corner radius to its depth and makes a handset look square.
function outline(width: number, height: number, radius: number) {
const x = -width / 2, y = -height / 2, r = radius
const shape = new THREE.Shape()
shape.moveTo(x + r, y); shape.lineTo(x + width - r, y)
shape.quadraticCurveTo(x + width, y, x + width, y + r)
shape.lineTo(x + width, y + height - r)
shape.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
shape.lineTo(x + r, y + height)
shape.quadraticCurveTo(x, y + height, x, y + height - r)
shape.lineTo(x, y + r); shape.quadraticCurveTo(x, y, x + r, y)
return shape
}
function panel(width: number, height: number, depth: number, radius: number, z: number, m: THREE.Material, x = 0, y = 0) {
const bevel = Math.min(.012, depth / 4)
const g = geometry(`phone${width},${height},${depth},${radius}`, () => {
const shape = outline(width - bevel * 2, height - bevel * 2, radius - bevel)
const result = new THREE.ExtrudeGeometry(shape, { depth: depth - bevel * 2, bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, steps: 1, curveSegments: 12 })
result.translate(0, 0, -depth / 2 + bevel)
return result
})
const mesh = new THREE.Mesh(g, m); mesh.position.set(x, y, z); root.add(mesh); return mesh
}
const frame = material(0x101113, .32, .72)
const edge = material(0x24262a, .27, .8)
const rear = material(0x08090b, .32, .12)
const lensRing = material(0x303238, .25, .78)
const lens = material(0x080d16, .12, .3)
const lensCoating = material(0x172a38, .16, .45)
const seal = material(0x030405, .85)
panel(w, h, d, .265, 0, frame)
panel(w - .024, h - .024, .022, .255, -d / 2, edge)
panel(w - .048, h - .048, .018, .245, -d / 2 - .013, rear)
panel(w - .02, h - .02, .02, .255, d / 2, seal)
panel(w - .044, h - .044, .012, .244, d / 2 + .011, material(0x050609, .2, .08))
const oled = new THREE.MeshBasicMaterial({ color: 0x000000, toneMapped: false }); materials.push(oled)
panel(w - .108, h - .108, .004, .216, d / 2 + .02, oled)
// Unmodified GrapheneOS boot mask, centered at its native Pixel 9 Pro
// screen ratio (448 / 1280). Static white phase of the OS boot animation.
const logoW = (w - .108) * 448 / 1280, logoH = logoW * 605 / 448
const logoGeometry = geometry('graphene-boot-logo', () => new THREE.PlaneGeometry(logoW, logoH))
const bootCanvas = document.createElement('canvas'); bootCanvas.width = 448; bootCanvas.height = 605
const boot = bootCanvas.getContext('2d')!
boot.fillStyle = '#000'; boot.fillRect(0, 0, 448, 605)
const bootTexture = new THREE.CanvasTexture(bootCanvas)
bootTexture.colorSpace = THREE.SRGBColorSpace; textures.push(bootTexture)
new THREE.ImageLoader().load(grapheneBootMaskUrl, image => {
if (disposed) return
boot.fillStyle = '#f3f3f3'; boot.fillRect(0, 0, 448, 605)
boot.drawImage(image, 0, 0); bootTexture.needsUpdate = true; invalidate()
})
const logoMaterial = new THREE.MeshBasicMaterial({ map: bootTexture, toneMapped: false }); materials.push(logoMaterial)
const logo = new THREE.Mesh(logoGeometry, logoMaterial); logo.position.z = d / 2 + .026; root.add(logo)
disk(.031, 0, h / 2 - .145, d / 2 + .028, seal)
disk(.017, 0, h / 2 - .145, d / 2 + .039, lens)
panel(.32, .014, .006, .007, d / 2 + .02, seal, 0, h / 2 - .044)
// Pixel 9 Pro's raised camera island, glass pill, three layered optics,
// flash and temperature sensor; all face outward on the rear.
const cameraY = h / 2 - .68
panel(1.57, .59, .105, .285, -d / 2 - .058, frame, 0, cameraY)
panel(1.51, .53, .018, .255, -d / 2 - .117, edge, 0, cameraY)
panel(1.14, .402, .012, .196, -d / 2 - .13, seal, .125, cameraY)
for (const x of [-.245, .125, .49]) {
disk(.154, x, cameraY, -d / 2 - .142, lensRing)
disk(.13, x, cameraY, -d / 2 - .153, dark)
disk(.103, x, cameraY, -d / 2 - .164, lens)
disk(.064, x, cameraY, -d / 2 - .175, lensCoating)
disk(.039, x, cameraY, -d / 2 - .186, dark)
disk(.018, x - .029, cameraY + .038, -d / 2 - .197, material(0x46556c, .18, .3))
}
disk(.069, -.582, cameraY + .11, -d / 2 - .139, material(0xc8c4ae, .5))
disk(.033, -.582, cameraY - .11, -d / 2 - .139, lens)
disk(.009, -.45, cameraY - .12, -d / 2 - .14, seal)
// Black buttons, antenna seams, bottom USB-C opening and speaker holes.
box(.038, .28, .09, w / 2 + .012, .74, 0, frame, .012)
box(.038, .45, .09, w / 2 + .012, .19, 0, frame, .012)
for (const side of [-1, 1]) for (const y of [-1.35, 1.36]) box(.008, .027, d - .035, side * (w / 2 + .001), y, 0, seal, .002)
const port = panel(.245, .071, .009, .032, 0, edge)
port.rotation.x = Math.PI / 2; port.position.set(0, -h / 2 - .002, 0)
const portHole = panel(.213, .047, .01, .021, 0, seal)
portHole.rotation.x = Math.PI / 2; portHole.position.set(0, -h / 2 - .008, 0)
box(.145, .008, .012, 0, -h / 2 - .015, 0, frame, .003)
for (let i = 0; i < 5; i++) {
const hole = disk(.016, 0, 0, 0, seal)
hole.rotation.x = 0; hole.position.set(.34 + i * .067, -h / 2 - .003, 0)
}
const mic = disk(.015, 0, 0, 0, seal); mic.rotation.x = 0; mic.position.set(-.4, -h / 2 - .003, 0)
} else if (spec.kind === 'board') {
box(w, h, d, 0, 0, 0, body, .06)
// Plated through holes, copper traces, solder joints and populated reverse.
const horizontal = w > h; const count = spec.pins ?? 10
for (const side of [-1, 1]) for (let i = 0; i < count; i++) {
const a = ((i + .5) / count - .5) * ((horizontal ? w : h) - .28)
const x = horizontal ? a : side * (w / 2 - .08)
const y = horizontal ? side * (h / 2 - .08) : a
for (const face of [-1, 1]) {
disk(.037, x, y, face * (d / 2 + .008), gold)
disk(.018, x, y, face * (d / 2 + .021), dark)
}
}
const heltec = id.startsWith('heltec')
const shieldX = spec.screen ? w * .28 : w * .05
if (!heltec || !spec.screen) {
box(w * .25, h * .43, .11, shieldX, .05, .095, silver)
label(spec.name.split(' ')[0]!, w * .23, .08, shieldX, .06, .158, false, '#354047')
} else {
box(w * .32, h * .43, .11, shieldX, .05, -.12, silver)
label('ESP32 · LoRa', w * .29, .08, shieldX, .06, -.18, true, '#354047')
}
for (let i = 0; i < 12; i++) {
const x = -w * .38 + (i % 6) * w * .14, y = -h * .28 + Math.floor(i / 6) * h * .56
box(.095, .043, .035, x, y, .065, i % 3 ? black : silver, .003)
box(.15, .023, .008, x, y, -.05, gold, .002)
box(.022, .18, .008, x, y + .07, -.047, pcb, .001)
}
box(w * .28, h * .34, .055, -w * .15, 0, -.08, black)
label(spec.name, w * .65, .12, 0, -h * .31, -.114, true)
// SMD passives with metal end caps, short traces and silkscreen outlines.
for (const face of [-1, 1]) for (let i = 0; i < 26; i++) {
const x = -w * .4 + (i % 13) * w * .065, y = (i < 13 ? -.33 : .33) * h
box(.065, .029, .021, x, y, face * .061, i % 4 ? black : material(0x8b7856), .002)
for (const end of [-1, 1]) box(.013, .032, .024, x + end * .032, y, face * .063, silver, .001)
box(.09, .007, .004, x, y + .03, face * .043, white, .001)
}
if (spec.screen) screen(w * (heltec ? .52 : .36), h * .51, w * (heltec ? .15 : -.15), 0, .145)
else {
box(w * .23, h * .28, .055, -w * .26, .03, .1, black)
for (let i = 0; i < 6; i++) box(.028, h * .32, .015, -w * .36 + i * .04, .03, .085, silver, .001)
}
// USB connector faces outward at the short edge.
const port = new THREE.Group(); root.add(port)
box(.29, .17, .14, 0, 0, 0, silver, .02, port)
box(.22, .11, .015, 0, 0, .078, dark, .018, port)
if (horizontal) { port.position.set((heltec ? -1 : 1) * (w / 2 + .025), 0, .12); port.rotation.y = (heltec ? -1 : 1) * Math.PI / 2 }
else { port.position.set(0, h / 2 + .025, .12); port.rotation.x = -Math.PI / 2 }
disk(.055, -w * .4, .12, .076, gold)
box(.105, .105, .055, -w * .4, -.14, .09, silver)
box(.052, .052, .024, -w * .4, -.14, .13, black)
if (spec.antenna) antenna(w * .27, h / 2, 1.25)
if (spec.battery) {
box(w * .82, .85, .5, 0, 0, -.31, black, .05)
const cell = cylinder(.34, w * .7, 0, 0, -.52, material(0x8461a5)); cell.rotation.z = Math.PI / 2
for (const x of [-w * .36, w * .36]) { const end = cylinder(.29, .05, x, 0, -.52); end.rotation.z = Math.PI / 2 }
}
if (id === 'wio-tracker-wm1110') for (const x of [-.66, .66]) for (let i = 0; i < 4; i++) box(.21, .2, .15, x, -.87 + i * .52, .1, white)
if (id === 'rak4631') {
box(1.08, .96, .035, 0, .45, .18, material(0x187b65))
box(.86, .73, .04, 0, .45, .22, silver)
label('RAK', .52, .19, 0, .56, .246, false, '#126d95')
label('4631', .45, .1, 0, .33, .247, false, '#354047')
for (const x of [-.43, 0, .43]) box(.21, .52, .06, x, -.74, .12, black)
}
} else {
box(w, h, d, 0, 0, 0, body, .17)
// A recessed rear panel preserves the rounded enclosure silhouette.
box(w * .88, h * .91, .025, 0, 0, -d / 2 - .012, body, .008)
screws(w * .94, h * .95, -d / 2 - .031)
label(spec.name, w * .66, .11, 0, -.2, -d / 2 - .045, true)
label('LoRa · wireless', w * .52, .07, 0, -.34, -d / 2 - .045, true)
if (spec.kind === 'deck') {
screen(w * .79, h * .38, 0, h * .19, d / 2 + .015)
const keys = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM']
keys.forEach((row, r) => [...row].forEach((key, i) => {
const x = (i - (row.length - 1) / 2) * .165, y = -.55 - r * .19
box(.146, .15, .07, x, y, d / 2 + .03, black, .02)
label(key, .065, .06, x, y, d / 2 + .069)
}))
box(.63, .13, .065, 0, -1.12, d / 2 + .03, black)
disk(.11, 0, -.28, d / 2 + .04, silver)
disk(.072, 0, -.28, d / 2 + .056, white)
label('LILYGO', .48, .09, 0, 1.47, d / 2 + .02)
usb(0, -h / 2 + .05, -.015)
// Exposed rear PCB on the original T-Deck development board.
box(w * .85, h * .83, .045, 0, 0, -d / 2 - .05, pcb)
box(.7, .65, .1, .35, .7, -d / 2 - .11, silver)
label('ESP32-S3', .55, .08, .35, .7, -d / 2 - .165, true, '#354047')
box(1.35, 1.1, .06, 0, -.65, -d / 2 - .09, black)
for (let i = 0; i < 8; i++) box(.07, .12, .04, -.65 + i * .18, .04, -d / 2 - .09, gold)
} else if (spec.kind === 'echo') {
box(w * .49, .42, d * .85, w * .25, h / 2 + .12, 0, body, .08)
label('LILYGO', w * .37, .13, w * .25, h / 2 + .14, d * .43, false, '#67b6d8')
screen(w * .57, h * .57, 0, -.05, d / 2 + .017, true)
screws(w, h, d / 2 + .024)
antenna(w * .25, h / 2 + .33, 1.15)
for (const y of [-.3, 0, .3]) box(.05, .14, .17, -w / 2 - .02, y, 0, white)
usb(0, -h / 2 + .1, -d / 2, true)
if (id === 't-echo_plus') {
const loop = new THREE.Mesh(geometry('loop', () => new THREE.TorusGeometry(.31, .065, 10, 28, Math.PI)), silver)
loop.position.set(-.25, h / 2 + .22, -.08); root.add(loop)
}
} else if (spec.kind === 'card') {
disk(.31, 0, -.3, d / 2 + .015, gold)
disk(.24, 0, -.3, d / 2 + .029, body)
disk(.06, 0, .92, d / 2 + .018, dark)
label('SenseCAP', 1.03, .17, 0, .35, d / 2 + .021, false, '#59645f')
box(.46, .085, .025, 0, h / 2 - .12, d / 2 + .008, dark)
for (let i = 0; i < 4; i++) disk(.045, -.22 + i * .145, -.95, -d / 2 - .047, gold)
} else {
if (spec.screen) screen(w * .6, Math.min(h * .3, .7), 0, h * -.04, d / 2 + .025, spec.screen === 'paper')
label(spec.name, w * .72, .13, 0, -h * .35, d / 2 + .025)
screws(w, h, d / 2 + .016)
for (let i = 0; i < 7; i++) box(.045, h * .16, .013, (i - 3) * w * .08, h * .3, d / 2 + .012, dark, .004)
usb(0, -h / 2 + .09, -d / 2, true)
if (spec.antenna) antenna(w * .25, h / 2, 1.05)
if (id === 'unknown-new') {
for (const x of [-.48, .48]) disk(.12, x, -.85, d / 2 + .02, accent)
}
}
}
// Batch repeated opaque hardware. Every device owns/disposes its resources.
function batch(parent: THREE.Object3D) {
const groups = new Map<string, THREE.Mesh[]>()
for (const child of [...parent.children]) {
if (child.children.length) batch(child)
if (!(child instanceof THREE.Mesh) || Array.isArray(child.material) || child.material.transparent) continue
const key = child.geometry.uuid + child.material.uuid
const list = groups.get(key) ?? []; list.push(child); groups.set(key, list)
}
for (const meshes of groups.values()) if (meshes.length > 1) {
const first = meshes[0]!
const instance = new THREE.InstancedMesh(first.geometry, first.material, meshes.length)
meshes.forEach((mesh, i) => { mesh.updateMatrix(); instance.setMatrixAt(i, mesh.matrix); parent.remove(mesh) })
parent.add(instance)
}
}
batch(root)
const bounds = new THREE.Box3().setFromObject(root)
root.position.sub(bounds.getCenter(new THREE.Vector3()))
const pivot = new THREE.Group(); pivot.add(root)
const radius = bounds.getBoundingSphere(new THREE.Sphere()).radius
root.traverse(object => { object.updateMatrix(); object.matrixAutoUpdate = false })
return {
pivot, radius, name: spec.name,
dispose() {
disposed = true
root.traverse(object => { if (object instanceof THREE.InstancedMesh) object.dispose() })
geometries.forEach(g => g.dispose()); materials.forEach(m => m.dispose()); textures.forEach(t => t.dispose())
},
}
}
+58
View File
@@ -0,0 +1,58 @@
import * as THREE from 'three'
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
import { createDeviceModel } from './deviceModels'
export function createDeviceScene(host: HTMLElement, id: string) {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true, powerPreference: 'low-power' })
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75))
renderer.setClearColor(0x000000, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = .94
renderer.domElement.setAttribute('aria-hidden', 'true')
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(33, 1, .1, 100)
let disposed = false
let model = createDeviceModel(id, render)
scene.add(model.pivot)
model.pivot.rotation.set(id === 'beryl-ax' ? .27 : -.08, -.4, 0)
const studio = new RoomEnvironment()
const pmrem = new THREE.PMREMGenerator(renderer)
const environment = pmrem.fromScene(studio, .04, .1, 100, { size: 128 })
scene.environment = environment.texture; scene.environmentIntensity = 1.05
studio.dispose(); pmrem.dispose()
scene.add(new THREE.HemisphereLight(0xe4eeff, 0x354052, 2.25))
for (const [color, intensity, x, y, z] of [
[0xffffff, 1.15, 3, 5, 6], [0x91c6e8, .85, -4, 1, -3], [0xe8c5ac, .65, 4, 0, -4],
]) {
const light = new THREE.DirectionalLight(color, intensity)
light.position.set(x!, y!, z!); scene.add(light)
}
host.append(renderer.domElement)
function render() { if (!disposed) renderer.render(scene, camera) }
function resize() {
if (disposed) return
const { width, height } = host.getBoundingClientRect()
if (!width || !height) return
renderer.setSize(width, height, false)
camera.aspect = width / height
const halfFov = Math.atan(Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * Math.min(1, camera.aspect))
camera.position.set(0, 0, model.radius / Math.sin(halfFov) * 1.09)
camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); render()
}
resize()
return {
canvas: renderer.domElement, get rotation() { return model.pivot.rotation }, render, resize,
setModel(next: string) {
const replacement = createDeviceModel(next, render)
scene.remove(model.pivot); model.dispose(); model = replacement
model.pivot.rotation.set(next === 'beryl-ax' ? .27 : -.08, -.4, 0)
scene.add(model.pivot); resize()
},
dispose() {
if (disposed) return
disposed = true; model.dispose(); environment.dispose(); renderer.dispose()
renderer.forceContextLoss(); renderer.domElement.remove()
},
}
}
+5
View File
@@ -255,6 +255,11 @@ const router = createRouter({
name: 'publish-website', name: 'publish-website',
component: () => import('@/views/publishing/PublishingSetup.vue'), component: () => import('@/views/publishing/PublishingSetup.vue'),
}, },
{
path: 'setup/non-surveillance-phone',
name: 'non-surveillance-phone',
component: () => import('@/views/goals/NonSurveillancePhone.vue'),
},
{ {
path: 'goals/:goalId', path: 'goals/:goalId',
name: 'goal-detail', name: 'goal-detail',
+35 -38
View File
@@ -1,7 +1,6 @@
/** /**
* Best-effort mapping from a detected serial port's USB identity to a board * Best-effort mapping from a detected serial port's USB identity to an
* image (SVGs vendored from the Meshtastic web-flasher, GPL-3.0 — * original procedural Three.js model.
* github.com/meshtastic/web-flasher, public/img/devices/).
* *
* Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB * Native-USB boards (T-Deck, RAK4631, T1000-E…) report their name in the USB
* product string, so those match precisely. UART-bridge boards only identify * product string, so those match precisely. UART-bridge boards only identify
@@ -18,58 +17,56 @@ export interface DetectedDeviceInfo {
} }
export interface MeshDeviceImage { export interface MeshDeviceImage {
/** Path under public/ */ /** Procedural model key in graphics/deviceModels.ts. */
image: string model: string
/** Human name shown in the modal */ /** Human name shown in the modal */
label: string label: string
/** True when the match is exact (product string), false for chip-level guesses */ /** True when the match is exact (product string), false for chip-level guesses */
exact: boolean exact: boolean
} }
const IMG = '/assets/img/mesh-devices'
/** product-string keyword → image (checked in order, case-insensitive) */ /** product-string keyword → image (checked in order, case-insensitive) */
const PRODUCT_MATCHES: Array<{ match: RegExp; image: string; label: string }> = [ const PRODUCT_MATCHES: Array<{ match: RegExp; model: string; label: string }> = [
{ match: /t-?deck/i, image: `${IMG}/t-deck.svg`, label: 'LILYGO T-Deck' }, { match: /t-?deck/i, model: 't-deck', label: 'LILYGO T-Deck' },
{ match: /t-?echo\s*plus/i, image: `${IMG}/t-echo_plus.svg`, label: 'LILYGO T-Echo Plus' }, { match: /t-?echo\s*plus/i, model: 't-echo_plus', label: 'LILYGO T-Echo Plus' },
{ match: /t-?echo/i, image: `${IMG}/t-echo.svg`, label: 'LILYGO T-Echo' }, { match: /t-?echo/i, model: 't-echo', label: 'LILYGO T-Echo' },
{ match: /t-?beam\s*s3/i, image: `${IMG}/tbeam-s3-core.svg`, label: 'LILYGO T-Beam S3' }, { match: /t-?beam\s*s3/i, model: 'tbeam-s3-core', label: 'LILYGO T-Beam S3' },
{ match: /t-?beam/i, image: `${IMG}/tbeam.svg`, label: 'LILYGO T-Beam' }, { match: /t-?beam/i, model: 'tbeam', label: 'LILYGO T-Beam' },
{ match: /t3.?s3/i, image: `${IMG}/tlora-t3s3-v1.svg`, label: 'LILYGO T3-S3' }, { match: /t3.?s3/i, model: 'tlora-t3s3-v1', label: 'LILYGO T3-S3' },
{ match: /t-?lora|tlora/i, image: `${IMG}/tlora-v2-1-1_6.svg`, label: 'LILYGO T-LoRa' }, { match: /t-?lora|tlora/i, model: 'tlora-v2-1-1_6', label: 'LILYGO T-LoRa' },
{ match: /rak.?4631|wisblock/i, image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631' }, { match: /rak.?4631|wisblock/i, model: 'rak4631', label: 'RAK WisBlock 4631' },
{ match: /wismesh|wistap/i, image: `${IMG}/rak-wismeshtap.svg`, label: 'RAK WisMesh Tap' }, { match: /wismesh|wistap/i, model: 'rak-wismeshtap', label: 'RAK WisMesh Tap' },
{ match: /t1000|tracker.?t1000/i, image: `${IMG}/tracker-t1000-e.svg`, label: 'Seeed Card Tracker T1000-E' }, { match: /t1000|tracker.?t1000/i, model: 'tracker-t1000-e', label: 'Seeed Card Tracker T1000-E' },
{ match: /xiao/i, image: `${IMG}/seeed-xiao-s3.svg`, label: 'Seeed XIAO S3' }, { match: /xiao/i, model: 'seeed-xiao-s3', label: 'Seeed XIAO S3' },
{ match: /wio.?tracker|wm1110/i, image: `${IMG}/wio-tracker-wm1110.svg`, label: 'Seeed Wio Tracker' }, { match: /wio.?tracker|wm1110/i, model: 'wio-tracker-wm1110', label: 'Seeed Wio Tracker' },
{ match: /station\s*g2/i, image: `${IMG}/station-g2.svg`, label: 'B&Q Station G2' }, { match: /station\s*g2/i, model: 'station-g2', label: 'B&Q Station G2' },
{ match: /nano\s*g2/i, image: `${IMG}/nano-g2-ultra.svg`, label: 'B&Q Nano G2 Ultra' }, { match: /nano\s*g2/i, model: 'nano-g2-ultra', label: 'B&Q Nano G2 Ultra' },
{ match: /t114/i, image: `${IMG}/heltec-mesh-node-t114.svg`, label: 'Heltec Mesh Node T114' }, { match: /t114/i, model: 'heltec-mesh-node-t114', label: 'Heltec Mesh Node T114' },
{ match: /wireless\s*stick|wsl/i, image: `${IMG}/heltec-wsl-v3.svg`, label: 'Heltec Wireless Stick Lite V3' }, { match: /wireless\s*stick|wsl/i, model: 'heltec-wsl-v3', label: 'Heltec Wireless Stick Lite V3' },
{ match: /heltec.*v4/i, image: `${IMG}/heltec_v4.svg`, label: 'Heltec V4' }, { match: /heltec.*v4/i, model: 'heltec_v4', label: 'Heltec V4' },
{ match: /heltec/i, image: `${IMG}/heltec-v3.svg`, label: 'Heltec LoRa 32 V3' }, { match: /heltec/i, model: 'heltec-v3', label: 'Heltec LoRa 32 V3' },
{ match: /thinknode/i, image: `${IMG}/thinknode_m1.svg`, label: 'Elecrow ThinkNode' }, { match: /thinknode/i, model: 'thinknode_m1', label: 'Elecrow ThinkNode' },
{ match: /pico/i, image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico' }, { match: /pico/i, model: 'rpipicow', label: 'Raspberry Pi Pico' },
{ match: /rnode/i, image: `${IMG}/diy.svg`, label: 'RNode' }, { match: /rnode/i, model: 'diy', label: 'RNode' },
] ]
/** vid:pid → most-likely board (bridge chips = chip-level guess) */ /** vid:pid → most-likely board (bridge chips = chip-level guess) */
const VIDPID_MATCHES: Record<string, { image: string; label: string; exact: boolean }> = { const VIDPID_MATCHES: Record<string, { model: string; label: string; exact: boolean }> = {
// Silicon Labs CP210x — Heltec V3 family ships this bridge // Silicon Labs CP210x — Heltec V3 family ships this bridge
'10c4:ea60': { image: `${IMG}/heltec-v3.svg`, label: 'LoRa radio (CP2102 serial)', exact: false }, '10c4:ea60': { model: 'heltec-v3', label: 'LoRa radio (CP2102 serial)', exact: false },
// WCH CH340 — most T-Beam / T-LoRa boards // WCH CH340 — most T-Beam / T-LoRa boards
'1a86:7523': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH340 serial)', exact: false }, '1a86:7523': { model: 'tbeam', label: 'LoRa radio (CH340 serial)', exact: false },
'1a86:55d4': { image: `${IMG}/tbeam.svg`, label: 'LoRa radio (CH9102 serial)', exact: false }, '1a86:55d4': { model: 'tbeam', label: 'LoRa radio (CH9102 serial)', exact: false },
// Espressif native USB (S3 boards: T3-S3, T-Deck without product str) // Espressif native USB (S3 boards: T3-S3, T-Deck without product str)
'303a:1001': { image: `${IMG}/tlora-t3s3-v1.svg`, label: 'ESP32-S3 LoRa board', exact: false }, '303a:1001': { model: 'tlora-t3s3-v1', label: 'ESP32-S3 LoRa board', exact: false },
// RAK4631 nRF52 native USB // RAK4631 nRF52 native USB
'239a:8029': { image: `${IMG}/rak4631.svg`, label: 'RAK WisBlock 4631', exact: true }, '239a:8029': { model: 'rak4631', label: 'RAK WisBlock 4631', exact: true },
// Raspberry Pi (Pico W) // Raspberry Pi (Pico W)
'2e8a:0005': { image: `${IMG}/rpipicow.svg`, label: 'Raspberry Pi Pico', exact: false }, '2e8a:0005': { model: 'rpipicow', label: 'Raspberry Pi Pico', exact: false },
} }
const FALLBACK: MeshDeviceImage = { const FALLBACK: MeshDeviceImage = {
image: `${IMG}/unknown-new.svg`, model: 'unknown-new',
label: 'LoRa mesh radio', label: 'LoRa mesh radio',
exact: false, exact: false,
} }
@@ -79,7 +76,7 @@ export function resolveMeshDeviceImage(info: DetectedDeviceInfo | undefined): Me
const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim() const product = `${info.manufacturer ?? ''} ${info.product ?? ''}`.trim()
if (product) { if (product) {
for (const m of PRODUCT_MATCHES) { for (const m of PRODUCT_MATCHES) {
if (m.match.test(product)) return { image: m.image, label: m.label, exact: true } if (m.match.test(product)) return { model: m.model, label: m.label, exact: true }
} }
} }
if (info.vid && info.pid) { if (info.vid && info.pid) {
+5 -2
View File
@@ -280,13 +280,16 @@ function stepIconUrl(step: GoalStep): string | undefined {
* generic services list. `launchAppId` opens via the app launcher (iframe apps * generic services list. `launchAppId` opens via the app launcher (iframe apps
* overlay on top of the current screen; X-Frame-Options apps open a tab). * overlay on top of the current screen; X-Frame-Options apps open a tab).
*/ */
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = { const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string | { path: string; query?: Record<string, string> }; launchAppId?: string }> = {
'instant-business-site': { label: 'Open Just Works', launchAppId: 'justworks' }, 'instant-business-site': { label: 'Open Just Works', launchAppId: 'justworks' },
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' }, 'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' }, 'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' }, 'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' }, 'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' }, // Keep the completion action inside Archipelago's native Cloud browser. The
// old CTA launched the standalone File Browser app, which lost the Cloud
// navigation context and bypassed the original in-app file browser view.
'file-browser': { label: 'Open my cloud files', route: { path: '/dashboard/cloud', query: { tab: 'mine' } } },
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' }, 'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' }, 'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' }, 'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
+16 -7
View File
@@ -163,13 +163,22 @@
</div> </div>
</template> </template>
</div> </div>
<router-link <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-4">
to="/dashboard/server/firewall-tunnels" <router-link
class="glass-button mt-4 w-full gap-2 rounded-lg text-sm font-medium" to="/dashboard/server/firewall-tunnels"
> class="glass-button w-full gap-2 rounded-lg text-sm font-medium"
Firewalls &amp; tunnels >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg> Firewalls &amp; tunnels
</router-link> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link>
<router-link
to="/dashboard/server/openwrt"
class="glass-button w-full gap-2 rounded-lg text-sm font-medium"
>
OpenWrt Gateway
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</router-link>
</div>
</div> </div>
<FipsNetworkCard /> <FipsNetworkCard />
@@ -69,7 +69,10 @@ vi.mock('../discover/curatedApps', () => ({
describe('Marketplace tracer tab: background refresh failure (D-07)', () => { describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))) vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
})
routerPushMock.mockClear() routerPushMock.mockClear()
toastErrorMock.mockClear() toastErrorMock.mockClear()
toastInfoMock.mockClear() toastInfoMock.mockClear()
@@ -37,7 +37,10 @@ function setGeometry(
describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => { describe('OnboardingSeedGenerate scroll cue (UIFIX-03)', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))) vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
disconnect = vi.fn()
})
vi.mocked(rpcClient.call).mockReset() vi.mocked(rpcClient.call).mockReset()
Element.prototype.scrollIntoView = vi.fn() Element.prototype.scrollIntoView = vi.fn()
}) })
@@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue' import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) vi.mock('vue-router', () => ({
RouterLink: { template: '<a><slot /></a>' },
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) })) vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64) const hash = 'a'.repeat(64)
@@ -5,6 +5,7 @@ import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ vi.mock('vue-router', () => ({
RouterLink: { template: '<a><slot /></a>' },
useRouter: () => ({ push: vi.fn() }), useRouter: () => ({ push: vi.fn() }),
})) }))

Some files were not shown because too many files have changed in this diff Show More