From fd6eb6e7794a791a86753be06799e4ce232e7ece Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 31 Aug 2026 20:25:19 +0700 Subject: [PATCH 1/4] Add ps-browse-core: browser policy as a shared crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tabs, back/forward history, address-bar resolution and load outcomes, with no engine dependency: the policy answers "this tab, at this generation, wants this URL" and the host does the fetching. Chuzz already keeps browser policy separate from the renderer — it is the first rule in its working agreement — but the separation is a module boundary inside one binary, so agencyzero cannot reach it. This is the same line drawn at a crate boundary. --- Cargo.toml | 3 + crates/browse-core/Cargo.toml | 26 ++ crates/browse-core/src/address.rs | 156 +++++++++ crates/browse-core/src/debug_log.rs | 115 +++++++ crates/browse-core/src/history.rs | 218 ++++++++++++ crates/browse-core/src/lib.rs | 42 +++ crates/browse-core/src/outcome.rs | 86 +++++ crates/browse-core/src/tabs.rs | 497 ++++++++++++++++++++++++++++ 8 files changed, 1143 insertions(+) create mode 100644 crates/browse-core/Cargo.toml create mode 100644 crates/browse-core/src/address.rs create mode 100644 crates/browse-core/src/debug_log.rs create mode 100644 crates/browse-core/src/history.rs create mode 100644 crates/browse-core/src/lib.rs create mode 100644 crates/browse-core/src/outcome.rs create mode 100644 crates/browse-core/src/tabs.rs diff --git a/Cargo.toml b/Cargo.toml index 297e9c11..24a7106d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/mcp-proxy", "crates/wt-migrate", "crates/agency-tools", + "crates/browse-core", ] [workspace.package] @@ -15,6 +16,8 @@ publish = false [workspace.dependencies] az-core = { path = "crates/core" } +# Browser policy shared with chuzz; see crates/browse-core/src/lib.rs. +ps-browse-core = { path = "crates/browse-core" } # One AgencyProxy release ships the client and the protocol together, and a # sidecar built from a client that disagrees with the protocol fails at connect diff --git a/crates/browse-core/Cargo.toml b/crates/browse-core/Cargo.toml new file mode 100644 index 00000000..cfb45c71 --- /dev/null +++ b/crates/browse-core/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ps-browse-core" +description = "Host-agnostic browser policy: tabs, history, address resolution, load outcomes" +version.workspace = true +edition.workspace = true +# Inherited `false` for now. This crate exists to be shared with chuzz, and +# chuzz takes every dependency as a published version with a caret rather than +# a path or a git revision — that is written into its working agreement. So +# the step that actually lets chuzz adopt this is flipping `publish` and +# releasing it; until then agencyzero consumes it by path and chuzz keeps its +# own copy of the policy. Nothing here depends on agencyzero, precisely so +# that flip is the only work left. +publish.workspace = true + +[dependencies] +# The entire dependency set, and deliberately so. +# +# Browser *policy* is not browser *rendering*. Tabs, a back stack, and the +# question of whether a typed string is an address are decisions a browser +# makes before any engine is involved, and they are the decisions two +# applications want to agree on. Nothing in this crate mentions blitz, tauri, +# a document, or a node: the host owns the engine and calls in here to ask what +# to do. That is what makes the crate shareable, and it is also what makes it +# testable without a window. +serde = { version = "1", features = ["derive"] } +url = "2" diff --git a/crates/browse-core/src/address.rs b/crates/browse-core/src/address.rs new file mode 100644 index 00000000..a5b4fd63 --- /dev/null +++ b/crates/browse-core/src/address.rs @@ -0,0 +1,156 @@ +//! Address-bar policy: deciding what a typed string means. +//! +//! The browser owns this, not the engine. A string is a URL if it parses as +//! one, a bare hostname if it looks like a domain, and nothing otherwise. +//! +//! Lifted from chuzz's `nav.rs` and cut loose from the engine: chuzz returned +//! a `blitz_traits::net::Request`, which dragged the whole engine into a +//! decision that is pure string handling. A [`Url`] is the same answer without +//! the dependency, and the host wraps it in whatever its fetch layer wants. + +use url::Url; + +/// Page opened by a new tab: nothing at all. +/// +/// A new tab should cost nothing until it is asked for something. Pointing it +/// at a home page instead means every new tab fetches a site, runs its +/// scripts, and decodes its images before the address bar has been touched. +/// The host answers the `about` scheme from a constant, without a request. +pub const NEW_TAB_URL: &str = "about:blank"; + +/// Turn whatever the user typed into a URL to navigate to. +/// +/// Returns `None` for anything that is not a URL or a bare hostname. The +/// toolbar treats that as "do nothing". +pub fn url_from_input(input: &str) -> Option { + let input = input.trim(); + if input.is_empty() { + return None; + } + + if let Ok(url) = Url::parse(input) + && url.scheme() != "localhost" + { + return Some(url); + } + + if looks_like_hostname(input) + && let Ok(url) = Url::parse(&format!("https://{input}")) + { + return Some(url); + } + + // No search fallback. Anything that is not a URL and not a hostname is a + // mistake, and quietly navigating somewhere unrelated hides it: that is how + // a wrong capture argument once ended up loading a search engine instead of + // the local file it was given. + None +} + +/// A dotted, space-free token is treated as a host rather than a query. +/// +/// `localhost` and `localhost:3000` are special-cased because developers type +/// them constantly and they carry no dot. +fn looks_like_hostname(input: &str) -> bool { + if input.contains(char::is_whitespace) { + return false; + } + if input == "localhost" || input.starts_with("localhost:") || input.starts_with("localhost/") { + return true; + } + let host = input + .split(['/', '?', '#']) + .next() + .unwrap_or(input) + .split(':') + .next() + .unwrap_or(input); + // A trailing dot ("foo.") or a leading dot (".foo") is a typo, not a host. + host.contains('.') && !host.starts_with('.') && !host.ends_with('.') +} + +/// Text shown in a tab strip and a window title. +pub fn display_title(title: &str, url: &Url) -> String { + if title.trim().is_empty() { + url.as_str().to_string() + } else { + title.to_string() + } +} + +/// Whether a URL is one the browser answers itself rather than fetching. +/// +/// `about:blank` is the new-tab document and has no server. Asking the network +/// for it is how a new tab ends up with an error page instead of an empty one. +pub fn is_internal(url: &Url) -> bool { + url.scheme() == "about" +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target(input: &str) -> String { + url_from_input(input).unwrap().to_string() + } + + /// The host answers the `about` scheme from a constant instead of + /// fetching. A new tab pointed anywhere else costs a request before it has + /// been asked for anything. + #[test] + fn a_new_tab_costs_no_request() { + let url = Url::parse(NEW_TAB_URL).unwrap(); + assert_eq!(url.scheme(), "about"); + assert!(is_internal(&url)); + } + + #[test] + fn blank_input_is_not_a_navigation() { + assert!(url_from_input("").is_none()); + assert!(url_from_input(" \t ").is_none()); + } + + #[test] + fn an_explicit_scheme_is_preserved() { + assert_eq!(target("http://example.com/a"), "http://example.com/a"); + assert_eq!(target("https://example.com/a"), "https://example.com/a"); + } + + #[test] + fn a_bare_hostname_gets_https() { + assert_eq!(target("example.com"), "https://example.com/"); + assert_eq!( + target("example.com/path?q=1"), + "https://example.com/path?q=1" + ); + } + + #[test] + fn localhost_is_treated_as_a_host_despite_having_no_dot() { + assert_eq!(target("localhost:3000"), "https://localhost:3000/"); + assert_eq!(target("localhost"), "https://localhost/"); + } + + #[test] + fn prose_is_not_a_navigation() { + assert!(url_from_input("how tall is the eiffel tower").is_none()); + } + + #[test] + fn a_dotted_phrase_with_spaces_is_not_a_host() { + assert!(url_from_input("what is rust.lang about").is_none()); + } + + #[test] + fn a_trailing_dot_is_a_typo_and_goes_nowhere() { + assert!(url_from_input("example.").is_none()); + } + + #[test] + fn an_untitled_page_falls_back_to_its_url() { + let url = Url::parse("https://example.com/a").unwrap(); + assert_eq!(display_title("", &url), "https://example.com/a"); + assert_eq!(display_title(" ", &url), "https://example.com/a"); + assert_eq!(display_title("Example", &url), "Example"); + } +} diff --git a/crates/browse-core/src/debug_log.rs b/crates/browse-core/src/debug_log.rs new file mode 100644 index 00000000..665ca7c5 --- /dev/null +++ b/crates/browse-core/src/debug_log.rs @@ -0,0 +1,115 @@ +//! What the browser did, where someone looking at the window can read it. + +use std::collections::VecDeque; + +use serde::Serialize; + +/// One line in the debugging panel. +/// +/// A browser says most of this on stderr, where nobody watching the window can +/// see it. A page that renders and then does nothing is almost always a script +/// or a module that never arrived, and that fact existing only in a terminal +/// the person looking at the blank page does not have open is why this is a +/// panel rather than a log line. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugEntry { + /// Monotonic, so the chrome can ask for everything after what it has + /// rather than re-reading the buffer and guessing what is new. + pub seq: u64, + /// `info`, `warn` or `error`. Drives the colour and nothing else. + pub level: &'static str, + /// Which part said it: `net`, `page`, `script`, `nav`. + pub source: &'static str, + pub message: String, +} + +/// The last [`DebugLog::CAPACITY`] things the browser did. +/// +/// A ring rather than a growing list: this records every subresource of every +/// page for the life of the process, and a browser that leaks a line per +/// request is a browser that eventually stops. +#[derive(Clone, Debug, Default)] +pub struct DebugLog { + next_seq: u64, + entries: VecDeque, +} + +impl DebugLog { + pub const CAPACITY: usize = 500; + + pub fn push(&mut self, level: &'static str, source: &'static str, message: String) -> u64 { + let seq = self.next_seq; + self.next_seq += 1; + if self.entries.len() == Self::CAPACITY { + self.entries.pop_front(); + } + self.entries.push_back(DebugEntry { + seq, + level, + source, + message, + }); + seq + } + + /// Everything the caller has not seen. `since` is the last `seq` it holds. + pub fn since(&self, since: Option) -> Vec { + match since { + Some(seq) => self + .entries + .iter() + .filter(|entry| entry.seq > seq) + .cloned() + .collect(), + None => self.entries.iter().cloned().collect(), + } + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_ring_drops_the_oldest_rather_than_growing() { + let mut log = DebugLog::default(); + for index in 0..DebugLog::CAPACITY + 10 { + log.push("info", "net", format!("line {index}")); + } + assert_eq!(log.len(), DebugLog::CAPACITY); + let all = log.since(None); + assert_eq!(all.first().unwrap().message, "line 10"); + } + + /// Sequence numbers keep counting past an eviction. A chrome polling with + /// `since` would otherwise be handed lines it has already drawn. + #[test] + fn sequence_numbers_survive_eviction() { + let mut log = DebugLog::default(); + for index in 0..DebugLog::CAPACITY + 5 { + log.push("info", "net", format!("line {index}")); + } + let last = log.since(None).last().unwrap().seq; + assert_eq!(last as usize, DebugLog::CAPACITY + 4); + assert!(log.since(Some(last)).is_empty()); + } + + #[test] + fn since_returns_only_what_the_caller_has_not_seen() { + let mut log = DebugLog::default(); + let first = log.push("info", "net", "a".into()); + log.push("warn", "page", "b".into()); + let fresh = log.since(Some(first)); + assert_eq!(fresh.len(), 1); + assert_eq!(fresh[0].message, "b"); + } +} diff --git a/crates/browse-core/src/history.rs b/crates/browse-core/src/history.rs new file mode 100644 index 00000000..d001188a --- /dev/null +++ b/crates/browse-core/src/history.rs @@ -0,0 +1,218 @@ +//! One tab's back/forward stack. + +use serde::Serialize; +use url::Url; + +use crate::address::display_title; + +/// One position in a tab's history. +/// +/// The title lives beside the URL rather than in a lookup table keyed by URL, +/// because the same address visited twice can legitimately have two titles and +/// a shared table would show the newer one against the older entry. +#[derive(Clone, Debug)] +pub struct Entry { + pub url: Url, + pub title: String, +} + +impl Entry { + fn new(url: Url) -> Self { + Self { + url, + title: String::new(), + } + } + + /// What a tab strip shows for this entry. + pub fn display_title(&self) -> String { + display_title(&self.title, &self.url) + } +} + +/// A tab's history, and where in it the tab currently sits. +/// +/// Never empty. A tab always has a current entry — a new tab's is +/// `about:blank` — which is what lets [`History::current`] return a reference +/// rather than an option and keeps every caller from handling a state that +/// cannot occur. +#[derive(Clone, Debug)] +pub struct History { + entries: Vec, + current: usize, +} + +impl History { + pub fn new(url: Url) -> Self { + Self { + entries: vec![Entry::new(url)], + current: 0, + } + } + + pub fn current(&self) -> &Entry { + &self.entries[self.current] + } + + pub fn current_mut(&mut self) -> &mut Entry { + &mut self.entries[self.current] + } + + pub fn can_go_back(&self) -> bool { + self.current > 0 + } + + pub fn can_go_forward(&self) -> bool { + self.current + 1 < self.entries.len() + } + + /// Visit a new address, discarding anything ahead of the cursor. + /// + /// The truncation is the whole of forward-history policy: going back three + /// pages and then following a link means the three pages you skipped are + /// no longer reachable, because you are no longer on the path that led to + /// them. Keeping them would make Forward go somewhere the user never was. + /// + /// Re-entering the address already showing is a reload, not a new entry. + /// Without that check, pressing Return in the address bar without editing + /// it grows the stack by one every time and Back stops meaning anything. + pub fn visit(&mut self, url: Url) { + if self.current().url == url { + return; + } + self.entries.truncate(self.current + 1); + self.entries.push(Entry::new(url)); + self.current = self.entries.len() - 1; + } + + /// Step back one entry, returning where the tab now is. + pub fn back(&mut self) -> Option<&Entry> { + if !self.can_go_back() { + return None; + } + self.current -= 1; + Some(self.current()) + } + + /// Step forward one entry, returning where the tab now is. + pub fn forward(&mut self) -> Option<&Entry> { + if !self.can_go_forward() { + return None; + } + self.current += 1; + Some(self.current()) + } + + /// Every entry, oldest first, for a history panel. + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + pub fn position(&self) -> usize { + self.current + } +} + +/// One history entry as the chrome sees it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EntrySnapshot { + pub url: String, + pub title: String, + /// Whether this is the entry the tab is currently showing. + pub current: bool, +} + +impl History { + pub fn snapshot(&self) -> Vec { + self.entries + .iter() + .enumerate() + .map(|(index, entry)| EntrySnapshot { + url: entry.url.to_string(), + title: entry.display_title(), + current: index == self.current, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn url(text: &str) -> Url { + Url::parse(text).unwrap() + } + + fn history() -> History { + History::new(url("https://a.example/")) + } + + #[test] + fn a_fresh_history_can_go_nowhere() { + let history = history(); + assert!(!history.can_go_back()); + assert!(!history.can_go_forward()); + assert_eq!(history.current().url.as_str(), "https://a.example/"); + } + + #[test] + fn back_then_forward_returns_to_where_it_started() { + let mut history = history(); + history.visit(url("https://b.example/")); + assert_eq!(history.back().unwrap().url.as_str(), "https://a.example/"); + assert!(history.can_go_forward()); + assert_eq!( + history.forward().unwrap().url.as_str(), + "https://b.example/" + ); + assert!(!history.can_go_forward()); + } + + /// Going back and then somewhere new drops the branch that was skipped. + /// Keeping it would put a page the user never navigated to behind Forward. + #[test] + fn visiting_after_going_back_discards_the_forward_branch() { + let mut history = history(); + history.visit(url("https://b.example/")); + history.visit(url("https://c.example/")); + history.back(); + history.visit(url("https://d.example/")); + + assert!(!history.can_go_forward()); + assert_eq!(history.entries().len(), 3); + assert_eq!(history.current().url.as_str(), "https://d.example/"); + // b, not c: c was the branch that going back stepped off, and d + // replaced it. + assert_eq!(history.back().unwrap().url.as_str(), "https://b.example/"); + } + + /// Pressing Return on the address already showing is a reload. Recording + /// it would grow the stack by one per keypress and make Back useless. + #[test] + fn re_entering_the_current_address_is_not_a_new_entry() { + let mut history = history(); + history.visit(url("https://a.example/")); + assert_eq!(history.entries().len(), 1); + assert!(!history.can_go_back()); + } + + #[test] + fn an_untitled_entry_shows_its_url() { + let mut history = history(); + assert_eq!(history.current().display_title(), "https://a.example/"); + history.current_mut().title = "A".into(); + assert_eq!(history.current().display_title(), "A"); + } + + #[test] + fn the_snapshot_marks_exactly_one_current_entry() { + let mut history = history(); + history.visit(url("https://b.example/")); + history.back(); + let snapshot = history.snapshot(); + assert_eq!(snapshot.iter().filter(|entry| entry.current).count(), 1); + assert!(snapshot[0].current); + } +} diff --git a/crates/browse-core/src/lib.rs b/crates/browse-core/src/lib.rs new file mode 100644 index 00000000..da77e18b --- /dev/null +++ b/crates/browse-core/src/lib.rs @@ -0,0 +1,42 @@ +//! Browser policy, with no browser engine in it. +//! +//! Tabs, a back stack, the question of whether a typed string is an address, +//! and how to describe a load that half worked. Every one of those is a +//! decision a browser makes before an engine is involved, and every one of +//! them is a decision two applications that both embed Blitz want to agree on. +//! +//! Chuzz already draws this line — "keep browser policy separate from the +//! renderer" is the first rule in its working agreement — but it draws it +//! inside a single 1700-line module in a single binary, so agencyzero could +//! not reach it. This crate is that same line, drawn at a crate boundary +//! instead of a module boundary. +//! +//! The shape that makes it shareable is [`tabs::Load`]: the policy never +//! fetches anything. It answers "this tab, at this generation, now wants this +//! URL", the host performs the fetch with whatever net stack and engine it +//! has, and reports back with [`tabs::Tabs::finish_load`]. Nothing here +//! mentions a document, a node, or a window, which is why the whole crate is +//! testable without one — and why the tests below run in milliseconds rather +//! than needing a compositor. +//! +//! ## Adoption by chuzz +//! +//! Chuzz's `apps/chuzz/src/browser.rs` still has its own copy of this policy. +//! Replacing it is a separate, mechanical change in a separate repository, and +//! it needs this crate published first: chuzz takes every dependency as a +//! caret range from crates.io, deliberately, so a path dependency is not an +//! option there. Nothing in this crate depends on agencyzero, so that flip is +//! the only work standing between the two applications and one policy. + +pub mod address; +pub mod debug_log; +pub mod history; +pub mod outcome; +pub mod tabs; + +pub use address::{NEW_TAB_URL, display_title, is_internal, url_from_input}; +pub use debug_log::{DebugEntry, DebugLog}; +pub use history::{Entry, EntrySnapshot, History}; +pub use outcome::PageOutcome; +pub use tabs::{BrowseSnapshot, Load, TabId, TabSnapshot, Tabs}; +pub use url::Url; diff --git a/crates/browse-core/src/outcome.rs b/crates/browse-core/src/outcome.rs new file mode 100644 index 00000000..a929f882 --- /dev/null +++ b/crates/browse-core/src/outcome.rs @@ -0,0 +1,86 @@ +//! How a load went. + +use serde::{Deserialize, Serialize}; + +/// How a load went, in the five states a tab indicator can show. +/// +/// Ordered by severity so a page with more than one thing wrong reports the +/// worst of them. Deliberately not a boolean pair: "loaded" and "loaded with +/// something missing" are the states a person actually wants to tell apart at +/// a glance, and a browser that only says loading/not-loading makes a page +/// whose scripts all 404'd look exactly like one that worked. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PageOutcome { + /// Nothing has been asked for yet. A new tab, before an address. + #[default] + Empty, + /// Everything the page asked for arrived. + Loaded, + /// The document arrived; some subresource did not. + Partial, + /// The document arrived and something in it failed to run. + Degraded, + /// The document itself did not arrive. + Error, +} + +impl PageOutcome { + /// The name the frontend switches on. Stable: it is part of the wire + /// contract with the chrome, not a debug rendering. + pub fn name(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::Loaded => "loaded", + Self::Partial => "partial", + Self::Degraded => "degraded", + Self::Error => "error", + } + } + + /// Fold another observation into this one, keeping the worse. + /// + /// A load reports many things as it proceeds — a missing stylesheet, then + /// a script that threw — and the tab shows one state. Taking the maximum + /// means the order the observations arrive in cannot change the answer, + /// which is exactly the property a running load needs. + pub fn worse_of(self, other: Self) -> Self { + self.max(other) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn severity_folds_regardless_of_arrival_order() { + let a = PageOutcome::Loaded + .worse_of(PageOutcome::Partial) + .worse_of(PageOutcome::Degraded); + let b = PageOutcome::Degraded + .worse_of(PageOutcome::Loaded) + .worse_of(PageOutcome::Partial); + assert_eq!(a, b); + assert_eq!(a, PageOutcome::Degraded); + } + + #[test] + fn an_error_beats_everything_below_it() { + assert_eq!( + PageOutcome::Error.worse_of(PageOutcome::Loaded), + PageOutcome::Error + ); + } + + /// The frontend switches on these. Renaming one silently breaks a status + /// dot rather than failing a build, so they are pinned here. + #[test] + fn the_wire_names_are_fixed() { + assert_eq!(PageOutcome::Empty.name(), "empty"); + assert_eq!(PageOutcome::Loaded.name(), "loaded"); + assert_eq!(PageOutcome::Partial.name(), "partial"); + assert_eq!(PageOutcome::Degraded.name(), "degraded"); + assert_eq!(PageOutcome::Error.name(), "error"); + } +} diff --git a/crates/browse-core/src/tabs.rs b/crates/browse-core/src/tabs.rs new file mode 100644 index 00000000..52d2f4d6 --- /dev/null +++ b/crates/browse-core/src/tabs.rs @@ -0,0 +1,497 @@ +//! The tab set, and what a navigation asks the host to do. + +use serde::Serialize; +use url::Url; + +use crate::address::{NEW_TAB_URL, url_from_input}; +use crate::history::{EntrySnapshot, History}; +use crate::outcome::PageOutcome; + +pub type TabId = u64; + +/// What the host must fetch and mount, and for whom. +/// +/// The policy never fetches. It answers "this tab, at this generation, now +/// wants this URL", and the host — which owns the network stack and the +/// engine — does the work and reports back through [`Tabs::finish_load`]. +/// That split is the reason this crate has no engine dependency, and it is +/// what lets the same policy sit under two different applications. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Load { + pub tab: TabId, + /// Bumped on every navigation in the tab. + /// + /// A fetch that finishes after the user has navigated somewhere else must + /// not attach; without this the slower of two loads wins and a page you + /// left comes back over the one you asked for. The host carries the + /// generation through its async work and hands it back, and + /// [`Tabs::finish_load`] drops anything stale. + pub generation: u64, + pub url: Url, +} + +#[derive(Clone, Debug)] +struct Tab { + id: TabId, + history: History, + title: String, + loading: bool, + /// How the last completed load went. `loading` wins over it while a load + /// is in flight, so the previous page's outcome never shows against the + /// new one's address. + outcome: PageOutcome, + generation: u64, +} + +impl Tab { + fn new(id: TabId, url: Url) -> Self { + Self { + id, + history: History::new(url), + title: String::new(), + loading: false, + outcome: PageOutcome::Empty, + generation: 0, + } + } + + fn snapshot(&self) -> TabSnapshot { + TabSnapshot { + id: self.id, + title: if self.title.trim().is_empty() { + self.history.current().display_title() + } else { + self.title.clone() + }, + url: self.history.current().url.to_string(), + status: if self.loading { + "loading" + } else { + self.outcome.name() + }, + can_go_back: self.history.can_go_back(), + can_go_forward: self.history.can_go_forward(), + } + } +} + +/// One tab as the chrome sees it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TabSnapshot { + pub id: TabId, + pub title: String, + pub url: String, + /// `loading`, or the name of the last [`PageOutcome`]. + pub status: &'static str, + pub can_go_back: bool, + pub can_go_forward: bool, +} + +/// The whole browsing surface as the chrome sees it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowseSnapshot { + pub tabs: Vec, + pub active: TabId, + /// The active tab's history, for the history panel. Only the active one: + /// the panel shows one tab at a time and serialising every tab's stack on + /// every state emission is a cost paid for nothing. + pub history: Vec, +} + +/// Tabs, history and navigation, with no engine anywhere in sight. +#[derive(Clone, Debug)] +pub struct Tabs { + tabs: Vec, + active: TabId, + next_id: TabId, +} + +impl Default for Tabs { + fn default() -> Self { + Self::new() + } +} + +impl Tabs { + /// A browser with one empty tab. + /// + /// Never zero tabs. A surface with no tab has no address bar target and no + /// place to put a page, and every caller would have to handle that state; + /// closing the last tab blanks it instead. See [`Tabs::close`]. + pub fn new() -> Self { + let url = Url::parse(NEW_TAB_URL).expect("the new-tab URL is a constant and parses"); + Self { + tabs: vec![Tab::new(0, url)], + active: 0, + next_id: 1, + } + } + + pub fn active(&self) -> TabId { + self.active + } + + pub fn len(&self) -> usize { + self.tabs.len() + } + + pub fn is_empty(&self) -> bool { + // Never true. Kept because clippy asks for it beside `len`, and a + // caller reading it as "can this be empty" gets the honest answer. + self.tabs.is_empty() + } + + fn tab(&self, id: TabId) -> Option<&Tab> { + self.tabs.iter().find(|tab| tab.id == id) + } + + fn tab_mut(&mut self, id: TabId) -> Option<&mut Tab> { + self.tabs.iter_mut().find(|tab| tab.id == id) + } + + /// The address the given tab is showing. + pub fn url(&self, id: TabId) -> Option<&Url> { + self.tab(id).map(|tab| &tab.history.current().url) + } + + /// Open a tab, optionally at an address, and make it active. + /// + /// Returns the load to perform, which is `None` for a blank tab: an empty + /// tab that fetches something has already failed at the one thing it is + /// for. + pub fn open(&mut self, input: Option<&str>) -> (TabId, Option) { + let url = input + .and_then(url_from_input) + .unwrap_or_else(|| Url::parse(NEW_TAB_URL).expect("constant")); + let id = self.next_id; + self.next_id += 1; + let internal = crate::address::is_internal(&url); + self.tabs.push(Tab::new(id, url)); + self.active = id; + if internal { + return (id, None); + } + (id, self.begin(id)) + } + + /// Close a tab. + /// + /// Closing the last one resets it to blank rather than leaving the surface + /// with nothing in it. Returns whether anything changed. + pub fn close(&mut self, id: TabId) -> bool { + let Some(index) = self.tabs.iter().position(|tab| tab.id == id) else { + return false; + }; + + if self.tabs.len() == 1 { + let url = Url::parse(NEW_TAB_URL).expect("constant"); + let generation = self.tabs[0].generation + 1; + self.tabs[0] = Tab::new(id, url); + // Carried across the reset. A load already in flight for the old + // page must not attach to the blank tab that replaced it, and a + // generation restarting at zero would let exactly that through. + self.tabs[0].generation = generation; + return true; + } + + self.tabs.remove(index); + if self.active == id { + // The neighbour to the right, or the new last tab. Falling back to + // the first tab instead sends focus across the strip for a close + // in the middle, which is where every browser that does it feels + // broken. + let next = index.min(self.tabs.len() - 1); + self.active = self.tabs[next].id; + } + true + } + + /// Make a tab active. Returns whether the tab exists. + pub fn select(&mut self, id: TabId) -> bool { + if self.tab(id).is_none() { + return false; + } + self.active = id; + true + } + + /// Navigate a tab to whatever the user typed. + /// + /// `None` means the input was not an address and nothing should happen — + /// see [`crate::address::url_from_input`], which deliberately has no + /// search fallback. + pub fn navigate(&mut self, id: TabId, input: &str) -> Option { + let url = url_from_input(input)?; + let tab = self.tab_mut(id)?; + tab.history.visit(url); + tab.title = String::new(); + self.begin(id) + } + + /// Re-fetch the address the tab is already showing. + pub fn reload(&mut self, id: TabId) -> Option { + let tab = self.tab(id)?; + if crate::address::is_internal(&tab.history.current().url) { + return None; + } + self.begin(id) + } + + pub fn back(&mut self, id: TabId) -> Option { + let tab = self.tab_mut(id)?; + tab.history.back()?; + tab.title = String::new(); + self.begin(id) + } + + pub fn forward(&mut self, id: TabId) -> Option { + let tab = self.tab_mut(id)?; + tab.history.forward()?; + tab.title = String::new(); + self.begin(id) + } + + /// Mark a tab as loading and produce the instruction for the host. + fn begin(&mut self, id: TabId) -> Option { + let tab = self.tab_mut(id)?; + tab.generation += 1; + tab.loading = true; + Some(Load { + tab: id, + generation: tab.generation, + url: tab.history.current().url.clone(), + }) + } + + /// Whether a load that has come back is still the one the tab wants. + pub fn accepts(&self, id: TabId, generation: u64) -> bool { + self.tab(id).is_some_and(|tab| tab.generation == generation) + } + + /// Record a finished load. Returns whether it was accepted. + /// + /// A stale generation is dropped rather than applied, which is the only + /// thing standing between a slow page you navigated away from and it + /// reappearing over the page you asked for. + pub fn finish_load( + &mut self, + id: TabId, + generation: u64, + title: &str, + outcome: PageOutcome, + ) -> bool { + if !self.accepts(id, generation) { + return false; + } + let Some(tab) = self.tab_mut(id) else { + return false; + }; + tab.loading = false; + tab.outcome = outcome; + tab.title = title.trim().to_string(); + tab.history.current_mut().title = tab.title.clone(); + true + } + + /// A load that redirected. The tab's current entry moves to where it + /// actually arrived, so Back returns to the page that linked here rather + /// than to the address that redirected away from itself. + pub fn record_redirect(&mut self, id: TabId, generation: u64, url: Url) -> bool { + if !self.accepts(id, generation) { + return false; + } + let Some(tab) = self.tab_mut(id) else { + return false; + }; + tab.history.current_mut().url = url; + true + } + + pub fn snapshot(&self) -> BrowseSnapshot { + BrowseSnapshot { + tabs: self.tabs.iter().map(Tab::snapshot).collect(), + active: self.active, + history: self + .tab(self.active) + .map(|tab| tab.history.snapshot()) + .unwrap_or_default(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_new_browser_has_one_blank_tab_and_asks_for_nothing() { + let tabs = Tabs::new(); + assert_eq!(tabs.len(), 1); + let snapshot = tabs.snapshot(); + assert_eq!(snapshot.tabs[0].url, NEW_TAB_URL); + assert_eq!(snapshot.tabs[0].status, "empty"); + } + + #[test] + fn opening_a_blank_tab_issues_no_load() { + let mut tabs = Tabs::new(); + let (id, load) = tabs.open(None); + assert!(load.is_none()); + assert_eq!(tabs.active(), id); + } + + #[test] + fn opening_at_an_address_issues_a_load_for_that_tab() { + let mut tabs = Tabs::new(); + let (id, load) = tabs.open(Some("example.com")); + let load = load.expect("an address should produce a load"); + assert_eq!(load.tab, id); + assert_eq!(load.url.as_str(), "https://example.com/"); + } + + #[test] + fn prose_in_the_address_bar_does_nothing() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + assert!( + tabs.navigate(active, "how tall is the eiffel tower") + .is_none() + ); + assert_eq!(tabs.snapshot().tabs[0].url, NEW_TAB_URL); + } + + /// The generation check, which is the whole reason it exists: a load that + /// comes back after the user has gone somewhere else must not attach. + #[test] + fn a_stale_load_is_dropped() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + let first = tabs.navigate(active, "a.example").unwrap(); + let second = tabs.navigate(active, "b.example").unwrap(); + + assert!(!tabs.finish_load(active, first.generation, "A", PageOutcome::Loaded)); + assert!(tabs.finish_load(active, second.generation, "B", PageOutcome::Loaded)); + assert_eq!(tabs.snapshot().tabs[0].title, "B"); + } + + #[test] + fn a_finished_load_clears_loading_and_shows_its_outcome() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + let load = tabs.navigate(active, "a.example").unwrap(); + assert_eq!(tabs.snapshot().tabs[0].status, "loading"); + tabs.finish_load(active, load.generation, "A", PageOutcome::Partial); + assert_eq!(tabs.snapshot().tabs[0].status, "partial"); + } + + /// While a load is in flight the tab shows "loading", not the outcome of + /// the page it is leaving. The old status against the new address is the + /// specific thing this prevents. + #[test] + fn the_previous_outcome_does_not_show_against_a_new_address() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + let first = tabs.navigate(active, "a.example").unwrap(); + tabs.finish_load(active, first.generation, "A", PageOutcome::Error); + tabs.navigate(active, "b.example").unwrap(); + let snapshot = tabs.snapshot(); + assert_eq!(snapshot.tabs[0].status, "loading"); + assert_eq!(snapshot.tabs[0].url, "https://b.example/"); + } + + #[test] + fn back_and_forward_reissue_loads_for_the_addresses_they_land_on() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + tabs.navigate(active, "a.example"); + tabs.navigate(active, "b.example"); + + let back = tabs.back(active).expect("back should load"); + assert_eq!(back.url.as_str(), "https://a.example/"); + let forward = tabs.forward(active).expect("forward should load"); + assert_eq!(forward.url.as_str(), "https://b.example/"); + assert!(tabs.forward(active).is_none()); + } + + #[test] + fn closing_the_last_tab_blanks_it_instead_of_emptying_the_surface() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + tabs.navigate(active, "a.example"); + assert!(tabs.close(active)); + assert_eq!(tabs.len(), 1); + assert_eq!(tabs.snapshot().tabs[0].url, NEW_TAB_URL); + } + + /// A load in flight when the last tab was closed must not attach to the + /// blank tab that replaced it. + #[test] + fn closing_the_last_tab_invalidates_its_load() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + let load = tabs.navigate(active, "a.example").unwrap(); + tabs.close(active); + assert!(!tabs.finish_load(active, load.generation, "A", PageOutcome::Loaded)); + } + + #[test] + fn closing_the_active_tab_activates_its_right_neighbour() { + let mut tabs = Tabs::new(); + let first = tabs.active(); + let (second, _) = tabs.open(Some("b.example")); + let (third, _) = tabs.open(Some("c.example")); + tabs.select(second); + + tabs.close(second); + assert_eq!(tabs.active(), third); + tabs.close(third); + assert_eq!(tabs.active(), first); + } + + #[test] + fn a_redirect_moves_the_entry_so_back_skips_the_redirector() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + tabs.navigate(active, "a.example"); + let load = tabs.navigate(active, "b.example").unwrap(); + assert!(tabs.record_redirect( + active, + load.generation, + Url::parse("https://c.example/").unwrap() + )); + tabs.finish_load(active, load.generation, "C", PageOutcome::Loaded); + + assert_eq!(tabs.snapshot().tabs[0].url, "https://c.example/"); + let back = tabs.back(active).unwrap(); + assert_eq!(back.url.as_str(), "https://a.example/"); + } + + #[test] + fn an_untitled_page_shows_its_address_in_the_strip() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + let load = tabs.navigate(active, "a.example").unwrap(); + tabs.finish_load(active, load.generation, " ", PageOutcome::Loaded); + assert_eq!(tabs.snapshot().tabs[0].title, "https://a.example/"); + } + + #[test] + fn reloading_a_blank_tab_asks_for_nothing() { + let mut tabs = Tabs::new(); + let active = tabs.active(); + assert!(tabs.reload(active).is_none()); + } + + #[test] + fn operations_on_an_unknown_tab_are_refused_rather_than_panicking() { + let mut tabs = Tabs::new(); + assert!(tabs.navigate(999, "a.example").is_none()); + assert!(tabs.back(999).is_none()); + assert!(tabs.forward(999).is_none()); + assert!(tabs.reload(999).is_none()); + assert!(!tabs.select(999)); + assert!(!tabs.close(999)); + } +} From 3f76e427c15b495b404ce8913b005dd0f758a693 Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 31 Aug 2026 20:38:25 +0700 Subject: [PATCH 2/4] Wire the browsing surface into az-gui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri commands over ps-browse-core, a blitz-net fetch per navigation, and a poll hook on the chrome document that attaches the fetched page to its mount. The policy decides and the host fetches: a load carries the generation it was issued at, so a page you navigated away from cannot arrive over the one you asked for. The mount is looked up on the UI thread, and a bundle whose mount has not been rendered yet is held rather than dropped — a page that loads and renders nowhere is indistinguishable from one that failed. --- apps/gui/Cargo.toml | 14 ++ apps/gui/src/browse.rs | 551 +++++++++++++++++++++++++++++++++++++++++ apps/gui/src/main.rs | 39 ++- 3 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 apps/gui/src/browse.rs diff --git a/apps/gui/Cargo.toml b/apps/gui/Cargo.toml index 53dc7c24..b71e242e 100644 --- a/apps/gui/Cargo.toml +++ b/apps/gui/Cargo.toml @@ -24,7 +24,12 @@ experimental = ["dep:agent-experimental"] webview-runtime = ["tauri/wry"] blitz-runtime = [ "dep:blitz-dom", + # The browsing surface fetches page documents itself; `blitz-net` is the + # same provider the engine hands a document for its subresources, so a page + # and everything in it come through one client and one cache. + "dep:blitz-net", "dep:blitz-script", + "dep:blitz-traits", "dep:brotli", "dep:tauri-runtime-blitz", "dep:url", @@ -71,6 +76,10 @@ agent-experimental = { version = "^0.1.3", default-features = false, optional = promptsyntax = "0.2.0" shlex = "1.3" az-core.workspace = true +# Browser policy, shared with chuzz. Not gated on a renderer: tabs, history and +# address resolution are the same in a build that cannot draw a page, which is +# what lets such a build say so instead of appearing to work. +ps-browse-core.workspace = true # Shared with the migration and headless tools so one schema cannot resolve # against a different WorkTable implementation. worktable.workspace = true @@ -119,6 +128,11 @@ tauri-runtime-blitz = { version = "^0.3", optional = true, features = ["macos-pr # actually resolves. blitz-dom = { package = "ps-blitz-dom", version = "^0.3", features = ["system-fonts", "parallel-construct"], optional = true } blitz-script = { package = "ps-blitz-script", version = "^0.3", features = ["system-fonts"], optional = true } +# Same `^0.3` line as blitz-dom and blitz-script, and it has to be: two engine +# versions in one graph put two `NetProvider` traits in it, and the document +# config stops accepting the provider with an error that names neither. +blitz-net = { package = "ps-blitz-net", version = "^0.3", optional = true } +blitz-traits = { package = "ps-blitz-traits", version = "^0.3", optional = true } brotli = { version = "8.0.4", default-features = false, features = ["std"], optional = true } url = { version = "2.5.8", optional = true } tauri-plugin-updater = "2" diff --git a/apps/gui/src/browse.rs b/apps/gui/src/browse.rs new file mode 100644 index 00000000..e99d22fa --- /dev/null +++ b/apps/gui/src/browse.rs @@ -0,0 +1,551 @@ +//! The browsing surface: pages rendered inside AgencyZero. +//! +//! # Why there is anything to do here at all +//! +//! AgencyZero already renders on Blitz. The engine that draws this window is +//! the same one a browser would use to draw a page, so "embed a browser" is +//! not an engine integration — the engine is present, and `apps/blitz-preview` +//! already proves it can show a document. What was missing is *policy*: which +//! tab is showing what, what Back means, whether the string someone typed is +//! an address, and what to do when half a page arrives. +//! +//! That policy lives in [`ps_browse_core`], deliberately outside this file and +//! outside this application, because chuzz needs the same answers. This module +//! is the half that cannot be shared: the network provider, the document, and +//! the mount inside the chrome that a page is attached to. +//! +//! # The shape +//! +//! ```text +//! chrome (Solid) ──command──▶ ps_browse_core::Tabs ──Load──▶ fetch task +//! ▲ ▲ │ +//! └────── browse:state ──────────┴────── completed queue ◀──────┘ +//! │ +//! poll hook on the chrome +//! document attaches the page +//! ``` +//! +//! The policy never fetches and the fetch never decides. A load that comes +//! back carries the generation it was issued at, and [`ps_browse_core::Tabs`] +//! drops it if the tab has moved on — which is the only thing standing between +//! a slow page you navigated away from and it reappearing over the one you +//! asked for. +//! +//! # Why the attach happens in a poll hook +//! +//! A page can only be mounted from the UI thread, into the chrome document, +//! and only once the chrome has actually rendered the `` element +//! that receives it. A fetch finishing has no access to any of that. So a +//! finished page joins a queue, the chrome document's poll hook drains it, and +//! a bundle whose mount is not in the tree yet is put back rather than +//! dropped: the alternative is a page that loaded correctly and rendered +//! nowhere, which is indistinguishable from a page that failed. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "blitz-runtime")] +use blitz_dom::Document as _; +use ps_browse_core::{BrowseSnapshot, DebugEntry, DebugLog, Load, PageOutcome, TabId, Tabs}; +use serde::Serialize; +use tauri::{Emitter, Manager, State}; + +use crate::AppHandle; + +/// Emitted whenever anything about the browsing surface changes. +/// +/// One topic for the whole surface rather than one per field. The chrome +/// re-reads a snapshot; it does not reconstruct state from a stream of deltas, +/// which is the design that makes a dropped event a permanently wrong tab +/// strip. +pub const BROWSE_STATE_EVENT: &str = "browse:state"; + +/// How a page's bytes came back, on their way to being mounted. +struct PageBundle { + tab: TabId, + generation: u64, + /// Where the response actually came from, after redirects. + resolved: ps_browse_core::Url, + html: String, + title_hint: String, + outcome: PageOutcome, +} + +struct BrowseInner { + tabs: Mutex, + log: Mutex, + /// Pages fetched but not yet mounted. Drained by the poll hook on the UI + /// thread; see the module docs for why the attach cannot happen inline. + completed: Mutex>, + app: Mutex>, + #[cfg(feature = "blitz-runtime")] + net: Arc, +} + +/// The browsing surface, shared between the Tauri commands and the UI thread. +#[derive(Clone)] +pub struct Browse(Arc); + +impl Browse { + pub fn new() -> Self { + Self(Arc::new(BrowseInner { + tabs: Mutex::new(Tabs::new()), + log: Mutex::new(DebugLog::default()), + completed: Mutex::new(VecDeque::new()), + app: Mutex::new(None), + #[cfg(feature = "blitz-runtime")] + // No waker. A page's own subresources wake the window through the + // document's provider; this one exists to fetch documents, and its + // completion path is the queue below rather than a redraw. + net: Arc::new(blitz_net::Provider::new(None)), + })) + } + + /// Give the surface a handle to emit state on. Called once from `setup`. + pub fn attach_app(&self, app: AppHandle) { + *self.0.app.lock().unwrap() = Some(app); + } + + fn note(&self, level: &'static str, source: &'static str, message: String) { + self.0.log.lock().unwrap().push(level, source, message); + } + + fn snapshot(&self) -> BrowseSnapshot { + self.0.tabs.lock().unwrap().snapshot() + } + + /// Tell the chrome the surface changed. + fn emit(&self) { + let snapshot = self.snapshot(); + if let Some(app) = self.0.app.lock().unwrap().as_ref() { + let _ = app.emit(BROWSE_STATE_EVENT, snapshot); + } + } + + /// Perform a load the policy asked for, then tell the chrome. + /// + /// Takes the load by value because it is a one-shot instruction: acting on + /// the same `Load` twice would issue two fetches at one generation and let + /// whichever finished second overwrite the first for no reason. + fn dispatch(&self, load: Option) { + if let Some(load) = load { + self.note("info", "nav", format!("tab {}: {}", load.tab, load.url)); + self.fetch(load); + } + self.emit(); + } + + #[cfg(feature = "blitz-runtime")] + fn fetch(&self, load: Load) { + let browse = self.clone(); + let net = Arc::clone(&self.0.net); + // Tauri's runtime, not one of our own. The document a fetch produces is + // mounted on the UI thread, which is already inside this reactor, and + // a second runtime would give page loads their own thread pool and + // their own shutdown for no benefit. + tauri::async_runtime::spawn(async move { + let request = blitz_traits::net::Request::get(load.url.clone()); + let bundle = match net.fetch_response_async(request).await { + Ok(response) => { + let status = response.status; + let resolved = response.url.clone(); + let html = decode_body(&response); + let outcome = if status.is_success() { + PageOutcome::Loaded + } else { + // The bytes are kept: a 404 page is a page, and every + // browser shows the server's own version of it rather + // than replacing it with a generic one. + PageOutcome::Partial + }; + browse.note( + if status.is_success() { "info" } else { "warn" }, + "net", + format!("{} {}", status.as_u16(), resolved), + ); + PageBundle { + tab: load.tab, + generation: load.generation, + resolved, + html, + title_hint: String::new(), + outcome, + } + } + Err(error) => { + browse.note("error", "net", format!("{}: {error}", load.url)); + PageBundle { + tab: load.tab, + generation: load.generation, + resolved: load.url.clone(), + html: error_html(&error.to_string()), + title_hint: load.url.to_string(), + outcome: PageOutcome::Error, + } + } + }; + + browse.0.completed.lock().unwrap().push_back(bundle); + // The chrome is told immediately, before the page is mounted. The + // address bar and the spinner belong to the chrome and should not + // wait on a mount that happens on the next frame. + browse.emit(); + }); + } + + /// Without the Blitz runtime there is no engine to render a page in. + /// + /// A webview-only build still has the whole surface — tabs, history, the + /// address bar — and says so on the page rather than appearing to work and + /// showing nothing. Silently doing nothing here is the failure mode this + /// avoids: it looks exactly like a page that never loads. + #[cfg(not(feature = "blitz-runtime"))] + fn fetch(&self, load: Load) { + self.note( + "error", + "net", + format!( + "this build has no renderer for pages; {} not loaded", + load.url + ), + ); + self.0.tabs.lock().unwrap().finish_load( + load.tab, + load.generation, + &load.url.to_string(), + PageOutcome::Error, + ); + } +} + +impl Default for Browse { + fn default() -> Self { + Self::new() + } +} + +/// Turn a response body into text. +/// +/// Charset from the `Content-Type` header, UTF-8 otherwise, and lossy rather +/// than fallible: a page with one bad byte in it is still a page, and refusing +/// to show it is a worse answer than a replacement character in one word. +#[cfg(feature = "blitz-runtime")] +fn decode_body(response: &blitz_traits::platform::FetchResponse) -> String { + let charset = response + .headers + .get(blitz_traits::net::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + value + .split(';') + .find_map(|part| part.trim().strip_prefix("charset=")) + }) + .map(|charset| charset.trim_matches('"').to_ascii_lowercase()) + .unwrap_or_else(|| "utf-8".to_string()); + + match charset.as_str() { + "utf-8" | "utf8" | "us-ascii" | "ascii" => { + String::from_utf8_lossy(&response.body).into_owned() + } + // Everything else is treated as Latin-1, which is what a byte-for-byte + // mapping gives and is right for the western pages that still declare + // `iso-8859-1`. A full charset table is a real dependency and belongs + // in the engine, not here. + _ => response.body.iter().map(|byte| *byte as char).collect(), + } +} + +/// The document shown when a page could not be fetched. +/// +/// Explicit colours, and light ones. This document declares none of its own, +/// so it would inherit the engine's defaults — black text on a transparent +/// background — over a viewport the shell paints with the dark theme surface. +/// The error would render, lay out, and be unreadable, which is +/// indistinguishable from not rendering at all. +fn error_html(error: &str) -> String { + let escaped = escape(error); + format!( + r#"Cannot load + +
+

This page could not be loaded

+

{escaped}

+
"# + ) +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// The chrome element a tab's page is mounted into. +/// +/// Matched by id first and by `data-tab-id` second, so the chrome can render +/// the mount either way without this having to know which. Nothing else in the +/// document is a ``; the element exists for exactly this. +#[cfg(feature = "blitz-runtime")] +fn mount_node(document: &blitz_dom::BaseDocument, tab: TabId) -> Option { + if let Some(node) = document.get_element_by_id(&format!("az-browse-page-{tab}")) { + return Some(node); + } + let tab = tab.to_string(); + document + .query_selector_all("web-view") + .ok()? + .into_iter() + .find(|node| { + document + .get_node(*node) + .and_then(|node| node.element_data()) + .is_some_and(|element| { + element + .attrs + .iter() + .any(|attr| attr.name.local.as_ref() == "data-tab-id" && attr.value == tab) + }) + }) +} + +#[cfg(feature = "blitz-runtime")] +impl Browse { + /// Attach the surface to the chrome document. + /// + /// The poll hook runs on the UI thread on every frame the document is + /// polled, which is the only place a sub-document may be mounted. + pub fn install_document_lifecycle(&self, document: &mut blitz_script::ScriptDocument) { + let browse = self.clone(); + let mut pending: VecDeque = VecDeque::new(); + document.add_poll_hook(move |document, _| browse.poll_document(document, &mut pending)); + } + + /// Mount whatever has finished fetching. Returns whether anything changed. + fn poll_document( + &self, + chrome: &mut blitz_script::ScriptDocument, + pending: &mut VecDeque, + ) -> bool { + pending.extend(self.0.completed.lock().unwrap().drain(..)); + if pending.is_empty() { + return false; + } + + let mut retained = VecDeque::new(); + let mut changed = false; + + while let Some(bundle) = pending.pop_front() { + if !self + .0 + .tabs + .lock() + .unwrap() + .accepts(bundle.tab, bundle.generation) + { + // The tab moved on while this was in flight. Dropped, not + // mounted: this is the stale-load case the generation exists + // for. + continue; + } + + let Some(target) = mount_node(&chrome.inner(), bundle.tab) else { + // The chrome has not rendered the mount yet. Held rather than + // dropped — a page that loaded and rendered nowhere looks + // exactly like a page that failed. + retained.push_back(bundle); + continue; + }; + + let shell_provider = chrome.inner().shell_provider.clone(); + let config = blitz_dom::DocumentConfig { + base_url: Some(bundle.resolved.to_string()), + // The page's own provider, so its images, stylesheets and + // fonts are fetched under the ordinary subresource path rather + // than through the document fetch above. + net_provider: Some( + Arc::clone(&self.0.net) as Arc + ), + shell_provider: Some(shell_provider), + ..Default::default() + }; + let page = blitz_script::ScriptDocument::from_html(&bundle.html, config); + let title = page + .inner() + .find_title_node() + .map(|node| node.text_content()) + .unwrap_or_default(); + let title = if title.trim().is_empty() { + bundle.title_hint.clone() + } else { + title + }; + + self.note( + "info", + "page", + format!( + "attached {} to tab {} as {}", + bundle.resolved, + bundle.tab, + bundle.outcome.name() + ), + ); + + { + let mut tabs = self.0.tabs.lock().unwrap(); + tabs.record_redirect(bundle.tab, bundle.generation, bundle.resolved.clone()); + tabs.finish_load(bundle.tab, bundle.generation, &title, bundle.outcome); + } + + chrome.inner_mut().set_sub_document(target, Box::new(page)); + changed = true; + } + + *pending = retained; + if changed { + self.emit(); + } + changed + } +} + +/// Everything the chrome needs to draw the surface in one read. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowseView { + #[serde(flatten)] + pub snapshot: BrowseSnapshot, + /// Whether this build can actually render a page. A surface that cannot + /// says so up front rather than after a navigation that goes nowhere. + pub can_render: bool, +} + +#[tauri::command] +pub fn browse_state(state: State<'_, Browse>) -> BrowseView { + BrowseView { + snapshot: state.snapshot(), + can_render: cfg!(feature = "blitz-runtime"), + } +} + +#[tauri::command] +pub fn browse_navigate(tab: TabId, input: String, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().navigate(tab, &input); + if load.is_none() { + state.note("warn", "nav", format!("not an address: {input}")); + } + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_open_tab(input: Option, state: State<'_, Browse>) -> BrowseView { + let (_, load) = state.0.tabs.lock().unwrap().open(input.as_deref()); + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_close_tab(tab: TabId, state: State<'_, Browse>) -> BrowseView { + state.0.tabs.lock().unwrap().close(tab); + state.emit(); + browse_state(state) +} + +#[tauri::command] +pub fn browse_select_tab(tab: TabId, state: State<'_, Browse>) -> BrowseView { + state.0.tabs.lock().unwrap().select(tab); + state.emit(); + browse_state(state) +} + +#[tauri::command] +pub fn browse_back(tab: TabId, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().back(tab); + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_forward(tab: TabId, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().forward(tab); + state.dispatch(load); + browse_state(state) +} + +#[tauri::command] +pub fn browse_reload(tab: TabId, state: State<'_, Browse>) -> BrowseView { + let load = state.0.tabs.lock().unwrap().reload(tab); + state.dispatch(load); + browse_state(state) +} + +/// The debugging stream, from where the caller left off. +#[tauri::command] +pub fn browse_debug_log(since: Option, state: State<'_, Browse>) -> Vec { + state.0.log.lock().unwrap().since(since) +} + +/// Wire the surface into the app. One call, so a build that forgets it fails +/// at the missing state rather than at a command that silently returns nothing. +pub fn manage(app: &AppHandle, browse: Browse) { + browse.attach_app(app.clone()); + app.manage(browse); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Not a rendering test. The policy is tested in `ps-browse-core`; what is + /// worth pinning here is that the commands route to it and that a surface + /// with no window still answers. + #[test] + fn a_fresh_surface_has_one_blank_tab() { + let browse = Browse::new(); + let snapshot = browse.snapshot(); + assert_eq!(snapshot.tabs.len(), 1); + assert_eq!(snapshot.tabs[0].url, ps_browse_core::NEW_TAB_URL); + } + + /// Emitting with no app handle attached must not panic. `setup` runs after + /// the state is managed, so there is a real window in which commands can + /// arrive before a handle exists. + #[test] + fn emitting_before_the_window_exists_is_a_no_op() { + let browse = Browse::new(); + browse.emit(); + } + + #[test] + fn the_error_page_escapes_what_the_server_said() { + let html = error_html(""); + assert!(!html.contains("