use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use rns_net::{LinkId, RnsNode};
use sha2::{Digest, Sha256};
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::{channel, Receiver, Sender};
use tracing::{debug, warn};
use crate::types::{
BrowseEvent, NomadError, DEFAULT_CACHE_TIME_SECS, DEST_TYPE_LXMF_DELIVERY,
DEST_TYPE_NOMADNET_NODE, DEST_TYPE_RRC_HUB, SHORTHAND_LXMF, SHORTHAND_NNN, SHORTHAND_RRC,
};
struct PendingRequest {
request_id: [u8; 16],
path: String,
data: Vec<u8>,
}
type RequestMap = HashMap<[u8; 16], PendingRequest>;
pub fn expand_shorthands(shorthand: &str) -> &str {
match shorthand {
SHORTHAND_NNN => DEST_TYPE_NOMADNET_NODE,
SHORTHAND_LXMF => DEST_TYPE_LXMF_DELIVERY,
SHORTHAND_RRC => DEST_TYPE_RRC_HUB,
other => other,
}
}
pub struct BrowseCache {
cache_dir: PathBuf,
}
impl BrowseCache {
pub fn new(cache_dir: PathBuf) -> Self {
Self { cache_dir }
}
fn url_hash(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
hex::encode(hasher.finalize())
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn put(&self, url: &str, data: &[u8], ttl_secs: u64) {
if ttl_secs == 0 || self.cache_dir.as_os_str().is_empty() {
return;
}
let _ = std::fs::create_dir_all(&self.cache_dir);
let hash = Self::url_hash(url);
self.remove_entries_for_hash(&hash);
let expires = Self::now_secs() + ttl_secs;
let filename = format!("{hash}_{expires}");
let path = self.cache_dir.join(&filename);
if let Err(e) = std::fs::write(&path, data) {
warn!(
"BrowseCache: failed to write cache file {}: {e}",
path.display()
);
}
}
pub fn get(&self, url: &str) -> Option<Vec<u8>> {
if self.cache_dir.as_os_str().is_empty() {
return None;
}
let hash = Self::url_hash(url);
let now = Self::now_secs();
let dir = std::fs::read_dir(&self.cache_dir).ok()?;
for entry in dir.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(&hash) {
continue;
}
let parts: Vec<&str> = name_str.splitn(2, '_').collect();
let expires: u64 = parts.get(1)?.parse().ok()?;
if expires <= now {
let _ = std::fs::remove_file(entry.path());
continue;
}
return std::fs::read(entry.path()).ok();
}
None
}
pub fn invalidate(&self, url: &str) {
if self.cache_dir.as_os_str().is_empty() {
return;
}
let hash = Self::url_hash(url);
self.remove_entries_for_hash(&hash);
}
fn remove_entries_for_hash(&self, hash: &str) {
let dir = match std::fs::read_dir(&self.cache_dir) {
Ok(d) => d,
Err(_) => return,
};
for entry in dir.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with(hash) {
let _ = std::fs::remove_file(entry.path());
}
}
}
pub fn clean_expired(&self) {
if self.cache_dir.as_os_str().is_empty() {
return;
}
let now = Self::now_secs();
let dir = match std::fs::read_dir(&self.cache_dir) {
Ok(d) => d,
Err(_) => return,
};
for entry in dir.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let parts: Vec<&str> = name_str.splitn(2, '_').collect();
if let Some(expires_str) = parts.get(1) {
if let Ok(expires) = expires_str.parse::<u64>() {
if expires <= now {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
}
}
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub dest_hash: [u8; 16],
pub path: String,
pub request_data: Option<Vec<u8>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HistoryGuard {
Normal,
Back,
Forward,
Reload,
}
pub struct BrowserHistory {
entries: Vec<HistoryEntry>,
pointer: usize,
guard: HistoryGuard,
}
impl BrowserHistory {
pub fn new() -> Self {
Self {
entries: Vec::new(),
pointer: 0,
guard: HistoryGuard::Normal,
}
}
pub fn push(&mut self, entry: HistoryEntry) {
self.entries.insert(self.pointer, entry);
self.pointer += 1;
if self.entries.len() > self.pointer {
self.entries.truncate(self.pointer);
}
}
pub fn back(&mut self) -> Option<HistoryEntry> {
if self.pointer == 0 {
return None;
}
self.pointer = self.pointer.saturating_sub(1);
self.entries.get(self.pointer).cloned()
}
pub fn forward(&mut self) -> Option<HistoryEntry> {
if self.pointer >= self.entries.len() {
return None;
}
self.pointer += 1;
self.entries.get(self.pointer - 1).cloned()
}
pub fn pointer(&self) -> usize {
self.pointer
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn guard(&self) -> HistoryGuard {
self.guard
}
pub fn set_guard(&mut self, guard: HistoryGuard) {
self.guard = guard;
}
pub fn reset_guard(&mut self) {
self.guard = HistoryGuard::Normal;
}
pub fn should_write(&self) -> bool {
self.guard == HistoryGuard::Normal
}
}
impl Default for BrowserHistory {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct PagePartial {
pub partial_hash: String,
pub partial_id: Option<String>,
pub url: String,
pub fields: Option<String>,
pub refresh: Option<f64>,
pub content: Option<Vec<u8>>,
pub updated: Option<f64>,
pub failed: bool,
}
pub struct PartialManager {
partials: HashMap<String, PagePartial>,
}
impl PartialManager {
pub fn new() -> Self {
Self {
partials: HashMap::new(),
}
}
pub fn register_from_blocks(&mut self, blocks: &[crate::micron_parser::MicronBlock]) {
for block in blocks {
if let crate::micron_parser::MicronBlock::Partial {
url,
refresh,
fields,
partial_id,
} = block
{
let descriptor = format!("{url}`{refresh:?}`{fields:?}`{partial_id:?}");
let mut hasher = Sha256::new();
hasher.update(descriptor.as_bytes());
let partial_hash = hex::encode(hasher.finalize());
self.partials
.entry(partial_hash.clone())
.or_insert_with(|| PagePartial {
partial_hash: partial_hash.clone(),
partial_id: partial_id.clone(),
url: url.clone(),
fields: fields.clone(),
refresh: *refresh,
content: None,
updated: None,
failed: false,
});
}
}
}
pub fn clear(&mut self) {
self.partials.clear();
}
pub fn partials(&self) -> &HashMap<String, PagePartial> {
&self.partials
}
pub fn partials_mut(&mut self) -> &mut HashMap<String, PagePartial> {
&mut self.partials
}
pub fn stale_partials(&self, now: f64) -> Vec<&PagePartial> {
self.partials
.values()
.filter(|p| {
if p.failed {
return false;
}
match (p.updated, p.refresh) {
(None, _) => true,
(Some(last), Some(interval)) if interval >= 1.0 => now - last >= interval,
_ => false,
}
})
.collect()
}
pub fn find_by_ids<'a>(&'a self, ids: &[&str]) -> Vec<&'a PagePartial> {
self.partials
.values()
.filter(|p| {
p.partial_id
.as_ref()
.map(|id| ids.contains(&id.as_str()))
.unwrap_or(false)
})
.collect()
}
pub fn mark_updated(&mut self, partial_hash: &str, content: Vec<u8>, now: f64) {
if let Some(p) = self.partials.get_mut(partial_hash) {
p.content = Some(content);
p.updated = Some(now);
p.failed = false;
}
}
pub fn mark_failed(&mut self, partial_hash: &str) {
if let Some(p) = self.partials.get_mut(partial_hash) {
p.failed = true;
}
}
pub fn len(&self) -> usize {
self.partials.len()
}
pub fn is_empty(&self) -> bool {
self.partials.is_empty()
}
}
impl Default for PartialManager {
fn default() -> Self {
Self::new()
}
}
pub struct ParsedUrlFields {
pub url: String,
pub fields: Vec<(String, String)>,
}
pub fn parse_url_fields(url: &str) -> ParsedUrlFields {
let mut components = url.split('`');
let base = components.next().unwrap_or_default();
let fields_str = components.next();
if fields_str.is_none() || components.next().is_some() {
return ParsedUrlFields {
url: url.to_string(),
fields: Vec::new(),
};
}
let fields = fields_str
.unwrap_or_default()
.split('|')
.filter_map(|entry| {
let mut parts = entry.split('=');
let key = parts.next()?;
let value = parts.next()?;
if parts.next().is_some() {
return None;
}
Some((format!("var_{key}"), value.to_string()))
})
.collect();
ParsedUrlFields {
url: base.to_string(),
fields,
}
}
fn prepare_url_request(path: &str) -> Result<(String, Vec<u8>), NomadError> {
let parsed = parse_url_fields(path);
if parsed.fields.is_empty() {
return Ok((parsed.url, Vec::new()));
}
let fields: BTreeMap<_, _> = parsed.fields.into_iter().collect();
Ok((parsed.url, rmp_serde::to_vec(&fields)?))
}
#[derive(Clone, Debug)]
pub enum LinkAction {
NomadNetNode { target: String },
LxmfLink { dest_hash: [u8; 16] },
RrcLink,
RefreshPartials(Vec<String>),
}
struct TransferState {
progress_updated_at: Option<f64>,
previous_progress: f64,
speed_bps: Option<f64>,
}
impl Default for TransferState {
fn default() -> Self {
Self {
progress_updated_at: None,
previous_progress: 0.0,
speed_bps: None,
}
}
}
pub struct NomadBrowser {
pending: Arc<Mutex<HashMap<[u8; 16], RequestMap>>>,
link_to_dest: Arc<Mutex<HashMap<[u8; 16], [u8; 16]>>>,
dest_to_link: Arc<Mutex<HashMap<[u8; 16], [u8; 16]>>>,
event_tx: Sender<BrowseEvent>,
event_rx: Arc<Mutex<Option<Receiver<BrowseEvent>>>>,
cache: BrowseCache,
history: Arc<Mutex<BrowserHistory>>,
partials: Arc<Mutex<PartialManager>>,
transfer_states: Arc<Mutex<HashMap<[u8; 16], TransferState>>>,
}
impl NomadBrowser {
pub fn new() -> Self {
let (event_tx, event_rx) = channel(64);
Self {
pending: Arc::new(Mutex::new(HashMap::new())),
link_to_dest: Arc::new(Mutex::new(HashMap::new())),
dest_to_link: Arc::new(Mutex::new(HashMap::new())),
event_tx,
event_rx: Arc::new(Mutex::new(Some(event_rx))),
cache: BrowseCache::new(PathBuf::new()),
history: Arc::new(Mutex::new(BrowserHistory::new())),
partials: Arc::new(Mutex::new(PartialManager::new())),
transfer_states: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
let mut browser = Self::new();
browser.cache = BrowseCache::new(cache_dir.clone());
browser.cache.clean_expired();
browser
}
pub fn events(&self) -> Receiver<BrowseEvent> {
let mut guard = self.event_rx.lock().unwrap_or_else(|e| e.into_inner());
guard.take().expect("events() called more than once")
}
pub fn owns_link(&self, link_id: &LinkId) -> bool {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&link_id.0)
|| self
.link_to_dest
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&link_id.0)
}
pub fn cache(&self) -> &BrowseCache {
&self.cache
}
pub fn history(&self) -> &Arc<Mutex<BrowserHistory>> {
&self.history
}
pub fn partials(&self) -> &Arc<Mutex<PartialManager>> {
&self.partials
}
pub fn current_url(dest_hash: &[u8; 16], path: &str) -> String {
format!("{}:{}", hex::encode(dest_hash), path)
}
pub fn handle_link(&self, link_target: &str) -> Result<LinkAction, String> {
if link_target.starts_with("rrc://") {
self.handle_rrc_link(link_target)?;
return Ok(LinkAction::RrcLink);
}
if let Some(ids_str) = link_target.strip_prefix("p:") {
let ids: Vec<&str> = ids_str.split(':').filter(|s| !s.is_empty()).collect();
if !ids.is_empty() {
let partials = self.partials.lock().unwrap_or_else(|e| e.into_inner());
let stale = partials.find_by_ids(&ids);
let hashes: Vec<String> = stale.iter().map(|p| p.partial_hash.clone()).collect();
return Ok(LinkAction::RefreshPartials(hashes));
}
}
if let Some(at_pos) = link_target.find('@') {
let shorthand = &link_target[..at_pos];
let target = &link_target[at_pos + 1..];
let dest_type = expand_shorthands(shorthand);
match dest_type {
DEST_TYPE_LXMF_DELIVERY => {
let hash_bytes =
hex::decode(target).map_err(|_| "invalid hex in lxmf link target")?;
let dest_hash: [u8; 16] = hash_bytes
.try_into()
.map_err(|_| "lxmf target must be 16 bytes (32 hex chars)")?;
self.emit_event(
BrowseEvent::LxmfLinkReceived { dest_hash },
"LxmfLinkReceived",
);
return Ok(LinkAction::LxmfLink { dest_hash });
}
DEST_TYPE_NOMADNET_NODE => {
return Ok(LinkAction::NomadNetNode {
target: target.to_string(),
});
}
DEST_TYPE_RRC_HUB => {
let rrc_url = if target.starts_with("rrc://") {
target.to_string()
} else {
format!("rrc://{target}")
};
self.handle_rrc_link(&rrc_url)?;
return Ok(LinkAction::RrcLink);
}
_ => return Err(format!("unknown destination type: {dest_type}")),
}
}
Ok(LinkAction::NomadNetNode {
target: link_target.to_string(),
})
}
pub fn handle_link_established(&self, link_id: LinkId, dest_hash: [u8; 16]) {
let _ = self.handle_link_established_with_node(None, link_id, dest_hash);
}
fn emit_event(&self, event: BrowseEvent, name: &str) {
match self.event_tx.try_send(event) {
Ok(()) => {}
Err(TrySendError::Full(_)) => {
warn!("NomadBrowser: dropping {name} event because event queue is full")
}
Err(TrySendError::Closed(_)) => {
warn!("NomadBrowser: dropping {name} event because event queue is closed")
}
}
}
pub fn handle_link_established_with_node(
&self,
node: Option<&Arc<RnsNode>>,
link_id: LinkId,
dest_hash: [u8; 16],
) -> Result<(), NomadError> {
debug!(
"NomadBrowser: link established link_id={} dest={}",
link_id,
hex::encode(dest_hash)
);
{
let mut link_to_dest = self.link_to_dest.lock().unwrap_or_else(|e| e.into_inner());
link_to_dest.insert(link_id.0, dest_hash);
}
{
let mut dest_to_link = self.dest_to_link.lock().unwrap_or_else(|e| e.into_inner());
dest_to_link.insert(dest_hash, link_id.0);
}
let event = BrowseEvent::LinkEstablished {
dest_hash,
link_id: link_id.0,
};
self.emit_event(event, "LinkEstablished");
let queued: Vec<([u8; 16], String, Vec<u8>)> = {
let pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending
.get(&link_id.0)
.map(|q| {
q.values()
.map(|req| (req.request_id, req.path.clone(), req.data.clone()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
if queued.is_empty() {
return Ok(());
}
let Some(node) = node else {
warn!(
"NomadBrowser: {} queued request(s) for link {} dropped — no node provided",
queued.len(),
link_id
);
return Ok(());
};
for (_rid, path, data) in queued {
node.send_request(link_id.0, &path, &data)
.map_err(NomadError::from)?;
}
Ok(())
}
pub fn handle_response(&self, link_id: LinkId, request_id: [u8; 16], data: Vec<u8>) {
let dest_hash = {
let link_to_dest = self.link_to_dest.lock().unwrap_or_else(|e| e.into_inner());
link_to_dest.get(&link_id.0).copied()
};
let dest_hash = match dest_hash {
Some(dh) => dh,
None => {
debug!(
"NomadBrowser: received response on unknown link_id={}",
link_id
);
return;
}
};
let (req_path, req_data) = {
let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
let queue = match pending.get_mut(&link_id.0) {
Some(q) => q,
None => return,
};
let req = match queue.remove(&request_id) {
Some(r) => r,
None => {
debug!(
"NomadBrowser: received response with unknown request_id={} on link_id={}",
hex::encode(request_id),
link_id
);
return;
}
};
if queue.is_empty() {
pending.remove(&link_id.0);
}
(req.path, req.data)
};
debug!(
"NomadBrowser: response received dest={} path={} size={}",
hex::encode(dest_hash),
req_path,
data.len()
);
let is_file = req_path.starts_with("/file/");
let event = if is_file {
let (file_name, content) =
crate::node::decode_file_response(&data).unwrap_or_else(|| {
let name = req_path.trim_start_matches("/file/").to_string();
(name, data)
});
let safe_name = file_name
.rsplit('/')
.next()
.unwrap_or(&file_name)
.to_string();
let file_name = if safe_name.is_empty() {
None
} else {
Some(safe_name)
};
BrowseEvent::FileReceived {
dest_hash,
path: req_path,
content,
file_name,
}
} else {
let url = Self::current_url(&dest_hash, &req_path);
let cache_secs = self.extract_cache_secs(&data);
{
let mut history = self.history.lock().unwrap_or_else(|e| e.into_inner());
if history.should_write() {
history.push(HistoryEntry {
dest_hash,
path: req_path.clone(),
request_data: if req_data.is_empty() {
None
} else {
Some(req_data.clone())
},
});
}
history.reset_guard();
}
if cache_secs > 0 {
self.cache.put(&url, &data, cache_secs);
self.emit_event(BrowseEvent::PageCached { url }, "PageCached");
}
BrowseEvent::PageReceived {
dest_hash,
path: req_path,
content: data,
}
};
self.emit_event(
event,
if is_file {
"FileReceived"
} else {
"PageReceived"
},
);
self.reset_transfer_progress(&link_id);
}
pub fn handle_transfer_progress(
&self,
link_id: LinkId,
progress: f64,
data_size: Option<u64>,
transfer_size: Option<u64>,
) {
let dest_hash = {
let link_to_dest = self.link_to_dest.lock().unwrap_or_else(|e| e.into_inner());
link_to_dest.get(&link_id.0).copied()
};
let dest_hash = match dest_hash {
Some(dh) => dh,
None => return,
};
let req_path = {
let pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending
.get(&link_id.0)
.and_then(|q| q.values().next().map(|r| r.path.clone()))
};
let path = req_path.unwrap_or_default();
let speed_bps = {
let mut states = self
.transfer_states
.lock()
.unwrap_or_else(|e| e.into_inner());
let state = states.entry(link_id.0).or_default();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
if let Some(updated_at) = state.progress_updated_at {
if now > updated_at + 0.5 {
let td = now - updated_at;
let pd = progress - state.previous_progress;
if let Some(ts) = transfer_size {
let bd = pd * ts as f64;
state.speed_bps = Some((bd / td) * 8.0);
}
state.previous_progress = progress;
state.progress_updated_at = Some(now);
}
} else {
state.progress_updated_at = Some(now);
state.previous_progress = progress;
}
state.speed_bps
};
self.emit_event(
BrowseEvent::TransferProgress {
dest_hash,
path,
progress,
data_size,
transfer_size,
speed_bps,
},
"TransferProgress",
);
}
pub fn reset_transfer_progress(&self, link_id: &LinkId) {
let mut states = self
.transfer_states
.lock()
.unwrap_or_else(|e| e.into_inner());
states.remove(&link_id.0);
}
pub fn handle_link_closed(&self, link_id: LinkId, reason: Option<String>) {
let dest_hash = {
let link_to_dest = self.link_to_dest.lock().unwrap_or_else(|e| e.into_inner());
link_to_dest.get(&link_id.0).copied()
};
{
let mut link_to_dest = self.link_to_dest.lock().unwrap_or_else(|e| e.into_inner());
link_to_dest.remove(&link_id.0);
}
{
let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending.remove(&link_id.0);
}
self.reset_transfer_progress(&link_id);
if let Some(dest_hash) = dest_hash {
{
let mut dest_to_link = self.dest_to_link.lock().unwrap_or_else(|e| e.into_inner());
dest_to_link.remove(&dest_hash);
}
self.emit_event(
BrowseEvent::LinkClosed {
dest_hash,
link_id: link_id.0,
reason,
},
"LinkClosed",
);
}
}
pub fn fetch(
&self,
node: &Arc<RnsNode>,
dest_hash: [u8; 16],
sig_pub_bytes: [u8; 32],
path: &str,
) -> Result<(), NomadError> {
let (path, data) = prepare_url_request(path)?;
self.fetch_with_data(node, dest_hash, sig_pub_bytes, &path, &data)
}
pub fn fetch_with_data(
&self,
node: &Arc<RnsNode>,
dest_hash: [u8; 16],
sig_pub_bytes: [u8; 32],
path: &str,
data: &[u8],
) -> Result<(), NomadError> {
let mut request_id = [0u8; 16];
getrandom::fill(&mut request_id).unwrap();
{
let dest_to_link = self.dest_to_link.lock().unwrap_or_else(|e| e.into_inner());
if let Some(link_id) = dest_to_link.get(&dest_hash) {
{
let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending.entry(*link_id).or_default().insert(
request_id,
PendingRequest {
request_id,
path: path.to_string(),
data: data.to_vec(),
},
);
}
return node
.send_request(*link_id, path, data)
.map_err(NomadError::from);
}
}
let link_id = match node.create_link(dest_hash, sig_pub_bytes) {
Ok(link_id) => link_id,
Err(err) => {
self.emit_event(
BrowseEvent::LinkFailed {
dest_hash,
error: format!("{err:?}"),
},
"LinkFailed",
);
return Err(NomadError::from(err));
}
};
{
let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending.entry(link_id).or_default().insert(
request_id,
PendingRequest {
request_id,
path: path.to_string(),
data: data.to_vec(),
},
);
}
Ok(())
}
pub fn fetch_file(
&self,
node: &Arc<RnsNode>,
dest_hash: [u8; 16],
sig_pub_bytes: [u8; 32],
path: &str,
data: Option<&[u8]>,
) -> Result<(), NomadError> {
let parsed = parse_url_fields(path);
let request_data = match data {
Some(data) => data.to_vec(),
None if parsed.fields.is_empty() => Vec::new(),
None => {
let fields: BTreeMap<_, _> = parsed.fields.into_iter().collect();
rmp_serde::to_vec(&fields)?
}
};
self.fetch_with_data(node, dest_hash, sig_pub_bytes, &parsed.url, &request_data)
}
pub fn handle_rrc_link(&self, url: &str) -> Result<(), String> {
let (hash_bytes, dest_name, room) =
crate::rrc::parse_rrc_url(url).ok_or("invalid rrc:// URL")?;
let hub_hash: [u8; 16] = hash_bytes
.try_into()
.map_err(|_| "hub hash must be 16 bytes")?;
self.emit_event(
BrowseEvent::RrcLinkReceived {
hub_hash,
dest_name,
room,
},
"RrcLinkReceived",
);
Ok(())
}
fn extract_cache_secs(&self, data: &[u8]) -> u64 {
let text = match std::str::from_utf8(data) {
Ok(t) => t,
Err(_) => return DEFAULT_CACHE_TIME_SECS,
};
let trimmed = text.trim_start();
if let Some(rest) = trimmed.strip_prefix("#!c=") {
let secs_str = rest.split(['\n', '\r']).next().unwrap_or("");
if let Ok(secs) = secs_str.trim().parse::<u64>() {
return secs;
}
}
DEFAULT_CACHE_TIME_SECS
}
pub fn try_cache(&self, dest_hash: &[u8; 16], path: &str) -> Option<Vec<u8>> {
let url = Self::current_url(dest_hash, path);
self.cache.get(&url)
}
pub fn history_back(&self) -> Option<HistoryEntry> {
let mut history = self.history.lock().unwrap_or_else(|e| e.into_inner());
let entry = history.back()?;
history.set_guard(HistoryGuard::Back);
self.emit_event(
BrowseEvent::HistoryNavigated {
dest_hash: entry.dest_hash,
path: entry.path.clone(),
},
"HistoryBack",
);
Some(entry)
}
pub fn history_forward(&self) -> Option<HistoryEntry> {
let mut history = self.history.lock().unwrap_or_else(|e| e.into_inner());
let entry = history.forward()?;
history.set_guard(HistoryGuard::Forward);
self.emit_event(
BrowseEvent::HistoryNavigated {
dest_hash: entry.dest_hash,
path: entry.path.clone(),
},
"HistoryForward",
);
Some(entry)
}
pub fn reload(&self, dest_hash: &[u8; 16], path: &str) {
let url = Self::current_url(dest_hash, path);
self.cache.invalidate(&url);
let mut history = self.history.lock().unwrap_or_else(|e| e.into_inner());
history.set_guard(HistoryGuard::Reload);
}
pub fn register_partials(&self, blocks: &[crate::micron_parser::MicronBlock]) {
let mut partials = self.partials.lock().unwrap_or_else(|e| e.into_inner());
partials.register_from_blocks(blocks);
}
pub fn clear_partials(&self) {
let mut partials = self.partials.lock().unwrap_or_else(|e| e.into_inner());
partials.clear();
}
pub fn stale_partials(&self, now: f64) -> Vec<PagePartial> {
let partials = self.partials.lock().unwrap_or_else(|e| e.into_inner());
partials.stale_partials(now).into_iter().cloned().collect()
}
pub fn mark_partial_updated(&self, partial_hash: &str, content: Vec<u8>, now: f64) {
let mut partials = self.partials.lock().unwrap_or_else(|e| e.into_inner());
partials.mark_updated(partial_hash, content, now);
}
pub fn mark_partial_failed(&self, partial_hash: &str) {
let mut partials = self.partials.lock().unwrap_or_else(|e| e.into_inner());
partials.mark_failed(partial_hash);
}
}
impl Default for NomadBrowser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn response_matches_by_request_id() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = [0x11; 16];
let dest_hash = [0x22; 16];
let req_id_1 = [0x01; 16];
let req_id_2 = [0x02; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id,
HashMap::from([
(
req_id_1,
PendingRequest {
request_id: req_id_1,
path: "/page/first.mu".to_string(),
data: Vec::new(),
},
),
(
req_id_2,
PendingRequest {
request_id: req_id_2,
path: "/page/second.mu".to_string(),
data: Vec::new(),
},
),
]),
);
browser.handle_response(LinkId(link_id), req_id_1, b"ok".to_vec());
let first_event = events.try_recv().expect("expected event");
match first_event {
BrowseEvent::PageCached { .. } => {}
other => panic!("expected PageCached event, got {other:?}"),
}
let page_event = events.try_recv().expect("expected page event");
match page_event {
BrowseEvent::PageReceived { path, .. } => assert_eq!(path, "/page/first.mu"),
_ => panic!("expected page-received event"),
}
let pending = browser.pending.lock().unwrap_or_else(|e| e.into_inner());
let queue = pending.get(&link_id).expect("queue should still exist");
assert_eq!(queue.len(), 1);
assert!(queue.contains_key(&req_id_2));
}
#[test]
fn link_close_clears_pending_requests() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = [0x33; 16];
let dest_hash = [0x44; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id, dest_hash);
browser
.dest_to_link
.lock()
.unwrap()
.insert(dest_hash, link_id);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/page/index.mu".to_string(),
data: Vec::new(),
},
)]),
);
browser.handle_link_closed(LinkId(link_id), Some("test".to_string()));
assert!(browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&link_id)
.is_none());
assert!(browser
.link_to_dest
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&link_id)
.is_none());
assert!(browser
.dest_to_link
.lock()
.unwrap()
.get(&dest_hash)
.is_none());
let close_event = events.try_recv().expect("expected link-closed event");
match close_event {
BrowseEvent::LinkClosed {
dest_hash: got_dest,
..
} => assert_eq!(got_dest, dest_hash),
_ => panic!("expected link-closed event"),
}
}
#[test]
#[should_panic(expected = "events() called more than once")]
fn events_called_twice_panics() {
let browser = NomadBrowser::new();
let _first = browser.events();
let _second = browser.events();
}
#[test]
fn has_active_link_reflects_state() {
let browser = NomadBrowser::new();
let dest_hash = [0xaa; 16];
let link_id = [0xbb; 16];
assert!(!browser
.dest_to_link
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&dest_hash));
browser
.dest_to_link
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(dest_hash, link_id);
assert!(browser
.dest_to_link
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&dest_hash));
}
#[test]
fn handle_link_established_records_mapping() {
let browser = NomadBrowser::new();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser.handle_link_established(link_id, dest_hash);
assert!(browser
.dest_to_link
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&dest_hash));
let link_to_dest = browser
.link_to_dest
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_eq!(link_to_dest.get(&link_id.0), Some(&dest_hash));
}
#[test]
fn handle_response_ignores_unknown_link() {
let browser = NomadBrowser::new();
let _events = browser.events();
browser.handle_response(LinkId([0xff; 16]), [0u8; 16], vec![1, 2, 3]);
let pending = browser.pending.lock().unwrap_or_else(|e| e.into_inner());
assert!(pending.is_empty());
}
#[test]
fn event_queue_is_bounded_and_overflow_drops_new_events() {
let browser = NomadBrowser::new();
let mut events = browser.events();
for i in 0..65u8 {
let link_id = LinkId([i; 16]);
let dest_hash = [i; 16];
browser.handle_link_established(link_id, dest_hash);
}
let mut count = 0usize;
while events.try_recv().is_ok() {
count += 1;
}
assert_eq!(count, 64);
}
#[test]
fn parse_url_fields_no_fields() {
let result = parse_url_fields("abc123:/page/index.mu");
assert_eq!(result.url, "abc123:/page/index.mu");
assert!(result.fields.is_empty());
}
#[test]
fn parse_url_fields_with_key_values() {
let result = parse_url_fields("abc123:/page/index.mu`name=Alice|age=30");
assert_eq!(result.url, "abc123:/page/index.mu");
assert_eq!(
result.fields,
vec![
("var_name".into(), "Alice".into()),
("var_age".into(), "30".into()),
]
);
}
#[test]
fn parse_url_fields_ignores_entries_without_equals() {
let result = parse_url_fields("abc123:/page/index.mu`invalid|name=Bob");
assert_eq!(result.url, "abc123:/page/index.mu");
assert_eq!(result.fields, vec![("var_name".into(), "Bob".into())]);
}
#[test]
fn parse_url_fields_matches_python_split_rules() {
let result =
parse_url_fields("abc123:/page/index.mu`empty=|=value|equals=a=b|missing|name=Bob");
assert_eq!(
result.fields,
vec![
("var_empty".into(), String::new()),
("var_".into(), "value".into()),
("var_name".into(), "Bob".into()),
]
);
let result = parse_url_fields("abc123:/page/index.mu`name=Bob`extra");
assert_eq!(result.url, "abc123:/page/index.mu`name=Bob`extra");
assert!(result.fields.is_empty());
}
#[test]
fn prepare_url_request_strips_fields_and_encodes_messagepack_map() {
let (path, data) = prepare_url_request("/page/index.mu`name=Alice|age=30").unwrap();
let fields: HashMap<String, String> = rmp_serde::from_slice(&data).unwrap();
assert_eq!(path, "/page/index.mu");
assert_eq!(fields.get("var_name").map(String::as_str), Some("Alice"));
assert_eq!(fields.get("var_age").map(String::as_str), Some("30"));
}
#[test]
fn file_path_emits_file_received() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id.0,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/file/readme.txt".to_string(),
data: Vec::new(),
},
)]),
);
browser.handle_response(link_id, [0x01; 16], b"file content".to_vec());
let event = events.try_recv().expect("expected event");
match event {
BrowseEvent::FileReceived {
path,
content,
file_name,
..
} => {
assert_eq!(path, "/file/readme.txt");
assert_eq!(content, b"file content");
assert_eq!(file_name, Some("readme.txt".to_string()));
}
_ => panic!("expected FileReceived event, got {event:?}"),
}
}
#[test]
fn file_path_extracts_filename_from_encoded_response() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id.0,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/file/docs/readme.txt".to_string(),
data: Vec::new(),
},
)]),
);
let name = "report.pdf";
let file_bytes = b"pdf content";
use rns_core::msgpack::{self, Value};
let encoded = msgpack::pack(&Value::Array(vec![
Value::Str(name.to_string()),
Value::Bin(file_bytes.to_vec()),
]));
browser.handle_response(link_id, [0x01; 16], encoded);
let event = events.try_recv().expect("expected event");
match event {
BrowseEvent::FileReceived {
path,
content,
file_name,
..
} => {
assert_eq!(path, "/file/docs/readme.txt");
assert_eq!(content, b"pdf content");
assert_eq!(file_name, Some("report.pdf".to_string()));
}
_ => panic!("expected FileReceived event, got {event:?}"),
}
}
#[test]
fn file_path_nested_path_falls_back_to_basename() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id.0,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/file/docs/reports/readme.txt".to_string(),
data: Vec::new(),
},
)]),
);
browser.handle_response(link_id, [0x01; 16], b"raw bytes".to_vec());
let event = events.try_recv().expect("expected event");
match event {
BrowseEvent::FileReceived {
path,
content,
file_name,
..
} => {
assert_eq!(path, "/file/docs/reports/readme.txt");
assert_eq!(content, b"raw bytes");
assert_eq!(file_name, Some("readme.txt".to_string()));
}
_ => panic!("expected FileReceived event, got {event:?}"),
}
}
#[test]
fn file_path_empty_filename_becomes_none() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id.0,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/file/".to_string(),
data: Vec::new(),
},
)]),
);
use rns_core::msgpack::{self, Value};
let encoded = msgpack::pack(&Value::Array(vec![
Value::Str(String::new()),
Value::Bin(b"data".to_vec()),
]));
browser.handle_response(link_id, [0x01; 16], encoded);
let event = events.try_recv().expect("expected event");
match event {
BrowseEvent::FileReceived {
file_name, content, ..
} => {
assert_eq!(file_name, None);
assert_eq!(content, b"data");
}
_ => panic!("expected FileReceived event, got {event:?}"),
}
}
#[test]
fn page_path_emits_page_received() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id.0,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/page/index.mu".to_string(),
data: Vec::new(),
},
)]),
);
browser.handle_response(link_id, [0x01; 16], b"page content".to_vec());
let cached = events.try_recv().expect("expected cached event");
match cached {
BrowseEvent::PageCached { url } => {
assert!(url.contains("/page/index.mu"));
}
_ => panic!("expected PageCached, got {cached:?}"),
}
let event = events.try_recv().expect("expected event");
match event {
BrowseEvent::PageReceived { path, .. } => {
assert_eq!(path, "/page/index.mu");
}
_ => panic!("expected PageReceived event, got {event:?}"),
}
}
#[test]
fn rrc_link_parses_and_emits_event() {
let browser = NomadBrowser::new();
let mut events = browser.events();
browser
.handle_rrc_link("rrc://aabbccddeeff00112233445566778899/general")
.unwrap();
let event = events.try_recv().expect("expected RrcLinkReceived event");
match event {
BrowseEvent::RrcLinkReceived {
hub_hash,
dest_name,
room,
} => {
assert_eq!(
hub_hash,
hex::decode("aabbccddeeff00112233445566778899")
.unwrap()
.as_slice()
);
assert!(dest_name.is_none());
assert_eq!(room, Some("general".to_string()));
}
_ => panic!("expected RrcLinkReceived, got {event:?}"),
}
}
#[test]
fn rrc_link_with_dest_name_and_room() {
let browser = NomadBrowser::new();
let mut events = browser.events();
browser
.handle_rrc_link("rrc://aabbccddeeff00112233445566778899:myhub/#chat")
.unwrap();
let event = events.try_recv().expect("expected event");
match event {
BrowseEvent::RrcLinkReceived {
hub_hash,
dest_name,
room,
} => {
assert_eq!(
hub_hash,
hex::decode("aabbccddeeff00112233445566778899")
.unwrap()
.as_slice()
);
assert_eq!(dest_name, Some("myhub".to_string()));
assert_eq!(room, Some("chat".to_string()));
}
_ => panic!("expected RrcLinkReceived, got {event:?}"),
}
}
#[test]
fn rrc_link_invalid_url_returns_error() {
let browser = NomadBrowser::new();
assert!(browser.handle_rrc_link("not_a_url").is_err());
assert!(browser.handle_rrc_link("rrc://not_hex/general").is_err());
assert!(browser.handle_rrc_link("rrc://aabb/general").is_err());
}
#[test]
fn browse_cache_put_and_get() {
let tmp = tempfile::tempdir().unwrap();
let cache = BrowseCache::new(tmp.path().to_path_buf());
cache.put("test:/page/index.mu", b"hello", 3600);
let data = cache
.get("test:/page/index.mu")
.expect("should find cached");
assert_eq!(data, b"hello");
}
#[test]
fn browse_cache_expired_entry_removed() {
let tmp = tempfile::tempdir().unwrap();
let cache = BrowseCache::new(tmp.path().to_path_buf());
cache.put("test:/page/old.mu", b"old", 0);
assert!(cache.get("test:/page/old.mu").is_none());
}
#[test]
fn browse_cache_invalidate_removes_entry() {
let tmp = tempfile::tempdir().unwrap();
let cache = BrowseCache::new(tmp.path().to_path_buf());
cache.put("test:/page/index.mu", b"data", 3600);
cache.invalidate("test:/page/index.mu");
assert!(cache.get("test:/page/index.mu").is_none());
}
#[test]
fn browse_cache_clean_expired_removes_stale() {
let tmp = tempfile::tempdir().unwrap();
let cache = BrowseCache::new(tmp.path().to_path_buf());
cache.put("test:/page/stale.mu", b"stale", 1);
cache.put("test:/page/fresh.mu", b"fresh", 3600);
std::thread::sleep(std::time::Duration::from_millis(1100));
cache.clean_expired();
assert!(cache.get("test:/page/stale.mu").is_none());
assert!(cache.get("test:/page/fresh.mu").is_some());
}
#[test]
fn browse_cache_ttl_zero_does_not_cache() {
let tmp = tempfile::tempdir().unwrap();
let cache = BrowseCache::new(tmp.path().to_path_buf());
cache.put("test:/page/nocache.mu", b"data", 0);
assert!(cache.get("test:/page/nocache.mu").is_none());
}
#[test]
fn url_hash_is_deterministic() {
let h1 = BrowseCache::url_hash("test:/page/index.mu");
let h2 = BrowseCache::url_hash("test:/page/index.mu");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
}
#[test]
fn url_hash_different_for_different_urls() {
let h1 = BrowseCache::url_hash("test:/page/a.mu");
let h2 = BrowseCache::url_hash("test:/page/b.mu");
assert_ne!(h1, h2);
}
#[test]
fn history_push_and_back() {
let mut history = BrowserHistory::new();
let e1 = HistoryEntry {
dest_hash: [0x11; 16],
path: "/page/one.mu".into(),
request_data: None,
};
let e2 = HistoryEntry {
dest_hash: [0x22; 16],
path: "/page/two.mu".into(),
request_data: None,
};
history.push(e1.clone());
history.push(e2.clone());
assert_eq!(history.len(), 2);
assert_eq!(history.pointer(), 2);
let back = history.back().unwrap();
assert_eq!(back.path, "/page/two.mu");
assert_eq!(history.pointer(), 1);
let back2 = history.back().unwrap();
assert_eq!(back2.path, "/page/one.mu");
assert_eq!(history.pointer(), 0);
assert!(history.back().is_none());
}
#[test]
fn history_forward() {
let mut history = BrowserHistory::new();
history.push(HistoryEntry {
dest_hash: [0x11; 16],
path: "/page/one.mu".into(),
request_data: None,
});
history.push(HistoryEntry {
dest_hash: [0x22; 16],
path: "/page/two.mu".into(),
request_data: None,
});
history.back();
history.back();
assert_eq!(history.pointer(), 0);
let fwd = history.forward().unwrap();
assert_eq!(fwd.path, "/page/one.mu");
let fwd2 = history.forward().unwrap();
assert_eq!(fwd2.path, "/page/two.mu");
assert!(history.forward().is_none());
}
#[test]
fn history_truncates_forward_on_push() {
let mut history = BrowserHistory::new();
history.push(HistoryEntry {
dest_hash: [0x11; 16],
path: "/page/one.mu".into(),
request_data: None,
});
history.push(HistoryEntry {
dest_hash: [0x22; 16],
path: "/page/two.mu".into(),
request_data: None,
});
history.back();
assert_eq!(history.pointer(), 1);
history.push(HistoryEntry {
dest_hash: [0x33; 16],
path: "/page/three.mu".into(),
request_data: None,
});
assert_eq!(history.len(), 2);
assert!(history.forward().is_none());
let back = history.back().unwrap();
assert_eq!(back.path, "/page/three.mu");
}
#[test]
fn history_guard_controls_write() {
let mut history = BrowserHistory::new();
assert!(history.should_write());
history.set_guard(HistoryGuard::Back);
assert!(!history.should_write());
history.reset_guard();
assert!(history.should_write());
history.set_guard(HistoryGuard::Reload);
assert!(!history.should_write());
}
#[test]
fn expand_shorthands_maps_correctly() {
assert_eq!(expand_shorthands("nnn"), "nomadnetwork.node");
assert_eq!(expand_shorthands("lxmf"), "lxmf.delivery");
assert_eq!(expand_shorthands("rrc"), "rrc.hub.session");
assert_eq!(expand_shorthands("unknown"), "unknown");
}
#[test]
fn handle_link_lxmf_dispatches_event() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let action = browser
.handle_link("lxmf@aabbccddeeff00112233445566778899")
.unwrap();
match action {
LinkAction::LxmfLink { dest_hash } => {
assert_eq!(
dest_hash,
hex::decode("aabbccddeeff00112233445566778899")
.unwrap()
.as_slice()
);
}
_ => panic!("expected LxmfLink action"),
}
let event = events.try_recv().expect("expected LxmfLinkReceived");
match event {
BrowseEvent::LxmfLinkReceived { dest_hash } => {
assert_eq!(
dest_hash.as_slice(),
hex::decode("aabbccddeeff00112233445566778899").unwrap()
);
}
_ => panic!("expected LxmfLinkReceived, got {event:?}"),
}
}
#[test]
fn handle_link_nnn_returns_nomadnet_node_action() {
let browser = NomadBrowser::new();
let _events = browser.events();
let action = browser
.handle_link("nnn@aabbccddeeff00112233445566778899")
.unwrap();
match action {
LinkAction::NomadNetNode { target } => {
assert_eq!(target, "aabbccddeeff00112233445566778899");
}
_ => panic!("expected NomadNetNode action"),
}
}
#[test]
fn handle_link_rrc_url_dispatches() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let action = browser
.handle_link("rrc://aabbccddeeff00112233445566778899/general")
.unwrap();
assert!(matches!(action, LinkAction::RrcLink));
let event = events.try_recv().expect("expected event");
assert!(matches!(event, BrowseEvent::RrcLinkReceived { .. }));
}
#[test]
fn handle_link_bare_hex_returns_nomadnet_node() {
let browser = NomadBrowser::new();
let _events = browser.events();
let action = browser
.handle_link("aabbccddeeff00112233445566778899:/page/index.mu")
.unwrap();
match action {
LinkAction::NomadNetNode { target } => {
assert!(target.contains("aabbccddeeff00112233445566778899"));
}
_ => panic!("expected NomadNetNode"),
}
}
#[test]
fn handle_link_partial_refresh_action() {
let browser = NomadBrowser::new();
{
let mut partials = browser.partials.lock().unwrap_or_else(|e| e.into_inner());
partials.partials.insert(
"hash1".to_string(),
PagePartial {
partial_hash: "hash1".to_string(),
partial_id: Some("p1".to_string()),
url: "test:/page/sub.mu".to_string(),
fields: None,
refresh: Some(5.0),
content: None,
updated: None,
failed: false,
},
);
}
let action = browser.handle_link("p:p1").unwrap();
match action {
LinkAction::RefreshPartials(hashes) => {
assert_eq!(hashes, vec!["hash1".to_string()]);
}
_ => panic!("expected RefreshPartials"),
}
}
#[test]
fn handle_link_lxmf_invalid_hex_returns_error() {
let browser = NomadBrowser::new();
assert!(browser.handle_link("lxmf@not_hex").is_err());
}
#[test]
fn handle_link_lxmf_wrong_length_returns_error() {
let browser = NomadBrowser::new();
assert!(browser.handle_link("lxmf@aabb").is_err());
}
#[test]
fn extract_cache_secs_from_micron_directive() {
let browser = NomadBrowser::new();
let _events = browser.events();
let secs = browser.extract_cache_secs(b"#!c=300\n> My Page\nSome content");
assert_eq!(secs, 300);
let default = browser.extract_cache_secs(b"> No cache directive");
assert_eq!(default, DEFAULT_CACHE_TIME_SECS);
}
#[test]
fn extract_cache_secs_zero() {
let browser = NomadBrowser::new();
let _events = browser.events();
let secs = browser.extract_cache_secs(b"#!c=0\n> Uncached page");
assert_eq!(secs, 0);
}
#[test]
fn partial_manager_register_and_stale() {
let mut mgr = PartialManager::new();
let blocks = vec![crate::micron_parser::MicronBlock::Partial {
url: "test:/page/sub.mu".to_string(),
refresh: Some(5.0),
fields: None,
partial_id: Some("p1".to_string()),
}];
mgr.register_from_blocks(&blocks);
assert_eq!(mgr.len(), 1);
let stale = mgr.stale_partials(0.0);
assert_eq!(stale.len(), 1);
assert!(stale[0].updated.is_none());
}
#[test]
fn partial_manager_not_stale_after_update() {
let mut mgr = PartialManager::new();
let blocks = vec![crate::micron_parser::MicronBlock::Partial {
url: "test:/page/sub.mu".to_string(),
refresh: Some(60.0),
fields: None,
partial_id: None,
}];
mgr.register_from_blocks(&blocks);
let hash = mgr.partials.keys().next().unwrap().clone();
mgr.mark_updated(&hash, b"content".to_vec(), 100.0);
let stale = mgr.stale_partials(120.0);
assert!(stale.is_empty(), "should not be stale within interval");
let stale = mgr.stale_partials(170.0);
assert_eq!(stale.len(), 1, "should be stale after interval");
}
#[test]
fn partial_manager_find_by_ids() {
let mut mgr = PartialManager::new();
let blocks = vec![
crate::micron_parser::MicronBlock::Partial {
url: "test:/page/a.mu".to_string(),
refresh: Some(5.0),
fields: None,
partial_id: Some("p1".to_string()),
},
crate::micron_parser::MicronBlock::Partial {
url: "test:/page/b.mu".to_string(),
refresh: Some(5.0),
fields: None,
partial_id: Some("p2".to_string()),
},
];
mgr.register_from_blocks(&blocks);
assert_eq!(mgr.len(), 2);
let found = mgr.find_by_ids(&["p1"]);
assert_eq!(found.len(), 1);
let found = mgr.find_by_ids(&["p1", "p2"]);
assert_eq!(found.len(), 2);
let found = mgr.find_by_ids(&["p3"]);
assert!(found.is_empty());
}
#[test]
fn partial_manager_clear() {
let mut mgr = PartialManager::new();
mgr.partials.insert(
"h".to_string(),
PagePartial {
partial_hash: "h".to_string(),
partial_id: None,
url: "test".to_string(),
fields: None,
refresh: None,
content: None,
updated: None,
failed: false,
},
);
assert_eq!(mgr.len(), 1);
mgr.clear();
assert!(mgr.is_empty());
}
#[test]
fn partial_manager_mark_failed() {
let mut mgr = PartialManager::new();
mgr.partials.insert(
"h".to_string(),
PagePartial {
partial_hash: "h".to_string(),
partial_id: None,
url: "test".to_string(),
fields: None,
refresh: Some(5.0),
content: None,
updated: None,
failed: false,
},
);
mgr.mark_failed("h");
assert!(mgr.partials["h"].failed);
let stale = mgr.stale_partials(0.0);
assert!(stale.is_empty(), "failed partials should not be stale");
}
#[test]
fn current_url_format() {
let url = NomadBrowser::current_url(&[0xaa; 16], "/page/index.mu");
assert_eq!(url, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:/page/index.mu");
}
#[test]
fn browser_history_back_emits_event() {
let browser = NomadBrowser::new();
let mut events = browser.events();
browser.history.lock().unwrap().push(HistoryEntry {
dest_hash: [0x11; 16],
path: "/page/one.mu".into(),
request_data: None,
});
browser.history.lock().unwrap().push(HistoryEntry {
dest_hash: [0x22; 16],
path: "/page/two.mu".into(),
request_data: None,
});
let entry = browser.history_back().unwrap();
assert_eq!(entry.path, "/page/two.mu");
let event = events.try_recv().expect("expected HistoryNavigated");
match event {
BrowseEvent::HistoryNavigated { dest_hash, path } => {
assert_eq!(dest_hash, [0x22; 16]);
assert_eq!(path, "/page/two.mu");
}
_ => panic!("expected HistoryNavigated, got {event:?}"),
}
}
#[test]
fn browser_with_cache_dir_works() {
let tmp = tempfile::tempdir().unwrap();
let browser = NomadBrowser::with_cache_dir(tmp.path().to_path_buf());
let _events = browser.events();
browser.cache().put("test:/page/index.mu", b"cached", 3600);
let data = browser.cache().get("test:/page/index.mu").unwrap();
assert_eq!(data, b"cached");
}
#[test]
fn history_guard_resets_after_response() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap()
.entry(link_id.0)
.or_default()
.insert(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/page/first.mu".to_string(),
data: Vec::new(),
},
);
browser
.history
.lock()
.unwrap()
.set_guard(HistoryGuard::Back);
assert!(!browser.history.lock().unwrap().should_write());
browser.handle_response(link_id, [0x01; 16], b"content".to_vec());
drain_events(&mut events);
assert!(browser.history.lock().unwrap().should_write());
browser
.pending
.lock()
.unwrap()
.entry(link_id.0)
.or_default()
.insert(
[0x02; 16],
PendingRequest {
request_id: [0x02; 16],
path: "/page/second.mu".to_string(),
data: vec![1, 2, 3],
},
);
browser.handle_response(link_id, [0x02; 16], b"content2".to_vec());
drain_events(&mut events);
let mut history = browser.history.lock().unwrap();
assert_eq!(history.len(), 1);
let entry = history.back().unwrap();
assert_eq!(entry.path, "/page/second.mu");
assert_eq!(entry.request_data, Some(vec![1, 2, 3]));
}
#[test]
fn browse_cache_put_removes_older_entries() {
let tmp = tempfile::tempdir().unwrap();
let cache = BrowseCache::new(tmp.path().to_path_buf());
cache.put("test:/page/index.mu", b"v1", 3600);
cache.put("test:/page/index.mu", b"v2", 3600);
let data = cache.get("test:/page/index.mu").unwrap();
assert_eq!(data, b"v2");
let count = std::fs::read_dir(tmp.path()).unwrap().count();
assert_eq!(count, 1, "should have exactly one cache file");
}
#[test]
fn handle_link_rrc_shorthand_constructs_url() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let action = browser
.handle_link("rrc@aabbccddeeff00112233445566778899/general")
.unwrap();
assert!(matches!(action, LinkAction::RrcLink));
let event = events.try_recv().expect("expected event");
assert!(matches!(event, BrowseEvent::RrcLinkReceived { .. }));
}
#[test]
fn partial_hash_includes_id() {
let mut mgr = PartialManager::new();
let blocks = vec![
crate::micron_parser::MicronBlock::Partial {
url: "test:/page/sub.mu".to_string(),
refresh: Some(5.0),
fields: None,
partial_id: Some("p1".to_string()),
},
crate::micron_parser::MicronBlock::Partial {
url: "test:/page/sub.mu".to_string(),
refresh: Some(5.0),
fields: None,
partial_id: Some("p2".to_string()),
},
];
mgr.register_from_blocks(&blocks);
assert_eq!(
mgr.len(),
2,
"distinct partial_ids should produce distinct entries"
);
}
fn drain_events(events: &mut Receiver<BrowseEvent>) {
while events.try_recv().is_ok() {}
}
#[test]
fn transfer_progress_emits_event() {
let browser = NomadBrowser::new();
let mut events = browser.events();
let link_id = LinkId([0x11; 16]);
let dest_hash = [0x22; 16];
browser
.link_to_dest
.lock()
.unwrap()
.insert(link_id.0, dest_hash);
browser
.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(
link_id.0,
HashMap::from([(
[0x01; 16],
PendingRequest {
request_id: [0x01; 16],
path: "/page/index.mu".to_string(),
data: Vec::new(),
},
)]),
);
browser.handle_transfer_progress(link_id, 0.5, Some(1024), Some(2048));
let event = events.try_recv().expect("expected TransferProgress event");
match event {
BrowseEvent::TransferProgress {
dest_hash: dh,
path,
progress,
data_size,
transfer_size,
..
} => {
assert_eq!(dh, dest_hash);
assert_eq!(path, "/page/index.mu");
assert!((progress - 0.5).abs() < f64::EPSILON);
assert_eq!(data_size, Some(1024));
assert_eq!(transfer_size, Some(2048));
}
_ => panic!("expected TransferProgress, got {event:?}"),
}
}
#[test]
fn transfer_progress_ignores_unknown_link() {
let browser = NomadBrowser::new();
let mut events = browser.events();
browser.handle_transfer_progress(LinkId([0xFF; 16]), 0.5, None, None);
assert!(events.try_recv().is_err());
}
#[test]
fn reset_transfer_progress_clears_state() {
let browser = NomadBrowser::new();
let link_id = LinkId([0x11; 16]);
browser.handle_transfer_progress(link_id, 0.5, None, None);
browser.reset_transfer_progress(&link_id);
let states = browser.transfer_states.lock().unwrap();
assert!(states.get(&link_id.0).is_none());
}
}