From c6eecb183c6ad0e8973ca271d79deafc81145fe4 Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 12:56:03 -0600
Subject: [PATCH 1/7] feat(reticulum): Nomad file metadata, image preview, LXMF
media ingest
Prefer Resource filename metadata on Nomad /file downloads, preview common
rasters in the Nomad panel, ingest FIELD_IMAGE and multi-file attachments,
and add Save/Reveal controls on chat attachment lines.
Depends on ratspeak/rsReticulum#26, Colorado-Mesh/rsNomad#7, ratspeak/rsLXMF#7.
---
reticulum-sidecar/src/stack/live.rs | 85 +++++++++++++------
reticulum-sidecar/src/stack/nomad_file.rs | 66 +++++++++++++-
src/renderer/components/NomadNetworkPanel.tsx | 47 +++++++++-
.../components/ReticulumAttachmentLine.tsx | 69 +++++++++++++++
src/renderer/lib/nomad/nomadRasterPreview.ts | 20 +++++
5 files changed, 261 insertions(+), 26 deletions(-)
create mode 100644 src/renderer/lib/nomad/nomadRasterPreview.ts
diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs
index 81c16ccf0..a82a68755 100644
--- a/reticulum-sidecar/src/stack/live.rs
+++ b/reticulum-sidecar/src/stack/live.rs
@@ -13,8 +13,8 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use lxmf_core::constants::{
- AM_OPUS_OGG, DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE, FIELD_REACTION,
- REACTION_CONTENT, REACTION_TO,
+ AM_OPUS_OGG, DeliveryMethod, FIELD_ICON_APPEARANCE, FIELD_REACTION, REACTION_CONTENT,
+ REACTION_TO,
};
use lxmf_core::message::LxMessage;
@@ -51,7 +51,7 @@ use super::lxmf_delivery::{
LXMF_APP, PROPAGATION_SYNC_ANNOUNCE_SETTLE, send_lxmf_delivery_announce,
spawn_lxmf_announce_loop, spawn_lxmf_inbound_receiver, spawn_lxmf_outbound_backchannel,
};
-use super::nomad_file::nomad_file_name_from_path;
+use super::nomad_file::{nomad_file_name_from_metadata_or_path, nomad_file_name_from_path};
use super::nomad_link_errors::map_nomad_link_error;
use super::nomad_request_payload::nomad_page_request_payload;
use super::nomad_server::NomadServerHandle;
@@ -1326,7 +1326,7 @@ impl LiveBridge {
force_path_ok: Option,
path_ensure_kind: Option<&'static str>,
my_gen: u64,
- ) -> Result, NomadRemoteQueryError> {
+ ) -> Result<(Vec, Option>), NomadRemoteQueryError> {
// Abort before touching the cancel slot so a superseded failover cannot
// cancel the newer request that already owns last-request-wins.
if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen {
@@ -1432,7 +1432,9 @@ impl LiveBridge {
last_iface: None,
}),
query_result = query_fut => {
- query_result.map_err(|e| {
+ query_result
+ .map(|resp| (resp.data, resp.metadata))
+ .map_err(|e| {
let raw = format!("{e}");
let code = map_nomad_link_error(&raw);
NomadRemoteQueryError {
@@ -1695,7 +1697,8 @@ impl LiveBridge {
if bytes.len() > DEFAULT_MAX_FILE_BYTES {
return nomad_response_too_large_json(&meta);
}
- let file_name = nomad_file_name_from_path(path);
+ let file_name =
+ nomad_file_name_from_metadata_or_path(meta.resource_metadata.as_deref(), path);
let content_base64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
let mut out = serde_json::json!({
@@ -5513,6 +5516,12 @@ fn mime_from_file_name(file_name: &str) -> String {
"image/jpeg".into()
} else if lower.ends_with(".gif") {
"image/gif".into()
+ } else if lower.ends_with(".webp") {
+ "image/webp".into()
+ } else if lower.ends_with(".bmp") {
+ "image/bmp".into()
+ } else if lower.ends_with(".avif") {
+ "image/avif".into()
} else {
"application/octet-stream".into()
}
@@ -5552,22 +5561,44 @@ fn icon_appearance_json_from_message(msg: &LxMessage) -> Option Option {
use base64::Engine as _;
- let field = msg.get_field(FIELD_FILE_ATTACHMENTS)?;
- let value = rmpv::decode::read_value(&mut Cursor::new(field.as_slice())).ok()?;
- let files = value.as_array()?;
- let first = files.first()?.as_array()?;
- let file_name = first.first()?.as_str()?.to_string();
- let bytes = match first.get(1)? {
- rmpv::Value::Binary(bin) => bin.clone(),
- _ => return None,
- };
- let mime_type = mime_from_file_name(&file_name);
- Some(serde_json::json!({
- "file_name": file_name,
- "mime_type": mime_type,
- "size_bytes": bytes.len(),
- "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
- }))
+ let mut attachments: Vec = Vec::new();
+
+ if let Ok(files) = msg.file_attachments() {
+ for (file_name, bytes) in files {
+ let mime_type = mime_from_file_name(&file_name);
+ attachments.push(serde_json::json!({
+ "file_name": file_name,
+ "mime_type": mime_type,
+ "size_bytes": bytes.len(),
+ "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
+ }));
+ }
+ }
+
+ if attachments.is_empty() {
+ if let Ok(Some((format, bytes))) = msg.image_attachment() {
+ let ext = format.trim().trim_start_matches('.').to_lowercase();
+ let file_name = if ext.is_empty() {
+ "image.bin".to_string()
+ } else {
+ format!("image.{ext}")
+ };
+ let mime_type = mime_from_file_name(&file_name);
+ attachments.push(serde_json::json!({
+ "file_name": file_name,
+ "mime_type": mime_type,
+ "size_bytes": bytes.len(),
+ "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
+ }));
+ }
+ }
+
+ let first = attachments.first()?.clone();
+ let mut out = first;
+ if let Some(obj) = out.as_object_mut() {
+ obj.insert("attachments".into(), serde_json::Value::Array(attachments));
+ }
+ Some(out)
}
fn audio_json_from_bytes(mode: u8, bytes: &[u8]) -> serde_json::Value {
@@ -5813,6 +5844,8 @@ struct NomadRemoteQueryOk {
force_path_ok: Option,
path_ensure_kind: Option<&'static str>,
elapsed_ms: u64,
+ /// Msgpack Resource metadata from a file response (`{"name": ...}`), if any.
+ resource_metadata: Option>,
}
/// Diagnostics for a failed remote Nomad Link query (page or file).
@@ -6181,7 +6214,7 @@ fn path_table_route_from_entry(e: &PathTableRpcEntry) -> PathTableRoute {
#[allow(clippy::too_many_arguments, clippy::result_large_err)] // Nomad Link diagnostics bundle
fn finish_nomad_link_result(
- result: Result, NomadRemoteQueryError>,
+ result: Result<(Vec, Option>), NomadRemoteQueryError>,
hash_hex: &str,
identity_hash_hex: &str,
hops: u8,
@@ -6194,7 +6227,7 @@ fn finish_nomad_link_result(
elapsed_ms: u64,
) -> Result<(Vec, NomadRemoteQueryOk), NomadRemoteQueryError> {
match result {
- Ok(bytes) => {
+ Ok((bytes, resource_metadata)) => {
tracing::debug!(
target: "nomad",
dest = %hash_hex,
@@ -6207,6 +6240,7 @@ fn finish_nomad_link_result(
force_path_ok = ?force_path_ok,
path_ensure_kind = ?path_ensure_kind,
elapsed_ms,
+ has_resource_metadata = resource_metadata.is_some(),
"Nomad Link query ok"
);
Ok((
@@ -6219,6 +6253,7 @@ fn finish_nomad_link_result(
force_path_ok,
path_ensure_kind,
elapsed_ms,
+ resource_metadata,
},
))
}
@@ -7238,6 +7273,7 @@ mod announce_display_name_tests {
force_path_ok: None,
path_ensure_kind: None,
elapsed_ms: 4200,
+ resource_metadata: None,
},
);
assert_eq!(out["egress"], "tcp");
@@ -7259,6 +7295,7 @@ mod announce_display_name_tests {
force_path_ok: Some(false),
path_ensure_kind: Some("cached_hit"),
elapsed_ms: 1200,
+ resource_metadata: None,
};
// Same helper used by remote page and file oversized branches.
let out = nomad_response_too_large_json(&meta);
diff --git a/reticulum-sidecar/src/stack/nomad_file.rs b/reticulum-sidecar/src/stack/nomad_file.rs
index d4622dbaa..111fea318 100644
--- a/reticulum-sidecar/src/stack/nomad_file.rs
+++ b/reticulum-sidecar/src/stack/nomad_file.rs
@@ -11,9 +11,48 @@ pub fn nomad_file_name_from_path(path: &str) -> String {
.to_string()
}
+/// Prefer Resource metadata `{"name": }` when present (NomadNet
+/// `serve_file` / `ReplyFile`), else fall back to the request path basename.
+pub fn nomad_file_name_from_metadata_or_path(metadata: Option<&[u8]>, path: &str) -> String {
+ if let Some(meta) = metadata {
+ if let Some(name) = file_name_from_resource_metadata(meta) {
+ return name;
+ }
+ }
+ nomad_file_name_from_path(path)
+}
+
+fn file_name_from_resource_metadata(metadata: &[u8]) -> Option {
+ let value = rmpv::decode::read_value(&mut &metadata[..]).ok()?;
+ let map = value.as_map()?;
+ for (key, val) in map {
+ if key.as_str() != Some("name") {
+ continue;
+ }
+ let raw = match val {
+ rmpv::Value::Binary(bin) => bin.as_slice(),
+ rmpv::Value::String(s) => s.as_bytes(),
+ _ => continue,
+ };
+ let name = String::from_utf8_lossy(raw);
+ let base = name
+ .rsplit(['/', '\\'])
+ .next()
+ .unwrap_or(name.as_ref())
+ .trim();
+ if !base.is_empty() {
+ return Some(base.to_string());
+ }
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
- use super::nomad_file_name_from_path;
+ use super::{
+ file_name_from_resource_metadata, nomad_file_name_from_metadata_or_path,
+ nomad_file_name_from_path,
+ };
#[test]
fn file_name_from_path_uses_basename() {
@@ -28,4 +67,29 @@ mod tests {
fn file_name_from_path_falls_back_when_empty() {
assert_eq!(nomad_file_name_from_path("/file/"), "downloaded_file");
}
+
+ #[test]
+ fn metadata_name_wins_over_path_basename() {
+ let mut meta = Vec::new();
+ rmpv::encode::write_value(
+ &mut meta,
+ &rmpv::Value::Map(vec![(
+ rmpv::Value::String("name".into()),
+ rmpv::Value::Binary(b"photos/pic.png".to_vec()),
+ )]),
+ )
+ .unwrap();
+ assert_eq!(
+ file_name_from_resource_metadata(&meta).as_deref(),
+ Some("pic.png")
+ );
+ assert_eq!(
+ nomad_file_name_from_metadata_or_path(Some(&meta), "/file/other.bin"),
+ "pic.png"
+ );
+ assert_eq!(
+ nomad_file_name_from_metadata_or_path(None, "/file/other.bin"),
+ "other.bin"
+ );
+ }
}
diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx
index c3066192a..a9260ef3e 100644
--- a/src/renderer/components/NomadNetworkPanel.tsx
+++ b/src/renderer/components/NomadNetworkPanel.tsx
@@ -42,6 +42,7 @@ import {
readNomadPageFitWidth,
writeNomadPageFitWidth,
} from '@/renderer/lib/nomad/nomadPageFitWidth';
+import { nomadRasterDataUrl } from '@/renderer/lib/nomad/nomadRasterPreview';
import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads';
import type { NomadNodeRow, NomadPageRequestData } from '@/shared/nomad-types';
@@ -282,6 +283,11 @@ export default function NomadNetworkPanel({
const [pageLoadingRemainingSec, setPageLoadingRemainingSec] = useState(0);
const [fileDownloading, setFileDownloading] = useState(false);
const [fileDownloadError, setFileDownloadError] = useState(null);
+ const [filePreview, setFilePreview] = useState<{
+ fileName: string;
+ dataUrl: string;
+ contentBase64: string;
+ } | null>(null);
const [nodeListCollapsed, setNodeListCollapsed] = useState(
() => localStorage.getItem(NOMAD_NODE_LIST_COLLAPSED_STORAGE_KEY) === 'true',
);
@@ -481,6 +487,7 @@ export default function NomadNetworkPanel({
fileDownloadInFlightRef.current = true;
setFileDownloading(true);
setFileDownloadError(null);
+ setFilePreview(null);
try {
const normalizedPath = normalizeNomadPagePath(path);
const res = await fetchNomadFile(hash, normalizedPath);
@@ -490,7 +497,12 @@ export default function NomadNetworkPanel({
return;
}
const fileName = res.file_name ?? normalizedPath.split('/').pop() ?? 'downloaded_file';
- downloadNomadFileFromBase64(fileName, res.content_base64);
+ const dataUrl = nomadRasterDataUrl(fileName, res.content_base64);
+ if (dataUrl) {
+ setFilePreview({ fileName, dataUrl, contentBase64: res.content_base64 });
+ } else {
+ downloadNomadFileFromBase64(fileName, res.content_base64);
+ }
} catch (e) {
// Failure point: unexpected fetchNomadFile reject. Fallback: humanize if possible.
if (!mountedRef.current) return;
@@ -1076,6 +1088,39 @@ export default function NomadNetworkPanel({
{t('nomadNetwork.fileDownloadFailed', { error: fileDownloadError })}
) : null}
+ {filePreview ? (
+
diff --git a/src/renderer/components/ReticulumAttachmentLine.tsx b/src/renderer/components/ReticulumAttachmentLine.tsx
index b5e46d1c4..2a1eef85a 100644
--- a/src/renderer/components/ReticulumAttachmentLine.tsx
+++ b/src/renderer/components/ReticulumAttachmentLine.tsx
@@ -28,6 +28,13 @@ function reticulumAttachmentLabel(
return t('chatPanel.reticulumFileAttachment', { name: fileName });
}
+function dataUrlToBase64(dataUrl: string): string | null {
+ const comma = dataUrl.indexOf(',');
+ if (comma < 0) return null;
+ const b64 = dataUrl.slice(comma + 1).trim();
+ return b64.length > 0 ? b64 : null;
+}
+
/** Read-only label (and inline image when cached) for historic LXMF `[file:name:mime]` payloads. */
export function ReticulumAttachmentLine({
payload,
@@ -38,6 +45,7 @@ export function ReticulumAttachmentLine({
const [imageDataUrl, setImageDataUrl] = useState(null);
const [imageFailed, setImageFailed] = useState(false);
const [fetchedFor, setFetchedFor] = useState(null);
+ const [busy, setBusy] = useState(false);
const mimeType = parsed?.mimeType;
const canRenderImage =
@@ -75,6 +83,44 @@ export function ReticulumAttachmentLine({
const showImage =
fetchKey != null && fetchedFor === fetchKey && Boolean(imageDataUrl) && !imageFailed;
+ const onSave = async () => {
+ if (!attachmentPath || busy) return;
+ setBusy(true);
+ try {
+ let dataBase64 = imageDataUrl != null ? dataUrlToBase64(imageDataUrl) : null;
+ if (!dataBase64) {
+ const res = await window.electronAPI.chat.readReticulumAttachmentAsDataUrl({
+ filePath: attachmentPath,
+ mimeType: parsed.mimeType,
+ });
+ dataBase64 = res.dataUrl ? dataUrlToBase64(res.dataUrl) : null;
+ }
+ if (!dataBase64) {
+ const bytes = await window.electronAPI.chat.readReticulumAttachmentBytes(attachmentPath);
+ dataBase64 = bytes.dataBase64;
+ }
+ if (!dataBase64) return;
+ await window.electronAPI.chat.saveReticulumAttachment({
+ fileName: parsed.fileName,
+ dataBase64,
+ promptSave: true,
+ });
+ } catch {
+ // catch-no-log-ok: save dialog cancel / read failure
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const onReveal = async () => {
+ if (!attachmentPath) return;
+ try {
+ await window.electronAPI.chat.showItemInFolder(attachmentPath);
+ } catch {
+ // catch-no-log-ok: reveal unavailable
+ }
+ };
+
return (
{showImage && imageDataUrl ? (
@@ -87,6 +133,29 @@ export function ReticulumAttachmentLine({
/>
) : null}
{label}
+ {attachmentPath ? (
+
+
+
+
+ ) : null}
);
}
diff --git a/src/renderer/lib/nomad/nomadRasterPreview.ts b/src/renderer/lib/nomad/nomadRasterPreview.ts
new file mode 100644
index 000000000..43df1632a
--- /dev/null
+++ b/src/renderer/lib/nomad/nomadRasterPreview.ts
@@ -0,0 +1,20 @@
+/** Common Nomad `/file/...` raster extensions for inline preview. */
+const NOMAD_RASTER_EXT = /\.(png|jpe?g|gif|webp|bmp|avif)$/i;
+
+export function isNomadRasterFileName(fileName: string): boolean {
+ return NOMAD_RASTER_EXT.test(fileName.trim());
+}
+
+/** Build a browser data URL from Nomad file base64 for raster preview. */
+export function nomadRasterDataUrl(fileName: string, contentBase64: string): string | null {
+ if (!isNomadRasterFileName(fileName)) return null;
+ const lower = fileName.toLowerCase();
+ let mime = 'application/octet-stream';
+ if (lower.endsWith('.png')) mime = 'image/png';
+ else if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) mime = 'image/jpeg';
+ else if (lower.endsWith('.gif')) mime = 'image/gif';
+ else if (lower.endsWith('.webp')) mime = 'image/webp';
+ else if (lower.endsWith('.bmp')) mime = 'image/bmp';
+ else if (lower.endsWith('.avif')) mime = 'image/avif';
+ return `data:${mime};base64,${contentBase64}`;
+}
From 4957ce4bee77dc921385bbab34f1aac1122dda1f Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 13:04:30 -0600
Subject: [PATCH 2/7] ci(reticulum): pin stacked Ratspeak PRs; keep rmpv for
stub
Sidecar CI floated siblings to main without ReplyFile / multi-file LXMF
APIs. Pin rsReticulum#26, rsLXMF#7, and rsNomad#7 heads, and make rmpv
non-optional so stub builds can parse Nomad Resource filename metadata.
---
.github/workflows/flatpak.yaml | 4 ++++
.github/workflows/reticulum-sidecar.yaml | 7 +++++++
.github/workflows/tests.yaml | 4 ++++
reticulum-sidecar/Cargo.toml | 4 ++--
4 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml
index 33f2a10b8..5cdd50832 100644
--- a/.github/workflows/flatpak.yaml
+++ b/.github/workflows/flatpak.yaml
@@ -67,6 +67,10 @@ jobs:
- name: Clone Ratspeak stack (rsReticulum, rsLXMF)
env:
WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack
+ # Keep in sync with reticulum-sidecar.yaml stacked pins until upstream merges.
+ RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
+ RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
+ RS_NOMAD_REF: 66d725785930039b5c527955e69dfa75474ebf71
run: bash scripts/clone-ratspeak-stack.sh
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml
index bad0e7672..287d254bf 100644
--- a/.github/workflows/reticulum-sidecar.yaml
+++ b/.github/workflows/reticulum-sidecar.yaml
@@ -3,6 +3,13 @@ name: Reticulum sidecar
permissions:
contents: read
+# Stacked Nomad file/metadata work: pin siblings until upstream PRs merge, then float again.
+# ratspeak/rsReticulum#26, ratspeak/rsLXMF#7, Colorado-Mesh/rsNomad#7
+env:
+ RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
+ RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
+ RS_NOMAD_REF: 66d725785930039b5c527955e69dfa75474ebf71
+
on:
workflow_dispatch:
push:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index d9bd75198..dd38a06bb 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -2,6 +2,10 @@ name: Tests
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ # Stacked Nomad file/metadata work (match reticulum-sidecar.yaml). Drop after upstream merges.
+ RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
+ RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
+ RS_NOMAD_REF: 66d725785930039b5c527955e69dfa75474ebf71
on:
push:
diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml
index 9a17ece7c..fe4a3d41a 100644
--- a/reticulum-sidecar/Cargo.toml
+++ b/reticulum-sidecar/Cargo.toml
@@ -30,7 +30,6 @@ rns-stack = [
"dep:rns-crypto",
"dep:argon2",
"dep:zeroize",
- "dep:rmpv",
"dep:tempfile",
"dep:notify",
"dep:rusqlite",
@@ -55,7 +54,8 @@ uuid = { version = "1", features = ["v4"] }
hex = "0.4"
sha2 = "0.10"
tempfile = { version = "3", optional = true }
-rmpv = { version = "1", optional = true }
+# Always on: stub builds compile `nomad_file` metadata helpers (Resource name msgpack).
+rmpv = "1"
base64 = "0.22"
bytes = "1"
From 2a73cd571b4ee557637584d3484f78318f03b756 Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 13:23:13 -0600
Subject: [PATCH 3/7] ci(reticulum): CI-only stack pins without forcing full
Vitest
Editing tests.yaml forced vitest_mode=full and failed per-shard coverage.
Load temporary ratspeak-stack-ci-pins.env from clone-ratspeak-stack.sh when
CI=true instead, and restore tests.yaml to match main.
---
.github/workflows/tests.yaml | 4 ----
scripts/clone-ratspeak-stack.sh | 7 +++++++
scripts/clone-ratspeak-stack.test.mjs | 13 ++++++++++++-
scripts/ratspeak-stack-ci-pins.env | 11 +++++++++++
4 files changed, 30 insertions(+), 5 deletions(-)
create mode 100644 scripts/ratspeak-stack-ci-pins.env
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index dd38a06bb..d9bd75198 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -2,10 +2,6 @@ name: Tests
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
- # Stacked Nomad file/metadata work (match reticulum-sidecar.yaml). Drop after upstream merges.
- RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
- RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
- RS_NOMAD_REF: 66d725785930039b5c527955e69dfa75474ebf71
on:
push:
diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh
index ad4a920db..409f87eba 100755
--- a/scripts/clone-ratspeak-stack.sh
+++ b/scripts/clone-ratspeak-stack.sh
@@ -21,6 +21,13 @@ export RS_RETICULUM_DIR="${RNS_DIR}"
export RS_LXMF_DIR="${LXMF_DIR}"
# Optional bisect / known-good overrides. Unset or empty → float to origin/main.
+# In CI, load temporary stacked pins when present (local clones stay floating).
+if [[ "${CI:-}" == 'true' && -f "${SCRIPT_DIR}/ratspeak-stack-ci-pins.env" ]]; then
+ set -a
+ # shellcheck disable=SC1091
+ source "${SCRIPT_DIR}/ratspeak-stack-ci-pins.env"
+ set +a
+fi
RS_RETICULUM_REF="${RS_RETICULUM_REF:-}"
RS_LXMF_REF="${RS_LXMF_REF:-}"
RS_NOMAD_REF="${RS_NOMAD_REF:-}"
diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs
index 9df5b0b5e..e8f8a2560 100644
--- a/scripts/clone-ratspeak-stack.test.mjs
+++ b/scripts/clone-ratspeak-stack.test.mjs
@@ -82,7 +82,16 @@ function runEnsureRepo({ remoteUrl, destDir, pinRef = '', env = {}, mergeStderr
].join('\n');
return execFileSync('bash', ['-c', script], {
encoding: 'utf8',
- env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', ...env },
+ env: {
+ ...process.env,
+ GIT_CONFIG_NOSYSTEM: '1',
+ GIT_CONFIG_GLOBAL: '/dev/null',
+ // Host shells often export RS_STACK_DISCARD_DIRTY=1 while debugging clones.
+ RS_STACK_DISCARD_DIRTY: '',
+ // Avoid CI pin file when sourcing the script under unit tests.
+ CI: '',
+ ...env,
+ },
});
}
@@ -93,6 +102,8 @@ describe('clone-ratspeak-stack.sh float policy', () => {
expect(cloneScript).toContain('checkout --quiet --detach');
expect(cloneScript).toMatch(/RS_RETICULUM_REF="\$\{RS_RETICULUM_REF:-\}"/);
expect(cloneScript).toMatch(/RS_LXMF_REF="\$\{RS_LXMF_REF:-\}"/);
+ expect(cloneScript).toContain('ratspeak-stack-ci-pins.env');
+ expect(cloneScript).toContain('CI:-');
expect(cloneScript).toContain('export RS_RETICULUM_DIR=');
expect(cloneScript).toContain('export RS_LXMF_DIR=');
expect(cloneScript).toContain('refuse to float/pin');
diff --git a/scripts/ratspeak-stack-ci-pins.env b/scripts/ratspeak-stack-ci-pins.env
new file mode 100644
index 000000000..99928e057
--- /dev/null
+++ b/scripts/ratspeak-stack-ci-pins.env
@@ -0,0 +1,11 @@
+# Temporary CI pins for stacked Nomad file/metadata PRs (mesh-client#966).
+# Applied only when CI=true (see clone-ratspeak-stack.sh). Remove this file
+# (or empty the assignments) after these merge:
+# ratspeak/rsReticulum#26 ReplyFile + LinkClient::query metadata
+# ratspeak/rsLXMF#7 multi-file attachment APIs
+# Colorado-Mesh/rsNomad#7 /file ReplyFile filename metadata
+#
+# Use := so workflow env overrides still win when explicitly set.
+: "${RS_RETICULUM_REF:=36456230cc29be5722c6f57c95f52c3b655e97f6}"
+: "${RS_LXMF_REF:=c3d8b44942e7726dbbe6bb53e0976d4c72134119}"
+: "${RS_NOMAD_REF:=66d725785930039b5c527955e69dfa75474ebf71}"
From 257f399343a429213bb01a1777433f649200bc40 Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 13:37:18 -0600
Subject: [PATCH 4/7] fix(reticulum): only reuse attachment preview bytes for
matching fetchKey
Avoid saving a previous image's cached data URL under a new attachment
filename if the selection changes mid-read.
---
src/renderer/components/ReticulumAttachmentLine.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/renderer/components/ReticulumAttachmentLine.tsx b/src/renderer/components/ReticulumAttachmentLine.tsx
index 2a1eef85a..38b89d0e3 100644
--- a/src/renderer/components/ReticulumAttachmentLine.tsx
+++ b/src/renderer/components/ReticulumAttachmentLine.tsx
@@ -87,7 +87,8 @@ export function ReticulumAttachmentLine({
if (!attachmentPath || busy) return;
setBusy(true);
try {
- let dataBase64 = imageDataUrl != null ? dataUrlToBase64(imageDataUrl) : null;
+ let dataBase64 =
+ fetchedFor === fetchKey && imageDataUrl != null ? dataUrlToBase64(imageDataUrl) : null;
if (!dataBase64) {
const res = await window.electronAPI.chat.readReticulumAttachmentAsDataUrl({
filePath: attachmentPath,
From 1eb68cd3f3ca83f59c5fb397a0f05ce138f4fa30 Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 14:46:37 -0600
Subject: [PATCH 5/7] chore(reticulum): track stacked rsReticulum/rsLXMF PRs in
pnpm update
Watch ratspeak/rsReticulum#26 and ratspeak/rsLXMF#7 via RATSPEAK_STACK_PR_ENTRIES
so update warns when CI pins can be cleared. Drop the rsNomad pin now that #7
merged, and document the stacked-PR pin workflow.
---
.github/workflows/flatpak.yaml | 3 +-
.github/workflows/reticulum-sidecar.yaml | 4 +-
AGENTS.md | 2 +-
docs/ci-cd.md | 9 ++-
docs/reticulum.md | 2 +-
reticulum-sidecar/README.md | 2 +-
reticulum-sidecar/patches/README.md | 11 ++++
scripts/ratspeak-stack-ci-pins.env | 10 +--
scripts/update.sh | 69 +++++++++++++++++++++
scripts/update.test.mjs | 78 ++++++++++++++++++++++++
10 files changed, 175 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml
index 5cdd50832..2f0fa4127 100644
--- a/.github/workflows/flatpak.yaml
+++ b/.github/workflows/flatpak.yaml
@@ -67,10 +67,9 @@ jobs:
- name: Clone Ratspeak stack (rsReticulum, rsLXMF)
env:
WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack
- # Keep in sync with reticulum-sidecar.yaml stacked pins until upstream merges.
+ # Keep in sync with reticulum-sidecar.yaml / ratspeak-stack-ci-pins.env.
RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
- RS_NOMAD_REF: 66d725785930039b5c527955e69dfa75474ebf71
run: bash scripts/clone-ratspeak-stack.sh
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml
index 287d254bf..b798c8f05 100644
--- a/.github/workflows/reticulum-sidecar.yaml
+++ b/.github/workflows/reticulum-sidecar.yaml
@@ -4,11 +4,11 @@ permissions:
contents: read
# Stacked Nomad file/metadata work: pin siblings until upstream PRs merge, then float again.
-# ratspeak/rsReticulum#26, ratspeak/rsLXMF#7, Colorado-Mesh/rsNomad#7
+# ratspeak/rsReticulum#26, ratspeak/rsLXMF#7 (rsNomad#7 merged — float). Keep in sync with
+# scripts/ratspeak-stack-ci-pins.env and RATSPEAK_STACK_PR_ENTRIES in scripts/update.sh.
env:
RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
- RS_NOMAD_REF: 66d725785930039b5c527955e69dfa75474ebf71
on:
workflow_dispatch:
diff --git a/AGENTS.md b/AGENTS.md
index e28e97c76..22cf777c0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -118,7 +118,7 @@ Adding a cross-boundary feature:
**Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing.
-> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad).
+> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. Stacked **feature** PRs that mesh-client CI pins (not overlays) live in `RATSPEAK_STACK_PR_ENTRIES` + `scripts/ratspeak-stack-ci-pins.env` (`check_ratspeak_stack_prs`) — today [rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) and [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7); clear pins when those merge. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad).
**Pre-commit hook order:**
diff --git a/docs/ci-cd.md b/docs/ci-cd.md
index b8bd7c7df..fce1bac17 100644
--- a/docs/ci-cd.md
+++ b/docs/ci-cd.md
@@ -90,7 +90,7 @@ Path-filtered on `reticulum-sidecar/**` and related scripts:
1. **`lint` job (ubuntu-latest)** — `cargo fmt --check` + `cargo clippy` with `rns-stack,rns-ble,rns-rnode-tcp` (`-D warnings`)
2. **Build matrix** — stub + full-stack `cargo test` and release builds on Linux, macOS, and Windows (including WoA arm64 jobs)
-CI and local **dev** clones float the `.rsstack/` workspace via `scripts/clone-ratspeak-stack.sh` to `origin/main` (overlays must apply; override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). **Release** packaging (`scripts/build-reticulum-sidecar-release.mjs`) runs the same clone and records the resolved commit SHAs for all five crates in `.rsstack/RESOLVED_SHAS.txt` so artifacts retain the exact source revisions used — pin via `RS_*_REF` when a release must not float.
+CI and local **dev** clones float the `.rsstack/` workspace via `scripts/clone-ratspeak-stack.sh` to `origin/main` (overlays must apply; override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). When CI must build against open stacked ratspeak PRs, `scripts/ratspeak-stack-ci-pins.env` supplies defaults under `CI=true` (tracked by `RATSPEAK_STACK_PR_ENTRIES` in `scripts/update.sh`; see [reticulum-sidecar/patches/README.md](../reticulum-sidecar/patches/README.md#stacked-upstream-feature-prs-ci-pins)). **Release** packaging (`scripts/build-reticulum-sidecar-release.mjs`) runs the same clone and records the resolved commit SHAs for all five crates in `.rsstack/RESOLVED_SHAS.txt` so artifacts retain the exact source revisions used — pin via `RS_*_REF` when a release must not float.
Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit full-feature). See [development-environment.md](development-environment.md#reticulum-sidecar-optional).
@@ -185,11 +185,14 @@ Automated dependency updates are configured in `.github/dependabot.yml`:
- **GitHub Actions:** Grouped into one PR
- **Open PRs:** `open-pull-requests-limit: 0` — Dependabot scans but does **not** open PRs.
Dependency bumps are applied manually via `pnpm run update` (`scripts/update.sh`), which
- also runs dedupe, Ratspeak overlay PR checks, and an upstream release / new-org-repo watch
+ also runs dedupe, Ratspeak overlay PR checks, stacked feature-PR pin watches
+ ([rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) ReplyFile,
+ [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7) multi-file attachments — see
+ `scripts/ratspeak-stack-ci-pins.env`), and an upstream release / new-org-repo watch
(rsLXST, lrgp-rs, Ratspeak Games-parity when a newer published release exists, LXMFace
`js/lxmface.js` commit). Sibling **rsReticulum** /
**rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** float to `origin/main` via
- `clone-ratspeak-stack.sh` (overlays must apply). See AGENTS.md §6.
+ `clone-ratspeak-stack.sh` (overlays must apply; CI may pin open stacked PRs). See AGENTS.md §6.
### Testing Dependabot PRs locally
diff --git a/docs/reticulum.md b/docs/reticulum.md
index 9128fab4c..edb3cbfb4 100644
--- a/docs/reticulum.md
+++ b/docs/reticulum.md
@@ -492,7 +492,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne
## Building the sidecar (development)
-`rns-stack` builds need the repo-local `.rsstack/` workspace checkouts `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set.
+`rns-stack` builds need the repo-local `.rsstack/` workspace checkouts `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). CI may pin open stacked feature PRs ([rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26), [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7)) via `scripts/ratspeak-stack-ci-pins.env` — tracked by `pnpm run update`. Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set.
End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do.
diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md
index e646c1224..6f47cd641 100644
--- a/reticulum-sidecar/README.md
+++ b/reticulum-sidecar/README.md
@@ -14,7 +14,7 @@ Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See
./scripts/clone-ratspeak-stack.sh
```
-That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` under `.rsstack/` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar.
+That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` under `.rsstack/` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). CI may temporarily pin rsReticulum / rsLXMF to open stacked PRs via `scripts/ratspeak-stack-ci-pins.env` (see [patches/README.md](patches/README.md#stacked-upstream-feature-prs-ci-pins); tracked by `pnpm run update`). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar.
**Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires the `.rsstack/` checkouts on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above):
diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md
index d1613d545..2f38f0c0e 100644
--- a/reticulum-sidecar/patches/README.md
+++ b/reticulum-sidecar/patches/README.md
@@ -4,6 +4,17 @@ Patches applied on top of [ratspeak/rsReticulum](https://github.com/ratspeak/rsR
By default `scripts/clone-ratspeak-stack.sh` floats the `.rsstack/` checkouts to **`origin/main`** and applies these overlays (fails loud if a patch will not apply). Use `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to pin a known-good SHA for bisect. Per-overlay **Base commit** tables below record the last regeneration baseline, not a permanent pin — when regenerating, prefer floated `origin/main` and record the short SHA in the PR.
+## Stacked upstream feature PRs (CI pins)
+
+mesh-client sometimes depends on **open** ratspeak PRs that are not local overlays (new library APIs). Those are pinned for CI via `scripts/ratspeak-stack-ci-pins.env` (loaded when `CI=true`) and matching workflow `env` blocks. `pnpm run update` tracks them in `RATSPEAK_STACK_PR_ENTRIES` (`scripts/update.sh`) and warns when they merge so pins can be cleared.
+
+| Upstream | What we need | Pin / watch |
+| -------- | ------------ | ----------- |
+| [ratspeak/rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) | `RequestOutcome::ReplyFile` + `LinkClient::query` Resource metadata | `RS_RETICULUM_REF` |
+| [ratspeak/rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7) | Multi-file LXMF attachment pack/list APIs | `RS_LXMF_REF` |
+
+After both merge and floated `origin/main` includes them: delete or empty `ratspeak-stack-ci-pins.env`, drop workflow env pins, and remove the matching `RATSPEAK_STACK_PR_ENTRIES` rows.
+
## Development — overlays/patches
Overlays require **git checkouts** in the repo-local `.rsstack/` workspace (not a bare Cargo cache path):
diff --git a/scripts/ratspeak-stack-ci-pins.env b/scripts/ratspeak-stack-ci-pins.env
index 99928e057..4e86a3916 100644
--- a/scripts/ratspeak-stack-ci-pins.env
+++ b/scripts/ratspeak-stack-ci-pins.env
@@ -1,11 +1,11 @@
-# Temporary CI pins for stacked Nomad file/metadata PRs (mesh-client#966).
-# Applied only when CI=true (see clone-ratspeak-stack.sh). Remove this file
-# (or empty the assignments) after these merge:
+# Temporary CI pins for stacked Nomad file/metadata work (mesh-client#966).
+# Applied only when CI=true (see clone-ratspeak-stack.sh). Clear assignments
+# (or delete this file) when the matching RATSPEAK_STACK_PR_ENTRIES in
+# scripts/update.sh report upstream MERGED:
# ratspeak/rsReticulum#26 ReplyFile + LinkClient::query metadata
# ratspeak/rsLXMF#7 multi-file attachment APIs
-# Colorado-Mesh/rsNomad#7 /file ReplyFile filename metadata
+# (Colorado-Mesh/rsNomad#7 already merged — float rsNomad to origin/main.)
#
# Use := so workflow env overrides still win when explicitly set.
: "${RS_RETICULUM_REF:=36456230cc29be5722c6f57c95f52c3b655e97f6}"
: "${RS_LXMF_REF:=c3d8b44942e7726dbbe6bb53e0976d4c72134119}"
-: "${RS_NOMAD_REF:=66d725785930039b5c527955e69dfa75474ebf71}"
diff --git a/scripts/update.sh b/scripts/update.sh
index 44ce8a42e..292d5b6e0 100755
--- a/scripts/update.sh
+++ b/scripts/update.sh
@@ -373,6 +373,70 @@ check_ratspeak_patches() {
fi
}
+# Track stacked upstream feature PRs that mesh-client pins until they land on main
+# (scripts/ratspeak-stack-ci-pins.env + optional workflow env). Not overlay patches —
+# these are dependency APIs (ReplyFile, multi-file LXMF attachments, …).
+# Format: "owner/repo|pr-number|display-label|cleanup-hint"
+# Keep in sync with scripts/ratspeak-stack-ci-pins.env and reticulum-sidecar/patches/README.md.
+RATSPEAK_STACK_PR_ENTRIES=(
+ 'ratspeak/rsReticulum|26|rsReticulum ReplyFile / LinkClient query metadata|clear RS_RETICULUM_REF from scripts/ratspeak-stack-ci-pins.env (+ reticulum-sidecar.yaml / flatpak.yaml env pins)'
+ 'ratspeak/rsLXMF|7|rsLXMF multi-file attachment APIs|clear RS_LXMF_REF from scripts/ratspeak-stack-ci-pins.env (+ reticulum-sidecar.yaml / flatpak.yaml env pins)'
+)
+
+check_ratspeak_stack_prs() {
+ local has_stack_pr_warning=0
+ local entry repo pr label cleanup url state
+
+ echo ''
+ echo 'Checking stacked Ratspeak feature PRs (CI pins until merge)...'
+
+ if [ "${#RATSPEAK_STACK_PR_ENTRIES[@]}" -eq 0 ]; then
+ echo ' No stacked feature PRs tracked.'
+ return 0
+ fi
+
+ for entry in "${RATSPEAK_STACK_PR_ENTRIES[@]}"; do
+ IFS='|' read -r repo pr label cleanup <<< "${entry}"
+ url="https://github.com/${repo}/pull/${pr}"
+ state="$(github_pr_state "${repo}" "${pr}")"
+ case "${state}" in
+ open)
+ echo " ${label}: still open — ${url}"
+ echo " CI pins via scripts/ratspeak-stack-ci-pins.env (and matching workflow env)."
+ ;;
+ merged)
+ warn_box "${label} (stacked feature PR)" "CI pin" "upstream MERGED" "${url}"
+ echo " Reason tracked: ${repo}#${pr} merged — ${cleanup}"
+ echo " then drop this entry from RATSPEAK_STACK_PR_ENTRIES in scripts/update.sh."
+ has_stack_pr_warning=1
+ HAS_WARNING=1
+ ;;
+ closed)
+ warn_box "${label} (stacked feature PR)" "CI pin" "PR closed (not merged?)" "${url}"
+ echo " Reason tracked: ${repo}#${pr} closed without merge — verify pin still needed,"
+ echo " then ${cleanup} and drop entry from RATSPEAK_STACK_PR_ENTRIES."
+ has_stack_pr_warning=1
+ HAS_WARNING=1
+ ;;
+ *)
+ echo " ${label}: could not query ${repo}#${pr} (install gh or check network) — ${url}"
+ ;;
+ esac
+ done
+
+ if [ "${has_stack_pr_warning}" -eq 0 ]; then
+ echo ' Stacked feature PR check complete (pins still match open upstream).'
+ fi
+}
+
+# Test hook: exercise check_ratspeak_stack_prs (fake gh/curl via PATH).
+if [ "${UPDATE_SH_TEST_HOOK:-}" = 'stack-prs-only' ]; then
+ HAS_WARNING=0
+ check_ratspeak_stack_prs
+ printf 'HAS_WARNING=%s\n' "${HAS_WARNING}"
+ exit 0
+fi
+
# GET GitHub API path (gh preferred, curl fallback). Body on stdout.
# Exit 0 = body (may be empty), exit 2 = rate-limit payload detected (empty body).
# Callers must handle exit 2 in the parent shell (command substitution drops side effects).
@@ -589,6 +653,10 @@ RATSPEAK_KNOWN_ORG_REPOS=(
print_ratspeak_upstream_catalog() {
local entry
+ echo 'RATSPEAK_STACK_PR_ENTRIES:'
+ for entry in "${RATSPEAK_STACK_PR_ENTRIES[@]}"; do
+ echo " ${entry}"
+ done
echo 'RATSPEAK_RELEASE_WATCH_ENTRIES:'
for entry in "${RATSPEAK_RELEASE_WATCH_ENTRIES[@]}"; do
echo " ${entry}"
@@ -838,6 +906,7 @@ done
check_pinned_majors
check_ratspeak_patches
+check_ratspeak_stack_prs
check_ratspeak_upstream
if [ "${HAS_WARNING}" -eq 0 ]; then
diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs
index f09f1e168..2bbbba813 100644
--- a/scripts/update.test.mjs
+++ b/scripts/update.test.mjs
@@ -104,6 +104,9 @@ describe('update.sh Reticulum stack functionality check', () => {
it('prints Ratspeak upstream catalog (upstream-catalog-only)', () => {
const result = runUpdate([], { UPDATE_SH_TEST_HOOK: 'upstream-catalog-only' });
expect(result.status, result.stderr || result.stdout).toBe(0);
+ expect(result.stdout).toContain('RATSPEAK_STACK_PR_ENTRIES:');
+ expect(result.stdout).toContain('ratspeak/rsReticulum|26|');
+ expect(result.stdout).toContain('ratspeak/rsLXMF|7|');
expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:');
expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)|v0.2.0');
expect(result.stdout).toContain('ratspeak/lrgp-rs||lrgp-rs games (LRGP)|v0.4.1');
@@ -122,6 +125,81 @@ describe('update.sh Reticulum stack functionality check', () => {
expect(result.stdout).toContain(' lrgp-rs');
});
+ it('wires check_ratspeak_stack_prs between overlay and upstream checks', () => {
+ expect(updateScript).toContain('check_ratspeak_stack_prs()');
+ expect(updateScript).toContain('RATSPEAK_STACK_PR_ENTRIES');
+ expect(updateScript).toContain('ratspeak/rsReticulum|26|');
+ expect(updateScript).toContain('ratspeak/rsLXMF|7|');
+ expect(updateScript).toContain('ratspeak-stack-ci-pins.env');
+ const patchesCall = updateScript.lastIndexOf('\ncheck_ratspeak_patches\n');
+ const stackPrsCall = updateScript.lastIndexOf('\ncheck_ratspeak_stack_prs\n');
+ const upstreamCall = updateScript.lastIndexOf('\ncheck_ratspeak_upstream\n');
+ expect(patchesCall).toBeGreaterThanOrEqual(0);
+ expect(stackPrsCall).toBeGreaterThan(patchesCall);
+ expect(upstreamCall).toBeGreaterThan(stackPrsCall);
+ });
+
+ it('stack-prs-only reports open pins without warning', () => {
+ const binDir = mkdtempSync(path.join(os.tmpdir(), 'mesh-update-stack-prs-'));
+ tempDirs.push(binDir);
+ const ghPath = path.join(binDir, 'gh');
+ writeFileSync(
+ ghPath,
+ `#!/bin/bash
+# Fake gh api for stack PR state
+if [[ "$*" == *repos/ratspeak/rsReticulum/pulls/26* ]] || [[ "$*" == *repos/ratspeak/rsLXMF/pulls/7* ]]; then
+ printf '%s' '{"state":"open","merged":false}'
+ exit 0
+fi
+printf '%s' '{}'
+exit 0
+`,
+ 'utf8',
+ );
+ chmodSync(ghPath, 0o755);
+ const result = runUpdate([], {
+ UPDATE_SH_TEST_HOOK: 'stack-prs-only',
+ PATH: `${binDir}:${process.env.PATH ?? ''}`,
+ });
+ expect(result.status, result.stderr || result.stdout).toBe(0);
+ expect(result.stdout).toContain('still open');
+ expect(result.stdout).toContain('rsReticulum ReplyFile');
+ expect(result.stdout).toContain('rsLXMF multi-file');
+ expect(result.stdout).toContain('HAS_WARNING=0');
+ expect(result.stdout).not.toContain('WARNING:');
+ });
+
+ it('stack-prs-only warns when a stacked PR is merged', () => {
+ const binDir = mkdtempSync(path.join(os.tmpdir(), 'mesh-update-stack-merged-'));
+ tempDirs.push(binDir);
+ const ghPath = path.join(binDir, 'gh');
+ writeFileSync(
+ ghPath,
+ `#!/bin/bash
+if [[ "$*" == *repos/ratspeak/rsReticulum/pulls/26* ]]; then
+ printf '%s' '{"state":"closed","merged":true,"merged_at":"2026-09-09T00:00:00Z"}'
+ exit 0
+fi
+if [[ "$*" == *repos/ratspeak/rsLXMF/pulls/7* ]]; then
+ printf '%s' '{"state":"open","merged":false}'
+ exit 0
+fi
+printf '%s' '{}'
+exit 0
+`,
+ 'utf8',
+ );
+ chmodSync(ghPath, 0o755);
+ const result = runUpdate([], {
+ UPDATE_SH_TEST_HOOK: 'stack-prs-only',
+ PATH: `${binDir}:${process.env.PATH ?? ''}`,
+ });
+ expect(result.status, result.stderr || result.stdout).toBe(0);
+ expect(result.stdout).toContain('upstream MERGED');
+ expect(result.stdout).toContain('ratspeak-stack-ci-pins.env');
+ expect(result.stdout).toContain('HAS_WARNING=1');
+ });
+
it('wires check_ratspeak_upstream after overlay PR checks', () => {
expect(updateScript).toContain('check_ratspeak_upstream()');
expect(updateScript).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES');
From b8c137d61e118ae4adb675c1ac48ca0bc06838cd Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 14:49:24 -0600
Subject: [PATCH 6/7] ci(reticulum): bump rsReticulum pin to ReplyFile PR tip
Follow rustfmt push on ratspeak/rsReticulum#26 so CI clones the current
PR head.
---
.github/workflows/flatpak.yaml | 2 +-
.github/workflows/reticulum-sidecar.yaml | 2 +-
scripts/ratspeak-stack-ci-pins.env | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml
index 2f0fa4127..146e833c3 100644
--- a/.github/workflows/flatpak.yaml
+++ b/.github/workflows/flatpak.yaml
@@ -68,7 +68,7 @@ jobs:
env:
WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack
# Keep in sync with reticulum-sidecar.yaml / ratspeak-stack-ci-pins.env.
- RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
+ RS_RETICULUM_REF: 90774570eae7498554b4809d9203765368c5bbed
RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
run: bash scripts/clone-ratspeak-stack.sh
diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml
index b798c8f05..406fa6431 100644
--- a/.github/workflows/reticulum-sidecar.yaml
+++ b/.github/workflows/reticulum-sidecar.yaml
@@ -7,7 +7,7 @@ permissions:
# ratspeak/rsReticulum#26, ratspeak/rsLXMF#7 (rsNomad#7 merged — float). Keep in sync with
# scripts/ratspeak-stack-ci-pins.env and RATSPEAK_STACK_PR_ENTRIES in scripts/update.sh.
env:
- RS_RETICULUM_REF: 36456230cc29be5722c6f57c95f52c3b655e97f6
+ RS_RETICULUM_REF: 90774570eae7498554b4809d9203765368c5bbed
RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119
on:
diff --git a/scripts/ratspeak-stack-ci-pins.env b/scripts/ratspeak-stack-ci-pins.env
index 4e86a3916..93a0d4992 100644
--- a/scripts/ratspeak-stack-ci-pins.env
+++ b/scripts/ratspeak-stack-ci-pins.env
@@ -7,5 +7,5 @@
# (Colorado-Mesh/rsNomad#7 already merged — float rsNomad to origin/main.)
#
# Use := so workflow env overrides still win when explicitly set.
-: "${RS_RETICULUM_REF:=36456230cc29be5722c6f57c95f52c3b655e97f6}"
+: "${RS_RETICULUM_REF:=90774570eae7498554b4809d9203765368c5bbed}"
: "${RS_LXMF_REF:=c3d8b44942e7726dbbe6bb53e0976d4c72134119}"
From 423541bb400c6b153e3139d000e276b25c41d539 Mon Sep 17 00:00:00 2001
From: Joey Stanford
Date: Wed, 9 Sep 2026 15:02:15 -0600
Subject: [PATCH 7/7] fix(deps): floor smol-toml for audit; add Nomad/chat
attachment i18n
pnpm audit was failing Code quality on GHSA-7w5x-hrqm-74c2 via markdownlint-cli2; add the missing download/save/reveal locale keys so check:i18n stays green.
---
pnpm-lock.yaml | 9 +++++----
pnpm-workspace.yaml | 4 +++-
src/renderer/locales/cs/translation.json | 8 ++++++--
src/renderer/locales/de/translation.json | 8 ++++++--
src/renderer/locales/en/translation.json | 4 ++++
src/renderer/locales/es/translation.json | 8 ++++++--
src/renderer/locales/fr/translation.json | 8 ++++++--
src/renderer/locales/id/translation.json | 8 ++++++--
src/renderer/locales/it/translation.json | 8 ++++++--
src/renderer/locales/ja/translation.json | 8 ++++++--
src/renderer/locales/ko/translation.json | 8 ++++++--
src/renderer/locales/nl/translation.json | 8 ++++++--
src/renderer/locales/pl/translation.json | 8 ++++++--
src/renderer/locales/pt-BR/translation.json | 8 ++++++--
src/renderer/locales/ru/translation.json | 8 ++++++--
src/renderer/locales/tr/translation.json | 8 ++++++--
src/renderer/locales/uk/translation.json | 8 ++++++--
src/renderer/locales/zh/translation.json | 8 ++++++--
18 files changed, 102 insertions(+), 35 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 27a0d3934..03d594170 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -147,6 +147,7 @@ overrides:
markdown-it@<=14.1.1: '>=14.2.0 <15'
postcss: ^8.5.25
shell-quote: ^1.9.0
+ smol-toml: ^1.7.1
tar: ^7.5.18
tmp: ^0.2.6
undici@<7.29.0: ^7.29.0
@@ -4219,8 +4220,8 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
- smol-toml@1.7.0:
- resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
+ smol-toml@1.8.0:
+ resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
engines: {node: '>= 18'}
socks@2.8.10:
@@ -7930,7 +7931,7 @@ snapshots:
markdownlint: 0.41.1(supports-color@8.1.1)
markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.23.2(supports-color@8.1.1))
micromatch: 4.0.8
- smol-toml: 1.7.0
+ smol-toml: 1.8.0
transitivePeerDependencies:
- supports-color
@@ -8939,7 +8940,7 @@ snapshots:
smart-buffer@4.2.0: {}
- smol-toml@1.7.0: {}
+ smol-toml@1.8.0: {}
socks@2.8.10:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 7ac9e1166..0029bdf27 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -59,7 +59,7 @@ overrides:
# GHSA-2883-xcg3-v3hh / GHSA-w5vr-8v7q-w6rv / GHSA-82fw-gwwq-j7x9 /
# GHSA-4w3w-2rp5-g8jm / GHSA-w2rr-34g9-rvrj / GHSA-93r5-fhx6-vmg9 /
# GHSA-8344-3jmq-59r6 / GHSA-6h8r-xr42-gp59 / GHSA-27p8-2357-5qqv /
- # GHSA-c7q8-3ch8-vqpv).
+ # GHSA-c7q8-3ch8-vqpv / GHSA-7w5x-hrqm-74c2).
# brace-expansion: keep a single 5.0.9 floor. GHSA-rgw5-rvv9-x895 is a
# bypass of the CVE-2026-14257 mitigation and marks >=4.0.0 <5.0.9 vulnerable
# (only >=5.0.9 counts as patched). CI audit is blocking.
@@ -85,6 +85,7 @@ overrides:
# baseline-browser-mapping: GHSA-w5vr-8v7q-w6rv — floor >=2.11.0.
# vitest / @vitest/mocker: GHSA-82fw-gwwq-j7x9 — package.json pins vitest
# ^4.1.11 (no override needed while the direct pin holds the floor).
+ # smol-toml: GHSA-7w5x-hrqm-74c2 (markdownlint-cli2) — floor >=1.7.1.
# extract-zip (GHSA-7pqw-9j4j-h8q3): no patched release; Electron replaced it
# with hardened @electron-internal/extract-zip (not the vulnerable package).
# electron: deliberate major pin — bump with Flatpak sync + native rebuild smoke.
@@ -100,6 +101,7 @@ overrides:
markdown-it@<=14.1.1: '>=14.2.0 <15'
postcss: ^8.5.25
shell-quote: ^1.9.0
+ smol-toml: ^1.7.1
tar: ^7.5.18
tmp: ^0.2.6
undici@<7.29.0: ^7.29.0
diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json
index dcae23f70..ad8df9a80 100644
--- a/src/renderer/locales/cs/translation.json
+++ b/src/renderer/locales/cs/translation.json
@@ -770,7 +770,9 @@
"heardByRepeatersDetail_few": "Slyšeno {{count}}: {{names}}",
"searchResults_few": "{{count}} výsledky",
"reticulumSendAwaitingPeerReceipt": "Odesláno (čeká se na potvrzení od kolegy)",
- "reticulumChatNeedsLxmfDelivery": "Tento protějšek zatím nemá doručovací adresu LXMF (Chat nemůže používat cíl pouze pro hlas). Počkejte na oznámení LXMF nebo vložte jeho 32místný hash LXMF."
+ "reticulumChatNeedsLxmfDelivery": "Tento protějšek zatím nemá doručovací adresu LXMF (Chat nemůže používat cíl pouze pro hlas). Počkejte na oznámení LXMF nebo vložte jeho 32místný hash LXMF.",
+ "saveAttachment": "Uložit...",
+ "revealAttachment": "Zobrazit ve složce"
},
"chatPayload": {
"mention": "Zmínit {{label}}",
@@ -3456,7 +3458,9 @@
"pageProgressFailoverIface": "Zkusit jinou trasu přes {{iface}}…",
"pageProgressFailoverGeneric": "Zkouším jinou trasu...",
"pageProgressNoAlternate": "Žádné jiné trasy nejsou k dispozici — dokončení tohoto pokusu...",
- "hopsAway_few": "{{count}} skoky daleko"
+ "hopsAway_few": "{{count}} skoky daleko",
+ "downloadFile": "Stáhnout",
+ "dismissPreview": "Zamítnout"
},
"packetDistribution": {
"overallDistribution": "Celková distribuce",
diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json
index a8f6d4067..0432fcbdd 100644
--- a/src/renderer/locales/de/translation.json
+++ b/src/renderer/locales/de/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} zusätzlicher nicht identifizierter Spediteur (Hashes: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} weitere nicht identifizierte Spediteure (Hashes: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Gesendet (wartet auf Peer-Eingang)",
- "reticulumChatNeedsLxmfDelivery": "Dieser Peer hat noch keine LXMF-Lieferadresse (Chat kann kein Voice-only-Ziel verwenden). Warten Sie auf eine LXMF-Ankündigung oder fügen Sie ihren 32-stelligen LXMF-Hash ein."
+ "reticulumChatNeedsLxmfDelivery": "Dieser Peer hat noch keine LXMF-Lieferadresse (Chat kann kein Voice-only-Ziel verwenden). Warten Sie auf eine LXMF-Ankündigung oder fügen Sie ihren 32-stelligen LXMF-Hash ein.",
+ "saveAttachment": "Speichern…",
+ "revealAttachment": "In Ordner"
},
"chatPayload": {
"mention": "Erwähne {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Versuche eine andere Route über {{iface}} ({{hops}} Hops)…",
"pageProgressFailoverIface": "Versuche eine andere Route über {{iface}}…",
"pageProgressFailoverGeneric": "Eine andere Route wird versucht...",
- "pageProgressNoAlternate": "Keine anderen Routen verfügbar — Beenden dieses Versuchs..."
+ "pageProgressNoAlternate": "Keine anderen Routen verfügbar — Beenden dieses Versuchs...",
+ "downloadFile": "Herunterladen",
+ "dismissPreview": "Ablehnen"
},
"packetDistribution": {
"overallDistribution": "Gesamtverteilung",
diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json
index d8e97a88d..58fa25535 100644
--- a/src/renderer/locales/en/translation.json
+++ b/src/renderer/locales/en/translation.json
@@ -496,6 +496,8 @@
"sendButton": "Send",
"sendButtonDm": "DM",
"sendButtonSending": "...",
+ "saveAttachment": "Save…",
+ "revealAttachment": "Show in folder",
"waitingMessagesQueued": "{{count}} queued message(s) on radio",
"waitingMessagesSyncProgress": "Syncing {{processed}} / {{total}} from radio…",
"waitingMessagesSyncProgressIndeterminate": "Syncing queued messages from radio…",
@@ -3458,6 +3460,8 @@
"pageLoadingCountdownOverdue": "Loading page… still working",
"pageLoadingRetryCountdown": "First attempt timed out — refreshing path and retrying… {{time}} left",
"pageLoadingRetryOverdue": "Refreshing path and retrying… still working",
+ "downloadFile": "Download",
+ "dismissPreview": "Dismiss",
"pageLoadingTimeLeft": "{{time}} left on this attempt",
"pageLoadingStillWorking": "Still working…",
"pageProgressLinking": "Connecting via {{iface}} ({{hops}} hops)…",
diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json
index a796edf20..bb0a02429 100644
--- a/src/renderer/locales/es/translation.json
+++ b/src/renderer/locales/es/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} reenviador no identificado adicional (hashes: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} reenviadores no identificados adicionales (hashes: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Enviado (pendiente de recibo del compañero)",
- "reticulumChatNeedsLxmfDelivery": "Este compañero aún no tiene una dirección de entrega LXMF (el chat no puede usar un destino de solo voz). Espere un anuncio de LXMF o pegue su hash LXMF de 32 caracteres."
+ "reticulumChatNeedsLxmfDelivery": "Este compañero aún no tiene una dirección de entrega LXMF (el chat no puede usar un destino de solo voz). Espere un anuncio de LXMF o pegue su hash LXMF de 32 caracteres.",
+ "saveAttachment": "Salvar…",
+ "revealAttachment": "Mostrar en la carpeta"
},
"chatPayload": {
"mention": "Mencionar {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Intentando una ruta diferente a través de {{iface}} ({{hops}} hops)...",
"pageProgressFailoverIface": "Intentando una ruta diferente a través de {{iface}}...",
"pageProgressFailoverGeneric": "Probando una ruta diferente...",
- "pageProgressNoAlternate": "No hay otras rutas disponibles — finalizando este intento..."
+ "pageProgressNoAlternate": "No hay otras rutas disponibles — finalizando este intento...",
+ "downloadFile": "Descargar",
+ "dismissPreview": "Descartar"
},
"packetDistribution": {
"overallDistribution": "Distribución general",
diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json
index 383278750..10904bcdd 100644
--- a/src/renderer/locales/fr/translation.json
+++ b/src/renderer/locales/fr/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} expéditeur supplémentaire non identifié (hachages : {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} expéditeurs supplémentaires non identifiés (hachages : {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Envoyé (en attente de réception par les pairs)",
- "reticulumChatNeedsLxmfDelivery": "Ce pair n'a pas encore d'adresse de livraison LXMF (le chat ne peut pas utiliser une destination vocale uniquement). Attendez une annonce LXMF ou collez leur hachage LXMF de 32 caractères."
+ "reticulumChatNeedsLxmfDelivery": "Ce pair n'a pas encore d'adresse de livraison LXMF (le chat ne peut pas utiliser une destination vocale uniquement). Attendez une annonce LXMF ou collez leur hachage LXMF de 32 caractères.",
+ "saveAttachment": "Sauvegarder…",
+ "revealAttachment": "Montrer dans son dossier"
},
"chatPayload": {
"mention": "Mention {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Essayer un autre itinéraire via {{iface}} ({{hops}} sauts)…",
"pageProgressFailoverIface": "Essayer un autre itinéraire via {{iface}}…",
"pageProgressFailoverGeneric": "Essayer un autre itinéraire...",
- "pageProgressNoAlternate": "Aucun autre itinéraire disponible — terminer cette tentative…"
+ "pageProgressNoAlternate": "Aucun autre itinéraire disponible — terminer cette tentative…",
+ "downloadFile": "Télécharger",
+ "dismissPreview": "Ignorer"
},
"packetDistribution": {
"overallDistribution": "Répartition globale",
diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json
index 23dc35152..1d59afd83 100644
--- a/src/renderer/locales/id/translation.json
+++ b/src/renderer/locales/id/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} forwarder tak dikenal tambahan (hash: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} forwarder tak dikenal tambahan (hash: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Terkirim (menunggu tanda terima sejawat)",
- "reticulumChatNeedsLxmfDelivery": "Rekan ini belum memiliki alamat pengiriman LXMF (Obrolan tidak dapat menggunakan tujuan khusus Suara). Tunggu pengumuman LXMF, atau tempelkan hash LXMF 32 karakternya."
+ "reticulumChatNeedsLxmfDelivery": "Rekan ini belum memiliki alamat pengiriman LXMF (Obrolan tidak dapat menggunakan tujuan khusus Suara). Tunggu pengumuman LXMF, atau tempelkan hash LXMF 32 karakternya.",
+ "saveAttachment": "Simpan",
+ "revealAttachment": "Tampilkan dalam Folder"
},
"chatPayload": {
"mention": "Sebutkan {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Mencoba rute yang berbeda melalui {{iface}} ({{hops}} hop)...",
"pageProgressFailoverIface": "Mencoba rute lain melalui {{iface}}…",
"pageProgressFailoverGeneric": "Mencoba rute yang berbeda...",
- "pageProgressNoAlternate": "Tidak ada rute lain yang tersedia — menyelesaikan upaya ini..."
+ "pageProgressNoAlternate": "Tidak ada rute lain yang tersedia — menyelesaikan upaya ini...",
+ "downloadFile": "Unduh",
+ "dismissPreview": "Tutup"
},
"packetDistribution": {
"overallDistribution": "Distribusi Keseluruhan",
diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json
index bb293f729..efdcb826b 100644
--- a/src/renderer/locales/it/translation.json
+++ b/src/renderer/locales/it/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} ulteriore spedizioniere non identificato (hash: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} ulteriori spedizionieri non identificati (hash: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Inviato (in attesa di ricezione da parte del pari)",
- "reticulumChatNeedsLxmfDelivery": "Questo peer non ha ancora un indirizzo di consegna LXMF (la chat non può utilizzare una destinazione solo vocale). Attendi un annuncio LXMF o incolla il loro hash LXMF di 32 caratteri."
+ "reticulumChatNeedsLxmfDelivery": "Questo peer non ha ancora un indirizzo di consegna LXMF (la chat non può utilizzare una destinazione solo vocale). Attendi un annuncio LXMF o incolla il loro hash LXMF di 32 caratteri.",
+ "saveAttachment": "Salva…",
+ "revealAttachment": "Nella cartella"
},
"chatPayload": {
"mention": "Menziona {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Prova un percorso diverso tramite {{iface}} ({{hops}} Hops)...",
"pageProgressFailoverIface": "Prova un percorso diverso tramite {{iface}}...",
"pageProgressFailoverGeneric": "Sto provando un percorso diverso...",
- "pageProgressNoAlternate": "Nessun altro percorso disponibile — terminare questo tentativo..."
+ "pageProgressNoAlternate": "Nessun altro percorso disponibile — terminare questo tentativo...",
+ "downloadFile": "Download",
+ "dismissPreview": "Ignora"
},
"packetDistribution": {
"overallDistribution": "Distribuzione complessiva",
diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json
index 8f8dc09f2..e08ee7133 100644
--- a/src/renderer/locales/ja/translation.json
+++ b/src/renderer/locales/ja/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}}追加の未確認フォワーダー(ハッシュ: {{hashes}} )",
"heardByRepeatersAdditional_other": "{{count}}追加の未確認フォワーダー(ハッシュ: {{hashes}} )",
"reticulumSendAwaitingPeerReceipt": "送信済み(ピアレシート待ち)",
- "reticulumChatNeedsLxmfDelivery": "このピアにはまだLXMF配信アドレスがありません(チャットは音声のみの宛先を使用できません)。LXMFの発表を待つか、32文字のLXMFハッシュを貼り付けます。"
+ "reticulumChatNeedsLxmfDelivery": "このピアにはまだLXMF配信アドレスがありません(チャットは音声のみの宛先を使用できません)。LXMFの発表を待つか、32文字のLXMFハッシュを貼り付けます。",
+ "saveAttachment": "保存...",
+ "revealAttachment": "フォルダを表示"
},
"chatPayload": {
"mention": "{{label}} について言及してください",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "{{iface}} ( {{hops}}ホップ)経由で別のルートを試しています…",
"pageProgressFailoverIface": "{{iface}}経由で別のルートを試しています…",
"pageProgressFailoverGeneric": "別のルートを試しています…",
- "pageProgressNoAlternate": "他に利用可能なルートはありません—この試みを終了しています…"
+ "pageProgressNoAlternate": "他に利用可能なルートはありません—この試みを終了しています…",
+ "downloadFile": "ダウンロード",
+ "dismissPreview": "却下"
},
"packetDistribution": {
"overallDistribution": "全体の分布",
diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json
index cc788c838..3b85b3530 100644
--- a/src/renderer/locales/ko/translation.json
+++ b/src/renderer/locales/ko/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} 추가 미확인 전달자 (해시: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} 추가 미확인 전달자 (해시: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "전송 완료 (동료 영수증 대기 중)",
- "reticulumChatNeedsLxmfDelivery": "이 동료는 아직 LXMF 배달 주소가 없습니다 (채팅은 음성 전용 목적지를 사용할 수 없습니다). LXMF가 발표될 때까지 기다리거나 32자 LXMF 해시를 붙여넣습니다."
+ "reticulumChatNeedsLxmfDelivery": "이 동료는 아직 LXMF 배달 주소가 없습니다 (채팅은 음성 전용 목적지를 사용할 수 없습니다). LXMF가 발표될 때까지 기다리거나 32자 LXMF 해시를 붙여넣습니다.",
+ "saveAttachment": "저장…",
+ "revealAttachment": "폴더에 표시"
},
"chatPayload": {
"mention": "{{label}}을(를) 언급하세요",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "{{iface}} ({{hops}} HOPS) 을 (를) 통해 다른 경로로 시도 중...",
"pageProgressFailoverIface": "{{iface}} 을 (를) 경유하는 다른 경로로 시도 중...",
"pageProgressFailoverGeneric": "다른 경로로 시도 중...",
- "pageProgressNoAlternate": "사용 가능한 다른 경로가 없습니다. 이 시도를 완료하는 중..."
+ "pageProgressNoAlternate": "사용 가능한 다른 경로가 없습니다. 이 시도를 완료하는 중...",
+ "downloadFile": "다운로드",
+ "dismissPreview": "무시하기"
},
"packetDistribution": {
"overallDistribution": "전체 분포",
diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json
index a5206b53a..c5ef9b196 100644
--- a/src/renderer/locales/nl/translation.json
+++ b/src/renderer/locales/nl/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} extra niet-geïdentificeerde expediteur (hashes: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} extra niet-geïdentificeerde expediteurs (hashes: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Verzonden (in afwachting van ontvangst door collega's)",
- "reticulumChatNeedsLxmfDelivery": "Deze peer heeft nog geen LXMF-bezorgadres (chat kan geen bestemming met alleen spraak gebruiken). Wacht op een LXMF-aankondiging of plak hun LXMF-hash van 32 tekens."
+ "reticulumChatNeedsLxmfDelivery": "Deze peer heeft nog geen LXMF-bezorgadres (chat kan geen bestemming met alleen spraak gebruiken). Wacht op een LXMF-aankondiging of plak hun LXMF-hash van 32 tekens.",
+ "saveAttachment": "Opslaan",
+ "revealAttachment": "Tonen in map"
},
"chatPayload": {
"mention": "Vermeld {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Een andere route proberen via {{iface}} ({{hops}} hops)...",
"pageProgressFailoverIface": "Een andere route proberen via {{iface}}…",
"pageProgressFailoverGeneric": "Probeer een andere route...",
- "pageProgressNoAlternate": "Geen andere routes beschikbaar — deze poging afronden..."
+ "pageProgressNoAlternate": "Geen andere routes beschikbaar — deze poging afronden...",
+ "downloadFile": "Download",
+ "dismissPreview": "Afwijzen"
},
"packetDistribution": {
"overallDistribution": "Algemene distributie",
diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json
index a16b60ca6..7a31fe869 100644
--- a/src/renderer/locales/pl/translation.json
+++ b/src/renderer/locales/pl/translation.json
@@ -788,7 +788,9 @@
"searchResults_few": "{{count}} wyniki",
"searchResults_many": "{{count}} wyników",
"reticulumSendAwaitingPeerReceipt": "Wysłano (oczekiwanie na pokwitowanie)",
- "reticulumChatNeedsLxmfDelivery": "Ten partner nie ma jeszcze adresu dostawy LXMF (czat nie może używać miejsca docelowego tylko dla połączeń głosowych). Poczekaj na ogłoszenie LXMF lub wklej 32-znakowy skrót LXMF."
+ "reticulumChatNeedsLxmfDelivery": "Ten partner nie ma jeszcze adresu dostawy LXMF (czat nie może używać miejsca docelowego tylko dla połączeń głosowych). Poczekaj na ogłoszenie LXMF lub wklej 32-znakowy skrót LXMF.",
+ "saveAttachment": "Zapisz",
+ "revealAttachment": "Pokaż w folderze"
},
"chatPayload": {
"mention": "Wspomnij o {{label}}",
@@ -3494,7 +3496,9 @@
"pageProgressFailoverGeneric": "Próbuję wybrać inną trasę…",
"pageProgressNoAlternate": "Brak innych dostępnych tras — kończenie tej próby…",
"hopsAway_few": "{{count}} przeskoki dalej",
- "hopsAway_many": "{{count}} przeskoków dalej"
+ "hopsAway_many": "{{count}} przeskoków dalej",
+ "downloadFile": "Pobierz",
+ "dismissPreview": "Odrzuć"
},
"packetDistribution": {
"overallDistribution": "Ogólna dystrybucja",
diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json
index 205a22b3f..57238ab3d 100644
--- a/src/renderer/locales/pt-BR/translation.json
+++ b/src/renderer/locales/pt-BR/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} encaminhador adicional não identificado (hashes: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} encaminhadores adicionais não identificados (hashes: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Enviado (aguardando recebimento dos pares)",
- "reticulumChatNeedsLxmfDelivery": "Este ponto ainda não tem endereço de entrega LXMF (o chat não pode usar um destino apenas de voz). Aguarde um anúncio LXMF ou cole o hash LXMF de 32 caracteres."
+ "reticulumChatNeedsLxmfDelivery": "Este ponto ainda não tem endereço de entrega LXMF (o chat não pode usar um destino apenas de voz). Aguarde um anúncio LXMF ou cole o hash LXMF de 32 caracteres.",
+ "saveAttachment": "Guardar…",
+ "revealAttachment": "_Mostrar na pasta"
},
"chatPayload": {
"mention": "Mencionar {{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "Tentando uma rota diferente via {{iface}} ({{hops}} saltos)…",
"pageProgressFailoverIface": "Tentando um trajeto diferente via {{iface}}…",
"pageProgressFailoverGeneric": "Tentando uma rota diferente...",
- "pageProgressNoAlternate": "Não há outras rotas disponíveis — concluindo esta tentativa..."
+ "pageProgressNoAlternate": "Não há outras rotas disponíveis — concluindo esta tentativa...",
+ "downloadFile": "Download",
+ "dismissPreview": "Dispensar"
},
"packetDistribution": {
"overallDistribution": "Distribuição Geral",
diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json
index d025f671c..37adf676a 100644
--- a/src/renderer/locales/ru/translation.json
+++ b/src/renderer/locales/ru/translation.json
@@ -788,7 +788,9 @@
"searchResults_few": "{{count}} результата",
"searchResults_many": "{{count}} результатов",
"reticulumSendAwaitingPeerReceipt": "Отправлено (ожидает получения коллегой)",
- "reticulumChatNeedsLxmfDelivery": "Этот узел еще не имеет адреса доставки LXMF (чат не может использовать пункт назначения только для голоса). Дождитесь объявления LXMF или вставьте их 32-символьный хэш LXMF."
+ "reticulumChatNeedsLxmfDelivery": "Этот узел еще не имеет адреса доставки LXMF (чат не может использовать пункт назначения только для голоса). Дождитесь объявления LXMF или вставьте их 32-символьный хэш LXMF.",
+ "saveAttachment": "Спасти…",
+ "revealAttachment": "Показать в папке"
},
"chatPayload": {
"mention": "Упоминание {{label}}",
@@ -3494,7 +3496,9 @@
"pageProgressFailoverGeneric": "Попробовать другой маршрут...",
"pageProgressNoAlternate": "Нет других доступных маршрутов — завершение этой попытки...",
"hopsAway_few": "На расстоянии {{count}} хопов",
- "hopsAway_many": "На расстоянии {{count}} хопов"
+ "hopsAway_many": "На расстоянии {{count}} хопов",
+ "downloadFile": "Загрузить",
+ "dismissPreview": "Пропустить"
},
"packetDistribution": {
"overallDistribution": "Общее распределение",
diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json
index 7ece41a22..5505b8329 100644
--- a/src/renderer/locales/tr/translation.json
+++ b/src/renderer/locales/tr/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}} EK tanımlanamayan nakliyeci (hash'ler: {{hashes}})",
"heardByRepeatersAdditional_other": "{{count}} kimliği belirsiz ek göndericiler (hash'ler: {{hashes}})",
"reticulumSendAwaitingPeerReceipt": "Gönderildi (akran makbuzu bekleniyor)",
- "reticulumChatNeedsLxmfDelivery": "Bu akranın henüz LXMF teslimat adresi yok (Sohbet yalnızca Sesli bir hedef kullanamaz). Bir LXMF duyurusu bekleyin veya 32 karakterlik LXMF karmasını yapıştırın."
+ "reticulumChatNeedsLxmfDelivery": "Bu akranın henüz LXMF teslimat adresi yok (Sohbet yalnızca Sesli bir hedef kullanamaz). Bir LXMF duyurusu bekleyin veya 32 karakterlik LXMF karmasını yapıştırın.",
+ "saveAttachment": "Kaydetmek…",
+ "revealAttachment": "Dizinde"
},
"chatPayload": {
"mention": "{{label}}'dan bahsedin",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "{{iface}} ({{hops}} atlama) üzerinden farklı bir rota deniyor…",
"pageProgressFailoverIface": "{{iface}} üzerinden farklı bir rota deniyor…",
"pageProgressFailoverGeneric": "Farklı bir rota deniyor...",
- "pageProgressNoAlternate": "Başka rota yok — bu girişim tamamlanıyor…"
+ "pageProgressNoAlternate": "Başka rota yok — bu girişim tamamlanıyor…",
+ "downloadFile": "İndir",
+ "dismissPreview": "Yok say"
},
"packetDistribution": {
"overallDistribution": "Genel Dağıtım",
diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json
index 5c9ff5ab9..62d4588fc 100644
--- a/src/renderer/locales/uk/translation.json
+++ b/src/renderer/locales/uk/translation.json
@@ -788,7 +788,9 @@
"searchResults_few": "{{count}} результати",
"searchResults_many": "{{count}} результатів",
"reticulumSendAwaitingPeerReceipt": "Надіслано (очікується отримання від колеги)",
- "reticulumChatNeedsLxmfDelivery": "Цей вузол ще не має адреси доставки LXMF (чат не може використовувати призначення лише для голосу). Дочекайтеся оголошення LXMF або вставте їх 32-символьний хеш LXMF."
+ "reticulumChatNeedsLxmfDelivery": "Цей вузол ще не має адреси доставки LXMF (чат не може використовувати призначення лише для голосу). Дочекайтеся оголошення LXMF або вставте їх 32-символьний хеш LXMF.",
+ "saveAttachment": "Зберегти",
+ "revealAttachment": "У теці"
},
"chatPayload": {
"mention": "Згадайте {{label}}",
@@ -3494,7 +3496,9 @@
"pageProgressFailoverGeneric": "Спробувати інший маршрут...",
"pageProgressNoAlternate": "Немає інших доступних маршрутів — завершення цієї спроби...",
"hopsAway_few": "за {{count}} хопи",
- "hopsAway_many": "за {{count}} хопів"
+ "hopsAway_many": "за {{count}} хопів",
+ "downloadFile": "Завантаження",
+ "dismissPreview": "Відпустити"
},
"packetDistribution": {
"overallDistribution": "Загальний розподіл",
diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json
index d1493e9dd..cf448423c 100644
--- a/src/renderer/locales/zh/translation.json
+++ b/src/renderer/locales/zh/translation.json
@@ -750,7 +750,9 @@
"heardByRepeatersAdditional_one": "{{count}}其他身份不明的货运代理(哈希值: {{hashes}} )",
"heardByRepeatersAdditional_other": "{{count}}其他身份不明的转发商(哈希: {{hashes}} )",
"reticulumSendAwaitingPeerReceipt": "已发送(等待同行回执)",
- "reticulumChatNeedsLxmfDelivery": "此对等方还没有LXMF交付地址(聊天不能使用仅语音目的地)。等待LXMF公告,或粘贴其32个字符的LXMF哈希。"
+ "reticulumChatNeedsLxmfDelivery": "此对等方还没有LXMF交付地址(聊天不能使用仅语音目的地)。等待LXMF公告,或粘贴其32个字符的LXMF哈希。",
+ "saveAttachment": "保存",
+ "revealAttachment": "在文件夹中"
},
"chatPayload": {
"mention": "提及{{label}}",
@@ -3416,7 +3418,9 @@
"pageProgressFailover": "正在尝试通过{{iface}} ( {{hops}}跳)选择其他路线……",
"pageProgressFailoverIface": "尝试通过{{iface}}选择其他路线…",
"pageProgressFailoverGeneric": "正在尝试其他路线…",
- "pageProgressNoAlternate": "没有其他路线可用—正在完成此尝试…"
+ "pageProgressNoAlternate": "没有其他路线可用—正在完成此尝试…",
+ "downloadFile": "下载",
+ "dismissPreview": "解散"
},
"packetDistribution": {
"overallDistribution": "总体分布",