use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use rich_embed::PageState;
use crate::engine::{
Engine, EngineEvent, Format, HistoryStep, MediaOp, NodeId, PageAction, Rect, TabId,
};
use crate::model::{build, Binding, Control, MediaKind, Page, Revisions, Role};
pub const DEBOUNCE: Duration = Duration::from_millis(100);
const TICK: Duration = Duration::from_millis(25);
const STILLS_PER_READ: usize = 8;
const PLAYBACK: Duration = Duration::from_millis(500);
#[derive(Clone, Debug, PartialEq)]
pub enum Intent {
Click,
Focus,
SetValue {
value: String,
base: Option<String>,
},
Select {
option: NodeId,
},
SetRange(f64),
}
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Open(String),
History(HistoryStep),
Act {
binding: Binding,
intent: Intent,
},
Activate {
role: String,
name: String,
nth: usize,
},
Scroll(f64),
Media {
document: u64,
node: NodeId,
op: MediaOp,
},
Resize(u16, u16),
Refresh,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
Stale(String),
Unavailable(String),
Conflict { remote: String },
Failed(String),
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refusal::Stale(why) | Refusal::Unavailable(why) | Refusal::Failed(why) => {
f.write_str(why)
}
Refusal::Conflict { remote } => write!(
f,
"Edit conflict: the page changed this value to {:?}. Reload it before applying.",
crate::clean::truncate(remote, 60)
),
}
}
}
#[derive(Clone, Debug)]
pub enum Update {
Page(Arc<Page>),
State(PageState),
Done(u64, Result<(), Refusal>),
Download {
guid: String,
name: String,
url: String,
},
DownloadDone {
guid: String,
path: String,
},
}
#[derive(Clone, Debug)]
pub struct Tab {
commands: Sender<(u64, Command)>,
next: Arc<std::sync::atomic::AtomicU64>,
}
struct Running {
stop: Arc<AtomicBool>,
ended: Receiver<()>,
}
static RUNNING: Mutex<Vec<Running>> = Mutex::new(Vec::new());
fn prune(running: &mut Vec<Running>) {
running.retain(|r| !matches!(r.ended.try_recv(), Err(TryRecvError::Disconnected)));
}
pub fn stop_all(timeout: Duration) {
let all = std::mem::take(&mut *RUNNING.lock().unwrap_or_else(|e| e.into_inner()));
for running in &all {
running.stop.store(true, Ordering::SeqCst);
}
let deadline = Instant::now() + timeout;
for running in all {
let left = deadline.saturating_duration_since(Instant::now());
let _ = running.ended.recv_timeout(left);
}
}
impl Tab {
pub fn spawn(engine: Box<dyn Engine>, update: impl Fn(Update) + Send + 'static) -> Tab {
let (commands, receive) = mpsc::channel();
let stop = Arc::new(AtomicBool::new(false));
let (ended, done) = mpsc::channel::<()>();
{
let mut running = RUNNING.lock().unwrap_or_else(|e| e.into_inner());
prune(&mut running);
running.push(Running {
stop: stop.clone(),
ended: done,
});
}
thread::spawn(move || {
let _ended = ended;
run(engine, receive, &stop, &update);
});
Tab {
commands,
next: Arc::default(),
}
}
pub fn send(&self, command: Command) -> u64 {
let id = self.next.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
let _ = self.commands.send((id, command));
id
}
}
fn guard(control: &Control, intent: &Intent) -> Result<(), Refusal> {
if control.disabled {
return Err(Refusal::Unavailable(format!(
"{} is disabled.",
describe(control)
)));
}
match intent {
Intent::SetValue { base, .. } => {
if control.readonly {
return Err(Refusal::Unavailable(format!(
"{} is read-only.",
describe(control)
)));
}
if let (Some(base), Some(now)) = (base, &control.value) {
if base != now {
return Err(Refusal::Conflict {
remote: now.clone(),
});
}
}
}
Intent::Select { option } => {
let choice = control
.options
.iter()
.find(|o| o.key == *option)
.ok_or_else(|| Refusal::Stale("Unknown or stale option key.".into()))?;
if choice.disabled {
return Err(Refusal::Unavailable("That option is disabled.".into()));
}
}
_ => {}
}
Ok(())
}
pub fn describe(control: &Control) -> String {
let role = match control.role {
Role::Password => "password field",
Role::TextBox | Role::SearchBox => "field",
Role::CheckBox => "check box",
Role::ComboBox => "select",
ref other => other.name(),
};
if control.name.is_empty() {
format!("This {role}")
} else {
format!("The {:?} {role}", crate::clean::truncate(&control.name, 40))
}
}
fn action(control: &Control, intent: &Intent) -> PageAction {
match intent {
Intent::Click => PageAction::Click(control.node),
Intent::Focus => PageAction::Focus(control.node),
Intent::SetValue { value, .. } => PageAction::SetValue {
node: control.node,
value: value.clone(),
},
Intent::Select { option } => PageAction::SelectOption {
select: control.node,
option: *option,
},
Intent::SetRange(value) => PageAction::SetRange {
node: control.node,
value: *value,
},
}
}
struct Worker<'a> {
engine: Box<dyn Engine>,
update: &'a dyn Fn(Update),
revisions: Revisions,
page: Option<Arc<Page>>,
state: PageState,
dirty: Option<Instant>,
stills: HashMap<(NodeId, String, u64, u64), Arc<[u8]>>,
}
impl Worker<'_> {
fn read(&mut self) -> Option<Arc<Page>> {
let snapshot = self.engine.snapshot(TabId(0)).ok()?;
let mut page = build(&snapshot, &mut self.revisions);
for media in &mut page.media {
if let Some(still) = self
.stills
.get(&still_key(media.node, &media.src, media.rect))
{
media.still = Some(still.clone());
}
}
self.probe(&mut page);
let page = Arc::new(page);
if self.page.as_deref() != Some(&*page) {
(self.update)(Update::Page(page.clone()));
}
self.page = Some(page.clone());
self.dirty = None;
Some(page)
}
fn probe(&mut self, page: &mut Page) {
for media in &mut page.media {
if matches!(media.kind, MediaKind::Video | MediaKind::Audio) {
media.playback = self.engine.playback(TabId(0), media.node).ok();
}
}
}
fn follow_playback(&mut self) {
let Some(page) = self.page.clone() else {
return;
};
if !page
.media
.iter()
.any(|m| m.playback.is_some_and(|p| !p.paused))
{
return;
}
let mut next = (*page).clone();
self.probe(&mut next);
if next != *page {
let next = Arc::new(next);
self.page = Some(next.clone());
(self.update)(Update::Page(next));
}
}
fn capture_stills(&mut self) {
let Some(page) = self.page.clone() else {
return;
};
let wanted: Vec<_> = page
.media
.iter()
.filter(|m| m.kind == MediaKind::Image && m.still.is_none())
.filter(|m| m.rect.width >= 4.0 && m.rect.height >= 4.0)
.take(STILLS_PER_READ)
.map(|m| (m.node, m.src.clone(), m.rect))
.collect();
if wanted.is_empty() {
return;
}
let mut page = (*page).clone();
let mut any = false;
for (node, src, rect) in wanted {
let region = Rect::new(
rect.x,
rect.y,
rect.width.min(4000.0),
rect.height.min(4000.0),
);
if let Ok(frame) = self.engine.capture(TabId(0), region, Format::Png) {
let still: Arc<[u8]> = frame.bytes.into();
self.stills
.insert(still_key(node, &src, rect), still.clone());
for media in page.media.iter_mut().filter(|m| m.node == node) {
media.still = Some(still.clone());
}
any = true;
}
}
if any {
let page = Arc::new(page);
self.page = Some(page.clone());
(self.update)(Update::Page(page));
}
}
fn run_command(&mut self, command: Command) -> Result<(), Refusal> {
let failed = |e: std::io::Error| Refusal::Failed(crate::clean::clean(&e.to_string()));
match command {
Command::Open(url) => self.engine.open(TabId(0), &url).map_err(failed),
Command::History(step) => self.engine.history(TabId(0), step).map_err(failed),
Command::Resize(columns, rows) => self.engine.resize(columns, rows).map_err(failed),
Command::Scroll(y) => self
.engine
.act(TabId(0), &PageAction::ScrollTo(y))
.map_err(failed),
Command::Refresh => {
self.read();
Ok(())
}
Command::Media { document, node, op } => {
let page = self
.read()
.ok_or_else(|| Refusal::Stale("The page is not ready.".into()))?;
if page.document != document {
return Err(Refusal::Stale(
"Stale document: the tab went to another page.".into(),
));
}
if !page
.media
.iter()
.any(|m| m.node == node && m.playback.is_some())
{
return Err(Refusal::Stale(
"That video or audio is gone from the page.".into(),
));
}
self.engine
.act(TabId(0), &PageAction::Media { node, op })
.map_err(failed)
}
Command::Act { binding, intent } => {
let page = self
.read()
.ok_or_else(|| Refusal::Stale("The page is not ready.".into()))?;
let control = page.check(&binding).map_err(Refusal::Stale)?;
guard(control, &intent)?;
self.engine
.act(TabId(0), &action(control, &intent))
.map_err(failed)
}
Command::Activate { role, name, nth } => {
let page = self
.read()
.ok_or_else(|| Refusal::Stale("The page is not ready.".into()))?;
let found = page.find(&role, &name);
let control = found.get(nth).ok_or_else(|| {
Refusal::Unavailable(if found.is_empty() {
format!("No {role} named {name:?} on this page.")
} else {
format!("Only {} {role}s named {name:?} on this page.", found.len())
})
})?;
let intent = if control.role.is_text() {
Intent::Focus
} else {
Intent::Click
};
guard(control, &intent)?;
self.engine
.act(TabId(0), &action(control, &intent))
.map_err(failed)
}
}
}
}
fn still_key(node: NodeId, src: &str, rect: Rect) -> (NodeId, String, u64, u64) {
(
node,
src.to_string(),
rect.width.round() as u64,
rect.height.round() as u64,
)
}
fn run(
engine: Box<dyn Engine>,
commands: Receiver<(u64, Command)>,
stop: &AtomicBool,
update: &dyn Fn(Update),
) {
let mut worker = Worker {
engine,
update,
revisions: Revisions::new(),
page: None,
state: PageState::default(),
dirty: None,
stills: HashMap::new(),
};
let mut read_now = true;
let mut probed = Instant::now();
loop {
if stop.load(Ordering::SeqCst) {
return;
}
match commands.recv_timeout(TICK) {
Ok((id, command)) => {
let acts = matches!(
command,
Command::Act { .. }
| Command::Activate { .. }
| Command::Scroll(_)
| Command::Media { .. }
);
let result = worker.run_command(command);
update(Update::Done(id, result));
read_now |= acts;
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
for event in worker.engine.events() {
match event {
EngineEvent::Changed => {
worker.dirty.get_or_insert_with(Instant::now);
}
EngineEvent::Navigated(_) | EngineEvent::Loaded => read_now = true,
EngineEvent::DownloadBegan { guid, name, url } => {
update(Update::Download { guid, name, url })
}
EngineEvent::DownloadDone { guid, path } => {
update(Update::DownloadDone { guid, path })
}
}
}
let state = worker.engine.state(TabId(0));
if state != worker.state {
if worker.state.loading && !state.loading {
read_now = true;
}
worker.state = state.clone();
update(Update::State(state));
}
if probed.elapsed() >= PLAYBACK {
probed = Instant::now();
worker.follow_playback();
}
let due = worker
.dirty
.is_some_and(|since| since.elapsed() >= DEBOUNCE);
if read_now || due {
if worker.read().is_some() {
read_now = false;
worker.capture_stills();
} else {
worker.dirty.get_or_insert_with(Instant::now);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::FakeEngine;
#[test]
fn ended_tabs_leave_the_registry() {
let dir = std::env::temp_dir().join(format!("noctiluca-tab-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let spawn = || Tab::spawn(Box::new(FakeEngine::from_dir(&dir).unwrap()), |_| {});
for _ in 0..50 {
drop(spawn());
}
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let live = spawn();
let ended = RUNNING
.lock()
.unwrap()
.iter()
.filter(|r| matches!(r.ended.try_recv(), Err(TryRecvError::Disconnected)))
.count();
drop(live);
if ended == 0 && RUNNING.lock().unwrap().len() <= 8 {
break;
}
assert!(Instant::now() < deadline, "ended tabs stay registered");
thread::sleep(Duration::from_millis(20));
}
let _ = std::fs::remove_dir_all(&dir);
}
}