fix terminal mesh chat and guarded IndeeHub playback
This commit is contained in:
@@ -46,6 +46,43 @@ pub(super) fn build_response(
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
|
||||
}
|
||||
|
||||
fn zero_sats(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0),
|
||||
Some(serde_json::Value::String(number)) => number.parse::<u64>() == Ok(0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public playback must be explicitly free in both the mutable project row and
|
||||
/// any signed rental offer. A stale project price must never override signed
|
||||
/// paid terms, and malformed offers fail closed.
|
||||
fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool {
|
||||
if !zero_sats(listing.get("rentalPrice")) {
|
||||
return false;
|
||||
}
|
||||
let Some(offer) = listing.get("offerEvent") else {
|
||||
return true;
|
||||
};
|
||||
if offer.is_null() {
|
||||
return true;
|
||||
}
|
||||
let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(terms) = tags.iter().find_map(|tag| {
|
||||
let tag = tag.as_array()?;
|
||||
(tag.first()?.as_str()? == "rental")
|
||||
.then(|| tag.get(1)?.as_str())
|
||||
.flatten()
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
serde_json::from_str::<serde_json::Value>(terms)
|
||||
.ok()
|
||||
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
|
||||
}
|
||||
|
||||
pub struct ApiHandler {
|
||||
config: Config,
|
||||
rpc_handler: Arc<RpcHandler>,
|
||||
@@ -282,6 +319,38 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"invalid content id"}"#),
|
||||
));
|
||||
}
|
||||
let catalog = reqwest::Client::new()
|
||||
.get("http://127.0.0.1:7778/api/projects/public-catalog")
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
if !catalog.status().is_success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
|
||||
));
|
||||
}
|
||||
let listings: serde_json::Value = catalog.json().await?;
|
||||
let listing = listings.as_array().and_then(|items| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id))
|
||||
});
|
||||
let Some(listing) = listing else {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"film is not publicly listed"}"#),
|
||||
));
|
||||
};
|
||||
if !indeehub_listing_is_publicly_free(listing) {
|
||||
return Ok(build_response(
|
||||
StatusCode::PAYMENT_REQUIRED,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"rental required"}"#),
|
||||
));
|
||||
}
|
||||
let upstream = reqwest::Client::new()
|
||||
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
@@ -1346,3 +1415,33 @@ mod terminal_origin_tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indeehub_public_stream_tests {
|
||||
use super::indeehub_listing_is_publicly_free;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn public_stream_requires_explicitly_free_project_and_signed_terms() {
|
||||
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": null
|
||||
})));
|
||||
assert!(indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": "0",
|
||||
"offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]}
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 15,
|
||||
"offerEvent": null
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]}
|
||||
})));
|
||||
assert!(!indeehub_listing_is_publicly_free(&json!({
|
||||
"rentalPrice": 0,
|
||||
"offerEvent": {"tags": [["rental", "invalid"]]}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
|
||||
}
|
||||
let id = format!("s-{}", Uuid::new_v4().simple());
|
||||
let tmux_name = format!("archy-{id}");
|
||||
let output = Command::new("tmux")
|
||||
let output = match Command::new("tmux")
|
||||
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
||||
.output()
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(output) => output,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#),
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !output.status.success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -551,17 +551,19 @@ pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
|
||||
}
|
||||
|
||||
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
|
||||
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
|
||||
/// Format after the response code has been removed:
|
||||
/// SNR(1B) + reserved(2B) + channel_idx(1B) + path_len(1B) +
|
||||
/// txt_type(1B) + timestamp(4B) + text.
|
||||
/// Returns (channel_idx, text).
|
||||
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
|
||||
if data.len() < 7 {
|
||||
if data.len() < 10 {
|
||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
// data[1] = path_len, data[2] = txt_type
|
||||
// data[3..7] = timestamp
|
||||
let text = if data.len() > 7 {
|
||||
String::from_utf8_lossy(&data[7..])
|
||||
let channel_idx = data[3];
|
||||
// data[0] = SNR, data[1..3] = reserved, data[4] = path_len,
|
||||
// data[5] = txt_type, data[6..10] = timestamp.
|
||||
let text = if data.len() > 10 {
|
||||
String::from_utf8_lossy(&data[10..])
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
} else {
|
||||
@@ -649,12 +651,12 @@ pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
|
||||
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
|
||||
/// Returns (channel_idx, raw_payload_bytes).
|
||||
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
|
||||
if data.len() < 7 {
|
||||
if data.len() < 10 {
|
||||
anyhow::bail!("Channel message too short: {} bytes", data.len());
|
||||
}
|
||||
let channel_idx = data[0];
|
||||
let payload = if data.len() > 7 {
|
||||
let mut p = data[7..].to_vec();
|
||||
let channel_idx = data[3];
|
||||
let payload = if data.len() > 10 {
|
||||
let mut p = data[10..].to_vec();
|
||||
// Strip trailing NUL bytes
|
||||
while p.last() == Some(&0) {
|
||||
p.pop();
|
||||
@@ -779,6 +781,23 @@ pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_v3_channel_after_snr_and_reserved_prefix() -> Result<()> {
|
||||
// decode_frame removes the 0x11 response code before this parser sees
|
||||
// the payload. A real V3 frame then begins with SNR + two reserved
|
||||
// bytes; treating SNR as the channel index filed public messages under
|
||||
// random channels such as 47 or 208.
|
||||
let data = [
|
||||
0xd0, 0x00, 0x00, // SNR and reserved
|
||||
0x00, 0xff, 0x00, // public channel, path, text type
|
||||
0x78, 0x56, 0x34, 0x12, // timestamp
|
||||
b'h', b'e', b'l', b'l', b'o', 0x00,
|
||||
];
|
||||
assert_eq!(parse_channel_msg_v3(&data)?, (0, "hello".to_string()));
|
||||
assert_eq!(parse_channel_msg_v3_raw(&data)?, (0, b"hello".to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_frame() {
|
||||
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
|
||||
|
||||
@@ -42,6 +42,56 @@ acceptance; this is a new paid-file incident.
|
||||
|
||||
## Current tasks
|
||||
|
||||
- 2026-10-10 three-node IndeeHub/terminal UAT checkpoint: Yaya, Framework and
|
||||
Shorty serve byte-identical IndeeHub entry pages
|
||||
(`0a2ef3393aacd96d834ad4666d4aa4c64c5fe0cc28f973351cfbc73a78156e7a`),
|
||||
dashboard entry pages
|
||||
(`b41a8d3c87ba923c9a4e5f7d0a61dafac593a3438510482d9e6b85a53bb63c9f`)
|
||||
and management binaries
|
||||
(`daf39c71b7029c0b51f8b74cc146fb3dd9af287744856e0e6a63bb63b40829ab`).
|
||||
All manager health endpoints report `ok` with crash recovery complete.
|
||||
Bitcoin/LND container IDs remained exact across the manager restarts. The
|
||||
canonical runtime Nginx source is identical on all three nodes at
|
||||
`524fd418ce71c6399f2772f7a105a968254e9a6414c96e45376422c414c18658`;
|
||||
this matters because manager bootstrap otherwise restored an older runtime
|
||||
template and removed terminal/playback routes on restart.
|
||||
|
||||
Terminal creation and authenticated listing pass on Yaya and Framework.
|
||||
The live failure was missing `tmux`, after routing and origin failures had
|
||||
separately been corrected. `tmux` is installed on all three UAT nodes and is
|
||||
now required by both ISO installation paths. Shorty's proxy/runtime is
|
||||
installed and Nginx-valid, but authenticated creation was not accepted because
|
||||
its dashboard password differs; keep that as unverified rather than a pass.
|
||||
Test-created Yaya/Framework session records were moved recoverably under the
|
||||
support directory after acceptance and no longer appear in the session list.
|
||||
The UI now includes actual response details for create failures instead of the
|
||||
generic message, and its empty state occupies and centers within the full
|
||||
terminal viewport. That dashboard build is byte-identical on all three nodes.
|
||||
|
||||
Cross-node public film discovery now works through FIPS in both directions:
|
||||
Yaya receives Framework's *Arch x Indie* and Framework receives Yaya's
|
||||
*Web5 Explained*. Framework's explicitly free film returned a JSON stream,
|
||||
HLS manifest, 16-byte AES key and 1,736,192-byte MPEG-TS segment through the
|
||||
Yaya same-origin bridge. A negative test found Yaya's project row said zero
|
||||
while its signed offer required 15 sats; the first bridge build would have
|
||||
treated that mismatch as free. The deployed guard now requires both project
|
||||
data and signed terms to be explicitly zero. Framework receives HTTP 402
|
||||
`rental required` and no stream URL for that title. Never weaken this guard to
|
||||
make a paid playback test pass. Paid Cashu/Lightning rental, metered ecash,
|
||||
entitlement recovery without another payment, and the intended native-identity
|
||||
replacement for the old `0494` offer remain open end-to-end acceptance gates.
|
||||
|
||||
MeshCore V3 parsing now reads the channel after SNR/reserved bytes. The UI
|
||||
recovers already-persisted impossible MeshCore channel IDs into Public while
|
||||
preserving valid channels and other transports. Connected Nodes uses `Map`,
|
||||
with full-width My connections/Find Nodes controls on mobile. Focused frontend
|
||||
tests (8), TypeScript, production build, terminal packaging regression and
|
||||
formatting pass. Three targeted backend regressions pass through the isolated
|
||||
runner: guarded public streaming, MeshCore V3 channel parsing, and the peer
|
||||
content path filter. Source review/commit, ngit proposal disposition, exact
|
||||
main mirroring and a complete mirror audit remain required before OTA/ISO. No
|
||||
OTA or ISO was published by this UAT deployment.
|
||||
|
||||
- 2026-10-09 dual-node UAT correction: Archy `main` at `5b0b35401` was already
|
||||
serving identical backend and dashboard artifacts on Yaya and Framework;
|
||||
browser cache hid the new device-model screens. Yaya's terminal failed
|
||||
|
||||
@@ -386,6 +386,7 @@ DOCKERFILE_HEAD
|
||||
python3 \
|
||||
curl \
|
||||
git \
|
||||
tmux \
|
||||
vim-tiny \
|
||||
nano \
|
||||
ca-certificates \
|
||||
|
||||
@@ -178,6 +178,7 @@ chroot /mnt/archipelago apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
htop \
|
||||
tmux \
|
||||
vim-tiny \
|
||||
nano \
|
||||
ca-certificates \
|
||||
|
||||
@@ -43,9 +43,13 @@ map "$host|$http_origin" $archy_cloud_picker_origin {
|
||||
default "";
|
||||
"~^([^|]+)\|http://\1:7778$" $http_origin;
|
||||
}
|
||||
map "$request_method|$http_sec_fetch_site" $archy_terminal_origin {
|
||||
map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin {
|
||||
default $http_origin;
|
||||
"GET|same-origin" "$scheme://$http_host";
|
||||
~^GET\|same-origin\| "$scheme://$http_host";
|
||||
# Older WebViews can omit both Origin and Fetch Metadata on same-origin
|
||||
# GETs. Only synthesize in that header-free case; a cross-origin request
|
||||
# carrying Origin still reaches the backend unchanged and is rejected.
|
||||
"GET||" "$scheme://$http_host";
|
||||
}
|
||||
|
||||
# Rate limit zones
|
||||
@@ -295,13 +299,25 @@ server {
|
||||
location ^~ /api/terminal/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header Origin $archy_terminal_origin;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Browser-facing bridge for IndeeHub streams hosted by connected nodes.
|
||||
# The backend enforces the owner session on peer routes and free-content
|
||||
# policy on public routes; keep these ahead of the dashboard SPA fallback.
|
||||
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
@@ -1309,13 +1325,22 @@ server {
|
||||
location ^~ /api/terminal/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header Origin $archy_terminal_origin;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,10 @@ async function createSession() {
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) })
|
||||
if (!response.ok) throw new Error('Could not create a developer session.')
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null
|
||||
throw new Error(payload?.error || `Could not create a developer session (${response.status}).`)
|
||||
}
|
||||
const session = await response.json()
|
||||
sessions.value = [session, ...sessions.value]
|
||||
selectedId.value = session.id
|
||||
@@ -215,6 +218,6 @@ onBeforeUnmount(() => {
|
||||
.terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; }
|
||||
.terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); }
|
||||
.terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; }
|
||||
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
|
||||
.terminal-empty { display: flex; flex: 1 1 100%; width: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
|
||||
@media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
|
||||
</style>
|
||||
|
||||
+20
-14
@@ -20,6 +20,7 @@ import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import { channelContactId, isChannelMessage, isPublicChannelMessage } from '@/views/mesh/channelMessages'
|
||||
import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
@@ -78,18 +79,18 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let wsUnsub: (() => void) | null = null
|
||||
|
||||
// The Public channel (always available on Meshcore)
|
||||
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
|
||||
// in mesh-config.json. Slot 0 is the firmware default Public, which uses
|
||||
// the universal meshcore key and only works between devices sharing keys
|
||||
// + RF region. Slot 1 is set by archipelago first-boot to a hash derived
|
||||
// from the channel_name, so all archipelago nodes are guaranteed on the
|
||||
// same channel regardless of region.
|
||||
// "Public" is the stock MeshCore/Meshtastic public channel in slot 0.
|
||||
const publicChannel = { index: 0, name: 'Public' }
|
||||
|
||||
// Channel contact_id convention: matches backend u32::MAX - channel_index
|
||||
function channelContactId(channelIndex: number): number {
|
||||
return 4294967295 - channelIndex // u32::MAX - index
|
||||
}
|
||||
const publicChannelUnread = computed(() => {
|
||||
const contactIds = new Set([
|
||||
channelContactId(publicChannel.index),
|
||||
...mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id),
|
||||
])
|
||||
return Object.entries(mesh.unreadCounts)
|
||||
.filter(([contactId]) => contactIds.has(Number(contactId)))
|
||||
.reduce((total, [, count]) => total + count, 0)
|
||||
})
|
||||
|
||||
// Archipelago Channel — Tor-based messaging to all federated/peered nodes
|
||||
const archChannelActive = ref(false)
|
||||
@@ -714,8 +715,10 @@ const chatMessages = computed(() => {
|
||||
const HIDDEN_TYPES = new Set(['reaction', 'read_receipt', 'edit', 'presence', 'channel_invite', 'contact_card'])
|
||||
const hideReactions = (m: MeshMessage) => !m.message_type || !HIDDEN_TYPES.has(m.message_type)
|
||||
if (activeChatChannel.value) {
|
||||
const chanId = channelContactId(activeChatChannel.value.index)
|
||||
return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m))
|
||||
return mesh.messages.filter(m => {
|
||||
const belongsToChannel = isChannelMessage(m, activeChatChannel.value!.index)
|
||||
return belongsToChannel && hideReactions(m)
|
||||
})
|
||||
}
|
||||
if (activeChatPeer.value) {
|
||||
// Pull from every underlying contact_id in the merged group so radio
|
||||
@@ -1134,7 +1137,10 @@ function openChannelChat(channel: { index: number; name: string }) {
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
mobileShowChat.value = true
|
||||
mesh.markChatRead(channelContactId(channel.index))
|
||||
const contactIds = channel.index === publicChannel.index
|
||||
? mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id)
|
||||
: [channelContactId(channel.index)]
|
||||
mesh.markChatRead([...new Set([channelContactId(channel.index), ...contactIds])])
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
|
||||
@@ -2275,7 +2281,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<div class="mesh-peer-name">Public</div>
|
||||
<div class="mesh-peer-sub">Mesh radio</div>
|
||||
</div>
|
||||
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
|
||||
<span v-if="publicChannelUnread" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ publicChannelUnread }}</span>
|
||||
</div>
|
||||
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
|
||||
No contacts match “{{ peerSearch.trim() }}”.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MeshMessage } from '@/stores/mesh'
|
||||
import { channelContactId, isChannelMessage, isPublicChannelMessage } from '../channelMessages'
|
||||
|
||||
function message(peerContactId: number, transport = 'meshcore'): MeshMessage {
|
||||
return {
|
||||
id: 1,
|
||||
direction: 'received',
|
||||
peer_contact_id: peerContactId,
|
||||
peer_name: null,
|
||||
plaintext: 'hello',
|
||||
timestamp: '2026-10-10T00:00:00Z',
|
||||
delivered: true,
|
||||
encrypted: false,
|
||||
transport,
|
||||
message_type: 'text',
|
||||
typed_payload: null,
|
||||
sender_pubkey: null,
|
||||
sender_seq: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Mesh public-channel compatibility', () => {
|
||||
it('shows a correctly decoded slot-zero message in Public', () => {
|
||||
expect(isPublicChannelMessage(message(channelContactId(0)))).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers persisted V3 messages whose SNR was mistaken for a channel index', () => {
|
||||
expect(isPublicChannelMessage(message(channelContactId(47)))).toBe(true)
|
||||
expect(isPublicChannelMessage(message(channelContactId(208)))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not merge valid non-public slots or another transport into Public', () => {
|
||||
expect(isPublicChannelMessage(message(channelContactId(1)))).toBe(false)
|
||||
expect(isPublicChannelMessage(message(channelContactId(47), 'meshtastic'))).toBe(false)
|
||||
expect(isChannelMessage(message(channelContactId(1)), 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MeshMessage } from '@/stores/mesh'
|
||||
|
||||
const MAX_U32 = 4294967295
|
||||
const MAX_MESHCORE_CHANNEL_INDEX = 7
|
||||
|
||||
export function channelContactId(channelIndex: number): number {
|
||||
return MAX_U32 - channelIndex
|
||||
}
|
||||
|
||||
export function isPublicChannelMessage(message: MeshMessage): boolean {
|
||||
if (message.peer_contact_id === channelContactId(0)) return true
|
||||
|
||||
// Compatibility for messages persisted by the old V3 decoder, which used
|
||||
// SNR as the channel index. MeshCore has only slots 0..7, making 8..255 an
|
||||
// unambiguous legacy decoder artifact.
|
||||
const derivedIndex = MAX_U32 - message.peer_contact_id
|
||||
return message.transport === 'meshcore'
|
||||
&& derivedIndex > MAX_MESHCORE_CHANNEL_INDEX
|
||||
&& derivedIndex <= 255
|
||||
}
|
||||
|
||||
export function isChannelMessage(message: MeshMessage, channelIndex: number): boolean {
|
||||
return channelIndex === 0
|
||||
? isPublicChannelMessage(message)
|
||||
: message.peer_contact_id === channelContactId(channelIndex)
|
||||
}
|
||||
|
||||
@@ -167,16 +167,19 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-auto shrink-0 pt-4 border-t border-white/10">
|
||||
<nav aria-label="Node connections" class="grid grid-cols-[repeat(2,minmax(0,1fr))_2.75rem] sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] gap-2">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
My connections
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Find Nodes
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11 col-span-2 sm:col-span-1">
|
||||
Map
|
||||
</button>
|
||||
<nav aria-label="Node connections" class="space-y-2 sm:grid sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] sm:gap-2 sm:space-y-0">
|
||||
<div class="grid grid-cols-2 gap-2 sm:contents">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
My connections
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Find Nodes
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2 sm:contents">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg flex-1 w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Map
|
||||
</button>
|
||||
<button
|
||||
@click="refreshActiveTab"
|
||||
:disabled="loadingPeers || loadingRequests"
|
||||
@@ -186,6 +189,7 @@
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('Web5ConnectedNodes', () => {
|
||||
expect(nav.text()).toContain('My connections')
|
||||
expect(nav.text()).toContain('Find Nodes')
|
||||
expect(nav.text()).toContain('Map')
|
||||
expect(nav.text()).not.toContain('Network Map')
|
||||
const mobilePrimaryRow = nav.get('div.grid.grid-cols-2')
|
||||
expect(mobilePrimaryRow.findAll('button')).toHaveLength(2)
|
||||
expect(mobilePrimaryRow.findAll('button').every(button => button.classes().includes('w-full'))).toBe(true)
|
||||
await nav.findAll('button')[0]!.trigger('click')
|
||||
await nav.findAll('button')[1]!.trigger('click')
|
||||
await nav.findAll('button')[2]!.trigger('click')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep the browser terminal's runtime and exact-authority proxy in releases."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
builder = (ROOT / "image-recipe/_archived/build-auto-installer-iso.sh").read_text()
|
||||
installer = (ROOT / "image-recipe/archipelago-scripts/install-to-disk.sh").read_text()
|
||||
nginx = (ROOT / "image-recipe/configs/nginx-archipelago.conf").read_text()
|
||||
|
||||
rootfs_packages = builder.split("cat >> \"$WORK_DIR/Dockerfile.rootfs\"", 1)[1].split(
|
||||
"# Create archipelago systemd service", 1
|
||||
)[0]
|
||||
assert " tmux \\\n" in rootfs_packages, "fresh ISO rootfs omits tmux"
|
||||
assert " tmux \\\n" in installer, "install-to-disk path omits tmux"
|
||||
|
||||
assert 'map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin' in nginx
|
||||
terminal_locations = nginx.split("location ^~ /api/terminal/ {")[1:]
|
||||
assert len(terminal_locations) == 2, "expected HTTP and HTTPS terminal proxies"
|
||||
for location in terminal_locations:
|
||||
block = location.split("}", 1)[0]
|
||||
assert "proxy_set_header Host $http_host;" in block, "terminal proxy drops authority port"
|
||||
assert "proxy_set_header Origin $archy_terminal_origin;" in block
|
||||
|
||||
assert nginx.count(
|
||||
"location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {"
|
||||
) == 2, "HTTP and HTTPS must proxy IndeeHub playback before the SPA fallback"
|
||||
|
||||
print("PASS terminal runtime packaging and proxy authority")
|
||||
@@ -73,6 +73,7 @@ stage "git-diff-check" git diff --check
|
||||
stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py
|
||||
stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py
|
||||
stage "iso-qualified-web-ui" python3 tests/regression/iso-qualified-web-ui.py
|
||||
stage "terminal-runtime-packaging" python3 tests/regression/terminal-runtime-packaging.py
|
||||
stage "first-boot-retry" python3 tests/regression/first-boot-retry.py
|
||||
stage "demo-rpc-parity" timeout 120 node neode-ui/scripts/mock-rpc-parity.mjs
|
||||
stage "demo-resumable-uploads" timeout 120 node --test neode-ui/scripts/demo-upload-test.mjs
|
||||
|
||||
Reference in New Issue
Block a user