From 8976e8acde9ddfbde5af79d1e7a2d0aef74a529a Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 21:00:05 -0600 Subject: [PATCH 1/6] fix(reticulum): floor Nomad TCP link proof waits at 30s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #756 proof-budget overlay capped LRPROOF at hops×6 (~18s for 1-hop hubs), so TTP/hub Nomad pages timed out after path OK while v5.25.0 could wait up to the MeshChat 45s overall. Floor at 30s (overall − transfer grace) and report matching proof_budget_secs in sidecar diagnostics. --- docs/troubleshooting.md | 2 +- reticulum-sidecar/patches/README.md | 2 +- ...rsReticulum-link-client-proof-budget.patch | 13 +++-- reticulum-sidecar/src/stack/live.rs | 10 ++-- reticulum-sidecar/src/stack/nomad_timeouts.rs | 30 +++++++++++- ...ly-rsReticulum-link-client-proof-budget.sh | 48 +++++++++++++++++-- ...eticulum-link-client-proof-budget.test.mjs | 40 ++++++++++++++-- scripts/update.sh | 2 +- src/renderer/stores/nomadNetworkStore.test.ts | 4 +- 9 files changed, 128 insertions(+), 23 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cd0ddb243..3c0f5bfbd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1034,7 +1034,7 @@ In dev, **Start stack** now rebuilds when `reticulum-sidecar/src/**/*.rs` or `Ca Unrecognized codes pass through unchanged. -TCP/network Nomad Links use path-scaled proof budgets (`link_hops = clamp(path_hops, 3, 7)` → ~18–42s), matching released v5.25.0 multi-hop behavior more closely than #756’s flat ~18s cap (a HEAD regression for slower hub peers). First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. Retries may DropPath + rediscover; `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure`, `elapsed_ms`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. +TCP/network Nomad Links use path-scaled initiator hops (`link_hops = clamp(path_hops, 3, 7)`) and a LinkClient proof wait of `max(hops×6, 30s)` capped by the MeshChat 45s overall (30s = overall − 15s transfer grace). That 30s floor fixes 1-hop hub peers that timed out at ~18s after #756’s establishment-only cap while still loading on v5.25.0. First attempts use a cached path when present (no DropPath storm); missing paths RequestPath briefly and may return `path_timeout`. Retries may DropPath + rediscover; `force_path_ok=true` means rediscovered after absence only (cache hits log `force_path_ok=false`). Failure logs (`[nomadNetworkStore] … fetch failed` and sidecar `Nomad Link query failed`) include `path_hops`, `link_hops`, `proof_budget_secs`, `force_path_ok`, `path_ensure`, `elapsed_ms`, and `raw=`. UI errors distinguish cached-path vs rediscovered-path link failures. **Cause**: Older `LinkClient` always waited for a fresh path-response announce for the destination public key, even when Nomad announces had already cached it. Successful fetches could also deregister all `nomadnetwork.node` announce handlers. Distant/high-hop nodes can still time out at the path stage (expected RF/mesh reachability limits). diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index 15cf48cd4..966b67e2a 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -164,7 +164,7 @@ When [ratspeak/rsReticulum#14](https://github.com/ratspeak/rsReticulum/pull/14) ## rsReticulum-link-client-proof-budget.patch -Cap `LinkClient::query` proof wait at `link.establishment_timeout` so a cached path cannot burn the entire overall Nomad deadline (MeshChat TCP link stage ~15s). Apply **after** the LinkClient Nomad overlay. +Cap `LinkClient::query` proof wait at `max(link.establishment_timeout, 30s)`, still bounded by the overall deadline. The 30s floor (MeshChat TCP 45s overall − 15s transfer grace) lets slow TCP hub LRPROOFs succeed; without it, low `link_hops` (e.g. 3 → 18s) false-failed reachable Nomad pages that worked on v5.25.0. Apply **after** the LinkClient Nomad overlay. The apply script migrates checkouts that still have the older establishment-only cap. | Field | Value | | ----- | ----- | diff --git a/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch b/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch index 8c312cd6d..75e5fe200 100644 --- a/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch +++ b/reticulum-sidecar/patches/rsReticulum-link-client-proof-budget.patch @@ -1,15 +1,18 @@ diff --git a/crates/rns-runtime/src/link_client.rs b/crates/rns-runtime/src/link_client.rs --- a/crates/rns-runtime/src/link_client.rs +++ b/crates/rns-runtime/src/link_client.rs -@@ -101,7 +101,11 @@ impl LinkClient { +@@ -101,7 +101,14 @@ impl LinkClient { })) .await?; - let proof_data = wait_for_proof(&mut dest_rx, link_id, time_remaining(deadline)?).await?; -+ // Cap proof wait at link establishment timeout (6s × hops). Otherwise a -+ // cached path lets wait_for_proof burn the entire overall deadline -+ // (e.g. TCP 45s) even when MeshChat would fail the link stage in ~15s. -+ let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); ++ // Cap proof wait at establishment (6s × hops), but floor at 30s so slow ++ // TCP hub LRPROOFs can succeed under the MeshChat 45s overall ++ // (45 − 15s transfer grace). Still capped by time remaining. ++ let proof_budget = time_remaining(deadline)?.min( ++ link.establishment_timeout ++ .max(Duration::from_secs(30)), ++ ); + let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; let identity_ed25519_pub: [u8; 32] = pubkey[32..64].try_into().map_err(|_| { diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index f83d193d4..aef60828e 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -993,7 +993,7 @@ impl LiveBridge { }); } let link_hops = nomad_timeouts::nomad_link_initiator_hops(egress, hops); - let proof_budget_secs = u64::from(link_hops).saturating_mul(6); + let proof_budget_secs = nomad_timeouts::nomad_link_proof_budget_secs(link_hops); // Announce destination (URL/path-table) vs LinkClient dest from identity+aspect. let link_dest_hex = hex::encode(Destination::hash_from_name_and_identity( NOMAD_NODE_ASPECT, @@ -3809,10 +3809,10 @@ fn insert_nomad_link_budget_fields( } if let Some(link_hops) = link_hops { obj.insert("link_hops".into(), serde_json::json!(link_hops)); - // Link::new_initiator uses ESTABLISHMENT_TIMEOUT_PER_HOP (6s) × hops. + // Matches LinkClient proof-budget overlay: max(hops×6, 30s floor). obj.insert( "proof_budget_secs".into(), - serde_json::json!(u64::from(link_hops).saturating_mul(6)), + serde_json::json!(nomad_timeouts::nomad_link_proof_budget_secs(link_hops)), ); } if let Some(timeout_secs) = timeout_secs { @@ -4245,7 +4245,7 @@ mod announce_display_name_tests { assert_eq!(with_diag["egress"], "tcp"); assert_eq!(with_diag["path_hops"], 1); assert_eq!(with_diag["link_hops"], 3); - assert_eq!(with_diag["proof_budget_secs"], 18); + assert_eq!(with_diag["proof_budget_secs"], 30); assert_eq!(with_diag["timeout_secs"], 45); assert_eq!(with_diag["force_path_ok"], true); assert_eq!(with_diag["elapsed_ms"], 18250); @@ -4287,7 +4287,7 @@ mod announce_display_name_tests { assert_eq!(out["egress"], "tcp"); assert_eq!(out["path_hops"], 1); assert_eq!(out["link_hops"], 3); - assert_eq!(out["proof_budget_secs"], 18); + assert_eq!(out["proof_budget_secs"], 30); assert_eq!(out["timeout_secs"], 45); assert_eq!(out["elapsed_ms"], 4200); assert!(out.get("force_path_ok").is_none()); diff --git a/reticulum-sidecar/src/stack/nomad_timeouts.rs b/reticulum-sidecar/src/stack/nomad_timeouts.rs index ed9a9d35b..741716839 100644 --- a/reticulum-sidecar/src/stack/nomad_timeouts.rs +++ b/reticulum-sidecar/src/stack/nomad_timeouts.rs @@ -12,6 +12,11 @@ pub const NOMAD_TCP_LINK_ESTABLISH_SECS: u64 = 15; /// Grace for RTT-scaled link.request transfer after path + link stages. pub const NOMAD_TCP_TRANSFER_GRACE_SECS: u64 = 15; +/// Floor for LinkClient LRPROOF wait (rsReticulum proof-budget overlay). +/// MeshChat TCP overall is 45s; keep 15s transfer grace → 30s proof floor so +/// slow hub peers are not cut off at hops×6 (18s for the TCP link_hops floor). +pub const NOMAD_TCP_PROOF_FLOOR_SECS: u64 = 30; + /// Python RNS `DEFAULT_PER_HOP_TIMEOUT`. pub const NOMAD_RF_PER_HOP_TIMEOUT_SECS: u64 = 6; @@ -87,6 +92,17 @@ pub fn nomad_link_initiator_hops(egress_via: &str, path_hops: u8) -> u8 { } } +/// Effective LRPROOF budget reported to the UI / failure logs. +/// +/// Matches `rsReticulum-link-client-proof-budget.patch`: +/// `min(remaining, max(establishment = hops×6, NOMAD_TCP_PROOF_FLOOR_SECS))`. +/// Callers that already know remaining overall time may still clamp further. +pub fn nomad_link_proof_budget_secs(link_hops: u8) -> u64 { + u64::from(link_hops) + .saturating_mul(NOMAD_RF_PER_HOP_TIMEOUT_SECS) + .max(NOMAD_TCP_PROOF_FLOOR_SECS) +} + fn interface_status_live(status: &str) -> bool { matches!( status.to_ascii_lowercase().as_str(), @@ -197,7 +213,8 @@ mod tests { #[test] fn tcp_link_initiator_hops_floored_and_capped_for_release_parity() { - // Floor 3 (~18s); scale with path; cap 7 (~42s) under 45s overall. + // Floor 3 (~18s establishment); scale with path; cap 7 (~42s) under 45s overall. + // LinkClient proof wait then floors at NOMAD_TCP_PROOF_FLOOR_SECS (30s). assert_eq!(nomad_link_initiator_hops("tcp", 1), 3); assert_eq!(nomad_link_initiator_hops("tcp", 2), 3); assert_eq!(nomad_link_initiator_hops("network", 1), 3); @@ -211,6 +228,17 @@ mod tests { assert_eq!(nomad_link_initiator_hops("rf", 1), 1); } + #[test] + fn link_proof_budget_floors_at_thirty_for_low_initiator_hops() { + // 3 × 6s = 18s establishment → floor 30s (TCP hub slow-LRPROOF fix). + assert_eq!(nomad_link_proof_budget_secs(3), 30); + assert_eq!(nomad_link_proof_budget_secs(1), 30); + assert_eq!(nomad_link_proof_budget_secs(5), 30); + // Above the floor, scale with hops×6. + assert_eq!(nomad_link_proof_budget_secs(6), 36); + assert_eq!(nomad_link_proof_budget_secs(7), 42); + } + #[test] fn network_ready_when_path_iface_or_live_egress() { let mut tcp = iface("tcp"); diff --git a/scripts/apply-rsReticulum-link-client-proof-budget.sh b/scripts/apply-rsReticulum-link-client-proof-budget.sh index 4db23ac09..0e661e87b 100755 --- a/scripts/apply-rsReticulum-link-client-proof-budget.sh +++ b/scripts/apply-rsReticulum-link-client-proof-budget.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Cap LinkClient wait_for_proof at establishment_timeout (MeshChat-like TCP fail-fast). +# Cap LinkClient wait_for_proof at max(establishment_timeout, 30s), still bounded +# by the overall deadline — restores slow TCP hub Nomad LRPROOFs under MeshChat's +# 45s overall without burning the full window when remaining time is shorter. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -37,10 +39,50 @@ if git -C "${RNS_DIR}" apply --check "${PATCH_FILE}" > "${apply_err}" 2>&1; then exit 0 fi +# Migrate #756 establishment-only cap → 30s floor (checkout already had the old overlay). +if [[ -f "${LINK_CLIENT_RS}" ]] \ + && grep -qE 'let proof_budget\s*=\s*time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\)\s*;' "${LINK_CLIENT_RS}" \ + && grep -qE 'wait_for_proof\([^;]*proof_budget' "${LINK_CLIENT_RS}"; then + python3 - "${LINK_CLIENT_RS}" << 'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +old = re.compile( + r"[ \t]*// Cap proof wait at link establishment timeout \(6s × hops\)\. Otherwise a\n" + r"[ \t]*// cached path lets wait_for_proof burn the entire overall deadline\n" + r"[ \t]*// \(e\.g\. TCP 45s\) even when MeshChat would fail the link stage in ~15s\.\n" + r"[ \t]*let proof_budget = time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\);\n" + r"[ \t]*let proof_data = wait_for_proof\(&mut dest_rx, link_id, proof_budget\)\.await\?;\n", +) +new = ( + " // Cap proof wait at establishment (6s × hops), but floor at 30s so slow\n" + " // TCP hub LRPROOFs can succeed under the MeshChat 45s overall\n" + " // (45 − 15s transfer grace). Still capped by time remaining.\n" + " let proof_budget = time_remaining(deadline)?.min(\n" + " link.establishment_timeout\n" + " .max(Duration::from_secs(30)),\n" + " );\n" + " let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?;\n" +) +updated, n = old.subn(new, text, count=1) +if n != 1: + sys.exit("migrate: old establishment-only proof-budget block not found") +path.write_text(updated) +PY + echo "migrated link-client proof-budget overlay to 30s floor on rsReticulum @ $(short_head)" + exit 0 +fi + # Neither reverse nor forward matched. Accept only the full upstream-equivalent data -# flow: proof_budget is capped by establishment_timeout AND passed to wait_for_proof. +# flow: proof_budget floors at 30s via max(establishment, 30s) AND is passed to wait_for_proof. if [[ -f "${LINK_CLIENT_RS}" ]] \ - && grep -qE 'let proof_budget\s*=\s*time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\)' "${LINK_CLIENT_RS}" \ + && grep -qE 'Duration::from_secs\(30\)' "${LINK_CLIENT_RS}" \ + && grep -qE 'establishment_timeout' "${LINK_CLIENT_RS}" \ + && grep -qE '\.max\(Duration::from_secs\(30\)\)' "${LINK_CLIENT_RS}" \ + && grep -qE 'let proof_budget\s*=' "${LINK_CLIENT_RS}" \ && grep -qE 'wait_for_proof\([^;]*proof_budget' "${LINK_CLIENT_RS}"; then echo "link-client proof-budget capability already upstream on rsReticulum @ $(short_head)" exit 0 diff --git a/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs b/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs index 00853d1f1..90f0dcd22 100644 --- a/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs +++ b/scripts/apply-rsReticulum-link-client-proof-budget.test.mjs @@ -32,7 +32,23 @@ const FRESH_LINK_CLIENT = `impl LinkClient { const UPSTREAM_EQUIVALENT = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { - // Cap proof wait at link establishment timeout (6s × hops). + // Cap proof wait at establishment (6s × hops), but floor at 30s. + let proof_budget = time_remaining(deadline)?.min( + link.establishment_timeout + .max(Duration::from_secs(30)), + ); + let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; + Ok(()) + } +} +`; + +/** Older #756 establishment-only cap — apply script must migrate to the 30s floor. */ +const LEGACY_ESTABLISHMENT_ONLY = `impl LinkClient { + async fn query(&self) -> Result<(), LinkClientError> { + // Cap proof wait at link establishment timeout (6s × hops). Otherwise a + // cached path lets wait_for_proof burn the entire overall deadline + // (e.g. TCP 45s) even when MeshChat would fail the link stage in ~15s. let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); let proof_data = wait_for_proof(&mut dest_rx, link_id, proof_budget).await?; Ok(()) @@ -48,7 +64,7 @@ const INCOMPATIBLE = `impl LinkClient { } `; -/** Has proof_budget but does not cap it with establishment_timeout. */ +/** Has proof_budget but does not cap/floor it. */ const UNCAPPED_PROOF_BUDGET = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { let proof_budget = time_remaining(deadline)?; @@ -61,7 +77,9 @@ const UNCAPPED_PROOF_BUDGET = `impl LinkClient { /** Caps proof_budget but wait_for_proof still uses the uncapped remaining deadline. */ const CAPPED_PROOF_BUDGET_UNUSED = `impl LinkClient { async fn query(&self) -> Result<(), LinkClientError> { - let proof_budget = time_remaining(deadline)?.min(link.establishment_timeout); + let proof_budget = time_remaining(deadline)?.min( + link.establishment_timeout.max(Duration::from_secs(30)), + ); let proof_data = wait_for_proof(&mut dest_rx, link_id, time_remaining(deadline)?).await?; Ok(()) } @@ -104,6 +122,7 @@ afterEach(() => { describe('apply-rsReticulum-link-client-proof-budget.sh', () => { it('applies the overlay on a fresh checkout', () => { expect(readFileSync(PATCH_FILE, 'utf8')).toContain('proof_budget'); + expect(readFileSync(PATCH_FILE, 'utf8')).toContain('Duration::from_secs(30)'); const rns = makeFakeRsReticulum(FRESH_LINK_CLIENT); const result = runApply(rns); expect(result.status, result.stderr || result.stdout).toBe(0); @@ -111,6 +130,7 @@ describe('apply-rsReticulum-link-client-proof-budget.sh', () => { const body = readFileSync(path.join(rns, 'crates/rns-runtime/src/link_client.rs'), 'utf8'); expect(body).toContain('let proof_budget'); expect(body).toContain('link.establishment_timeout'); + expect(body).toContain('Duration::from_secs(30)'); }); it('is a no-op when the exact overlay is already applied (repeated run)', () => { @@ -122,7 +142,19 @@ describe('apply-rsReticulum-link-client-proof-budget.sh', () => { expect(second.stdout).toMatch(/already present/); }); - it('accepts an upstream-equivalent proof-budget cap when the patch does not apply', () => { + it('migrates the legacy establishment-only cap to the 30s floor', () => { + const rns = makeFakeRsReticulum(LEGACY_ESTABLISHMENT_ONLY); + const result = runApply(rns); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toMatch(/migrated .*30s floor/); + const body = readFileSync(path.join(rns, 'crates/rns-runtime/src/link_client.rs'), 'utf8'); + expect(body).toContain('Duration::from_secs(30)'); + expect(body).not.toMatch( + /let proof_budget = time_remaining\(deadline\)\?\.min\(link\.establishment_timeout\);/, + ); + }); + + it('accepts an upstream-equivalent proof-budget floor when the patch does not apply', () => { const rns = makeFakeRsReticulum(UPSTREAM_EQUIVALENT); const result = runApply(rns); expect(result.status, result.stderr || result.stdout).toBe(0); diff --git a/scripts/update.sh b/scripts/update.sh index 3b92c92b0..e3c8de1fe 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -211,7 +211,7 @@ check_ratspeak_patches() { 'rsReticulum-path-medium-slots.patch|ratspeak/rsReticulum||rsReticulum path-medium slots|' 'rsReticulum-auto-beacon-utun.patch|ratspeak/rsReticulum|11|rsReticulum auto-beacon utun|https://github.com/ratspeak/rsReticulum/pull/11' 'rsReticulum-link-client-nomad.patch|ratspeak/rsReticulum|14|rsReticulum LinkClient Nomad|https://github.com/ratspeak/rsReticulum/pull/14' - 'rsReticulum-link-client-proof-budget.patch|ratspeak/rsReticulum||rsReticulum LinkClient proof-budget cap|' + 'rsReticulum-link-client-proof-budget.patch|ratspeak/rsReticulum||rsReticulum LinkClient proof-budget floor (30s)|' 'rsReticulum-ble-rnode-pairing-transition-debounce.patch|ratspeak/rsReticulum|20|rsReticulum BLE RNode pairing-transition debounce|https://github.com/ratspeak/rsReticulum/pull/20' 'rsReticulum-discovery-announce-egress.patch|ratspeak/rsReticulum|19|rsReticulum discovery announce egress|https://github.com/ratspeak/rsReticulum/pull/19' 'rsLXMF-propagation-sync-peering.patch|ratspeak/rsLXMF|4|rsLXMF propagation sync peering|https://github.com/ratspeak/rsLXMF/pull/4' diff --git a/src/renderer/stores/nomadNetworkStore.test.ts b/src/renderer/stores/nomadNetworkStore.test.ts index 001b5641e..d590ff423 100644 --- a/src/renderer/stores/nomadNetworkStore.test.ts +++ b/src/renderer/stores/nomadNetworkStore.test.ts @@ -195,7 +195,7 @@ describe('nomadNetworkStore', () => { egress: 'tcp', path_hops: 1, link_hops: 3, - proof_budget_secs: 18, + proof_budget_secs: 30, timeout_secs: 45, force_path_ok: true, path_ensure_kind: 'rediscovered', @@ -216,7 +216,7 @@ describe('nomadNetworkStore', () => { expect(failed).toContain('error=link_timeout'); expect(failed).toContain('hash=abcdef12'); expect(failed).toContain('link_hops=3'); - expect(failed).toContain('proof_budget_secs=18'); + expect(failed).toContain('proof_budget_secs=30'); expect(failed).toContain('timeout_secs=45'); expect(failed).toContain('force_path_ok=true'); expect(failed).toContain('path_ensure=rediscovered'); From 3221adabe13e94588d30b1f931977e48832f4f07 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sat, 1 Aug 2026 21:03:53 -0600 Subject: [PATCH 2/6] feat(nomad): allow paste-and-go addresses before selecting a node Open Nomad pages from a blank-browser URL entry so hashes not yet in the announces list can be browsed without picking a sidebar node first. --- .../components/NomadNetworkPanel.test.tsx | 72 +++++++++++++++++++ src/renderer/components/NomadNetworkPanel.tsx | 72 ++++++++++++++----- src/renderer/locales/cs/translation.json | 6 +- src/renderer/locales/de/translation.json | 6 +- src/renderer/locales/en/translation.json | 4 +- src/renderer/locales/es/translation.json | 6 +- src/renderer/locales/fr/translation.json | 6 +- src/renderer/locales/id/translation.json | 6 +- src/renderer/locales/it/translation.json | 6 +- src/renderer/locales/ja/translation.json | 6 +- src/renderer/locales/ko/translation.json | 6 +- src/renderer/locales/nl/translation.json | 6 +- src/renderer/locales/pl/translation.json | 6 +- src/renderer/locales/pt-BR/translation.json | 6 +- src/renderer/locales/ru/translation.json | 6 +- src/renderer/locales/tr/translation.json | 6 +- src/renderer/locales/uk/translation.json | 6 +- src/renderer/locales/zh/translation.json | 6 +- 18 files changed, 189 insertions(+), 49 deletions(-) diff --git a/src/renderer/components/NomadNetworkPanel.test.tsx b/src/renderer/components/NomadNetworkPanel.test.tsx index ec5d5f693..9dfeaf5c0 100644 --- a/src/renderer/components/NomadNetworkPanel.test.tsx +++ b/src/renderer/components/NomadNetworkPanel.test.tsx @@ -120,6 +120,78 @@ describe('NomadNetworkPanel', () => { expect(screen.queryByText('Announce only')).not.toBeInTheDocument(); }); + it('shows empty-state URL entry before a node is selected', () => { + render(); + + expect(screen.getByText('nomadNetwork.enterUrlHint')).toBeInTheDocument(); + expect(screen.getByLabelText('nomadNetwork.urlBarAria')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'nomadNetwork.closeViewer' }), + ).not.toBeInTheDocument(); + }); + + it('opens a pasted absolute Nomad URL without a listed node', async () => { + const user = userEvent.setup(); + const hash = '53819f99223ed8a5676b5900d285eb3f'; + const fetchNomadPage = vi.fn().mockResolvedValue({ + ok: true, + content: 'pasted page', + content_type: 'text/plain', + }); + useNomadNetworkStore.setState({ + nodes: new Map(), + fetchNomadPage, + }); + + render(); + const urlBar = screen.getByLabelText('nomadNetwork.urlBarAria'); + await user.clear(urlBar); + await user.type(urlBar, `${hash}:/page/index.mu`); + await user.click(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })); + + await waitFor(() => { + expect(fetchNomadPage).toHaveBeenCalledWith(hash, '/page/index.mu', undefined, undefined); + }); + expect(screen.getByText(hash.slice(0, 16))).toBeInTheDocument(); + expect(screen.getByLabelText('nomadNetwork.urlBarAria')).toHaveValue(`${hash}:/page/index.mu`); + expect(screen.getByText('pasted page')).toBeInTheDocument(); + }); + + it('shows invalid URL error from empty-state paste without opening viewer', async () => { + const user = userEvent.setup(); + const fetchNomadPage = vi.fn(); + useNomadNetworkStore.setState({ fetchNomadPage }); + + render(); + const urlBar = screen.getByLabelText('nomadNetwork.urlBarAria'); + await user.type(urlBar, 'not-a-nomad-url'); + await user.click(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })); + + expect(fetchNomadPage).not.toHaveBeenCalled(); + expect(screen.getByText('nomadNetwork.pageFailed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'nomadNetwork.closeViewer' }), + ).not.toBeInTheDocument(); + }); + + it('rejects relative path paste when no page is open', async () => { + const user = userEvent.setup(); + const fetchNomadPage = vi.fn(); + useNomadNetworkStore.setState({ fetchNomadPage }); + + render(); + const urlBar = screen.getByLabelText('nomadNetwork.urlBarAria'); + await user.type(urlBar, ':/page/other.mu'); + await user.click(screen.getByRole('button', { name: 'nomadNetwork.goToUrl' })); + + expect(fetchNomadPage).not.toHaveBeenCalled(); + expect(screen.getByText('nomadNetwork.pageFailed')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'nomadNetwork.closeViewer' }), + ).not.toBeInTheDocument(); + }); + it('sorts announces by last heard by default and by hops when selected', async () => { const user = userEvent.setup(); useNomadNetworkStore.setState({ diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index c2124ab83..4fb76ba13 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -525,14 +525,19 @@ export default function NomadNetworkPanel({ [historyIndex, historyStack, loadNodePage], ); + const activeDestinationHash = selectedNode?.destination_hash ?? selectedHash; + const submitUrlBar = useCallback(() => { - if (!selectedNode) return; const trimmed = urlBarValue.trim(); if (!trimmed) return; let target = trimmed; if (target.startsWith(':')) { - target = `${selectedNode.destination_hash}${target}`; + if (!activeDestinationHash) { + setInvalidUrlError(); + return; + } + target = `${activeDestinationHash}${target}`; } const { destination: baseDestination, requestData } = buildNomadLinkRequest(target, null, null); @@ -542,12 +547,16 @@ export default function NomadNetworkPanel({ return; } - const hash = parsed.destination_hash ?? selectedNode.destination_hash; + const hash = parsed.destination_hash ?? activeDestinationHash; + if (!hash) { + setInvalidUrlError(); + return; + } const normalizedRequest = normalizeNomadPageRequestData(requestData); void loadNodePage(hash, parsed.path, { requestData: normalizedRequest, }); - }, [loadNodePage, selectedNode, setInvalidUrlError, urlBarValue]); + }, [activeDestinationHash, loadNodePage, setInvalidUrlError, urlBarValue]); const closeViewer = useCallback(() => { closeViewerStore(); @@ -854,24 +863,54 @@ export default function NomadNetworkPanel({ onPreviewHostedSite={handlePreviewHostedSite} /> ) : null} - {activeTab !== 'myPages' && !selectedNode ? ( -

- {t('nomadNetwork.selectNode')} -

+ {activeTab !== 'myPages' && !selectedHash ? ( +
+

{t('nomadNetwork.enterUrlHint')}

+
{ + e.preventDefault(); + submitUrlBar(); + }} + > + { + setUrlBarValue(e.target.value); + }} + aria-label={t('nomadNetwork.urlBarAria')} + placeholder={t('nomadNetwork.enterUrlPlaceholder')} + className="min-w-0 flex-1 rounded border border-gray-600 bg-slate-900 px-2 py-1.5 font-mono text-xs text-gray-200" + /> + +
+ {pageError ? ( +

+ {t('nomadNetwork.pageFailed', { error: pageError })} +

+ ) : null} +
) : null} - {activeTab !== 'myPages' && selectedNode ? ( + {activeTab !== 'myPages' && selectedHash ? (
- {selectedNode.display_name ?? selectedNode.destination_hash.slice(0, 16)} + {selectedNode?.display_name ?? selectedHash.slice(0, 16)} - {selectedNode.hops != null ? ( + {selectedNode?.hops != null ? ( {t('nomadNetwork.hopsAway', { count: selectedNode.hops })} ) : null}
- {onOpenDm ? ( + {onOpenDm && selectedNode ? (