diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb34201..2463f67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,10 @@ jobs: npm test --prefix Ember.Browser browser-package: ember-browser # The offline application shell of the default site. - browser-script: node Ember.Browser/write-offline-manifest.mjs . --out "$SITE" + browser-script: node Ember.Browser/write-offline-manifest.mjs . --package-font --out "$SITE" + # The packaged interactions reuse this site's artifact and the runner's Chrome. + browser-tests-setup: '' + browser-tests: node Ember.Browser/tests/browser-smoke.mjs "$SITE" # The Android and iOS hosts, packaged as in docs/ember.md (artifacts android-apk and ios-app). android: >- --package ember-android --label Ember --application-id org.ferroui.ember diff --git a/Cargo.lock b/Cargo.lock index 9cb416c..ef0b6da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,10 +619,12 @@ dependencies = [ "ferroui-markup", "ferroui-markup-xaml", "ferroui-themes-winui", + "futures-util", "sample-build", "sample-support", "sample-testing", "serde_json", + "url", ] [[package]] @@ -646,7 +648,10 @@ dependencies = [ "ferroui-browser", "ferroui-controls", "ferroui-fonts-inter", + "ferroui-skia", + "js-sys", "serde_json", + "url", "wasm-bindgen", ] @@ -821,9 +826,11 @@ dependencies = [ "ferroui-harfbuzz", "ferroui-opengl", "ferroui-skia", + "ferroui-system", "ferroui-vulkan", "jni-sys", "libc", + "url", ] [[package]] @@ -853,6 +860,7 @@ dependencies = [ "ferroui-opengl", "ferroui-skia", "ferroui-vello", + "url", "wasm-bindgen", ] @@ -887,6 +895,7 @@ dependencies = [ "ferroui-remote-protocol", "ferroui-system", "rustc-hash 2.1.3", + "url", ] [[package]] @@ -910,6 +919,7 @@ dependencies = [ "ferroui-build", "ferroui-controls", "ferroui-markup-xaml", + "ferroui-system", "sysinfo", ] @@ -919,6 +929,7 @@ version = "0.5.0-preview.1" dependencies = [ "ferroui-base", "ferroui-controls", + "url", ] [[package]] @@ -928,7 +939,9 @@ dependencies = [ "bitflags", "ferroui-base", "ferroui-controls", + "ferroui-system", "futures-util", + "url", "uuid", "zbus", ] @@ -954,14 +967,17 @@ dependencies = [ "ferroui-metal", "ferroui-opengl", "ferroui-skia", + "ferroui-system", "libc", "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-io-surface", "objc2-metal", "objc2-open-gl-es", "objc2-quartz-core", "objc2-ui-kit", + "url", ] [[package]] @@ -979,6 +995,7 @@ dependencies = [ "ferroui-build-scan", "ferroui-controls", "ferroui-markup", + "url", ] [[package]] @@ -989,6 +1006,8 @@ dependencies = [ "ferroui-build-scan", "ferroui-markup", "ferroui-markup-xaml", + "roxmltree 0.20.0", + "url", "xamlx", ] @@ -1013,11 +1032,11 @@ dependencies = [ "cc", "ferroui-base", "ferroui-controls", - "ferroui-dialogs", "ferroui-metal", "ferroui-microcom", "ferroui-opengl", "microcom-codegen", + "url", ] [[package]] @@ -1035,6 +1054,7 @@ dependencies = [ name = "ferroui-remote-protocol" version = "0.5.0-preview.1" dependencies = [ + "ferroui-system", "uuid", ] @@ -1115,6 +1135,7 @@ dependencies = [ "ferroui-vulkan", "microcom-codegen", "mozangle", + "url", "windows-sys 0.61.2", ] @@ -1128,8 +1149,10 @@ dependencies = [ "ferroui-dialogs", "ferroui-freedesktop", "ferroui-opengl", + "ferroui-system", "ferroui-vulkan", "libc", + "url", "x11-dl", ] @@ -2129,6 +2152,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.3.2" @@ -2138,6 +2172,7 @@ dependencies = [ "bitflags", "objc2", "objc2-foundation", + "objc2-io-surface", ] [[package]] @@ -2861,8 +2896,6 @@ checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skia-bindings" version = "0.153.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb325787c91b3f6c34a7c6f2b8a2297b20292d733ca53a08c233e5028aec49af" dependencies = [ "bindgen", "cc", diff --git a/Cargo.toml b/Cargo.toml index 71aa1f1..aca25ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,34 @@ repository = "https://github.com/FerroUI/Ember" [workspace.dependencies] # >>> FerroUI workspace dependencies (written by FerroUI/scripts/apps/sync-workspace.py from FerroUI/Cargo.toml; do not edit) +ferroui-media-camera-windows = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera.Windows" } +ferroui-media-video-windows = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video.Windows" } +ferroui-media-windows = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Windows" } +ferroui-media-camera-linux = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera.Linux" } +ferroui-media-video-linux = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video.Linux" } +ferroui-media-linux = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Linux" } +ferroui-media-video = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video" } +ferroui-media-camera = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera" } +ferroui-media-video-controls = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video.Controls" } +ferroui-media-video-apple = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video.Apple" } +ferroui-media-camera-apple = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera.Apple" } +ferroui-media-controls = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Controls" } +ferroui-media-apple = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Apple" } +ferroui-media-browser = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Browser" } +ferroui-media-video-browser = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video.Browser" } +ferroui-media-camera-browser = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera.Browser" } +ferroui-media = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media" } +ferroui-media3d = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media3D" } +ferroui-render3d = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Rendering3D" } +ferroui-media-metal = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Metal" } +ferroui-media-opengl = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.OpenGL" } +ferroui-media-android = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Android" } +ferroui-media-video-android = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Video.Android" } +ferroui-media-camera-android = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera.Android" } +ferroui-media-camera-controls = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Camera.Controls" } +ferroui-media-qr = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Qr", default-features = false } +ferroui-media-qr-android = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Qr.Android" } +ferroui-media-qr-apple = { version = "=0.5.0-preview.1", path = "FerroUI/src/FerroUI.Media.Qr.Apple" } # The Dock port (docs/porting/dock): its crates and the dependencies only it has. ferrodock-controls-recycling-model = { version = "=0.5.0-preview.1", path = "FerroUI/src/Dock/FerroDock.Controls.Recycling.Model" } ferrodock-controls-recycling-model-ferro = { version = "=0.5.0-preview.1", path = "FerroUI/src/Dock/FerroDock.Controls.Recycling.Model.Ferro" } @@ -201,7 +229,7 @@ wayland-protocols-misc = { version = "=0.3.12", features = ["client"] } jni-sys = { version = "=0.4.1" } paste = "1" cc = "1" -skia-safe = { version = "0.153" } +skia-safe = { version = "=0.153.3" } # The Vello render backend (docs/porting/vello-backend.md, section 2): exact versions, because the # renderers of the Vello project change their interfaces between minor releases. `vello_cpu` without # its default features: its thread pool is added by the backend on targets with threads (the @@ -238,6 +266,9 @@ gif = { version = "=0.14.2", default-features = false, features = ["std"] } vello_gpu = { version = "=0.3.0", default-features = false, features = ["std", "text"] } vello = { version = "=0.11.0", default-features = false, features = ["wgpu"] } wgpu = { version = "=30.0.1", default-features = false, features = ["std", "wgsl", "metal"] } +# The shader translator of `wgpu`, at its version: `ferroui-build` translates the WGSL of the runtime +# shader effects to GLSL ES 3.0 for WebGL2 at build time (its features `wgsl-in` and `glsl-out`). +naga = { version = "=30.0.1", default-features = false } # The version of the `wasm-bindgen` command-line tool on PATH has to be this exact version. wasm-bindgen = "=0.2.129" # The bindings of the WebGL2 renderer of `vello_gpu`, at the versions that belong to that release @@ -285,11 +316,27 @@ lto = false # then shows addresses (docs/binary-size.md, section 4.3). On Windows the executable never holds the # symbols (they are in the PDB beside it), so the setting changes nothing there. Build with the # release profile for a backtrace with names. +# +# Without unwinding (`panic = "abort"`, docs/binary-size.md, section 4.7, #567): a panic ends the +# process, after the panic hook has run. The unwind tables and the landing pads are left out: hello +# world's executable is 21 % smaller. No production path of the framework carries an expected +# error as a panic (docs/porting/panics-as-exceptions.md); what a catch of a panic still isolates +# is a bug, which then ends the process instead of being logged. The development tools that isolate +# the code of the application they inspect (the Designer, the previewer, hot reload, DevTools) are +# built with `dev`, `release` or `dist-unwind`. Cargo ignores the setting for tests, benches, build +# scripts and procedural macros, which always unwind. [profile.dist] inherits = "release" lto = "fat" codegen-units = 1 strip = "symbols" +panic = "abort" + +# The shipped build with unwinding: `dist` with `panic = "unwind"`, for an application that catches +# panics to carry on (a plug-in host, a tool that isolates the code it runs). +[profile.dist-unwind] +inherits = "dist" +panic = "unwind" # The WebAssembly module of the browser (scripts/build-browser.sh): optimised for size, because the # module is downloaded and compiled by every visitor (docs/porting/browser-platform.md, section 18). @@ -362,4 +409,8 @@ opt-level = 3 [profile.browser.package.read-fonts] opt-level = 3 + +[patch.crates-io] +# Pinned source hotfix for Graphite Metal's retained Objective-C objects. +skia-bindings = { path = "FerroUI/external/rust-skia-bindings" } # <<< FerroUI profiles diff --git a/Ember.Browser/Cargo.toml b/Ember.Browser/Cargo.toml index 7c5e9ba..70b18d8 100644 --- a/Ember.Browser/Cargo.toml +++ b/Ember.Browser/Cargo.toml @@ -27,4 +27,9 @@ ferroui-controls = { workspace = true } ferroui-browser = { workspace = true } ferroui-fonts-inter = { workspace = true } wasm-bindgen = { workspace = true } +js-sys = { workspace = true } serde_json = "1" +url = { workspace = true } + +[target.'cfg(not(target_os = "emscripten"))'.dev-dependencies] +ferroui-skia = { workspace = true } diff --git a/Ember.Browser/README.md b/Ember.Browser/README.md index 90bfb93..9435f3e 100644 --- a/Ember.Browser/README.md +++ b/Ember.Browser/README.md @@ -28,15 +28,21 @@ The wrapper (`scripts/build-browser.sh` of this repository) accepts the options framework's Skia backend. `--features vello` adds FerroUI's alternative renderer, selected with `?Renderer=Vello`. All drawing remains behind the FerroUI rendering API. -For a development build on a machine with limited memory, optimize the Rust code -before the WebAssembly binding step. An entirely unoptimized framework module is -much larger: +For a single-threaded development build on a memory-constrained machine, use the +same pinned toolchain and run: ```sh -CARGO_PROFILE_DEV_OPT_LEVEL=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ - CARGO_BUILD_JOBS=1 scripts/build-browser.sh --debug +source /path/to/browser-tools/env.sh +EMBER_LOW_MEMORY=1 scripts/build-browser.sh --debug ``` +This mode builds Rust at optimization level 1 with one build job, then uses the +SDK's Binaryen `wasm-opt` to reduce the linked module before the pinned +`wasm-bindgen` generates JavaScript. It strips WebAssembly debug symbols and +function names, so browser stack traces lack those names. The temporary optimized +module is removed after binding. The site location and offline shell are the same +as a standard non-threaded build. + ## Database and offline behavior `platform.rs` implements the reader core's `HttpClient` and `Store` contracts. Promises diff --git a/Ember.Browser/cjk.rs b/Ember.Browser/cjk.rs new file mode 100644 index 0000000..ffb4f34 --- /dev/null +++ b/Ember.Browser/cjk.rs @@ -0,0 +1,362 @@ +//! Load a real fallback face only when the text formatter requests a missing CJK glyph. +use ember_core::BoxFuture; +use ferroui_base::media::fonts::{FontCollectionBase, FontCollectionBaseImpl}; +use ferroui_base::media::{ + FontFallback, FontFamily, FontManagerOptions, FontStretch, FontStyle, FontWeight, + GlyphTypeface, UnicodeRange, UnicodeRangeSegment, +}; +use ferroui_base::threading::Dispatcher; +use ferroui_base::{Ref, Visual}; +use ferroui_controls::presenters::TextPresenter; +use ferroui_controls::{AppBuilder, Application, TextBlock, TextBox, TopLevel}; +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; +use std::time::{Duration, Instant}; + +ferroui_base::ferro_thread_local! { + static ACTIVE: RefCell> = RefCell::new(Weak::new()); +} +const RETRY_DELAY: Duration = Duration::from_secs(15); +const FAMILY: &str = "Noto Sans CJK SC"; +const COLLECTION: &str = "fonts:EmberCjk"; +type LoadFont = fn() -> BoxFuture<'static, Option>>; + +fn fallback_range() -> UnicodeRange { + UnicodeRange::from_segments( + [ + (0x1100, 0x11ff), // Hangul jamo + (0x2e80, 0x2fff), // Han radicals and ideographic descriptions + (0x3000, 0x33ff), // CJK punctuation, kana, bopomofo and compatibility forms + (0x3400, 0x4dbf), + (0x4e00, 0x9fff), // Unified ideographs + (0xa960, 0xa97f), + (0xac00, 0xd7ff), // Hangul extensions and syllables + (0xf900, 0xfaff), + (0xfe10, 0xfe1f), + (0xfe30, 0xfe4f), + (0xff01, 0xffef), + (0x1aff0, 0x1b16f), + (0x20000, 0x2fa1f), + (0x30000, 0x323af), + ] + .into_iter() + .map(|(start, end)| UnicodeRangeSegment::new(start, end)) + .collect(), + ) +} + +#[derive(Clone, Copy)] +enum LoadState { + Idle, + Loading, + Ready, + RetryAfter(Instant), +} + +struct CjkCollection { + base: FontCollectionBase, + key: url::Url, + weak: Weak, + load: LoadFont, + state: Cell, + last_online_retry: Cell>, +} + +impl CjkCollection { + fn new(load: LoadFont) -> Rc { + Rc::new_cyclic(|weak| Self { + base: FontCollectionBase::new(), + key: url::Url::parse(COLLECTION).expect("a valid CJK collection key"), + weak: weak.clone(), + load, + state: Cell::new(LoadState::Idle), + last_online_retry: Cell::new(None), + }) + } + + fn retry_on_connectivity(&self) { + if matches!(self.state.get(), LoadState::RetryAfter(_)) + && self + .last_online_retry + .get() + .is_none_or(|last| last.elapsed() >= RETRY_DELAY) + { + self.last_online_retry.set(Some(Instant::now())); + self.state.set(LoadState::Idle); + self.request(); + } + } + + fn request(&self) { + match self.state.get() { + LoadState::Ready | LoadState::Loading => return, + LoadState::RetryAfter(time) if Instant::now() < time => return, + _ => {} + } + self.state.set(LoadState::Loading); + let weak = self.weak.clone(); + let load = self.load; + // Finish shaping this frame before registering the downloaded face or + // invalidating controls. The fetch future resumes on the UI dispatcher. + drop( + Dispatcher::ui_thread().invoke_async_task_local(move || async move { + let bytes = load().await; + let Some(collection) = weak.upgrade() else { + return; + }; + let font = bytes.and_then(|bytes| { + FontCollectionBase::try_add_glyph_typeface_from_stream( + &*collection, + &mut bytes.as_slice(), + ) + }); + if font.is_some_and(|f| { + f.family_name() == FAMILY || f.typographic_family_name() == FAMILY + }) { + collection.state.set(LoadState::Ready); + if let Some(top) = top_level() { + for visual in top.get_visual_descendants() { + if let Some(text) = visual.cast::() { + text.invalidate_text_layout(); + } else if let Some(editor) = visual.cast::() { + editor.invalidate_text_layout(); + } + } + } + } else { + // Further typing can retry after a transient offline/fetch error. + collection + .state + .set(LoadState::RetryAfter(Instant::now() + RETRY_DELAY)); + } + }), + ); + } +} + +impl FontCollectionBaseImpl for CjkCollection { + fn base(this: &Self) -> &FontCollectionBase { + &this.base + } + + fn key(this: &Self) -> url::Url { + this.key.clone() + } + + fn try_get_glyph_typeface( + this: &Self, + family: &str, + style: FontStyle, + weight: FontWeight, + stretch: FontStretch, + ) -> Option> { + if family != FAMILY { + return None; + } + this.request(); + if !matches!(this.state.get(), LoadState::Ready) { + return None; + } + FontCollectionBase::try_get_glyph_typeface(this, family, style, weight, stretch) + } +} + +fn font_options() -> FontManagerOptions { + FontManagerOptions { + default_family_name: Some("fonts:Inter#Inter".into()), + font_fallbacks: Some(vec![FontFallback { + font_family: FontFamily::new(&format!("{COLLECTION}#{FAMILY}")), + unicode_range: fallback_range(), + }]), + ..Default::default() + } +} + +pub(super) fn configure(builder: &AppBuilder, load: LoadFont) -> AppBuilder { + builder + .with(Rc::new(font_options())) + .configure_fonts(move |manager| { + let collection = CjkCollection::new(load); + ACTIVE.with(|active| *active.borrow_mut() = Rc::downgrade(&collection)); + manager.add_font_collection(collection); + }) +} + +pub(super) fn retry_after_online() { + if let Some(collection) = ACTIVE.with(|active| active.borrow().upgrade()) { + collection.retry_on_connectivity(); + } +} + +pub(super) fn top_level() -> Option> { + Application::current()? + .application_lifetime()? + .as_single_top_level_application_lifetime()? + .top_level() +} + +/// Read shaped output only for an explicitly requested control in the browser UI probe. +pub(super) fn probe(visual: &Visual, element: &mut serde_json::Value) { + use ferroui_base::media::text_formatting::{ShapedTextRun, TextRun}; + let layout = if let Some(editor) = visual.to_ref().cast::() { + element["caretIndex"] = editor.caret_index().into(); + element["selectionStart"] = editor.selection_start().into(); + element["selectionEnd"] = editor.selection_end().into(); + element["focused"] = editor.is_focused().into(); + editor + .get_visual_descendants() + .find_map(|v| v.cast::()) + .map(|p| p.text_layout()) + } else { + visual.to_ref().cast::().map(|b| b.text_layout()) + }; + if let Some(layout) = layout { + let mut runs = Vec::new(); + for line in layout.text_lines() { + for run in line.text_runs().iter() { + if let Some(run) = run.downcast_ref::() { + let buffer = run.shaped_buffer(); + runs.push(serde_json::json!({ + "text": String::from_utf16_lossy(run.text_span()), + "family": buffer.glyph_typeface().family_name(), + "glyphs": &*buffer.glyph_indices(), + })); + } + } + } + element["glyphRuns"] = runs.into(); + } +} + +#[cfg(test)] +mod tests { + #[cfg(not(target_os = "emscripten"))] + #[test] + fn connectivity_retries_a_failed_face_only_once_while_offline() { + use super::*; + use ferroui_base::platform::IFontManagerImpl; + use ferroui_base::FerroLocator; + + ferroui_base::ferro_thread_local! { static ATTEMPTS: Cell = const { Cell::new(0) }; } + let _dispatcher = Dispatcher::unit_test_scope(); + let scope = FerroLocator::enter_scope(); + FerroLocator::current_mutable() + .bind::() + .to_constant(Rc::new(ferroui_skia::FontManagerImpl::new())); + let cjk = CjkCollection::new(|| { + ATTEMPTS.with(|count| count.set(count.get() + 1)); + Box::pin(async { None }) + }); + cjk.retry_on_connectivity(); + Dispatcher::ui_thread().run_jobs(None); + assert_eq!(ATTEMPTS.with(Cell::get), 0); + cjk.request(); + Dispatcher::ui_thread().run_jobs(None); + assert_eq!(ATTEMPTS.with(Cell::get), 1); + cjk.request(); + cjk.retry_on_connectivity(); + cjk.retry_on_connectivity(); + Dispatcher::ui_thread().run_jobs(None); + cjk.retry_on_connectivity(); + Dispatcher::ui_thread().run_jobs(None); + assert_eq!(ATTEMPTS.with(Cell::get), 2); + scope.dispose(); + } + + #[cfg(not(target_os = "emscripten"))] + #[test] + #[ignore = "uses the complete pinned OTF from EMBER_CJK_FONT"] + fn one_deferred_load_supplies_real_han_kana_and_hangul_glyphs() { + use super::*; + use ferroui_base::media::{FontManager, Typeface}; + use ferroui_base::platform::{IAssetLoader, IFontManagerImpl, StandardAssetLoader}; + use ferroui_base::FerroLocator; + use ferroui_fonts_inter::InterFontCollection; + + ferroui_base::ferro_thread_local! { static LOADS: Cell = const { Cell::new(0) }; } + let _dispatcher = Dispatcher::unit_test_scope(); + let scope = FerroLocator::enter_scope(); + let locator = FerroLocator::current_mutable(); + locator + .bind::() + .to_constant(Rc::new(ferroui_skia::FontManagerImpl::new())); + locator + .bind::() + .to_constant(Rc::new(StandardAssetLoader::new(None))); + locator + .bind::() + .to_constant(Rc::new(font_options())); + let manager = FontManager::current(); + manager.add_font_collection(Rc::new(InterFontCollection::new())); + let cjk = CjkCollection::new(|| { + LOADS.with(|loads| loads.set(loads.get() + 1)); + Box::pin(async { + Some( + std::fs::read( + std::env::var_os("EMBER_CJK_FONT").expect("path to the pinned font"), + ) + .unwrap(), + ) + }) + }); + manager.add_font_collection(cjk.clone()); + let inter = FontFamily::new("fonts:Inter#Inter"); + let original = manager + .try_get_glyph_typeface(&Typeface::new(inter.clone())) + .unwrap(); + assert!( + original + .character_to_glyph_map() + .try_get_glyph('A' as i32) + .unwrap() + != 0 + ); + assert!(matches!(cjk.state.get(), LoadState::Idle)); + let match_face = |c: char| { + manager.try_match_character( + c as i32, + FontStyle::Normal, + FontWeight::Normal, + FontStretch::Normal, + Some(&inter), + None, + ) + }; + for c in ['界', 'あ', '한'] { + assert_eq!( + original + .character_to_glyph_map() + .try_get_glyph(c as i32) + .unwrap_or(0), + 0 + ); + match_face(c); + } + Dispatcher::ui_thread().run_jobs(None); + assert_eq!(LOADS.with(Cell::get), 1); + for c in ['界', 'あ', '한'] { + let face = manager + .try_get_glyph_typeface(&match_face(c).unwrap()) + .unwrap(); + assert_eq!(face.family_name(), FAMILY); + assert_ne!( + face.character_to_glyph_map() + .try_get_glyph(c as i32) + .unwrap_or(0), + 0 + ); + } + scope.dispose(); + } + + #[test] + fn only_cjk_ranges_are_eligible_for_download() { + let range = super::fallback_range(); + for c in ['界', 'あ', 'カ', '한', '。', '𠮷'] { + assert!(range.is_in_range(c as i32)); + } + for c in ['A', 'ż', 'ش', 'א', '🔎'] { + assert!(!range.is_in_range(c as i32)); + } + } +} diff --git a/Ember.Browser/platform.rs b/Ember.Browser/platform.rs index cf8b4bd..dcc2ed7 100644 --- a/Ember.Browser/platform.rs +++ b/Ember.Browser/platform.rs @@ -8,18 +8,43 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen(raw_module = "./ember-platform.js")] extern "C" { + #[wasm_bindgen(js_name = loadCjkFallbackFont)] + fn load_cjk_fallback_font() -> JsValue; #[wasm_bindgen(js_name = fetchJson)] fn fetch_json(url: &str) -> JsValue; #[wasm_bindgen(js_name = databaseGet)] fn database_get(key: &str) -> JsValue; + #[wasm_bindgen(js_name = databaseGetMany)] + fn database_get_many(keys: js_sys::Array) -> JsValue; + #[wasm_bindgen(js_name = databaseRemoveMany)] + fn database_remove_many(keys: js_sys::Array) -> JsValue; + #[wasm_bindgen(js_name = databaseRemovePrefix)] + fn database_remove_prefix(prefix: &str) -> JsValue; + #[wasm_bindgen(js_name = databaseCount)] + fn database_count(prefix: &str) -> JsValue; #[wasm_bindgen(js_name = databasePut)] fn database_put(key: &str, value: &str) -> JsValue; #[wasm_bindgen(js_name = databaseRemove)] fn database_remove(key: &str) -> JsValue; + #[wasm_bindgen(js_name = databaseSizes)] + fn database_sizes(prefix: &str) -> JsValue; #[wasm_bindgen(js_name = databaseScan)] fn database_scan(prefix: &str) -> JsValue; #[wasm_bindgen(js_name = setLocation)] - pub fn set_location(item: Option); + pub fn set_location(item: Option, back: bool); + #[wasm_bindgen(js_name = requestBrowserBack)] + pub fn request_browser_back() -> bool; +} + +pub(super) fn load_cjk_font() -> BoxFuture<'static, Option>> { + Box::pin(async { + let bytes = await_promise(&load_cjk_fallback_font()) + .await + .ok()? + .dyn_into::() + .ok()?; + Some(bytes.to_vec()) + }) } pub struct BrowserHttpClient; @@ -75,6 +100,80 @@ impl Store for IndexedDbStore { }) } + fn get_many<'a>( + &'a self, + keys: &'a [String], + ) -> BoxFuture<'a, Result>, ReaderError>> { + Box::pin(async move { + let value = await_promise(&database_get_many( + keys.iter().map(|k| JsValue::from_str(k)).collect(), + )) + .await + .map_err(|e| ReaderError::Storage(e.to_string()))?; + let array = value.dyn_into::().map_err(|_| { + ReaderError::Storage("The browser returned an invalid record batch".into()) + })?; + array + .iter() + .map(|value| { + if value.is_null() { + Ok(None) + } else { + value.as_string().map(Some).ok_or_else(|| { + ReaderError::Storage("The stored value is invalid".into()) + }) + } + }) + .collect() + }) + } + + fn remove_many<'a>(&'a self, keys: &'a [String]) -> BoxFuture<'a, Result<(), ReaderError>> { + Box::pin(async move { + await_promise(&database_remove_many( + keys.iter().map(|k| JsValue::from_str(k)).collect(), + )) + .await + .map(drop) + .map_err(|e| ReaderError::Storage(e.to_string())) + }) + } + + fn remove_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<(), ReaderError>> { + Box::pin(async move { + await_promise(&database_remove_prefix(prefix)) + .await + .map(drop) + .map_err(|e| ReaderError::Storage(e.to_string())) + }) + } + + fn count_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result> { + Box::pin(async move { + let count = await_promise(&database_count(prefix)) + .await + .map_err(|e| ReaderError::Storage(e.to_string()))?; + count.as_f64().map(|n| n as usize).ok_or_else(|| { + ReaderError::Storage("The browser returned an invalid record count".into()) + }) + }) + } + + fn scan_sizes<'a>( + &'a self, + prefix: &'a str, + ) -> BoxFuture<'a, Result, ReaderError>> { + Box::pin(async move { + let value = await_promise(&database_sizes(prefix)) + .await + .map_err(|e| ReaderError::Storage(e.to_string()))?; + let json = value.as_string().ok_or_else(|| { + ReaderError::Storage("The browser returned invalid record sizes".into()) + })?; + serde_json::from_str(&json).map_err(|e| ReaderError::Storage(e.to_string())) + }) + } + fn scan_prefix<'a>( &'a self, prefix: &'a str, diff --git a/Ember.Browser/prepare-cjk-font.mjs b/Ember.Browser/prepare-cjk-font.mjs new file mode 100644 index 0000000..8b7c204 --- /dev/null +++ b/Ember.Browser/prepare-cjk-font.mjs @@ -0,0 +1,45 @@ +import {execFile} from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import {promisify} from "node:util"; +import { cjkFontAsset } from "./wwwroot/ember-platform.js"; + +const run = promisify(execFile); +const upstream = "https://raw.githubusercontent.com/notofonts/noto-cjk/523d033d6cb47f4a80c58a35753646f5c3608a78/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf"; + +function matches(bytes) { + return bytes.length === cjkFontAsset.size && crypto.createHash("sha256").update(bytes).digest("hex") === cjkFontAsset.sha256; +} + +// The optional payload belongs to the published site. It is intentionally absent +// from git, the Wasm module and the service worker's installation transaction. +export async function prepareCjkFont(out) { + const target = path.join(out, cjkFontAsset.path); + await fs.mkdir(path.dirname(target), {recursive: true}); + for (const name of ["OFL.txt", "NOTICE.txt"]) { + const source = new URL("./wwwroot/optional-fonts/" + name, import.meta.url); + await fs.copyFile(source, path.join(path.dirname(target), name)); + } + try { + if (matches(await fs.readFile(target))) return; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const temporary = target + "." + crypto.randomUUID() + ".tmp"; + try { + if (process.env.EMBER_CJK_FONT) { + await fs.copyFile(process.env.EMBER_CJK_FONT, temporary); + } else { + // Use the browser toolchain's curl, including its proxy and system CA configuration. + await run("curl", ["--fail", "--silent", "--show-error", "--proto", "=https", + "--retry", "2", "--max-time", "120", "--max-filesize", String(cjkFontAsset.size), + "--output", temporary, upstream]); + } + if (!matches(await fs.readFile(temporary))) throw new Error("Noto Sans CJK has an unexpected size or SHA-256 digest"); + await fs.rename(temporary, target); + } finally { + await fs.rm(temporary, {force: true}); + } + console.log("Packaged optional Noto Sans CJK SC Regular (" + cjkFontAsset.size + " bytes)"); +} diff --git a/Ember.Browser/program.rs b/Ember.Browser/program.rs index 3fa7db2..92e042d 100644 --- a/Ember.Browser/program.rs +++ b/Ember.Browser/program.rs @@ -6,6 +6,9 @@ #[cfg(target_os = "emscripten")] mod platform; +#[cfg(any(target_os = "emscripten", test))] +mod cjk; + #[cfg(target_os = "emscripten")] use wasm_bindgen::prelude::*; @@ -13,7 +16,6 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen(js_name = runMain)] pub fn run_main(href: &str) { use ferroui_base::logging::LogEventLevel; - use ferroui_base::media::FontManagerOptions; use ferroui_browser::{ BrowserAppBuilder, BrowserPlatformOptions, BrowserRenderer, BrowserRenderingMode, }; @@ -28,8 +30,15 @@ pub fn run_main(href: &str) { )) })); ember::set_initial_location(href.to_owned()); - ember::set_location_changed(Rc::new(|item| { - platform::set_location(item.map(|id| id.to_string())) + ember::set_location_navigation(Rc::new(|item, action| { + if action == ember::LocationNavigation::RequestBack { + return platform::request_browser_back(); + } + platform::set_location( + item.map(|id| id.to_string()), + action == ember::LocationNavigation::Back, + ); + true })); let mut options = BrowserPlatformOptions::default(); if href.contains("RenderingMode=Software2D") { @@ -41,39 +50,40 @@ pub fn run_main(href: &str) { if href.contains("RenderThread=false") { options.render_thread = false; } - AppBuilder::configure::() - .with_inter_font() - .with(Rc::new(FontManagerOptions { - default_family_name: Some("fonts:Inter#Inter".to_owned()), - ..Default::default() - })) + let builder = AppBuilder::configure::().with_inter_font(); + cjk::configure(&builder, platform::load_cjk_font) .log_to_text_writer(std::io::stdout(), LogEventLevel::Warning, &[]) .start_browser_app("out", Some(options)); } #[cfg(target_os = "emscripten")] #[wasm_bindgen(js_name = emberNavigate)] -pub fn navigate(href: &str) { - ember::navigate_location(href); +pub fn navigate(href: &str, entry: u32, backward: bool) { + ember::navigate_browser_entry(href, (entry != 0).then_some(u64::from(entry)), backward); +} + +#[cfg(target_os = "emscripten")] +#[wasm_bindgen(js_name = emberSetBrowserEntry)] +pub fn set_browser_entry(entry: u32) { + ember::set_browser_entry((entry != 0).then_some(u64::from(entry))); +} + +#[cfg(target_os = "emscripten")] +#[wasm_bindgen(js_name = emberRetryCjkFont)] +pub fn retry_cjk_font() { + cjk::retry_after_online(); } /// Read-only visual-tree probe used by browser interaction tests. Coordinates /// are CSS pixels of the actual FerroUI view, including touch hit testing. #[cfg(target_os = "emscripten")] #[wasm_bindgen(js_name = emberState)] -pub fn reader_state() -> String { +pub fn reader_state(probe_name: Option) -> String { use ferroui_base::{Point, Visual}; use ferroui_controls::automation::AutomationProperties; - use ferroui_controls::{Application, Button, ComboBox, TextBlock, TextBox}; + use ferroui_controls::{Button, ComboBox, TextBlock, TextBox}; - let top_level = Application::current() - .and_then(|app| app.application_lifetime()) - .and_then(|lifetime| { - lifetime - .as_single_top_level_application_lifetime() - .and_then(|view| view.top_level()) - }); - let Some(top_level) = top_level else { + let Some(top_level) = cjk::top_level() else { return "null".to_owned(); }; let viewport = top_level.bounds(); @@ -111,12 +121,17 @@ pub fn reader_state() -> String { || hit.is_visual_ancestor_of(&visual) || visual.is_visual_ancestor_of(hit) }); - elements.push(serde_json::json!({ + let mut element = serde_json::json!({ "type": visual.get_type().name(), "name": visual.name(), "text": text, "hit": hit, "automationName": AutomationProperties::get_name(&visual), + "helpText": AutomationProperties::get_help_text(&visual), "selectedIndex": visual.cast::().map(|combo| combo.selected_index()), "x": origin.x, "y": origin.y, "width": size.width, "height": size.height, - })); + }); + if probe_name.is_some() && visual.name().as_ref() == probe_name.as_ref() { + cjk::probe(&visual, &mut element); + } + elements.push(element); } serde_json::json!({ "width": viewport.width, "height": viewport.height, "elements": elements }) .to_string() diff --git a/Ember.Browser/tests/browser-font.mjs b/Ember.Browser/tests/browser-font.mjs new file mode 100644 index 0000000..bdfc448 --- /dev/null +++ b/Ember.Browser/tests/browser-font.mjs @@ -0,0 +1,81 @@ +// Exercise font shaping and focus on the packaged reader before and after the +// first optional download, then repeat it from a worker-controlled offline page. +import path from "node:path"; +import { open, assert } from "../../FerroUI/scripts/browser/harness.mjs"; + +export async function verifyCjkFallback(site, captures) { + const title = "漢字とかな · 中文 · 한글"; + const input = "界語かな한글"; + const fixture = `(() => { + const original = fetch.bind(globalThis); + const title = ${JSON.stringify(title)}; + const requests = globalThis.fontRequests = []; + const gate = new Promise(resolve => { globalThis.allowFont = resolve; }); + globalThis.fetch = async (source, options) => { + const url = new URL(source instanceof Request ? source.url : source, location.href); + if (url.pathname.includes("/optional-fonts/") && url.pathname.endsWith(".otf")) { + requests.push(url.href); + if (!sessionStorage.offlineFontTest) { + if (requests.length === 1) return new Response(null, { status: 503 }); + await gate; + } + } + if (url.hostname === "hacker-news.firebaseio.com") { + if (sessionStorage.offlineFontTest) throw new TypeError("Offline"); + const id = Number(/item\\/(\\d+)/.exec(url.pathname)?.[1]); + return Response.json(id ? { id, type: "story", by: "reader", time: 1793577600, + title: id === 9001 ? title : "An English headline", kids: [] } : [1]); + } + return original(source, options); + }; + })();`; + const page = await open(site, { width: 1280, height: 800, initScript: fixture }); + const element = name => `JSON.parse(ember.emberState(${JSON.stringify(name)})).elements.find(e => e.name === ${JSON.stringify(name)})`; + const shaped = name => `(() => { + const runs = (${element(name)})?.glyphRuns ?? []; + const glyphs = runs.filter(run => run.family?.includes("Noto Sans CJK")).flatMap(run => run.glyphs); + return glyphs.length >= 4 && glyphs.every(id => id !== 0) && new Set(glyphs).size >= 4; + })()`; + try { + await page.waitForView(); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.text === 'An English headline')"); + await page.waitFor("!!navigator.serviceWorker.controller", 60000); + const manifest = await page.evaluate('fetch("./offline-manifest.json").then(r => r.json())'); + assert(!manifest.files.some(f => f.startsWith("optional-fonts/")), "The offline shell eagerly includes the optional font"); + assert(await page.evaluate("fontRequests.length") === 0, "A Latin feed requested the CJK face"); + await page.evaluate('location.hash = "item=9001"'); + await page.waitFor(`(${element("DetailTitle")})?.text === ${JSON.stringify(title)}`); + await page.waitFor("fontRequests.length === 1", 20000); + const box = await page.evaluate(element("SearchBox")); + await page.click(box.x + box.width / 2, box.y + box.height / 2); + await page.type(input); + await page.press("ArrowLeft"); + await page.press("ArrowLeft", ["shift"]); + const before = await page.evaluate(element("SearchBox")); + assert(before.text === input && before.focused && before.selectionStart !== before.selectionEnd, + "The editor must contain a focused, non-empty selection before the font arrives"); + assert(!await page.evaluate(shaped("SearchBox")), "An unavailable face should not report successful shaping"); + await page.evaluate("dispatchEvent(new Event('online'))"); + await page.waitFor("fontRequests.length === 2", 12000); + await page.evaluate("dispatchEvent(new Event('online')); allowFont()"); + + await page.waitFor(shaped("DetailTitle"), 30000); + await page.waitFor(shaped("SearchBox"), 30000); + const after = await page.evaluate(element("SearchBox")); + assert(after.text === before.text && after.focused && after.caretIndex === before.caretIndex && + after.selectionStart === before.selectionStart && after.selectionEnd === before.selectionEnd, + "Installing the fallback disturbed the focused editor"); + assert(await page.evaluate("fontRequests.length") === 2, "Multiple text surfaces or online events duplicated the retry"); + await page.screenshot(path.join(captures, "desktop-multilingual-reader.png")); + + await page.evaluate("sessionStorage.offlineFontTest = '1'"); + await page.send("Network.enable"); + await page.send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }); + const origin = await page.evaluate("performance.timeOrigin"); + await page.send("Page.reload", {}); + await page.waitFor(`performance.timeOrigin !== ${origin} && !!globalThis.ember?.emberState`); + await page.waitFor(shaped("DetailTitle"), 30000); + assert(await page.evaluate("fontRequests.length") === 0, "Offline shaping tried to download an already verified face"); + assert(!page.errors.length, page.errors.join("\n")); + } finally { await page.close(); } +} diff --git a/Ember.Browser/tests/browser-smoke.mjs b/Ember.Browser/tests/browser-smoke.mjs index 77c08e8..a4a76be 100644 --- a/Ember.Browser/tests/browser-smoke.mjs +++ b/Ember.Browser/tests/browser-smoke.mjs @@ -2,6 +2,7 @@ // Usage: CHROME=/path/to/chrome node Ember.Browser/tests/browser-smoke.mjs [site] // Only the HTTP boundary is deterministic; rendering, navigation, DB and controls are real. import fs from "node:fs"; +import { verifyCjkFallback } from "./browser-font.mjs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { open, run, assert, sleep, colours } from "../../FerroUI/scripts/browser/harness.mjs"; @@ -18,21 +19,28 @@ const fixtures = { 103: { id: 103, type: "story", title: "Ask HN: What have you learned by building something slowly?", by: "curious", time: 1792567200, score: 142, descendants: 0, text: "

What did the process teach you?

" }, 201: { id: 201, type: "comment", by: "reader", parent: 101, time: 1792573800, kids: [202], text: "

Small details make a meaningful difference. A native reading experience feels wonderful.

See the design notes.

" }, 202: { id: 202, type: "comment", by: "maker", parent: 201, time: 1792574000, text: "

Thank you! Here is the tiny core:

fn main() {\n    println!(\"Stay curious.\");\n}
" }, - 203: { id: 203, type: "comment", by: "engineer", parent: 101, time: 1792574400, text: "
Make the important things easy.

Offline reading is a feature I use every day.

" }, + 250: { id: 250, type: "comment", by: "archivist", parent: 101, time: 1792574450, text: "

Margin notes are part of the archive.

" }, + 203: { id: 203, type: "comment", by: "engineer", parent: 101, time: 1792574400, text: "
Make the important things easy.

I keep the design notes available offline.

" }, }; const initScript = `(() => { const original = globalThis.fetch.bind(globalThis); - const items = ${JSON.stringify(fixtures)}; + const items = globalThis.__emberFixtureItems = ${JSON.stringify(fixtures)}; globalThis.fetch = async (input, options) => { const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url, location.href); if (!["hacker-news.firebaseio.com", "hn.algolia.com"].includes(url.hostname)) return original(input, options); globalThis.__emberApiRequests = (globalThis.__emberApiRequests ?? 0) + 1; if (sessionStorage.getItem("ember-test-offline") === "1") throw new TypeError("Simulated offline connection"); let value = null; - if (url.hostname === "hn.algolia.com") value = { hits: [{ objectID: "101", title: items[101].title, author: "maker", points: 284, num_comments: 3, created_at_i: 1792573200, url: items[101].url }], nbHits: 1, nbPages: 1, page: 0, hitsPerPage: 30 }; - else if (/stories\\.json$/.test(url.pathname)) value = [101, 102, 103]; + if (url.hostname === "hn.algolia.com") { + globalThis.__emberLastSearch = url.href; + const scoped = url.searchParams.get("tags") === "comment,story_101"; + const hit = scoped + ? { objectID: "250", story_id: 101, story_title: items[101].title, comment_text: items[250].text, author: items[250].by, _tags: ["comment"] } + : { objectID: "101", title: items[101].title, author: "maker", points: 284, num_comments: 3, created_at_i: 1792573200, url: items[101].url }; + value = { hits: [hit], nbHits: 1, nbPages: 1, page: 0, hitsPerPage: 30 }; + } else if (/stories\\.json$/.test(url.pathname)) value = [101, 102, 103]; else if (/\\/item\\//.test(url.pathname)) value = items[Number(url.pathname.split("/").pop().replace(".json", ""))] ?? null; - else if (/\\/user\\//.test(url.pathname)) value = { id: "maker", karma: 1234, created: 1500000000, about: "

I build small things.

", submitted: [101, 202] }; + else if (/\\/user\\//.test(url.pathname)) value = { id: decodeURIComponent(url.pathname.split("/").pop().slice(0, -5)), karma: 1234, created: 1500000000, about: "

I build small things.

", submitted: [101, 202] }; return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); }; })();`; @@ -51,14 +59,16 @@ const byName = (page, name) => click(page, element => element.name === name, nam const byText = (page, text) => click(page, element => element.text === text, text); const byAutomation = (page, name) => click(page, element => element.automationName === name, name); -async function touchDrag(page, x, y, endX, endY) { +async function touchDrag(page, x, y, endX, endY, beforeRelease) { await page.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); const point = (x, y) => ({ x, y, id: 1, radiusX: 6, radiusY: 6, force: 1 }); + const move = (x, y) => page.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [point(x, y)] }); await page.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point(x, y)] }); for (let step = 1; step <= 10; ++step) { - await page.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [point(x + (endX - x) * step / 10, y + (endY - y) * step / 10)] }); + await move(x + (endX - x) * step / 10, y + (endY - y) * step / 10); await sleep(20); } + if (beforeRelease) await beforeRelease(move); await page.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await sleep(300); } @@ -93,6 +103,61 @@ async function applyTheme(page, index) { await sleep(350); } +async function reviewLocalData(page) { + const revealAction = async name => { + await reveal(page, element => element.automationName === name); + await byAutomation(page, name); + }; + await revealAction("Import this backup"); + await waitText(page, "Paste an Ember backup first."); + // Validation puts focus back in the JSON field so the correction can be typed directly. + await page.type("{}"); + assert(!(await state(page)).elements.some(e => e.text === "Paste an Ember backup first."), "The corrected import still reports an empty field"); + await revealAction("Import this backup"); + await waitText(page, "Replace reading data"); + await byName(page, "BackButton"); + assert(!(await state(page)).elements.some(e => e.automationName === "Replace reading data"), "Back left a restore confirmation active"); + await reveal(page, element => element.automationName === "Copy backup", -1); + await byAutomation(page, "Copy backup"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.automationName === 'Generated backup JSON')"); + await byName(page, "BackButton"); + const settings = (await state(page)).elements; + assert(settings.some(e => e.name === "DetailTitle" && e.text === "Settings"), "Closing the backup left Settings"); + assert(!settings.some(e => e.automationName === "Generated backup JSON"), "Backup output remained after Back"); + + await reveal(page, element => element.text === "Clear local data"); + await byText(page, "Clear local data"); + await revealAction("Clear cached content"); + await waitText(page, "Remove cached content"); + await page.press("Escape"); + assert(!(await state(page)).elements.some(e => e.automationName === "Remove cached content"), "Escape did not cancel cache removal"); +} + +async function verifyProfileActivity(page) { + await byAutomation(page, "View maker’s profile"); + const comment = e => e.automationName?.startsWith("Thank you! Here is the tiny core:"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.hit && e.automationName?.startsWith('Thank you! Here is the tiny core:'))"); + await page.screenshot(path.join(captures, "desktop-profile.png")); + await page.resize(390, 844); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'MobileMore' && e.hit)"); + await page.screenshot(path.join(captures, "phone-profile.png")); + await click(page, comment, "maker’s recent comment"); + await page.waitFor("new URL(location.href).searchParams.get('item') === '202'"); + await waitText(page, "Comment by maker"); + await byName(page, "BackButton"); + await waitText(page, "Recent activity"); + await byAutomation(page, "All comments"); + await page.waitFor("globalThis.__emberLastSearch?.includes('author_maker')"); + const search = new URL(await page.evaluate("globalThis.__emberLastSearch")); + assert(search.searchParams.get("tags") === "comment,author_maker" && search.pathname.endsWith("/search_by_date"), + "Profile comments must request recent results scoped to this author"); + await page.resize(1440, 960); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'NavMore' && e.hit)"); + await byName(page, "NavMore"); + await waitText(page, "Your library"); + await page.screenshot(path.join(captures, "desktop-explore.png")); +} + async function start(width, height) { const page = await open(site, { width, height, initScript }); try { await page.waitForView(); await waitText(page, title); return page; } @@ -107,29 +172,671 @@ async function failure(page, name) { } await run([ + ["reader Back and browser Forward traverse the same discussion entries", async () => { + const page = await open(site, { width: 1280, height: 800, query: "?RenderThread=false", initScript }); + const second = fixtures[102].title; + const at = (id, text) => page.waitFor(`new URL(location.href).searchParams.get("item") === ${JSON.stringify(id)} && + JSON.parse(ember.emberState()).elements.some(e => e.name === "DetailTitle" && e.text === ${JSON.stringify(text)})`); + try { + await page.waitForView(); + await byText(page, title); + await at("101", title); + await byText(page, second); + await at("102", second); + const length = await page.evaluate("history.length"); + await byName(page, "BackButton"); + await at("101", title); + assert(await page.evaluate("history.length") === length, "Reader Back appended a duplicate discussion"); + await page.evaluate("history.forward()"); + await at("102", second); + await page.evaluate("history.back()"); + await at("101", title); + await byName(page, "BackButton"); + await page.waitFor("!new URL(location.href).searchParams.has('item')"); + assert(await page.evaluate("new URL(location.href).searchParams.get('RenderThread')") === "false", + "Back discarded a renderer setting"); + await page.evaluate("history.forward()"); + await at("101", title); + // Distinct visits to one URL retain their own Find state, even when + // a browser traversal jumps over the intermediate discussion. + await byName(page, "FindButton"); + await page.type("design notes"); + await waitText(page, "1 of 2 matching comments"); + await byText(page, second); + await at("102", second); + await byText(page, title); + await at("101", title); + await byName(page, "FindButton"); + await page.type("wonderful"); + await waitText(page, "1 matching comment"); + const expectFind = text => page.waitFor(`JSON.parse(ember.emberState()).elements.some(e => + e.name === "ThreadFind" && e.text === ${JSON.stringify(text)})`); + await page.evaluate("history.go(-2)"); + await expectFind("design notes"); + await waitText(page, "1 of 2 matching comments"); + await page.evaluate("history.go(2)"); + await expectFind("wonderful"); + await byName(page, "BackButton"); // close Find before leaving the discussion + await byName(page, "BackButton"); + await at("102", second); + await page.evaluate("history.forward()"); + await at("101", title); + await page.evaluate("history.back()"); + await at("102", second); + const third = fixtures[103].title; + await byText(page, third); + await at("103", third); + // Opening C at B replaces the former Forward branch to the second A. + const tail = await page.evaluate("history.state.emberEntry"); + await page.evaluate("history.forward()"); + await sleep(200); + assert(await page.evaluate("history.state.emberEntry") === tail, "An abandoned Forward branch survived"); + await page.evaluate("history.go(-2)"); + await expectFind("design notes"); + await page.evaluate("history.go(2)"); + await at("103", third); + await byName(page, "BackButton"); + await at("102", second); + await page.evaluate("history.forward()"); + await at("103", third); + // An explicit tab selection starts a new feed entry and clears the + // in-reader detail trail. Browser Back still exposes C, B and A. + await byName(page, "NavTop"); + await page.waitFor("!new URL(location.href).searchParams.has('item')"); + const detachedLength = await page.evaluate("history.length"); + await page.evaluate("history.back()"); + await at("103", third); + await byName(page, "BackButton"); + await at("102", second); + await byName(page, "BackButton"); + await at("101", title); + await expectFind("design notes"); + await byName(page, "BackButton"); // close the original A's restored Find + await byName(page, "BackButton"); + await page.waitFor("!new URL(location.href).searchParams.has('item')"); + assert(await page.evaluate("history.length") === detachedLength, "Returning to the feed duplicated a browser entry"); + assert(page.errors.length === 0, page.errors.join("\n")); + } finally { await page.close(); } + const direct = await open(site, { width: 390, height: 844, query: "?id=101&RenderThread=false#note", initScript }); + try { + await direct.waitForView(); + await waitText(direct, title); + const length = await direct.evaluate("history.length"); + await byName(direct, "BackButton"); + await direct.waitFor("!new URL(location.href).searchParams.has('id')"); + await waitText(direct, "Front page"); + assert(await direct.evaluate("history.length") === length, "Leaving an initial permalink pushed another entry"); + assert(await direct.evaluate("location.hash === '#note' && new URL(location.href).searchParams.get('RenderThread') === 'false'"), + "Leaving a permalink discarded unrelated address state"); + assert(direct.errors.length === 0, direct.errors.join("\n")); + } finally { await direct.close(); } + }], + ["optional CJK glyphs shape in place and remain available offline", () => verifyCjkFallback(site, captures)], + ["primary navigation follows the layout and the selected discussion keeps Find", async () => { + const page = await open(site, { width: 390, height: 844, initScript }); + try { + await page.waitForView(); + await waitText(page, title); + await byName(page, "MobileHome"); + await page.press("End"); + assert((await state(page)).elements.some(e => e.name === "FeedTitle" && e.text === "Front page"), + "Moving navigation focus activated a destination"); + await page.press("Enter"); + await waitText(page, "Explore Ember"); + await page.screenshot(path.join(captures, "phone-primary-navigation.png")); + await byName(page, "MobileHome"); + await page.resize(844, 390); + await page.waitFor("JSON.parse(ember.emberState()).width === 844"); + await byName(page, "MobileNew"); + await page.press("ArrowDown"); + await page.press("Enter"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.automationName === 'Search terms' && e.hit)"); + await page.resize(1280, 800); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'NavTop' && e.hit)"); + await byName(page, "NavTop"); + await byText(page, title); + await byName(page, "FindButton"); + await page.type("design notes"); + await waitText(page, "1 of 2 matching comments"); + const row = (await state(page)).elements.find(e => e.hit && e.automationName?.startsWith(title + ",")); + assert(row, "Selected story is missing from the feed"); + await page.click(row.x + row.width / 2, row.y + row.height / 2); + await waitText(page, "1 of 2 matching comments"); + await byName(page, "SaveButton"); + await byAutomation(page, "Remove saved story"); + // Undo the second edit while focus is on a reader command. + await page.press("z", ["ctrl"]); + await waitText(page, "Change undone."); + assert((await state(page)).elements.some(e => e.name === "SaveButton" && e.automationName === "Remove saved story"), + "Undo did not restore the story bookmark"); + await page.screenshot(path.join(captures, "desktop-selected-discussion.png")); + assert(page.errors.length === 0, page.errors.join("\n")); + } finally { await page.close(); } + }], + ["a failed final feed page retries only its missing stories and keeps their rank", async () => { + const testFeed = initScript + `(() => { + const original = fetch.bind(globalThis); + const ids = Array.from({length: 33}, (_, index) => index + 1); + globalThis.retryRequests = []; + globalThis.fetch = async (input, options) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.hostname !== "hacker-news.firebaseio.com") return original(input, options); + if (url.pathname.endsWith("/topstories.json")) return Response.json(ids); + const id = Number(url.pathname.match(/item\\/(\\d+)/)?.[1]); + if (globalThis.feedOnline) retryRequests.push(id); + if (id > 30 && !globalThis.feedOnline) throw new TypeError("Page unavailable"); + return Response.json({id, type:"story", by:"reader", title:"Ranked story " + id}); + }; + })();`; + const page = await open(site, {width:390, height:844, initScript:testFeed}); + try { + await page.waitForView(); + await waitText(page, "Unread · 30"); + await byName(page, "LoadMoreButton"); + await waitText(page, "Retry 3 unavailable stories"); + await waitText(page, "30 of 33 loaded · 3 unavailable"); + assert(!(await state(page)).elements.some(e => e.name === "StatusText" && e.text), + "Per-row failures should be reported once by the recovery controls"); + assert(!(await state(page)).elements.some(e => e.name === "LoadMoreButton" && e.hit), + "There is no page beyond this failed final page"); + await page.screenshot(path.join(captures, "phone-feed-recovery.png")); + for (const [width, height] of [[320, 568], [700, 360]]) { + await page.resize(width, height); + await page.waitFor(`JSON.parse(ember.emberState()).width === ${width}`); + const retry = (await state(page)).elements.find(e => e.name === "RetryFeedItems" && e.hit); + assert(retry && retry.x >= 0 && retry.y >= 0 && retry.x + retry.width <= width + 1 && + retry.y + retry.height <= height + 1 && retry.height >= 44, "Retry target is clipped or undersized"); + } + await page.resize(390, 844); + await page.waitFor("JSON.parse(ember.emberState()).width === 390"); + await page.evaluate("globalThis.feedOnline = true"); + await byName(page, "RetryFeedItems"); + await waitText(page, "Unread · 33"); + assert(JSON.stringify(await page.evaluate("retryRequests.slice().sort((a,b) => a-b)")) === "[31,32,33]", + "Retry requested stories already present in the feed"); + await reveal(page, e => e.hit && e.automationName?.startsWith("Ranked story 33,")); + const {elements} = await state(page); + const rows = [31, 32, 33].map(id => elements.find(e => e.hit && e.automationName?.startsWith("Ranked story " + id + ","))); + assert(rows.every(Boolean) && rows[0].y < rows[1].y && rows[1].y < rows[2].y, + "Recovered stories should be visible in provider order"); + assert(!(await state(page)).elements.some(e => e.name === "RetryFeedItems" && e.hit), + "Successful recovery left a stale retry action"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "feed-page-recovery"); throw error; } + finally { await page.close(); } + }], + ["feed tabs resume their paged viewport and shortcuts target the focused story", async () => { + const feeds = initScript + `(() => { + const original = globalThis.fetch; + const ids = Array.from({ length: 60 }, (_, i) => 1001 + i); + for (const id of ids) globalThis.__emberFixtureItems[id] = { + id, type: "story", by: "editor", title: "Archive story " + id, kids: [] + }; + globalThis.fetch = (input, options) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url, location.href); + if (url.hostname === "hacker-news.firebaseio.com" && url.pathname === "/v0/topstories.json") { + return Promise.resolve(new Response(JSON.stringify(ids))); + } + return original(input, options); + }; + })();`; + for (const width of [390, 1280]) { + const phone = width < 760; + const page = await open(site, { width, height: 844, initScript: feeds }); + try { + await page.waitForView(); + await waitText(page, "Archive story 1001"); + await byName(page, "LoadMoreButton"); + await page.waitFor("!JSON.parse(ember.emberState()).elements.some(e => e.name === 'LoadMoreButton' && e.hit)"); + let anchor; + for (let step = 0; step < 16 && !anchor; step++) { + await page.wheel(phone ? 160 : 350, 470, 0, 520); + anchor = (await state(page)).elements.find(e => { + const match = /^Archive story (\d+),/.exec(e.automationName ?? ""); + return e.hit && match && Number(match[1]) > 1030 && Number(match[1]) < 1058 + && e.y > 130 && e.y + e.height < 730; + }); + } + assert(anchor, "A fully visible story on the second page was not found"); + await byName(page, phone ? "MobileNew" : "NavNew"); + await waitText(page, title); + await byName(page, phone ? "MobileHome" : "NavTop"); + await page.waitFor(`JSON.parse(ember.emberState()).elements.some(e => e.hit + && e.automationName === ${JSON.stringify(anchor.automationName)} && Math.abs(e.y - ${anchor.y}) < 2)`, 10000); + assert(!(await state(page)).elements.some(e => e.name === "LoadMoreButton" && e.hit), + "The restored feed lost its final page"); + if (!phone) { + const id = Number(/^Archive story (\d+),/.exec(anchor.automationName)[1]); + await byAutomation(page, anchor.automationName); + await page.waitFor(`new URL(location.href).searchParams.get("item") === "${id}"`); + // ArrowDown focuses the next native ListBoxItem while the prior discussion stays open. + await page.press("ArrowDown"); + await page.press("s"); + await page.waitFor(`JSON.parse(ember.emberState()).elements.some(e => + e.automationName?.startsWith("Archive story ${id + 1},") && e.helpText?.startsWith("Saved"))`, 10000); + await page.press("j"); + await page.waitFor(`new URL(location.href).searchParams.get("item") === "${id + 2}"`, 10000); + } + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, `feed-tabs-${width}`); throw error; } + finally { await page.close(); } + } + }], + ["startup recovery keeps the discussion reachable by keyboard on a narrow screen", async () => { + const page = await open(site, { + width: 320, height: 420, query: "?item=08", + initScript: 'Object.defineProperty(globalThis, "WebAssembly", {value: undefined})', + }); + try { + await page.waitFor("document.activeElement?.id === 'error-title'"); + assert(await page.evaluate("document.querySelector('#out p').textContent.includes('WebAssembly')"), "Missing capability explanation"); + await page.press("Tab"); + assert(await page.evaluate("document.activeElement?.textContent === 'Reload reader'"), "Reload is unreachable by keyboard"); + await page.press("Tab"); + const link = await page.evaluate(`(() => { + const { href, textContent } = document.activeElement; + const { width, height, left, right } = document.activeElement.getBoundingClientRect(); + return { href, textContent, width, height, left, right }; + })()`); + assert(link.href === "https://news.ycombinator.com/item?id=8", "The fallback lost the discussion ID"); + assert(link.height >= 44 && link.left >= 0 && link.right <= 320, "Fallback link is clipped or too small"); + } finally { await page.close(); } + }], ["desktop renders and opens a rich thread with shareable history", async () => { const page = await start(1440, 960); try { const screen = await state(page); assert(screen.elements.some(element => element.name === "NavTop"), "Desktop navigation rail is absent"); - await byText(page, title); + await byName(page, "WelcomeOpen"); await page.waitFor("location.search.includes('item=101')"); - await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'ThreadFind')"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'FindButton')"); assert(!(await page.evaluate("globalThis.__emberUnsafeHtml === true")), "HN content executed script"); + await page.resize(2080, 960); + await page.waitFor(`(() => { + const view = JSON.parse(ember.emberState()); + const back = view.elements.find(e => e.name === "BackButton" && e.hit); + const focus = view.elements.find(e => e.name === "FocusButton" && e.hit); + return view.width === 2080 && back && focus && focus.x + focus.width - back.x <= 860; + })()`); + await byName(page, "FocusButton"); + assert(!(await state(page)).elements.some(e => e.name === "NavTop"), "The feed rail remains visible in focus reading"); + await byAutomation(page, "Exit focus reading"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'NavTop')"); + await page.resize(1440, 960); + await page.waitFor("JSON.parse(ember.emberState()).width === 1440"); + await byName(page, "FindButton"); + await byAutomation(page, "Find in thread"); + // Both the prefix and the completed query match the same two comments. + // Treat a settled count as belonging to the exact text visible beside it. + await page.type("design note"); + await waitText(page, "1 of 2 matching comments"); + await page.press("Enter"); + await waitText(page, "2 of 2 matching comments"); + await page.evaluate(`(() => { + const check = globalThis.findFeedbackCheck = { stale: false, done: false }; + function frame() { + if (check.done) return; + const {elements} = JSON.parse(ember.emberState()); + if (elements.some(e => e.name === "ThreadFind" && e.text === "design notes") && + elements.some(e => e.name === "FindCount" && e.text === "2 of 2 matching comments")) { + check.stale = true; + } + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + })()`); + await page.type("s"); + await waitText(page, "1 of 2 matching comments"); + assert(await page.evaluate("findFeedbackCheck.done = true; !findFeedbackCheck.stale"), + "The visible Find count described a previous query"); + + await page.press("Enter"); + await waitText(page, "2 of 2 matching comments"); + await page.press("Enter", ["shift"]); + await waitText(page, "1 of 2 matching comments"); + await page.press("Escape"); + assert(!(await state(page)).elements.some(e => e.name === "ThreadFind"), "Escape should close find without leaving the discussion"); const shot = await page.screenshot(path.join(captures, "desktop-thread.png")); assert(colours(shot) > 40, "The screenshot does not contain a rendered reader"); + await verifyProfileActivity(page); await byName(page, "ThemeButton"); - await waitText(page, "Make Ember yours"); + await waitText(page, "Appearance & reading"); await page.screenshot(path.join(captures, "desktop-settings.png")); await applyTheme(page, 2); const dark = await page.screenshot(path.join(captures, "desktop-dark.png")); assert(dark.pixel(4, 4).reduce((sum, channel) => sum + channel, 0) < shot.pixel(4, 4).reduce((sum, channel) => sum + channel, 0) - 80, "Dark theme did not change the reader surface"); await applyTheme(page, 3); await page.screenshot(path.join(captures, "desktop-custom.png")); + await reviewLocalData(page); assert(page.errors.length === 0, page.errors.join("\n")); } catch (error) { await failure(page, "desktop"); throw error; } finally { await page.close(); } }], + ["phone and desktop retain Find on repeated navigation and reveal a reply in context", async () => { + for (const width of [390, 1280]) { + const page = await start(width, 844); + try { + await page.evaluate("location.hash = 'id=0101'"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'FindButton' && e.hit)"); + await byName(page, "FindButton"); + await page.type("DESIGN NOTES"); + await waitText(page, "1 of 2 matching comments"); + await page.press("Enter"); + await waitText(page, "2 of 2 matching comments"); + await reveal(page, e => e.automationName === "View engineer’s profile"); + await byAutomation(page, "View engineer’s profile"); + await waitText(page, "Recent activity"); + await byName(page, "BackButton"); + await waitText(page, "2 of 2 matching comments"); + assert((await state(page)).elements.find(e => e.name === "ThreadFind")?.text === "DESIGN NOTES", + "Back replaced or normalized the user's Find text"); + await byName(page, "ThreadFind"); + await page.press("a", ["ctrl"]); + await page.type("tiny core"); + await waitText(page, "1 matching comment"); + // Browsers may deliver both events when traversing a hash URL. + await page.evaluate("dispatchEvent(new PopStateEvent('popstate'))"); + await waitText(page, "1 matching comment"); + const context = e => e.name === "ReadInContext"; + await reveal(page, context); + await page.screenshot(path.join(captures, `find-context-${width}.png`)); + if (width < 760) { + const button = (await state(page)).elements.find(e => e.hit && context(e)); + await page.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + await page.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: button.x + button.width / 2, y: button.y + button.height / 2 }], + }); + await page.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + } else { + await byName(page, "ThreadFind"); + await page.press("Enter", ["ctrl"]); + } + await page.waitFor("!JSON.parse(ember.emberState()).elements.some(e => e.name === 'ThreadFind')"); + // Only the selected nested reply contains a code block. + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.automationName === 'Wrap code lines')"); + await byName(page, "BackButton"); + await page.waitFor("!location.hash && !new URL(location.href).searchParams.has('item')"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, `context-${width}`); throw error; } + finally { await page.close(); } + } + }], + ["settings retain preference drafts and discard them across desktop and phone layouts", async () => { + const page = await start(1440, 960); + const field = label => element => element.automationName === label; + const readText = async label => (await state(page)).elements.find(field(label))?.text; + try { + await byName(page, "ThemeButton"); + await reveal(page, field("Ocean accent")); + await byAutomation(page, "Ocean accent"); + await byName(page, "ThemeButton"); + assert(await readText("Accent color") === "#2864A7", "Opening Settings again lost the draft accent"); + await byName(page, "UnreadButton"); + await page.resize(390, 844); + await page.waitFor("JSON.parse(ember.emberState()).width === 390"); + await reveal(page, field("Discard preference edits")); + await page.screenshot(path.join(captures, "phone-preference-draft.png")); + await byAutomation(page, "Discard preference edits"); + await waitText(page, "Preference edits discarded."); + await reveal(page, field("Accent color"), -1); + assert(await readText("Accent color") === "#FF6B35", "Discard did not restore the saved accent"); + + await reveal(page, field("Forest accent")); + await byAutomation(page, "Forest accent"); + await reveal(page, field("Apply preferences")); + await byAutomation(page, "Apply preferences"); + await waitText(page, "Preferences applied."); + const saved = await page.evaluate(`import("./ember-platform.js") + .then(async m => JSON.parse(await m.databaseGet("reader:state"))?.preferences)`); + assert(saved?.accent === "#216E58" && saved.hide_read === true, + "Applying the draft lost the live unread filter or saved the wrong accent"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "settings-draft"); throw error; } + finally { await page.close(); } + }], + ["an offline download can be started from a feed, canceled from another tab and retried", async () => { + const page = await start(390, 844); + try { + await page.evaluate(`(() => { + const original = globalThis.fetch; + const gate = new Promise(resolve => { globalThis.__releaseReply = resolve; }); + globalThis.fetch = async (input, options) => { + const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url, location.href); + if (url.hostname === "hacker-news.firebaseio.com" && url.pathname === "/v0/item/201.json") { + globalThis.__waitingForReply = true; + await gate; + } + return original(input, options); + }; + })()`); + const download = async () => { + const row = (await state(page)).elements.find(e => e.hit && e.text === title); + const x = row.x + row.width / 2, y = row.y + row.height / 2; + await page.mouseMove(x, y); + for (const [type, buttons] of [["mousePressed", 2], ["mouseReleased", 0]]) { + await page.send("Input.dispatchMouseEvent", { type, button: "right", buttons, pointerType: "mouse", x, y, clickCount: 1 }); + } + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.hit && e.text === 'Download for offline reading')", 10000); + await byText(page, "Download for offline reading"); + }; + await download(); + await page.waitFor("globalThis.__waitingForReply === true"); + await byName(page, "MobileSaved"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'CancelTransfer' && e.hit)"); + await page.screenshot(path.join(captures, "phone-offline-progress.png")); + await byName(page, "CancelTransfer"); + await waitText(page, "Download canceled."); + await page.evaluate("globalThis.__releaseReply()"); + await byName(page, "MobileHome"); + await download(); + await waitText(page, "Discussion downloaded. It is available offline in Saved stories."); + const saved = await page.evaluate(`import("./ember-platform.js").then(async m => JSON.parse(await m.databaseGet("reader:state")))`); + assert(saved.downloaded_threads["101"] && !saved.items["101"].read_at, "Saving a feed download should leave it unread"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "transfer"); throw error; } + finally { await page.close(); } + }], + ["storage cleanup releases temporary items and preserves verified downloads", async () => { + const page = await start(1024, 820); + try { + await byName(page, "WelcomeOpen"); + await waitText(page, title); + await byName(page, "DownloadButton"); + await waitText(page, "Discussion downloaded. It is available offline in Saved stories."); + await byName(page, "ThemeButton"); + await reveal(page, element => element.text === "Clear local data"); + await byText(page, "Clear local data"); + const activate = async label => { + await reveal(page, element => element.automationName === label); + await byAutomation(page, label); + }; + await activate("Check storage use"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.text?.endsWith('6 items · 1 download'))"); + await activate("Clean temporary cache"); + await waitText(page, "Clean temporary data"); + await page.press("Escape"); + const cached = id => page.evaluate(`import('./ember-platform.js').then(m => m.databaseGet('cache:item:${id}'))`); + assert(await cached(102), "Cancelling cleanup lost a cached story"); + await activate("Clean temporary cache"); + await activate("Clean temporary data"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.text?.endsWith('4 items · 1 download'))"); + assert(!await cached(102) && await cached(101) && await cached(203), + "Cleanup must preserve the downloaded root and replies and release ordinary cached stories"); + await page.screenshot(path.join(captures, "tablet-offline-storage.png")); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "storage"); throw error; } + finally { await page.close(); } + }], + ["phone readers wrap and copy a code block and follow an internal rich link", async () => { + const page = await start(390, 844); + try { + const source = "fn measure(column: &str, gutter: usize, margin: usize) -> usize {\n column.len() + 2 * margin + gutter\n}\n"; + const html = "

A width calculation:

" +
+                source.replaceAll("&", "&").replaceAll(">", ">") +
+                "

See the related reply.

"; + await page.evaluate(`(() => { + globalThis.__emberFixtureItems[201].text = ${JSON.stringify(html)}; + globalThis.__emberFixtureItems[202].text = "

That is the same layout function I use.

"; + })()`); + await byText(page, title); + await reveal(page, e => e.automationName === "Wrap code lines"); + await byAutomation(page, "Wrap code lines"); + await page.setPermission("clipboard-read", "granted"); + await page.setPermission("clipboard-write", "granted"); + await byAutomation(page, "Copy code"); + await waitText(page, "Code copied."); + assert(await page.evaluate("navigator.clipboard.readText()") === source, + "Wrapping changed the code copied to the clipboard"); + await byName(page, "DismissStatus"); + await reveal(page, e => e.automationName === "the related reply"); + await page.screenshot(path.join(captures, "phone-code-tools.png")); + await byAutomation(page, "the related reply"); + await waitText(page, "Comment by maker"); + assert(await page.evaluate("new URL(location.href).searchParams.get('item')") === "202", + "A rich HN permalink must open its exact item in the reader"); + await byName(page, "BackButton"); + await waitText(page, title); + assert(await page.evaluate("new URL(location.href).searchParams.get('item')") === "101", + "Back should restore the original discussion"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "code-tools"); throw error; } + finally { await page.close(); } + }], + ["discussion search opens an indexed comment beyond the loaded thread", async () => { + const page = await start(390, 844); + try { + await byText(page, title); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.automationName === 'More story actions' && e.hit)"); + await byAutomation(page, "More story actions"); + await page.press("Home"); + await page.press("ArrowDown"); + await page.press("Enter"); + await waitText(page, "Discussion 101"); + await byAutomation(page, "Search terms"); + await page.type("margin notes"); + await page.screenshot(path.join(captures, "phone-discussion-search.png")); + await page.press("Enter"); + await waitText(page, "Margin notes are part of the archive."); + const query = new URL(await page.evaluate("globalThis.__emberLastSearch")); + assert(query.searchParams.get("tags") === "comment,story_101", "Discussion scope was lost"); + assert(query.searchParams.get("query") === "margin notes", "Search terms were lost"); + await byText(page, "Margin notes are part of the archive."); + await waitText(page, "Comment by archivist"); + assert(await page.evaluate("new URL(location.href).searchParams.get('item') === '250'"), "Search opened the containing story instead of the exact comment"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "scoped-search"); throw error; } + finally { await page.close(); } + }], + ["nested replies can jump over a subthread and open a single reply on a phone", async () => { + const page = await start(390, 844); + try { + await page.evaluate(`(() => { + const items = globalThis.__emberFixtureItems; + items[201].kids.push(204); + items[202].kids = [205]; + items[202].by = "designer"; + items[204] = { id: 204, parent: 201, type: "comment", by: "visitor", + text: "

I kept my reading position & context.

Even across pages.

" }; + items[205] = { id: 205, parent: 202, type: "comment", by: "typesetter", + text: "

A longer exchange about the details of the implementation.

".repeat(20) }; + })()`); + await byText(page, title); + const rowTool = async (author, name) => { + const byline = e => e.hit && e.automationName === `View ${author}’s profile`; + await reveal(page, byline); + const { elements } = await state(page); + const row = elements.filter(byline).at(-1); + const tool = elements.find(e => e.hit && e.name === name + && Math.abs((e.y + e.height / 2) - (row.y + row.height / 2)) < 12); + assert(tool, `Missing ${name} for the reply by ${author}`); + await page.click(tool.x + tool.width / 2, tool.y + tool.height / 2); + }; + // The flyout has its own popup root. Use its native keyboard navigation. + await rowTool("designer", "CommentMenu"); + await page.press("Home"); + for (let i = 0; i < 5; ++i) await page.press("ArrowDown"); + await page.screenshot(path.join(captures, "phone-reply-navigation.png")); + await page.press("Enter"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.hit && e.automationName === 'View visitor’s profile')"); + assert(await page.evaluate("new URL(location.href).searchParams.get('item')") === "101", + "Jumping to another reply should keep the containing discussion open"); + await page.setPermission("clipboard-read", "granted"); + await page.setPermission("clipboard-write", "granted"); + await rowTool("visitor", "CommentMenu"); + await page.press("Home"); + await page.press("ArrowDown"); + await page.press("ArrowDown"); + await page.press("Enter"); + await waitText(page, "Comment text copied."); + assert(await page.evaluate("navigator.clipboard.readText()") === "I kept my reading position & context.\nEven across pages.", + "Copied reply text lost its paragraph boundary or contained markup"); + await byName(page, "DismissStatus"); + await rowTool("visitor", "CommentMenu"); + await page.press("Home"); + for (let i = 0; i < 3; ++i) await page.press("ArrowDown"); + await page.press("Enter"); + await byAutomation(page, "Expand comment"); + await rowTool("visitor", "CommentPermalink"); + await waitText(page, "Comment by visitor"); + assert(await page.evaluate("new URL(location.href).searchParams.get('item')") === "204", + "The standalone thread must use the selected comment's permalink"); + await byName(page, "BackButton"); + await page.waitFor("new URL(location.href).searchParams.get('item') === '101'"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "reply-navigation"); throw error; } + finally { await page.close(); } + }], + ["returning readers catch up on new nested replies and copy their link", async () => { + const page = await start(1024, 820); + try { + await byName(page, "WelcomeOpen"); + await byAutomation(page, "Collapse 1 reply"); + await byName(page, "BackButton"); + await page.waitFor("!new URL(location.href).searchParams.has('item')"); + await page.evaluate(`(() => { + const items = globalThis.__emberFixtureItems; + items[101].descendants++; + items[201].kids.push(204); + items[204] = { id: 204, parent: 201, type: "comment", by: "visitor", time: Math.floor(Date.now() / 1000), text: "

A new nested reply for returning readers.

" }; + })()`); + await byText(page, title); + await waitText(page, "3 comments"); + await byName(page, "RefreshButton"); + await waitText(page, "4 comments"); + await byName(page, "NewReplyFilter"); + await waitText(page, "1 new reply"); + await waitText(page, "Earlier reply"); + const nested = (await state(page)).elements.find(e => e.hit && e.automationName === "View visitor’s profile"); + assert(nested, "The new reply is missing under its previously collapsed parent"); + assert(!(await state(page)).elements.some(e => e.automationName === "View engineer’s profile"), + "A branch without new replies should be filtered out"); + await page.screenshot(path.join(captures, "tablet-new-replies.png")); + const menu = (await state(page)).elements.find(e => e.hit && e.automationName === "More comment actions" + && Math.abs((e.y + e.height / 2) - (nested.y + nested.height / 2)) < 12); + assert(menu, "No overflow next to the new reply"); + await page.click(menu.x + menu.width / 2, menu.y + menu.height / 2); + await page.setPermission("clipboard-read", "granted"); + await page.setPermission("clipboard-write", "granted"); + // Flyout menus can use a separate popup root. Select the first action by keyboard. + await page.press("Home"); + await page.press("Enter"); + await waitText(page, "Comment link copied."); + assert(await page.evaluate("navigator.clipboard.readText()") === "https://news.ycombinator.com/item?id=204", "The copy action used the wrong comment"); + await reveal(page, e => e.automationName === "View visitor’s profile"); + await byAutomation(page, "View visitor’s profile"); + await waitText(page, "Recent activity"); + await byName(page, "BackButton"); + await waitText(page, "1 new reply"); + await waitText(page, "Earlier reply"); + await byName(page, "NewReplyFilter"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.automationName === 'Expand comment and 2 replies')"); + assert(!(await state(page)).elements.some(e => e.automationName === "View visitor’s profile"), + "Clearing the scope lost the original collapsed state"); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "new-replies"); throw error; } + finally { await page.close(); } + }], ["phone touch gestures, navigation, downloads, persistence, and offline reload", async () => { const page = await start(390, 844); try { @@ -142,10 +849,34 @@ await run([ assert(headline, "The first story has no touch target"); const fingerX = Math.min(screen.width - 35, headline.x + headline.width - 15); const fingerY = headline.y + headline.height / 2; + await touchDrag(page, fingerX, fingerY, fingerX - 170, fingerY, async move => { + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'SwipeAction' && e.text === 'Save')"); + await page.screenshot(path.join(captures, "phone-swipe-preview.png")); + // Retreat across the threshold before releasing the same touch. + await move(fingerX - 32, fingerY); + await sleep(60); + }); + assert(!(await state(page)).elements.some(e => e.automationName?.startsWith(title) && e.helpText === "Saved. Open discussion."), "Retracting the swipe still saved the story"); + assert(!(await state(page)).elements.some(e => e.name === "SwipeAction"), "Swipe feedback stayed visible after release"); + await touchDrag(page, fingerX - 180, fingerY, fingerX, fingerY); + await waitText(page, "Story hidden."); + assert(!(await state(page)).elements.some(element => element.text === title), "Hidden headline remained in the feed"); + await byName(page, "UndoStory"); + await waitText(page, title); + await byName(page, "DismissStatus"); await touchDrag(page, fingerX, fingerY, fingerX - 180, fingerY); - await waitText(page, "SAVED"); + await page.waitFor(`JSON.parse(ember.emberState()).elements.some(e => + e.automationName?.startsWith(${JSON.stringify(title)}) && e.helpText === "Saved. Open discussion.")`); assert(!(await page.evaluate("new URL(location.href).searchParams.has('item')")), "Swiping a card also opened its discussion"); await byName(page, "DismissStatus"); + const otherTitle = fixtures[102].title; + const other = (await state(page)).elements.find(element => element.hit && element.text === otherTitle); + assert(other, "Second bookmark fixture has no touch target"); + const secondX = Math.min(screen.width - 35, other.x + other.width - 15); + const secondY = other.y + other.height / 2; + await touchDrag(page, secondX, secondY, secondX - 180, secondY); + await waitText(page, "Added to Saved stories. Download the thread to keep all its comments offline."); + await byName(page, "DismissStatus"); const heading = (await state(page)).elements.find(element => element.name === "FeedTitle"); assert(heading, "The feed heading is absent"); const beforeRefresh = await page.evaluate("globalThis.__emberApiRequests"); @@ -157,6 +888,10 @@ await run([ await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'SaveButton')"); await byName(page, "SaveButton"); await waitText(page, "Removed from Saved stories."); + await byName(page, "UndoStory"); + await waitText(page, "Change undone."); + await byAutomation(page, "Remove saved story"); + await waitText(page, "Removed from Saved stories."); await byName(page, "SaveButton"); await waitText(page, "Added to Saved stories. Download the thread to keep all its comments offline."); await sleep(400); @@ -164,6 +899,35 @@ await run([ await waitText(page, "Discussion downloaded. It is available offline in Saved stories."); await byName(page, "DismissStatus"); await page.screenshot(path.join(captures, "phone-thread.png")); + // A live breakpoint crossing lays out an existing, virtualized discussion. + // Starting independent windows at these widths does not exercise this path. + for (const [width, height, rail] of [[320, 640, false], [700, 420, false], [844, 390, false], [844, 550, true], [760, 550, true], [390, 844, false]]) { + await page.resize(width, height); + // The viewport changes before the deferred adaptive layout arranges its controls. + await page.waitFor(`(() => { + const view = JSON.parse(ember.emberState()); + return view.width === ${width} && view.height === ${height} + && view.elements.some(e => e.name === 'NavTop') === ${rail} + && view.elements.some(e => e.name === 'SaveButton' && e.hit); + })()`); + if (width === 320) { + assert(!(await state(page)).elements.some(e => e.name === "DownloadButton" && e.hit), "Download still crowds the narrow toolbar"); + await byName(page, "ToolbarMore"); + await waitText(page, "Focus reading"); + await byText(page, "Focus reading"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.automationName === 'Exit focus reading' && e.hit)"); + await byAutomation(page, "Exit focus reading"); + } + if (width === 844 && height === 390) { + const controls = (await state(page)).elements; + const first = controls.find(e => e.name === "MobileHome" && e.hit); + const last = controls.find(e => e.name === "MobileMore" && e.hit); + assert(first && last && first.x === last.x && first.y < last.y, "Landscape primary tabs must form a usable side strip"); + await page.screenshot(path.join(captures, "landscape-reader.png")); + } + assert(await page.evaluate("new URL(location.href).searchParams.get('item') === '101'"), "Resizing changed the selected URL"); + assert(page.errors.length === 0, page.errors.join("\n")); + } await page.wheel(340, 600, 0, 440); await page.screenshot(path.join(captures, "phone-comments.png")); await page.wheel(340, 400, 0, -1000); @@ -172,6 +936,12 @@ await run([ await page.waitFor("!new URL(location.href).searchParams.has('item')"); await byName(page, "MobileSaved"); await waitText(page, title); + await waitText(page, otherTitle); + await byAutomation(page, "Show downloaded discussions only"); + await waitText(page, title); + assert(!(await state(page)).elements.some(e => e.text === otherTitle), "Downloaded filter retained a bookmark without a download"); + await byAutomation(page, "Show all saved stories"); + await waitText(page, otherTitle); await byText(page, title); await page.waitFor("location.search.includes('item=101')"); await page.waitFor("navigator.serviceWorker.controller !== null", 30000); @@ -185,15 +955,168 @@ await run([ await page.waitFor(`performance.timeOrigin !== ${previousDocument}`); await page.waitForView(); await page.waitFor("globalThis.ember?.emberState"); - await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'ThreadFind')"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'FindButton')"); await waitText(page, title); await page.screenshot(path.join(captures, "phone-offline-thread.png")); await byName(page, "BackButton"); - await byName(page, "MobileSaved"); + await byName(page, "MobileMore"); + await waitText(page, "Your library"); + await page.screenshot(path.join(captures, "phone-explore.png")); + const requests = await page.evaluate("globalThis.__emberApiRequests ?? 0"); + await byAutomation(page, "Downloaded discussions"); await waitText(page, title); + const downloaded = await state(page); + assert(downloaded.elements.some(e => e.hit && e.automationName === "Show all saved stories"), + "Explore did not activate the Downloaded filter"); + assert(!downloaded.elements.some(e => e.text === otherTitle), "Explore included an undownloaded bookmark"); + assert(await page.evaluate("globalThis.__emberApiRequests ?? 0") === requests, "Explore fetched its offline library from the network"); await page.screenshot(path.join(captures, "phone-offline-saved.png")); + await byName(page, "LibraryFindButton"); + await page.type("curious maker example.org"); + await page.press("Enter"); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'FeedSummary' && e.text?.startsWith('1 match'))"); + await page.screenshot(path.join(captures, "phone-library-find.png")); + await byText(page, title); + await page.waitFor("new URL(location.href).searchParams.get('item') === '101' && JSON.parse(ember.emberState()).elements.some(e => e.name === 'FindButton' && e.hit)"); + await byName(page, "BackButton"); + await waitText(page, title); + const result = await state(page); + assert(result.elements.some(e => e.name === "LibraryFind" && e.text === "curious maker example.org"), "Returning from a match discarded the collection query"); + await byName(page, "CloseLibraryFind"); + await waitText(page, title); + assert(!(await state(page)).elements.some(e => e.name === "LibraryFind"), "Closing collection Find did not restore browsing"); + assert(await page.evaluate("globalThis.__emberApiRequests ?? 0") === requests, "Searching and reopening the downloaded discussion used the network"); + + // Downloads and bookmarks are independent. Exercise both collections offline. + await byText(page, title); + await byAutomation(page, "Remove saved story"); + await waitText(page, "Removed from Saved stories."); + await byName(page, "BackButton"); + await waitText(page, title); + await byAutomation(page, "Show all saved stories"); + await waitText(page, otherTitle); + assert(!(await state(page)).elements.some(e => e.text === title), "Unsaving retained the item in ordinary bookmarks"); + await byAutomation(page, "Show downloaded discussions only"); + await waitText(page, title); + await byText(page, title); + await waitText(page, "3 comments"); + await byAutomation(page, "Manage downloaded discussion"); + // The platform flyout may be hosted outside the main canvas control tree. + await page.press("End"); + await page.press("Enter"); + await waitText(page, "Removed from Downloads. Temporary pages can be reclaimed in Settings."); + await byName(page, "BackButton"); + await waitText(page, "No downloaded discussions"); + await byName(page, "RetryButton"); + await waitText(page, otherTitle); + assert(!(await state(page)).elements.some(e => e.text === title), "Releasing a download restored its bookmark"); + assert(page.errors.length === 0, page.errors.join("\n")); } catch (error) { await failure(page, "phone"); throw error; } finally { await page.close(); } }], + ["search corrections retain the current feed and restore the source draft", async () => { + const missingUser = initScript + `(() => { + const get = fetch.bind(globalThis); + globalThis.fetch = (input, options) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + return url.hostname === "hacker-news.firebaseio.com" && url.pathname === "/v0/user/noone.json" + ? Promise.resolve(Response.json(null)) : get(input, options); + }; + })();`; + const page = await open(site, { width: 1280, height: 800, initScript: missingUser }); + try { + await page.waitForView(); + await waitText(page, title); + await byName(page, "SearchBox"); + await page.setPermission("clipboard-read", "granted"); + await page.setPermission("clipboard-write", "granted"); + const terms = "żółta herbata ".repeat(140); + assert(terms.length < 2048 && new TextEncoder().encode(terms.trim()).length > 2048, + "The fixture should exceed the byte limit only"); + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(terms)})`); + await page.press("v", ["ctrl"]); + await page.waitFor(`JSON.parse(ember.emberState()).elements.some(e => e.name === 'SearchBox' && e.text?.length === ${terms.length})`); + await page.press("Enter"); + const message = "Search terms exceed the 2,048-byte limit."; + await waitText(page, message); + assert((await state(page)).elements.filter(e => e.text === message).length === 1, + "Show a single inline validation message"); + assert((await state(page)).elements.some(e => e.name === "FeedTitle" && e.text === "Front page"), + "Invalid input replaced the feed"); + await page.screenshot(path.join(captures, "desktop-search-validation.png")); + await page.press("a", ["ctrl"]); + await page.type("systems"); + await page.waitFor(`!JSON.parse(ember.emberState()).elements.some(e => e.text === ${JSON.stringify(message)})`); + await page.resize(390, 844); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.name === 'MobileSearch' && e.hit)"); + await byName(page, "MobileSearch"); + await byName(page, "SearchTerms"); + await page.press("a", ["ctrl"]); + await page.type("systems draft"); + await reveal(page, e => e.text === "Find a person"); + await byText(page, "Find a person"); + await reveal(page, e => e.automationName === "Hacker News username"); + await byAutomation(page, "Hacker News username"); + await page.type("@noone"); + await page.press("Enter"); + await waitText(page, "Profile unavailable"); + await byAutomation(page, "Edit username"); + await page.screenshot(path.join(captures, "phone-profile-correction.png")); + // The failed username is selected, so ordinary typing corrects it. + await page.type("maker"); + await page.press("Enter"); + await waitText(page, "Recent activity"); + await byName(page, "BackButton"); + await reveal(page, e => e.name === "SearchTerms", -1); + await waitText(page, "systems draft"); + await byName(page, "BackButton"); + await waitText(page, title); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "search-corrections"); throw error; } + finally { await page.close(); } + }], + ["retrying profile activity preserves readable submissions and recovers a missing row", async () => { + const profileFixture = initScript + `(() => { + const existing = fetch.bind(globalThis); + const requests = globalThis.activityRequests = []; + globalThis.fetch = async (input, options) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.hostname === "hacker-news.firebaseio.com") { + const file = url.pathname.split("/").pop(); + if (file === "maker.json") { + requests.push(file); + return Response.json({id:"maker", submitted:[6001,6002]}); + } + if (file === "6001.json" || file === "6002.json") { + requests.push(file); + const id = parseInt(file, 10); + if (id === 6001 && !globalThis.recoverActivity) throw new TypeError("Unavailable submission"); + return Response.json({id, type:"story", by:"maker", title:"Submission " + id}); + } + } + return existing(input, options); + }; + })();`; + const page = await open(site, {width:1280, height:900, initScript:profileFixture}); + try { + await page.waitForView(); + await byName(page, "SearchBox"); + await page.type("https://news.ycombinator.com/user?id=maker"); + await page.press("Enter"); + await waitText(page, "Submission 6002"); + await reveal(page, e => e.automationName === "Retry activity"); + await page.evaluate("activityRequests.length = 0; globalThis.recoverActivity = true;"); + await byAutomation(page, "Retry activity"); + await waitText(page, "Submission 6001"); + assert(JSON.stringify(await page.evaluate("activityRequests.slice()")) === '["6001.json"]', + "Activity retry refetched part of the readable profile"); + const cards = (await state(page)).elements.filter(e => e.automationName?.startsWith("Submission 600")); + assert(cards.length === 2 && cards[0].y < cards[1].y, "Recovered submissions are out of order"); + await page.press("Enter"); + await page.waitFor('new URL(location.href).searchParams.get("item") === "6001"'); + assert(page.errors.length === 0, page.errors.join("\n")); + } catch (error) { await failure(page, "profile-activity-retry"); throw error; } + finally { await page.close(); } + }], ].filter(([name]) => !process.env.EMBER_BROWSER_CASE || name.includes(process.env.EMBER_BROWSER_CASE))); diff --git a/Ember.Browser/tests/discussion-perf.mjs b/Ember.Browser/tests/discussion-perf.mjs new file mode 100644 index 0000000..8c96612 --- /dev/null +++ b/Ember.Browser/tests/discussion-perf.mjs @@ -0,0 +1,95 @@ +// Measures a real Find commit in a released Wasm reader. The timer starts at Enter +// keydown and stops on the first animation frame exposing the expected result count. +// Run with CHROME=/path/to/chrome node Ember.Browser/tests/discussion-perf.mjs SITE [SAMPLES] +import { open } from "../../FerroUI/scripts/browser/harness.mjs"; + +const [site, repetitions = "5"] = process.argv.slice(2); +const samples = Number(repetitions); +if (!site || !Number.isSafeInteger(samples) || samples < 1) { + throw new Error("Specify an extracted release site and a positive sample count"); +} + +const initScript = `(() => { + const root = 1, count = 4096, title = "Thread projection workload"; + const frontier = Array.from({length: count / 8}, (_, i) => 2 + i * 8); + const original = fetch.bind(globalThis); + globalThis.fetch = async (input, options) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.hostname !== "hacker-news.firebaseio.com") return original(input, options); + const id = Number(url.pathname.match(/item\\/(\\d+)/)?.[1]); + let data = [root]; + if (id === root) { + data = { id, type: "story", by: "editor", title, kids: frontier, descendants: count }; + } else if (id) { + const branch = (id - 2) % 8 === 0; + const marker = id % 128 === 0 ? "Rare needle" : "notes"; + data = { id, type: "comment", by: "reader" + id % 73, + parent: branch ? root : id - (id - 2) % 8, + kids: branch ? Array.from({length: 7}, (_, i) => id + 1 + i) : [], + text: "

Entry " + id + ": layout & paint. " + marker + ".

Second paragraph with UTF-8 details.

" }; + } + return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } }); + }; +})();`; + +const page = await open(site, { width: 1280, height: 844, query: "?item=1", initScript }); +const elements = "JSON.parse(ember.emberState()).elements"; +async function click(name) { + const selector = `${elements}.find(e => e.name === ${JSON.stringify(name)} && e.hit)`; + await page.waitFor(selector); + const rect = await page.evaluate(selector); + await page.click(rect.x + rect.width / 2, rect.y + rect.height / 2); +} + +async function measure(query, count) { + await page.press("a", ["ctrl"]); + await page.type(query); + const expected = count ? `1 of ${count} matching comments` : "No matches in loaded comments"; + const label = `${elements}.find(e => e.name === 'FindCount')?.text`; + await page.evaluate(`(() => { + if (${label} === ${JSON.stringify(expected)}) throw new Error("The debounce already applied this query"); + globalThis.findLatency = null; + addEventListener("keydown", () => { + const start = performance.now(); + function observe() { + if (${label} === ${JSON.stringify(expected)}) { + globalThis.findLatency = performance.now() - start; + } else { + requestAnimationFrame(observe); + } + } + requestAnimationFrame(observe); + }, { capture: true, once: true }); + })()`); + await page.press("Enter"); + await page.waitFor("globalThis.findLatency !== null", 10000); + return Number((await page.evaluate("findLatency")).toFixed(2)); +} + +try { + await page.waitForView(); + await page.waitFor(`${elements}.some(e => e.name === "DetailTitle" && e.text === "Thread projection workload")`); + await click("DownloadButton"); + await page.waitFor(`${elements}.some(e => e.text === "Discussion downloaded. It is available offline in Saved stories.")`); + const loaded = await page.evaluate(`import("./ember-platform.js").then(async m => + JSON.parse(await m.databaseGet("reader:state")).downloaded_threads["1"].length)`); + if (loaded !== 4097) throw new Error(`Expected 4,097 verified items, got ${loaded}`); + await click("FindButton"); + const firstFindMs = await measure("rare needle", 32); + const series = []; + for (let i = 0; i < samples; i++) { + const times = []; + for (const [query, matches] of [["absent", 0], ["rare", 32], ["layout", 4096], ["rare needle", 32]]) { + times.push(await measure(query, matches)); + } + series.push(times); + } + const totals = series.map(row => row.reduce((a, b) => a + b, 0)).sort((a, b) => a - b); + const middle = Math.floor(totals.length / 2); + const median = totals.length % 2 ? totals[middle] : (totals[middle - 1] + totals[middle]) / 2; + console.log(JSON.stringify({ replies: loaded - 1, firstFindMs, queries: ["absent", "rare", "layout", "rare needle"], + samplesMs: series, medianFourFindsMs: Number(median.toFixed(2)) })); + if (page.errors.length) throw new Error(page.errors.join("\n")); +} finally { + await page.close(); +} diff --git a/Ember.Browser/tests/feed-perf.mjs b/Ember.Browser/tests/feed-perf.mjs new file mode 100644 index 0000000..0fe9c0c --- /dev/null +++ b/Ember.Browser/tests/feed-perf.mjs @@ -0,0 +1,71 @@ +// Times the frame showing each accepted 30-story page after a real pointer release. +// Data-provider responses are deterministic. IndexedDB, Rust, XAML and layout are real. +import { open } from "../../FerroUI/scripts/browser/harness.mjs"; + +const [site, count = "5"] = process.argv.slice(2); +const runs = Number(count); +if (!site || !Number.isSafeInteger(runs) || runs < 1) { + throw new Error("Usage: node feed-perf.mjs SITE [SAMPLES]"); +} + +const setup = `(() => { + const original = globalThis.fetch.bind(globalThis); + const ids = Array.from({ length: 300 }, (_, i) => i + 1001); + globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.hostname !== "hacker-news.firebaseio.com") return original(input, init); + const id = Number(url.pathname.match(/item\\/(\\d+)/)?.[1]); + const body = id ? { id, type: "story", by: "reader", title: "Feed story " + id, url: "https://example.org/" + id } : ids; + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); + }; + globalThis.measurePage = count => { + globalThis.pageLatency = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Unread count did not reach " + count)), 30000); + addEventListener("pointerup", () => { + const start = performance.now(); + const sample = () => { + const view = JSON.parse(ember.emberState()); + if (view.elements.some(e => e.text === "Unread · " + count)) { + clearTimeout(timeout); + return resolve(performance.now() - start); + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, { once: true, capture: true }); + }); + }; +})()`; + +const samples = []; +for (let run = 0; run < runs; run++) { + const page = await open(site, { width: 1280, height: 800, initScript: setup }); + try { + await page.waitForView(); + await page.waitFor("JSON.parse(ember.emberState()).elements.some(e => e.text === 'Unread · 30')"); + // Keep installation of the offline shell outside the measured interaction window. + await page.waitFor("!!navigator.serviceWorker.controller", 60000); + const times = []; + for (let rows = 60; rows <= 300; rows += 30) { + const button = await page.evaluate("JSON.parse(ember.emberState()).elements.find(e => e.name === 'LoadMoreButton' && e.hit)"); + if (!button) throw new Error("Load More missing before " + rows); + const x = button.x + button.width / 2, y = button.y + button.height / 2; + await page.mouseMove(x, y); + await page.evaluate(`measurePage(${rows})`); + await page.mouseDown(x, y); + await page.mouseUp(x, y); + times.push(await page.evaluate("pageLatency")); + } + samples.push(times); + } finally { + await page.close(); + } +} + +function median(values) { + values.sort((a, b) => a - b); + const mid = Math.floor(values.length / 2); + return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2; +} +const totals = samples.map(times => times.reduce((sum, value) => sum + value, 0)); +console.log(JSON.stringify({ pagesMs: samples, totalMs: totals, medianTotalMs: median([...totals]) }, null, 2)); diff --git a/Ember.Browser/tests/offline.test.mjs b/Ember.Browser/tests/offline.test.mjs index 59161a0..c713203 100644 --- a/Ember.Browser/tests/offline.test.mjs +++ b/Ember.Browser/tests/offline.test.mjs @@ -5,14 +5,24 @@ import { test } from "node:test"; const source = fs.readFileSync(new URL("../wwwroot/ember-offline.js", import.meta.url), "utf8"); -function worker({ isolate = false, failure = false } = {}) { +function worker({ isolate = false, failure = false, streamFailure = false, moduleStatus = 200, existing = false, cachedModule = false } = {}) { const listeners = new Map(); const deleted = []; const added = []; const requested = []; const scope = "https://example.test/ember/"; + const writes = new Map(); const cache = { - addAll: async files => { added.push(...files); if (failure) throw new Error("QuotaExceededError"); }, + keys: async file => cachedModule && file.endsWith(".wasm") ? [file] : [], + add: async request => { + if (streamFailure) throw new DOMException("Streamed module could not be cached", "NetworkError"); + added.push(request.url); + }, + addAll: async files => { + if (failure) throw new DOMException("QuotaExceededError", "QuotaExceededError"); + added.push(...files); + }, + put: async (file, response) => { writes.set(file, response); }, match: async key => typeof key === "string" && key.endsWith("index.html") ? "cached index" : undefined, }; const self = { @@ -20,14 +30,27 @@ function worker({ isolate = false, failure = false } = {}) { registration: { scope }, EMBER_SHELL_VERSION: "new", EMBER_SHELL_FILES: ["index.html", "app.css", "ember-browser.wasm"], + EMBER_MODULE_HASHES: { "ember-browser.wasm": "test-digest" }, addEventListener: (type, callback) => listeners.set(type, callback), }; vm.runInNewContext(source, { - self, URL, - caches: { open: async () => cache, keys: async () => [`ember-shell:${scope}:old`, `ember-shell:${scope}:new`, "different-app:old"], delete: async key => deleted.push(key) }, - fetch: async request => { requested.push(request); return "network"; }, + self, URL, Headers, Response, Request, + caches: { + has: async name => existing || (cachedModule && name === `ember-wasm:${scope}:test-digest`), + open: async () => cache, + keys: async () => [`ember-shell:${scope}:old`, `ember-shell:${scope}:new`, + `ember-wasm:${scope}:outdated`, `ember-wasm:${scope}:test-digest`, "different-app:old"], + delete: async key => deleted.push(key), + }, + fetch: async request => { + requested.push(request.url ?? request); + if (streamFailure) return new Response(new Uint8Array([0, 97, 115, 109]), { + status: moduleStatus, headers: { "Content-Type": "application/wasm", "Content-Encoding": "gzip" }, + }); + return "network"; + }, }); - return { listeners, deleted, added, requested, scope }; + return { listeners, deleted, added, requested, scope, writes }; } test("offline install waits for the complete version and removes only this scope's obsolete caches", async () => { @@ -38,7 +61,7 @@ test("offline install waits for the complete version and removes only this scope assert.equal(instance.added.length, 3); instance.listeners.get("activate")({ waitUntil: promise => { pending = promise; } }); await pending; - assert.deepEqual(instance.deleted, [`ember-shell:${instance.scope}:old`]); + assert.deepEqual(instance.deleted, [`ember-shell:${instance.scope}:old`, `ember-wasm:${instance.scope}:outdated`]); }); test("failed shell precache rejects installation, preserving the preceding worker", async () => { @@ -65,3 +88,39 @@ test("offline root with an item query uses the application shell, downloads and test("the isolation worker owns requests in threaded mode", () => { assert.equal(worker({ isolate: true }).listeners.size, 0); }); + +test("a streamed cache failure retries the complete shell with decoded Wasm bytes", async () => { + const instance = worker({ streamFailure: true }); + let install; + instance.listeners.get("install")({ waitUntil: promise => { install = promise; } }); + await install; + const wasm = instance.scope + "ember-browser.wasm"; + assert.deepEqual(instance.added, [instance.scope + "index.html", instance.scope + "app.css"]); + assert.deepEqual(instance.requested, [wasm]); + const response = instance.writes.get(wasm); + assert.equal(response.headers.get("Content-Type"), "application/wasm"); + assert.equal(response.headers.get("Content-Encoding"), null); + assert.equal(response.headers.get("Content-Length"), "4"); + assert.deepEqual([...new Uint8Array(await response.arrayBuffer())], [0, 97, 115, 109]); +}); + +test("an incomplete module rejects installation and cleans only a newly created cache", async () => { + for (const existing of [false, true]) { + const instance = worker({ streamFailure: true, moduleStatus: 206, existing }); + let install; + instance.listeners.get("install")({ waitUntil: promise => { install = promise; } }); + await assert.rejects(install, /HTTP 206/); + assert.equal(instance.writes.size, 0); + assert.deepEqual(instance.deleted, existing ? [] : [`ember-shell:${instance.scope}:new`, `ember-wasm:${instance.scope}:test-digest`]); + } +}); + +test("a shell-only upgrade neither fetches nor rewrites the existing module", async () => { + const instance = worker({ cachedModule: true }); + let install; + instance.listeners.get("install")({ waitUntil: promise => { install = promise; } }); + await install; + assert.deepEqual(instance.added, [instance.scope + "index.html", instance.scope + "app.css"]); + assert.deepEqual(instance.requested, []); + assert.equal(instance.writes.size, 0); +}); diff --git a/Ember.Browser/tests/optional-font.test.mjs b/Ember.Browser/tests/optional-font.test.mjs new file mode 100644 index 0000000..7e3cde1 --- /dev/null +++ b/Ember.Browser/tests/optional-font.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { createFontLoader } from "../wwwroot/ember-platform.js"; + +const moduleUrl = "https://example.test/deployment/ember-platform.js"; +const bytes = Buffer.from("OTTO test font bytes"); +const asset = {path: "optional-fonts/test.otf", size: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex")}; +const url = new URL(asset.path, moduleUrl).href; +const cacheName = "ember-fonts:https://example.test/deployment/"; + +function storage(t, {denied = false, full = false} = {}) { + const responses = new Map(); + const opened = []; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "caches"); + Object.defineProperty(globalThis, "caches", {configurable: true, value: { + open: async name => { + opened.push(name); + if (denied) throw new Error("Storage access denied"); + return { + match: async url => responses.get(url)?.clone(), + put: async (url, response) => { + if (full) throw new Error("Quota exceeded"); + responses.set(url, response); + }, + delete: async key => responses.delete(typeof key === "string" ? key : key.url), + keys: async () => Array.from(responses.keys(), url => ({url})), + }; + }, + }}); + t.after(() => { if (descriptor) Object.defineProperty(globalThis, "caches", descriptor); else delete globalThis.caches; }); + return {opened, responses}; +} + +test("font loading is lazy, coalesces misses, and reuses verified bytes across visits offline", async t => { + const {opened, responses} = storage(t); + let requests = 0; + let online = true; + t.mock.method(globalThis, "fetch", async (requested, options) => { + requests++; + assert.equal(requested, url); + assert.equal(options.credentials, "omit"); + assert.equal(options.referrerPolicy, "no-referrer"); + assert.equal(options.mode, "same-origin"); + assert.equal(options.redirect, "error"); + if (!online) throw new Error("offline"); + return new Response(bytes); + }); + await import("../wwwroot/ember-platform.js?fresh-font-module"); + const load = createFontLoader(asset, moduleUrl); + assert.equal(requests, 0); + assert.deepEqual(opened, []); + const first = load(); + assert.equal(load(), first); + assert.deepEqual(Buffer.from(await first), bytes); + assert.equal(requests, 1); + assert.deepEqual([...responses.keys()], [url]); + online = false; + assert.deepEqual(Buffer.from(await createFontLoader(asset, moduleUrl)()), bytes); + assert.equal(requests, 1); + assert.deepEqual(opened, [cacheName, cacheName]); +}); + +test("a corrupt cached font and invalid network bytes cannot render or replace a previous digest", async t => { + const {responses} = storage(t); + const previous = url.replace("test.otf", "earlier.otf"); + responses.set(previous, new Response(bytes)); + const corrupt = Buffer.from(bytes); + corrupt[corrupt.length - 1] ^= 1; + responses.set(url, new Response(corrupt)); + const attempts = [ + new Response(corrupt), + new Response(bytes.subarray(0, bytes.length - 1)), + new Response(Buffer.concat([bytes, bytes])), + new Response(bytes, {status: 206}), + new Response(new Uint8Array(bytes.length)), + new Response(bytes), + ]; + t.mock.method(globalThis, "fetch", async () => { + const response = attempts.shift(); + assert.ok(response, "unexpected fetch"); + return response; + }); + const load = createFontLoader(asset, moduleUrl); + for (const reason of [/integrity/, /incomplete/, /size/, /unavailable/, /invalid/]) { + await assert.rejects(load(), reason); + assert.deepEqual([...responses.keys()], [previous]); + } + assert.deepEqual(Buffer.from(await load()), bytes); + assert.deepEqual([...responses.keys()], [url]); +}); + +test("storage denial and quota failures still allow verified font rendering", async t => { + for (const options of [{denied:true}, {full:true}]) { + await t.test(JSON.stringify(options), async t => { + storage(t, options); + t.mock.method(globalThis, "fetch", async () => new Response(bytes)); + assert.deepEqual(Buffer.from(await createFontLoader(asset, moduleUrl)()), bytes); + }); + } +}); + +test("a transient network failure allows a later request to retry", async t => { + storage(t); + let offline = true; + t.mock.method(globalThis, "fetch", async () => { + if (offline) throw new TypeError("offline"); + return new Response(bytes); + }); + const load = createFontLoader(asset, moduleUrl); + await assert.rejects(load(), /offline/); + offline = false; + assert.deepEqual(Buffer.from(await load()), bytes); +}); + +test("optional faces and their notices stay outside the installed offline shell", async t => { + const site = await fs.mkdtemp(path.join(os.tmpdir(), "ember-font-shell-")); + t.after(() => fs.rm(site, {recursive:true, force:true})); + await fs.mkdir(path.join(site, "optional-fonts")); + await fs.writeFile(path.join(site, asset.path), bytes); + await fs.writeFile(path.join(site, "optional-fonts", "OFL.txt"), "fixture license"); + await fs.writeFile(path.join(site, "ferroui-sw.js"), "// base worker"); + await fs.writeFile(path.join(site, "index.html"), ""); + const manifest = fileURLToPath(new URL("../write-offline-manifest.mjs", import.meta.url)); + execFileSync(process.execPath, [manifest, ".", "--out", site]); + const result = JSON.parse(await fs.readFile(path.join(site, "offline-manifest.json"), "utf8")); + assert.deepEqual(result.files, ["index.html"]); +}); + +test("packaging rejects an invalid offline font mirror without publishing a partial asset", async t => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "ember-font-mirror-")); + t.after(() => fs.rm(directory, {recursive:true, force:true})); + const mirror = path.join(directory, "mirror.otf"); + const site = path.join(directory, "site"); + await fs.writeFile(mirror, bytes); + const packager = fileURLToPath(new URL("../write-offline-manifest.mjs", import.meta.url)); + assert.throws(() => execFileSync(process.execPath, [packager, ".", "--package-font", "--out", site], { + env: {...process.env, EMBER_CJK_FONT: mirror}, stdio: "pipe", + }), /unexpected size or SHA-256 digest/); + assert.deepEqual((await fs.readdir(path.join(site, "optional-fonts"))).sort(), ["NOTICE.txt", "OFL.txt"]); +}); diff --git a/Ember.Browser/tests/platform.test.mjs b/Ember.Browser/tests/platform.test.mjs index 9fdeca7..e198949 100644 --- a/Ember.Browser/tests/platform.test.mjs +++ b/Ember.Browser/tests/platform.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { after, test } from "node:test"; import "fake-indexeddb/auto"; -import { databaseGet, databasePut, databaseRemove, databaseScan, fetchJson, setLocation, validateApiUrl } from "../wwwroot/ember-platform.js"; +import { databaseGet, databasePut, databaseRemove, databaseScan, fetchJson, locationItem, observeLocation, requestBrowserBack, setLocation, validateApiUrl } from "../wwwroot/ember-platform.js"; const originalFetch = globalThis.fetch; after(() => { globalThis.fetch = originalFetch; }); @@ -114,3 +114,140 @@ test("reader navigation retains rendering options and does not duplicate browser if (history === undefined) delete globalThis.history; else globalThis.history = history; } }); + +test("legacy discussion URLs can return to the feed and keep unrelated URL state", () => { + const original = { location: globalThis.location, history: globalThis.history }; + const base = "https://example.test/Ember/?Renderer=Vello"; + globalThis.history = { pushState: (_state, _title, href) => { globalThis.location.href = href; } }; + try { + for (const route of ["&id=00042", "&item/42", "#id=42", "#/item/42", "#item=42&debug", "&id=42&item=7#item/9"]) { + globalThis.location = { href: base + route }; + assert.equal(locationItem(globalThis.location.href), "42"); + setLocation("42"); + assert.equal(globalThis.location.href, base + route); + setLocation("64"); + assert.equal(locationItem(globalThis.location.href), "64"); + setLocation(null); + assert.equal(globalThis.location.href, base + (route.includes("debug") ? "#debug" : "")); + } + assert.equal(locationItem(base + "&id=18446744073709551615"), "18446744073709551615"); + assert.equal(locationItem(base + "&id=18446744073709551616"), null); + assert.equal(locationItem(base + "&id=0"), null); + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete globalThis[key]; else globalThis[key] = value; + } + } +}); + +test("reader Back reuses owned entries, queues rapid actions and replaces an initial permalink", t => { + const start = "https://example.test/read/?theme=dark#id=5&debug"; + const entries = [{ href: start, state: { renderer: "native" } }]; + const events = new EventTarget(); + let position = 0; + let moving = false; + const location = { href: start }; + const history = { + get state() { return entries[position].state; }, + pushState(state, _, href) { + entries.splice(++position, Infinity, { state, href }); + location.href = href; + }, + replaceState(state, _, href) { + entries[position] = { state, href }; + location.href = href; + }, + back() { + assert.ok(position > 0 && !moving, "Only one owned traversal may be pending"); + moving = true; + }, + }; + const traverse = delta => { + moving = false; + position += delta; + location.href = entries[position].href; + events.dispatchEvent(new Event("popstate")); + events.dispatchEvent(new Event("hashchange")); + }; + const original = new Map(["location", "history", "navigation", "addEventListener"].map(key => [key, globalThis[key]])); + Object.assign(globalThis, { location, history, navigation: { currentEntry: { index: -1 } }, + addEventListener: events.addEventListener.bind(events) }); + t.after(() => { + for (const [key, value] of original) { + if (value === undefined) delete globalThis[key]; else globalThis[key] = value; + } + }); + const visits = [], directions = []; + observeLocation((href, _, backward) => { visits.push(locationItem(href)); directions.push(backward); }); + assert.equal(requestBrowserBack(), false); + setLocation(null, true); + assert.equal(entries.length, 1); + assert.equal(location.href, "https://example.test/read/?theme=dark#debug"); + assert.equal(history.state.renderer, "native"); + setLocation("10"); + setLocation("20"); + setLocation("10", true); + setLocation(null, true); + setLocation("30"); + assert.equal(entries.length, 3); + traverse(-1); + assert.ok(moving); + traverse(-1); + assert.equal(locationItem(location.href), "30"); + assert.equal(entries.length, 2, "Only opening a new item should truncate Forward"); + assert.deepEqual(visits, [], "Reader-initiated traversals must preserve restored views"); + traverse(-1); + traverse(1); + assert.deepEqual(visits, [null, "30"]); + assert.deepEqual(directions, [true, false]); + assert.equal(requestBrowserBack(), true); + traverse(-1); + assert.deepEqual(visits, [null, "30", null], "A delegated toolbar Back must navigate the reader"); + assert.equal(requestBrowserBack(), false); + for (const address of ["not a URL", "https://unrelated.test/read/?item=4"]) { + entries[position].state.emberPreviousHref = address; + assert.equal(requestBrowserBack(), false); + } +}); + +test("ordered batch reads use one transaction and range cleanup preserves neighboring keys", async () => { + const { databaseCount, databaseGetMany, databaseRemoveMany, databaseRemovePrefix } = await import("../wwwroot/ember-platform.js"); + const prefix = "batch/%_/\uffff"; + const keys = [prefix + "3", prefix + "😀", prefix + "\uffffZ"]; + for (const key of [...keys, "batch/%_/X"]) await databasePut(key, key); + const original = IDBDatabase.prototype.transaction; + let transactions = 0; + IDBDatabase.prototype.transaction = function (...args) { + transactions++; + return original.apply(this, args); + }; + try { + assert.deepEqual(await databaseGetMany([keys[2], prefix + "missing", keys[0], keys[2]]), + [keys[2], null, keys[0], keys[2]]); + assert.equal(transactions, 1); + assert.equal(await databaseCount(prefix), 3); + await databaseRemoveMany([keys[0], keys[1]]); + assert.equal(transactions, 3); + assert.equal(await databaseCount(prefix), 1); + await databaseRemovePrefix(prefix); + assert.equal(await databaseCount(prefix), 0); + assert.equal(await databaseGet("batch/%_/X"), "batch/%_/X"); + } finally { + IDBDatabase.prototype.transaction = original; + } +}); + +test("paged size scans return ordered UTF-8 lengths without returning cached documents", async () => { + const { databaseCount, databaseRemoveMany, databaseRemovePrefix, databaseSizes } = await import("../wwwroot/ember-platform.js"); + const prefix = "\uffff"; + const entries = Array.from({ length: 139 }, (_, i) => [prefix + i.toString().padStart(3, "0"), "Ł😀" + i]); + await Promise.all(entries.map(([key, value]) => databasePut(key, value))); + assert.equal(await databaseCount(prefix), entries.length); + assert.deepEqual(JSON.parse(await databaseSizes(prefix)), entries.map(([key, value]) => [key, Buffer.byteLength(value)])); + assert.deepEqual(JSON.parse(await databaseScan(prefix)), entries); + // An invalid key after the first queued delete aborts the entire transaction. + await assert.rejects(databaseRemoveMany([entries[0][0], null])); + assert.equal(await databaseCount(prefix), entries.length); + await databaseRemovePrefix(prefix); + assert.equal(await databaseCount(prefix), 0); +}); diff --git a/Ember.Browser/tests/scroll-perf.mjs b/Ember.Browser/tests/scroll-perf.mjs new file mode 100644 index 0000000..9ea1cbe --- /dev/null +++ b/Ember.Browser/tests/scroll-perf.mjs @@ -0,0 +1,132 @@ +// CHROME=/path/to/chrome node Ember.Browser/tests/scroll-perf.mjs SITE [SAMPLES] +// Warm main-thread rAF gaps and wheel timestamp to next rAF callback. +// Visible rows are inspected only before and after each input train. +import { setTimeout as sleep } from "node:timers/promises"; +import { open } from "../../FerroUI/scripts/browser/harness.mjs"; + +const [site, repetitions = "5"] = process.argv.slice(2); +const samples = Number(repetitions); +if (!site || !Number.isSafeInteger(samples) || samples < 1) { + throw new Error("Specify an extracted release site and a positive sample count"); +} + +const initScript = `(() => { + const original = fetch.bind(globalThis); + const ids = Array.from({ length: 180 }, (_, i) => 1001 + i); + const kids = Array.from({ length: 119 }, (_, i) => 2000 + i); + const text = "

On a fast reader, moving to the next discussion should be immediate. Nested details and source trees still need to remain readable.

We keep the selected page and reading position while navigating.

"; + globalThis.fetch = async (input, options) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.hostname !== "hacker-news.firebaseio.com") return original(input, options); + const id = Number(/\\/item\\/(\\d+)\\.json$/.exec(url.pathname)?.[1]); + let data = ids; + if (id >= 2000) { + data = { id, type: "comment", by: "user" + id, parent: 1001, text, kids: [], time: 1792573200 }; + } else if (id) { + data = { id, type: "story", by: "reader", title: "Scroll story " + id, + url: "https://example.org/" + id, score: 214, time: 1792573200, + descendants: id === 1001 ? kids.length : 0, kids: id === 1001 ? kids : [] }; + } + return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } }); + }; + + let recording, frameRequest, nextWheel; + function frame(timestamp) { + const now = performance.now(); + recording.frames.push(timestamp); + while (nextWheel < recording.wheels.length) recording.wheels[nextWheel++].nextFrame = now; + frameRequest = requestAnimationFrame(frame); + } + addEventListener("wheel", event => { + if (recording) recording.wheels.push({ timestamp: event.timeStamp, nextFrame: null }); + }, { passive: true, capture: true }); + globalThis.startScrollSample = () => { + recording = { frames: [], wheels: [] }; + nextWheel = 0; + frameRequest = requestAnimationFrame(frame); + }; + globalThis.endScrollSample = () => { + cancelAnimationFrame(frameRequest); + const result = recording; + recording = null; + return result; + }; +})();`; + +const page = await open(site, { width: 1280, height: 800, initScript }); +const elements = "JSON.parse(ember.emberState()).elements"; +const wheelCount = 90; + +async function firstVisible(pane) { + const prefix = pane === "feed" ? "Scroll story " : "View user"; + return page.evaluate(`${elements}.find(e => e.hit && e.automationName?.startsWith(${JSON.stringify(prefix)}))?.automationName`); +} + +function percentile(values, fraction) { + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction)); + return Number(sorted[index].toFixed(2)); +} + +async function scroll(pane, deltaY) { + const from = await firstVisible(pane); + await page.evaluate("startScrollSample()"); + const start = performance.now(); + const sends = []; + // Schedule from the same clock even when acknowledgements take longer than + // a refresh interval. Chromium may coalesce wheel events under load. + for (let i = 0; i < wheelCount; i++) { + const delay = start + i * (1000 / 60) - performance.now(); + if (delay > 0) await sleep(delay); + sends.push(page.send("Input.dispatchMouseEvent", { + type: "mouseWheel", pointerType: "mouse", x: pane === "feed" ? 345 : 980, + y: 550, deltaX: 0, deltaY, + })); + } + await Promise.all(sends); + await sleep(100); + const { frames, wheels } = await page.evaluate("endScrollSample()"); + const to = await firstVisible(pane); + if (!from || !to || from === to || !wheels.length || wheels.at(-1).nextFrame === null) { + throw new Error(`Incomplete ${pane} scroll: ${from} → ${to}, ${wheels.length} delivered events`); + } + const active = frames.filter(t => t >= wheels[0].timestamp && t <= wheels.at(-1).nextFrame); + if (active.length < 2) throw new Error(`Too few animation frames in the ${pane} sample`); + const gaps = active.slice(1).map((timestamp, i) => timestamp - active[i]); + const input = wheels.map(event => event.nextFrame - event.timestamp); + return { + from, to, deliveredEvents: wheels.length, frameCount: active.length, + frameGapP50Ms: percentile(gaps, 0.5), frameGapP95Ms: percentile(gaps, 0.95), + maxFrameGapMs: Number(Math.max(...gaps).toFixed(2)), gapsOver25Ms: gaps.filter(gap => gap > 25).length, + inputToRafP50Ms: percentile(input, 0.5), inputToRafP95Ms: percentile(input, 0.95), + }; +} + +try { + await page.waitForView(); + await page.waitFor(`${elements}.some(e => e.text === "Unread · 30")`); + await page.waitFor("!!navigator.serviceWorker.controller", 60000); + for (let count = 60; count <= 180; count += 30) { + const button = await page.evaluate(`${elements}.find(e => e.name === "LoadMoreButton" && e.hit)`); + if (!button) throw new Error("Missing Load more before " + count); + await page.click(button.x + button.width / 2, button.y + button.height / 2); + await page.waitFor(`${elements}.some(e => e.text === "Unread · ${count}")`); + } + for (const pane of ["feed", "thread"]) { + if (pane === "thread") { + await page.evaluate('location.hash = "item=1001"'); + await page.waitFor(`${elements}.some(e => e.automationName === "View user2000’s profile")`, 30000); + } + await scroll(pane, 12); + await scroll(pane, -12); + const forward = []; + for (let sample = 0; sample < samples; sample++) { + forward.push(await scroll(pane, 12)); + await scroll(pane, -12); + } + console.log(JSON.stringify({ pane, wheelCount, deltaY: 12, forward })); + } + if (page.errors.length) throw new Error(page.errors.join("\n")); +} finally { + await page.close(); +} diff --git a/Ember.Browser/tests/shell-update.mjs b/Ember.Browser/tests/shell-update.mjs new file mode 100644 index 0000000..4d76829 --- /dev/null +++ b/Ember.Browser/tests/shell-update.mjs @@ -0,0 +1,57 @@ +// Exercise a shell upgrade on a disposable copy of a packaged, single-module site. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { open } from "../../FerroUI/scripts/browser/harness.mjs"; + +if (!process.argv[2]) throw new Error("Usage: node shell-update.mjs SITE"); +const buildManifest = fileURLToPath(new URL("../write-offline-manifest.mjs", import.meta.url)); +const folder = fs.mkdtempSync(path.join(os.tmpdir(), "ember-update-")); +const site = path.join(folder, "site"); +const initScript = `const get = globalThis.fetch.bind(globalThis); +globalThis.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + return url.hostname === "hacker-news.firebaseio.com" ? Promise.resolve(Response.json([])) : get(input, init); +};`; +let page; +try { + fs.cpSync(path.resolve(process.argv[2]), site, { recursive: true }); + const initial = JSON.parse(fs.readFileSync(path.join(site, "offline-manifest.json"))); + const modules = Object.keys(initial.moduleHashes); + assert.equal(modules.length, 1); + page = await open(site, { width: 390, height: 844, initScript }); + await page.waitFor("!!navigator.serviceWorker.controller"); + const key = await page.evaluate('caches.keys().then(keys => keys.find(k => k.startsWith("ember-wasm:")))'); + assert.ok(key.endsWith(initial.moduleHashes[modules[0]])); + fs.appendFileSync(path.join(site, "app.css"), "\n/* shell upgrade probe */\n"); + execFileSync(process.execPath, [buildManifest, ".", "--out", site]); + const next = JSON.parse(fs.readFileSync(path.join(site, "offline-manifest.json"))); + assert.notEqual(next.version, initial.version); + assert.deepEqual(next.moduleHashes, initial.moduleHashes); + + const bytes = fs.statSync(path.join(site, modules[0])).size; + fs.renameSync(path.join(site, modules[0]), path.join(folder, "unavailable.wasm")); + await page.send("Network.clearBrowserCache"); + await page.evaluate(`new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Updated worker did not take control")), 60000); + navigator.serviceWorker.addEventListener("controllerchange", () => { clearTimeout(timeout); resolve(true); }, {once:true}); + navigator.serviceWorker.getRegistration().then(r => r.update()).catch(reject); + })`); + await page.send("Network.enable"); + await page.send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 }); + const origin = await page.evaluate("performance.timeOrigin"); + await page.send("Page.reload", {}); + await page.waitFor(`performance.timeOrigin !== ${origin} && !!globalThis.ember?.emberState && JSON.parse(ember.emberState())?.elements.some(e => e.name === "FeedTitle")`); + const css = await page.evaluate('fetch("./app.css", {cache:"no-store"}).then(r => r.text())'); + assert.ok(css.includes("shell upgrade probe"), "offline reload must use the *new* shell"); + const installed = await page.evaluate("caches.keys()"); + assert.ok(installed.some(k => k.endsWith(":" + next.version))); + assert.ok(!installed.some(k => k.endsWith(":" + initial.version))); + console.log(`Passed: shell updated and reloaded offline with the ${bytes}-byte module unavailable from the server.`); +} finally { + if (page) await page.close(); + fs.rmSync(folder, { recursive: true, force: true }); +} diff --git a/Ember.Browser/tests/startup-perf.mjs b/Ember.Browser/tests/startup-perf.mjs new file mode 100644 index 0000000..84f8afa --- /dev/null +++ b/Ember.Browser/tests/startup-perf.mjs @@ -0,0 +1,74 @@ +// Run against an extracted release site. Each sample gets a new Chromium process +// and profile, then reloads that same site after its offline shell has activated. +import { open, sleep } from "../../FerroUI/scripts/browser/harness.mjs"; + +const [site, count = "5"] = process.argv.slice(2); +if (!site || !Number.isSafeInteger(Number(count)) || Number(count) < 1) { + throw new Error("Usage: node startup-perf.mjs SITE [SAMPLES]"); +} +const initScript = `(() => { + const original = globalThis.fetch.bind(globalThis); + const ids = Array.from({length: 30}, (_, i) => i + 1); + const requests = globalThis.readerRequests = []; + globalThis.fetch = async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input, location.href); + if (url.hostname !== "hacker-news.firebaseio.com") return original(input, init); + requests.push(url.pathname); + const id = Number(url.pathname.match(/item\\/(\\d+)/)?.[1]); + const data = id ? {id, type: "story", by: "perf", title: id === 9001 ? "Benchmark discussion" : "Feed story " + id, kids: []} : ids; + return new Response(JSON.stringify(data), {headers: {"Content-Type": "application/json"}}); + }; + function sample() { + const wasm = globalThis.ember; + if (wasm?.emberState && document.querySelector("canvas")) { + const view = JSON.parse(wasm.emberState()); + if (view?.elements.some(e => e.name === "DetailTitle" && e.text === "Benchmark discussion")) { + globalThis.detailReadyMs = performance.now(); + return; + } + } + requestAnimationFrame(sample); + } + requestAnimationFrame(sample); +})();`; + +async function read(page) { + await page.waitFor("globalThis.detailReadyMs > 0"); + await sleep(300); + return page.evaluate(`(() => { + const wasm = performance.getEntriesByType("resource").filter(e => new URL(e.name).pathname.endsWith(".wasm")); + if (!wasm.length) throw new Error("Missing Wasm resource timing"); + return { + wasmStartMs: Math.round(wasm[0].startTime), + wasmEndMs: Math.round(wasm[0].responseEnd), + detailReadyMs: Math.round(detailReadyMs), + wasmRequests: wasm.length, + feedRequests: readerRequests.filter(p => p !== "/v0/item/9001.json").length + }; + })()`); +} +const cold = [], warm = []; +for (let i = 0; i < Number(count); i++) { + const page = await open(site, { width: 390, height: 844, query: "?item=9001", initScript, navigate: false }); + try { + await page.send("Network.enable"); + await page.send("Network.emulateNetworkConditions", { + offline: false, latency: 50, downloadThroughput: 12 * 1024 * 1024, uploadThroughput: 12 * 1024 * 1024, + }); + await page.navigate(); + cold.push(await read(page)); + await page.waitFor("!!navigator.serviceWorker.controller", 60000); + const origin = await page.evaluate("performance.timeOrigin"); + await page.send("Page.reload", {}); + await page.waitFor(`performance.timeOrigin !== ${origin}`); + warm.push(await read(page)); + } finally { await page.close(); } +} +function medians(samples) { + return Object.fromEntries(Object.keys(samples[0]).map(key => { + const values = samples.map(s => s[key]).sort((a, b) => a - b); + const mid = Math.floor(values.length / 2); + return [key, values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2]; + })); +} +console.log(JSON.stringify({cold, warm, coldMedian: medians(cold), warmMedian: medians(warm)}, null, 2)); diff --git a/Ember.Browser/tests/storage-perf.mjs b/Ember.Browser/tests/storage-perf.mjs new file mode 100644 index 0000000..91c0ac9 --- /dev/null +++ b/Ember.Browser/tests/storage-perf.mjs @@ -0,0 +1,71 @@ +// CHROME=/path/to/chrome node Ember.Browser/tests/storage-perf.mjs +// Compare storage bridge calls on identical records, without network or renderer work. +import { copyFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { open } from "../../FerroUI/scripts/browser/harness.mjs"; + +const site = await mkdtemp(path.join(os.tmpdir(), "ember-idb-bench-")); +let page; +try { + await copyFile(new URL("../wwwroot/ember-platform.js", import.meta.url), path.join(site, "ember-platform.js")); + await writeFile(path.join(site, "index.html"), "Ember storage benchmark"); + page = await open(site); + const results = await page.evaluate(`(async () => { + const db = await import("./ember-platform.js"); + const keys = Array.from({ length: 1001 }, (_, i) => "cache:item:" + (i + 1)); + const text = "x".repeat(1024); + for (let i = 0; i < keys.length; i += 32) { + await Promise.all(keys.slice(i, i + 32).map((key, j) => db.databasePut(key, + JSON.stringify({ fetched_at: 1, value: { id: i + j + 1, text } })))); + } + async function measure(batched) { + const transaction = IDBDatabase.prototype.transaction; + let transactions = 0, checksum = 0; + IDBDatabase.prototype.transaction = function (...args) { + transactions++; + return transaction.apply(this, args); + }; + const read = raw => { checksum += JSON.parse(raw).value.id; }; + const start = performance.now(); + try { + read(await db.databaseGet(keys[0])); + const width = batched ? 32 : 8; + for (let i = 1; i < keys.length; i += width) { + const group = keys.slice(i, i + width); + const values = batched ? await db.databaseGetMany(group) : await Promise.all(group.map(db.databaseGet)); + values.forEach(read); + } + if (batched) { + for (let i = 0; i < keys.length; i += 32) { + (await db.databaseGetMany(keys.slice(i, i + 32))).forEach(read); + } + } else { + for (const key of keys) read(await db.databaseGet(key)); + } + const ms = performance.now() - start; + if (checksum !== keys.length * (keys.length + 1)) throw new Error("Read checksum mismatch"); + return { ms, transactions }; + } finally { + IDBDatabase.prototype.transaction = transaction; + } + } + await measure(false); + await measure(true); + const scalar = [], batch = []; + for (let sample = 0; sample < 9; sample++) { + for (const batched of sample % 2 ? [true, false] : [false, true]) { + (batched ? batch : scalar).push(await measure(batched)); + } + } + return { scalar, batch }; + })()`); + for (const [name, samples] of Object.entries(results)) { + samples.sort((a, b) => a.ms - b.ms); + const { ms, transactions } = samples[Math.floor(samples.length / 2)]; + console.log(`${name}: ${ms.toFixed(2)} ms median, ${transactions} transactions (${samples.length} samples)`); + } +} finally { + if (page) await page.close(); + await rm(site, { recursive: true, force: true }); +} diff --git a/Ember.Browser/write-offline-manifest.mjs b/Ember.Browser/write-offline-manifest.mjs index 0641dfc..06ca326 100644 --- a/Ember.Browser/write-offline-manifest.mjs +++ b/Ember.Browser/write-offline-manifest.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; +import { prepareCjkFont } from "./prepare-cjk-font.mjs"; const [root, ...args] = process.argv.slice(2); let out; @@ -15,6 +16,7 @@ for (let index = 0; index < args.length; ++index) { } const target = process.env.CARGO_TARGET_DIR ?? path.join(root, "target"); out = path.resolve(out ?? path.join(target, mode + (features ? "-" + features.replaceAll(",", "-") : ""), "ember-browser")); +if (args.includes("--package-font")) await prepareCjkFont(out); if (mode !== "browser") { console.log("Ember IndexedDB offline data is enabled. Shell precaching is available in the default non-threaded build."); process.exit(0); @@ -23,16 +25,38 @@ const files = []; function visit(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const absolute = path.join(directory, entry.name); - if (entry.isDirectory()) visit(absolute); + if (entry.isDirectory()) { + if (absolute !== path.join(out, "optional-fonts")) visit(absolute); + } else if (!entry.name.endsWith(".map") && !["offline-manifest.json", "ferroui-sw.js"].includes(entry.name)) files.push(path.relative(out, absolute).replaceAll(path.sep, "/")); } } visit(out); files.sort(); +const modules = files.filter(file => file.endsWith(".wasm")); +const html = path.join(out, "index.html"); +if (modules.length === 1 && /^[a-zA-Z0-9_.-]+$/.test(modules[0])) { + const markup = fs.readFileSync(html, "utf8").replace("", + ``); + fs.writeFileSync(html, markup); +} const hash = crypto.createHash("sha256"); -for (const file of files) { hash.update(file); hash.update(fs.readFileSync(path.join(out, file))); } -hash.update(fs.readFileSync(path.join(out, "ferroui-sw.js"))); +const moduleHashes = {}; +for (const file of files) { + hash.update(file); + const moduleHash = modules.includes(file) ? crypto.createHash("sha256") : null; + for await (const chunk of fs.createReadStream(path.join(out, file))) { + hash.update(chunk); + moduleHash?.update(chunk); + } + if (moduleHash) moduleHashes[file] = moduleHash.digest("base64"); +} +const worker = path.join(out, "ferroui-sw.js"); +// Rebuilding the manifest for an existing site must not recursively hash or +// append the previous version's generated worker configuration. +const baseWorker = fs.readFileSync(worker, "utf8").split("\nself.EMBER_SHELL_VERSION = ")[0]; +hash.update(baseWorker); const version = hash.digest("hex").slice(0, 20); -fs.writeFileSync(path.join(out, "offline-manifest.json"), JSON.stringify({ version, files }, null, 2) + "\n"); -fs.appendFileSync(path.join(out, "ferroui-sw.js"), `\nself.EMBER_SHELL_VERSION = ${JSON.stringify(version)};\nself.EMBER_SHELL_FILES = ${JSON.stringify(files)};\nimportScripts("./ember-offline.js");\n`); +fs.writeFileSync(path.join(out, "offline-manifest.json"), JSON.stringify({ version, files, moduleHashes }, null, 2) + "\n"); +fs.writeFileSync(worker, baseWorker + `\nself.EMBER_SHELL_VERSION = ${JSON.stringify(version)};\nself.EMBER_SHELL_FILES = ${JSON.stringify(files)};\nself.EMBER_MODULE_HASHES = ${JSON.stringify(moduleHashes)};\nimportScripts("./ember-offline.js");\n`); console.log(`Ember offline shell: ${files.length} files, version ${version}`); diff --git a/Ember.Browser/wwwroot/app.css b/Ember.Browser/wwwroot/app.css index 6d3896d..e89cd51 100644 --- a/Ember.Browser/wwwroot/app.css +++ b/Ember.Browser/wwwroot/app.css @@ -1,17 +1,44 @@ -/* Only the app's loading/error surface is HTML. The reader itself is FerroUI. */ -:root { color-scheme: light dark; font-family: Inter, "Segoe UI", system-ui, sans-serif; } -html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #f6f4f0; } +/* The reader itself is a FerroUI control tree. */ +:root { + color-scheme: light dark; + font-family: "Segoe UI", system-ui, sans-serif; + --paper: #FAF8F5; + --ink: #242321; + --muted: #625D57; + --line: #DEDCD8; + --accent: #983C1E; +} +@media (prefers-color-scheme: dark) { + :root { --paper: #1A1B1D; --ink: #F5F1EC; --muted: #B6B2AE; --line: #393A3C; --accent: #FFB28C; } +} +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: var(--paper); color: var(--ink); } #out { position: relative; width: 100%; height: 100%; height: 100dvh; } -.ferroui-splash, .boot-error { box-sizing: border-box; position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; background: #f6f4f0; color: #242423; text-align: center; } +.boot-surface { + position: absolute; + inset: 0; + display: flex; + box-sizing: border-box; + overflow: auto; + padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) + max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); + background: var(--paper); +} +.boot-content { width: min(100%, 28rem); margin: auto; text-align: center; } +.boot-content img { display: block; margin: 0 auto; } +.boot-content h1 { margin: 14px 0 4px; font-size: 1.625rem; font-weight: 650; letter-spacing: -.035em; text-wrap: balance; } +.boot-content h1:focus { outline: none; } +.boot-content p { margin: 6px 0 16px; color: var(--muted); line-height: 1.5; } +.boot-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 4px 12px; } +.boot-link { display: inline-flex; align-items: center; min-height: 44px; padding-inline: 4px; color: var(--accent); text-underline-offset: .18em; } +.ferroui-splash .boot-link { pointer-events: auto; margin-top: 12px; } .ferroui-splash { pointer-events: none; } -.ferroui-splash h1 { font-size: 36px; font-weight: 650; letter-spacing: -1.5px; margin: 16px 0 4px; } -.ferroui-splash p { color: #716b63; margin: 8px 0 28px; } -.loading-line { width: 96px; height: 3px; border-radius: 3px; background: #e6ddd5; overflow: hidden; } -.loading-line::after { content: ""; display: block; width: 50%; height: 100%; border-radius: inherit; background: #d95b32; animation: ember-loading 1.2s ease-in-out infinite alternate; } .ferroui-splash.splash-close { display: none; } -.boot-error h1 { font-size: 24px; } -.boot-error p { max-width: 500px; line-height: 1.6; overflow-wrap: anywhere; } -.boot-error button { border: 0; background: #bd4622; color: white; padding: 12px 24px; min-height: 48px; border-radius: 8px; font: inherit; cursor: pointer; } -@keyframes ember-loading { from { transform: translateX(-60%); } to { transform: translateX(160%); } } -@media (prefers-color-scheme: dark) { html, body, .ferroui-splash, .boot-error { background: #181a1c; color: #f4f0e9; } .ferroui-splash p { color: #b5afa6; } .loading-line { background: #35312e; } } -@media (prefers-reduced-motion: reduce) { .loading-line::after { animation: none; width: 100%; } } +.loading-line { display: block; width: 88px; height: 3px; margin: auto; border-radius: 3px; background: var(--line); overflow: hidden; } +.loading-line::after { content: ""; display: block; width: 40%; height: 100%; border-radius: inherit; background: var(--accent); animation: loading 1.1s ease-in-out infinite alternate; } +@keyframes loading { to { transform: translateX(150%); } } +@media (prefers-reduced-motion: reduce) { .loading-line::after { animation: none; } } +.boot-content button { min-height: 44px; border: 1px solid transparent; border-radius: 6px; padding: 7px 18px; background: var(--ink); color: var(--paper); font: inherit; font-weight: 600; cursor: pointer; } +.boot-content :is(button, summary, a):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.boot-content details { margin-top: 16px; color: var(--muted); font-size: .875rem; } +.boot-content summary { width: fit-content; margin: auto; padding: 12px 4px; cursor: pointer; } +.boot-content pre { padding: 12px; margin: 0; border: 1px solid var(--line); border-radius: 6px; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/Ember.Browser/wwwroot/ember-offline.js b/Ember.Browser/wwwroot/ember-offline.js index c6ed4f5..5d9266c 100644 --- a/Ember.Browser/wwwroot/ember-offline.js +++ b/Ember.Browser/wwwroot/ember-offline.js @@ -7,21 +7,64 @@ const files = self.EMBER_SHELL_FILES.map(file => new URL(file, scope).href); const known = new Set(files); const index = new URL("index.html", scope).href; + const shellName = prefix + self.EMBER_SHELL_VERSION; + const modulePrefix = `ember-wasm:${scope}:`; + const modules = new Map(); + for (const path of self.EMBER_SHELL_FILES.filter(file => file.endsWith(".wasm"))) { + const digest = self.EMBER_MODULE_HASHES?.[path]; + modules.set(new URL(path, scope).href, { + name: digest ? modulePrefix + digest : shellName, + integrity: digest ? "sha256-" + digest : "", + }); + } + + async function cacheModule(cache, file, integrity) { + // A style or JavaScript-only update can use the exact previously installed + // module in place. Check the key without opening or copying its body. + if ((await cache.keys(file)).length) return; + const request = new Request(file, { integrity }); + try { + await cache.add(request); + } catch (error) { + // Chromium can reject a streamed cache write for a large module. + if (error.name !== "NetworkError") throw error; + const response = await fetch(request); + if (response.status !== 200) { + throw new Error("Cannot cache the offline module (HTTP " + response.status + ")"); + } + const bytes = await response.arrayBuffer(); + const headers = new Headers(response.headers); + headers.delete("Content-Encoding"); + headers.set("Content-Length", String(bytes.byteLength)); + await cache.put(file, new Response(bytes, { headers })); + } + } + self.addEventListener("install", event => { event.waitUntil((async () => { - const cache = await caches.open(prefix + self.EMBER_SHELL_VERSION); - // Either the complete shell is ready, or installation fails and the - // previously working worker keeps serving its complete version. - await cache.addAll(files); + const created = new Set(); + async function open(name) { + if (!await caches.has(name)) created.add(name); + return caches.open(name); + } + try { + const shell = await open(shellName); + await shell.addAll(files.filter(file => !modules.has(file))); + for (const [file, { name, integrity }] of modules) { + await cacheModule(name === shellName ? shell : await open(name), file, integrity); + } + } catch (error) { + // Leave caches belonging to the active worker intact on a failed upgrade. + for (const name of created) await caches.delete(name); + throw error; + } })()); }); self.addEventListener("activate", event => { event.waitUntil((async () => { - // The version is embedded by the build wrapper. Activation must not - // require the network after a successfully installed shell. - const keep = prefix + self.EMBER_SHELL_VERSION; + const keep = new Set([shellName, ...Array.from(modules.values(), module => module.name)]); for (const key of await caches.keys()) { - if (key.startsWith(prefix) && key !== keep) await caches.delete(key); + if ((key.startsWith(prefix) || key.startsWith(modulePrefix)) && !keep.has(key)) await caches.delete(key); } })()); }); @@ -36,7 +79,7 @@ // platform's generated download URLs belong to its existing handler. if (!isIndex && !known.has(url.href)) return; event.respondWith((async () => { - const cache = await caches.open(prefix + self.EMBER_SHELL_VERSION); + const cache = await caches.open(modules.get(url.href)?.name ?? shellName); const key = isIndex ? index : request; const cached = await cache.match(key, { ignoreSearch: true }); return cached ?? fetch(request); diff --git a/Ember.Browser/wwwroot/ember-platform.js b/Ember.Browser/wwwroot/ember-platform.js index ce7e060..3651aa7 100644 --- a/Ember.Browser/wwwroot/ember-platform.js +++ b/Ember.Browser/wwwroot/ember-platform.js @@ -139,44 +139,346 @@ export function databaseRemove(key) { return transaction("readwrite", store => { store.delete(key); }); } -export function databaseScan(prefix) { +// IndexedDB orders string keys by UTF-16 code units. A true exclusive end +// includes suffixes containing U+FFFF, without inspecting unrelated keys. +function prefixRange(prefix) { + for (let i = prefix.length - 1; i >= 0; --i) { + const unit = prefix.charCodeAt(i); + if (unit !== 0xffff) { + return IDBKeyRange.bound(prefix, prefix.slice(0, i) + String.fromCharCode(unit + 1), false, true); + } + } + // Binary keys sort immediately after all string keys. + try { + return IDBKeyRange.bound(prefix, new Uint8Array(0), false, true); + } catch (error) { + // Some implementations reject zero-length binary keys. For those, + // a single zero byte is the smallest admissible binary key. + if (error.name !== "DataError") throw error; + return IDBKeyRange.bound(prefix, new Uint8Array([0]), false, true); + } +} + +export function databaseGetMany(keys) { + return transaction("readonly", (store, answer) => { + const values = Array(keys.length).fill(null); + answer(values); + keys.forEach((key, index) => { + const request = store.get(key); + request.onsuccess = () => { values[index] = request.result ?? null; }; + }); + }); +} + +export function databaseRemoveMany(keys) { + return transaction("readwrite", store => { + for (const key of keys) store.delete(key); + }); +} + +export function databaseRemovePrefix(prefix) { + return transaction("readwrite", store => { store.delete(prefixRange(prefix)); }); +} + +export function databaseCount(prefix) { + return transaction("readonly", (store, answer) => { + const request = store.count(prefixRange(prefix)); + request.onsuccess = () => answer(request.result); + }); +} + +// A cursor posts a separate success callback for every record. Pair bounded +// getAllKeys/getAll requests on the same read snapshot; enqueue each next +// page before leaving the callback so the transaction stays active. +function scanRecords(prefix, project) { return transaction("readonly", (store, answer) => { const records = []; - // Core keys are ASCII namespaces (item/, state/, feed/, ...). Iterating - // from lowerBound also handles arbitrary Unicode values without a sentinel. - const request = store.openCursor(IDBKeyRange.lowerBound(prefix)); - request.onsuccess = () => { - const cursor = request.result; - if (!cursor || typeof cursor.key !== "string" || !cursor.key.startsWith(prefix)) { - answer(JSON.stringify(records)); - return; - } - if (typeof cursor.value !== "string") { - request.transaction?.abort(); - return; - } - records.push([cursor.key, cursor.value]); - cursor.continue(); - }; + const range = prefixRange(prefix); + const pageSize = 64; + function readPage(page) { + const keys = store.getAllKeys(page, pageSize); + const values = store.getAll(page, pageSize); + values.onsuccess = () => { + const pageKeys = keys.result; + const pageValues = values.result; + for (let i = 0; i < pageKeys.length; i++) { + const value = pageValues[i]; + if (typeof value !== "string") { + store.transaction.abort(); + return; + } + records.push([pageKeys[i], project(value)]); + } + if (pageKeys.length === pageSize) { + readPage(IDBKeyRange.bound(pageKeys[pageSize - 1], range.upper, true, true)); + } else { + answer(JSON.stringify(records)); + } + }; + } + readPage(range); }); } +export function databaseScan(prefix) { + return scanRecords(prefix, value => value); +} + +export function databaseSizes(prefix) { + const encoder = new TextEncoder(); + return scanRecords(prefix, value => /[^\x00-\x7f]/.test(value) ? encoder.encode(value).byteLength : value.length); +} + export async function storageEstimate() { const estimate = await globalThis.navigator?.storage?.estimate?.(); return { usage: estimate?.usage ?? null, quota: estimate?.quota ?? null }; } -/** Keep story links shareable and make browser Back/Forward follow the reader. */ -export function setLocation(item) { - // wasm-bindgen represents Option::None as undefined. +// Keep the startup HN link and history writes in sync with the reader's u64 item IDs. +function itemId(value) { + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + const digits = value.replace(/^0+/, ""); + if (!digits || digits.length > 20 || (digits.length === 20 && digits > "18446744073709551615")) return null; + return digits; +} + +export function locationItem(href) { + for (const part of href.split(/[?&#]/)) { + const match = /^(?:item=|id=|\/?item\/)(\d+)$/.exec(part); + const id = match && itemId(match[1]); + if (id) return id; + } + return null; +} + +function discussionAddress(href, item) { + const url = new URL(href); + // A legacy id= or fragment route would otherwise reopen its story on reload. + for (const [name, value] of [...url.searchParams]) { + const namedRoute = (name === "id" || name === "item") && itemId(value); + const pathRoute = value === "" && locationItem(name); + if (namedRoute || pathRoute) url.searchParams.delete(name, value); + } + if (url.hash) { + const parts = url.hash.slice(1).split("&").filter(part => !locationItem(part)); + url.hash = parts.join("&"); + } + if (item !== null) url.searchParams.set("item", item); + return url.href; +} + +// Browser history survives a reload, while reader visits belong to one running +// document. Stamp entries lazily so a previous document's keys cannot alias ours. +const historySession = Array.from(crypto.getRandomValues(new Uint32Array(4))).join("-"); +let nextEntry = 1; +let lastAddress; +let lastEntry = 0; +let lastPosition; +let previousAddress; +let reportEntry; +let pendingBack = null; +const queuedChanges = []; + +function currentEntry() { + const history = globalThis.history, href = globalThis.location.href; + const state = history.state; + if (state?.emberSession === historySession && state.emberHref === href && state.emberEntry > 0) { + return state.emberEntry; + } + const id = nextEntry++; + history.replaceState({ ...state, emberSession: historySession, emberEntry: id, emberHref: href, + emberPreviousHref: state?.emberHref === href ? state.emberPreviousHref : null, + emberPreviousEntry: 0 }, "", href); + return id; +} + +function rememberAddress() { + lastAddress = globalThis.location.href; + lastEntry = currentEntry(); + const state = globalThis.history.state; + const index = globalThis.navigation?.currentEntry?.index; + lastPosition = Number.isInteger(index) && index >= 0 ? index : state.emberPosition; + previousAddress = state.emberPreviousHref; +} + +function ownedPredecessor() { + const href = globalThis.location.href, state = globalThis.history.state; + if (state?.emberHref !== href || typeof state.emberPreviousHref !== "string") return null; + try { + const previous = new URL(state.emberPreviousHref), current = new URL(href); + if (previous.origin === current.origin && previous.pathname === current.pathname) { + return { href: previous.href, entry: state.emberPreviousEntry || 0 }; + } + } catch { + // An old or foreign history.state can have an unusable predecessor. + } + return null; +} + +// A tab switch can discard the native route stack. Only traverse a predecessor +// written by this reader; a direct permalink must not send the user off-site. +export function requestBrowserBack() { + if (!reportEntry) return false; + if (pendingBack) return pendingBack.navigate; + const previous = ownedPredecessor(); + if (!previous) return false; + pendingBack = { ...previous, navigate: true }; + globalThis.history.back(); + return true; +} + +// popstate and hashchange can both describe the same traversal. When Ember +// initiated Back, its restored view can be a profile or an unfinished form. +export function observeLocation(navigate, committed = () => {}) { + reportEntry = committed; + rememberAddress(); + committed(lastEntry); + const changed = () => { + const href = globalThis.location.href; + const entry = currentEntry(); + if (href === lastAddress && entry === lastEntry) return; + const from = lastPosition, previous = previousAddress; + rememberAddress(); + const backward = Number.isInteger(from) && Number.isInteger(lastPosition) + ? lastPosition < from : href === previous; + const pending = pendingBack; + const restored = pending?.href === href && (!pending.entry || pending.entry === entry); + pendingBack = null; + if (!restored || (pending.navigate && !queuedChanges.length)) { + queuedChanges.length = 0; + navigate(href, entry, backward || restored); + return; + } + committed(entry); + while (queuedChanges.length && !pendingBack) { + const [item, back] = queuedChanges.shift(); + setLocation(item, back); + } + }; + globalThis.addEventListener("popstate", changed); + globalThis.addEventListener("hashchange", changed); +} + +/** Keep reader Back on the browser's existing stack, including its Forward entries. */ +export function setLocation(item, back = false) { item = item ?? null; - const url = new URL(globalThis.location.href); - const hashItem = /^(?:#item=|#\/?item\/)(\d+)$/.exec(url.hash)?.[1] ?? null; - const current = url.searchParams.get("item") ?? hashItem; - if (current === item) return; - if (item === null || item === undefined) url.searchParams.delete("item"); - else if (/^[1-9]\d*$/.test(item)) url.searchParams.set("item", item); - else return; - if (hashItem !== null) url.hash = ""; - globalThis.history.pushState({ emberItem: item ?? null }, "", url.href); + if (item !== null && itemId(item) !== item) return; + if (pendingBack) { + queuedChanges.push([item, back]); + reportEntry?.(0); + return; + } + const href = globalThis.location.href; + if (locationItem(href) === item) { + if (reportEntry) reportEntry(currentEntry()); + return; + } + const history = globalThis.history; + const address = discussionAddress(href, item); + const entry = history.state; + const previous = back && reportEntry ? ownedPredecessor() : null; + if (previous && locationItem(previous.href) === item) { + pendingBack = { ...previous, navigate: false }; + reportEntry?.(pendingBack.entry); + history.back(); + return; + } + const key = nextEntry++; + const position = entry?.emberPosition ?? 0; + const stamp = { emberSession: historySession, emberEntry: key, emberHref: address, + emberItem: item, emberPosition: back ? position : position + 1 }; + if (back) { + // A direct permalink or a legacy entry has no known Ember predecessor. + // Keep the preceding site's entry intact when returning to the feed. + history.replaceState({ ...entry, ...stamp, emberPreviousHref: null, emberPreviousEntry: 0 }, "", address); + } else { + const previousEntry = reportEntry ? currentEntry() : 0; + history.pushState({ ...stamp, emberPreviousHref: href, emberPreviousEntry: previousEntry }, "", address); + } + if (reportEntry) { + rememberAddress(); + reportEntry(key); + } } + +// This face is fetched only when the reader needs a glyph from its CJK fallback. +// The packager uses the same immutable identity. No text enters an asset request. +export const cjkFontAsset = Object.freeze({ + path: "optional-fonts/NotoSansCJKsc-Regular.2c76254f6fc379fd.otf", + size: 16437364, + sha256: "2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b", +}); + +async function readVerifiedFont(response, asset) { + if (response.status !== 200 || !response.body) throw new Error("The optional reader font is unavailable."); + const bytes = new Uint8Array(asset.size); + const reader = response.body.getReader(); + let offset = 0; + try { + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + if (value.byteLength > bytes.length - offset) { + await reader.cancel(); + throw new Error("The optional reader font exceeds its expected size."); + } + bytes.set(value, offset); + offset += value.byteLength; + } + } finally { + reader.releaseLock(); + } + if (offset !== bytes.length || bytes[0] !== 0x4f || bytes[1] !== 0x54 || bytes[2] !== 0x54 || bytes[3] !== 0x4f) { + throw new Error("The optional reader font is incomplete or invalid."); + } + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + const digest = Array.from(hash, byte => byte.toString(16).padStart(2, "0")).join(""); + if (digest !== asset.sha256) throw new Error("The optional reader font failed its integrity check."); + return bytes; +} + +export function createFontLoader(asset, moduleUrl) { + const root = new URL("./", moduleUrl); + const url = new URL(asset.path, root).href; + const name = "ember-fonts:" + root.href; + let pending; + async function read() { + let cache; + try { + cache = await globalThis.caches?.open(name); + const cached = await cache?.match(url); + if (cached) { + try { + return await readVerifiedFont(cached, asset); + } catch { + await cache.delete(url); + } + } + } catch { + // CacheStorage may be blocked in private browsing. Rendering can still proceed. + } + const response = await fetch(url, { + mode: "same-origin", credentials: "omit", redirect: "error", referrerPolicy: "no-referrer", cache: "no-store", + }); + const bytes = await readVerifiedFont(response, asset); + if (cache) { + try { + await cache.put(url, new Response(bytes, {headers: {"Content-Type": "font/otf"}})); + for (const key of await cache.keys()) { + if (key.url !== url) await cache.delete(key); + } + } catch { + // Quota failures must not prevent use of the verified font in this session. + } + } + return bytes; + } + return () => { + // Coalesce concurrent glyph misses, then release the JS buffer after the native copy. + // A failed attempt can be retried after connectivity or storage access changes. + pending ??= read().finally(() => { pending = undefined; }); + return pending; + }; +} + +export const loadCjkFallbackFont = createFontLoader(cjkFontAsset, import.meta.url); diff --git a/Ember.Browser/wwwroot/index.html b/Ember.Browser/wwwroot/index.html index 9d95397..c4503b5 100644 --- a/Ember.Browser/wwwroot/index.html +++ b/Ember.Browser/wwwroot/index.html @@ -3,22 +3,56 @@ - - + + + Ember — Hacker News + + + +
-
- -

Ember

-

A little curiosity, every day.

- +
+
+ +

Ember

+

Opening your reader…

+ + Read Hacker News +
+
+ diff --git a/Ember.Browser/wwwroot/main.js b/Ember.Browser/wwwroot/main.js index 2760dae..0a2ce07 100644 --- a/Ember.Browser/wwwroot/main.js +++ b/Ember.Browser/wwwroot/main.js @@ -1,35 +1,82 @@ -import { FerroExports } from "./ferroui.js"; -import { loadModule } from "./ferroui-loader.js"; +import { locationItem, observeLocation } from "./ember-platform.js"; const host = document.getElementById("out"); -try { + +// Accept the same public discussion ID forms as the reader. Only the decimal ID +// is carried across to the fixed Hacker News origin. +function setSourceLink(link) { + const id = locationItem(location.href); + if (id) { + link.href = "https://news.ycombinator.com/item?id=" + id; + link.textContent = "Read discussion on Hacker News"; + } +} +setSourceLink(host.querySelector(".boot-link")); + +function showError(message, detail) { + const panel = document.getElementById("startup-error").content.cloneNode(true); + panel.querySelector("p").textContent = message; + setSourceLink(panel.querySelector(".boot-link")); + panel.querySelector("pre").textContent = detail; + panel.querySelector("button").addEventListener("click", () => location.reload()); + host.replaceChildren(panel); + host.querySelector("h1").focus(); +} + +async function startReader() { + if (typeof WebAssembly !== "object") { + showError("Open Ember in a browser with WebAssembly enabled, or continue on Hacker News.", + "WebAssembly is unavailable in this browser."); + return; + } + // The build supplies this hint only for the single-module site. Combined + // builds let FerroUI choose between plain and threaded modules first. + const wasm = document.querySelector('meta[name="ember-wasm"]'); + if (wasm) { + const preload = document.createElement("link"); + preload.rel = "preload"; + preload.as = "fetch"; + preload.type = "application/wasm"; + preload.crossOrigin = "anonymous"; + preload.href = wasm.content; + document.head.append(preload); + const glue = document.createElement("link"); + glue.rel = "modulepreload"; + glue.href = "./ember-browser.js"; + document.head.append(glue); + } + const [{ FerroExports }, { loadModule }] = await Promise.all([ + import("./ferroui.js"), + import("./ferroui-loader.js"), + ]); const loaded = await loadModule({ script: "./ember-browser.js", element: host }); if (loaded) { const runtime = await loaded.createRuntime(); FerroExports.attach(runtime); globalThis.ember = runtime; + observeLocation((href, entry, backward) => runtime.emberNavigate(href, entry, backward), + entry => runtime.emberSetBrowserEntry?.(entry)); runtime.runMain(globalThis.location.href); - const navigate = () => runtime.emberNavigate(globalThis.location.href); - globalThis.addEventListener("popstate", navigate); - globalThis.addEventListener("hashchange", navigate); + globalThis.addEventListener("online", () => runtime.emberRetryCjkFont?.()); if ("serviceWorker" in navigator && isSecureContext && navigator.onLine !== false && !globalThis.ferrouiModule?.threads) { - // The build wrapper adds offline shell support to the same service - // worker used by FerroUI's file picker; registrations never compete. + // The offline shell and FerroUI file picker share one worker registration. navigator.serviceWorker.register("./ferroui-sw.js", { updateViaCache: "none" }) .catch(error => console.warn("Ember's offline shell is unavailable:", error)); } + } else { + // On a threaded-only site, null means isolation failed and the loader + // placed its specific reason and hosting instructions inside the host. + const reason = host.querySelector("#ferroui-threads-message")?.textContent; + showError("This version needs a browser window that supports isolated threads.", reason || "The runtime could not start isolated threads."); } +} + +try { + await startReader(); } catch (error) { console.error(error); - const panel = document.createElement("section"); - panel.className = "boot-error"; - const title = document.createElement("h1"); - title.textContent = "Ember could not start"; - const detail = document.createElement("p"); - detail.textContent = error instanceof Error ? error.message : String(error); - const retry = document.createElement("button"); - retry.textContent = "Try again"; - retry.addEventListener("click", () => location.reload()); - panel.append(title, detail, retry); - host.replaceChildren(panel); + const message = navigator.onLine === false + ? "Reconnect and reload to finish opening the reader." + : "Reload to try again, or continue on Hacker News."; + showError(message, error instanceof Error ? error.message : String(error)); } diff --git a/Ember.Browser/wwwroot/manifest.webmanifest b/Ember.Browser/wwwroot/manifest.webmanifest index d29e2d8..6be3288 100644 --- a/Ember.Browser/wwwroot/manifest.webmanifest +++ b/Ember.Browser/wwwroot/manifest.webmanifest @@ -2,11 +2,11 @@ "id": "./", "name": "Ember — Hacker News", "short_name": "Ember", - "description": "A thoughtful, touch-friendly Hacker News reader built with FerroUI.", + "description": "Read Hacker News stories and discussions, save favorites, and take conversations offline.", "start_url": "./", "scope": "./", "display": "standalone", - "background_color": "#f6f4f0", - "theme_color": "#D95B32", + "background_color": "#FAF8F5", + "theme_color": "#FAF8F5", "icons": [{ "src": "./icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }] } diff --git a/Ember.Browser/wwwroot/optional-fonts/NOTICE.txt b/Ember.Browser/wwwroot/optional-fonts/NOTICE.txt new file mode 100644 index 0000000..b91a264 --- /dev/null +++ b/Ember.Browser/wwwroot/optional-fonts/NOTICE.txt @@ -0,0 +1,6 @@ +Noto Sans CJK SC Regular 2.004 +Copyright 2014–2021 Adobe (http://www.adobe.com/). +Licensed under the SIL Open Font License, Version 1.1. See OFL.txt. + +This optional font is redistributed without modification. +https://github.com/notofonts/noto-cjk/tree/523d033d6cb47f4a80c58a35753646f5c3608a78 diff --git a/Ember.Browser/wwwroot/optional-fonts/OFL.txt b/Ember.Browser/wwwroot/optional-fonts/OFL.txt new file mode 100644 index 0000000..d952d62 --- /dev/null +++ b/Ember.Browser/wwwroot/optional-fonts/OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Ember.Content/README.md b/Ember.Content/README.md index b00e6c3..7bc0232 100644 --- a/Ember.Content/README.md +++ b/Ember.Content/README.md @@ -65,6 +65,12 @@ hooks `ember-html`, `ember-html-heading`, `ember-html-quote`, `ember-html-list`, and `ember-html-link` allow application styles. Font size, line height, paragraph spacing and code font are configurable; invalid floating-point inputs are normalized before reaching layout. +A host can use `render_with_code_toolbar` to supply code-block actions alongside the +existing link callback and optional Find highlight. The toolbar receives the decoded +source and a callback for switching visual line wrapping. Ember provides a line count, +a native Wrap toggle and a Copy code button in story text, replies and profiles. +Copy uses the complete block, preserving indentation, blank lines and trailing newlines. + ## Validation Build output goes to `$CARGO_TARGET_DIR` (`target` when it is not set). diff --git a/Ember.Content/document.rs b/Ember.Content/document.rs index 2a03f3e..2432590 100644 --- a/Ember.Content/document.rs +++ b/Ember.Content/document.rs @@ -57,7 +57,10 @@ impl HtmlDocument { pub fn plain_text(&self) -> String { let mut result = String::new(); append_blocks(&self.blocks, &mut result); - result.trim().to_owned() + result.truncate(result.trim_end().len()); + let start = result.len() - result.trim_start().len(); + result.drain(..start); + result } /// First appearance order, deduplicated by normalized destination. diff --git a/Ember.Content/find.rs b/Ember.Content/find.rs new file mode 100644 index 0000000..72b6c91 --- /dev/null +++ b/Ember.Content/find.rs @@ -0,0 +1,119 @@ +//! Match byte ranges in the original, decoded text while comparing lowercase strings. +//! Native runs can split a match at any formatting boundary. + +use std::collections::VecDeque; +use std::ops::Range; + +#[derive(Default)] +pub(crate) struct FindRanges { + ranges: VecDeque>, + offset: usize, +} + +impl FindRanges { + pub(crate) fn new(text: &str, needle: &str, budget: &mut usize) -> Self { + if needle.is_empty() || *budget == 0 { + return Self::default(); + } + let folded = text.to_lowercase(); + let mut source = text.char_indices(); + let mut folded_end = 0; + let mut scalar = 0..0; + // A lowercase expansion (e.g. İ → i + combining dot) can change the UTF-8 + // length, or even contain both boundaries of a hit. Walk source scalars + // once, in hit order. Contextual lowercase sigma has the same byte width. + let mut source_scalar = |byte| { + while byte >= folded_end { + let (start, ch) = source.next().expect("byte lies inside folded text"); + scalar = start..start + ch.len_utf8(); + folded_end += ch.to_lowercase().map(char::len_utf8).sum::(); + } + scalar.clone() + }; + let ascii = text.is_ascii(); + let mut ranges: VecDeque> = VecDeque::new(); + for (start, found) in folded.match_indices(needle).take(*budget) { + let end = start + found.len(); + let range = if ascii { + start..end + } else { + source_scalar(start).start..source_scalar(end - 1).end + }; + *budget -= 1; + match ranges.back_mut() { + Some(last) if last.end >= range.start => last.end = range.end, + _ => ranges.push_back(range), + } + } + Self { ranges, offset: 0 } + } + + pub(crate) fn has_hits(&self) -> bool { + !self.ranges.is_empty() + } + + /// Emit exactly the original input, partitioned into marked and unmarked runs. + pub(crate) fn segments<'a>(&mut self, text: &'a str, mut emit: impl FnMut(&'a str, bool)) { + let base = self.offset; + let end = base + text.len(); + let mut pos = base; + while let Some(hit) = self.ranges.front() { + if hit.start >= end { + break; + } + let start = hit.start.max(pos); + if start > pos { + emit(&text[pos - base..start - base], false); + } + pos = hit.end.min(end); + if pos > start { + emit(&text[start - base..pos - base], true); + } + if hit.end > end { + break; + } + self.ranges.pop_front(); + } + if pos < end { + emit(&text[pos - base..], false); + } + self.offset = end; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projects_unicode_lowercase_matches_back_to_original_run_boundaries() { + let mut budget = 128; + let mut hits = FindRanges::new("İstanbul & ΟΣ!", "i\u{307}stan", &mut budget); + let mut segments = Vec::new(); + for part in ["İs", "tan", "bul & ", "ΟΣ!"] { + hits.segments(part, |s, marked| segments.push((s.to_owned(), marked))); + } + assert_eq!( + segments, + [ + ("İs".into(), true), + ("tan".into(), true), + ("bul & ".into(), false), + ("ΟΣ!".into(), false) + ] + ); + let mut hits = FindRanges::new("İstanbul & ΟΣ!", "ς", &mut budget); + let mut marked = Vec::new(); + hits.segments("İstanbul & ΟΣ!", |s, hit| { + if hit { + marked.push(s) + } + }); + assert_eq!(marked, ["Σ"]); + let mut hits = FindRanges::new("İ", "\u{307}", &mut budget); + hits.segments("İ", |s, hit| { + assert_eq!(s, "İ"); + assert!(hit); + }); + } +} diff --git a/Ember.Content/lib.rs b/Ember.Content/lib.rs index a797793..58d2f31 100644 --- a/Ember.Content/lib.rs +++ b/Ember.Content/lib.rs @@ -11,10 +11,14 @@ mod parser; pub use document::{Block, HtmlDocument, Inline, Link}; pub use parser::{safe_link, ParseOptions}; +#[cfg(feature = "render")] +mod find; #[cfg(feature = "render")] mod renderer; #[cfg(feature = "render")] -pub use renderer::{render, RenderOptions}; +pub use renderer::{ + render, render_with_code_toolbar, render_with_find, CodeToolbar, FindHighlight, RenderOptions, +}; #[cfg(test)] mod tests; diff --git a/Ember.Content/parser.rs b/Ember.Content/parser.rs index b01e1a1..0d0c5de 100644 --- a/Ember.Content/parser.rs +++ b/Ember.Content/parser.rs @@ -47,13 +47,31 @@ pub(crate) fn parse(html: &str, options: &ParseOptions) -> HtmlDocument { while !html.is_char_boundary(end) { end -= 1; } + let input = &html[..end]; + let truncated = end < html.len(); + // Titles and many comments contain just a single text node. Use the same whitespace + // and paragraph rules, and defer anything requiring HTML tokenization to html5ever. + if options.max_nodes > 0 + && !input.starts_with('\u{feff}') + && !input.bytes().any(|c| matches!(c, b'<' | b'&' | 0 | b'\r')) + { + let mut blocks = Vec::new(); + let mut inlines = Vec::new(); + append_text(&mut inlines, input, false); + flush(&mut inlines, &mut blocks); + return HtmlDocument { blocks, truncated }; + } + parse_fragment(input, options, truncated) +} + +fn parse_fragment(input: &str, options: &ParseOptions, truncated: bool) -> HtmlDocument { // scraper uses html5ever and an arena-backed tree. Unlike a nested Rc tree, releasing an // adversarially deep source DOM does not recurse through a chain of child destructors. - let fragment = Html::parse_fragment(&html[..end]); + let fragment = Html::parse_fragment(input); let mut state = Parser { remaining: options.max_nodes, max_depth: options.max_depth.min(64), - truncated: end < html.len(), + truncated, }; let blocks = state.blocks(fragment.root_element(), 0); HtmlDocument { @@ -334,11 +352,25 @@ fn flush(inlines: &mut Vec, blocks: &mut Vec) { } normalize_inline_spaces(inlines); let content = std::mem::take(inlines); - if !Inline::plain_text(&content).trim().is_empty() { + if content.iter().any(has_visible_text) { blocks.push(Block::Paragraph(content)); } } +fn has_visible_text(inline: &Inline) -> bool { + match inline { + Inline::Text(text) | Inline::Code(text) => !text.trim().is_empty(), + Inline::Strong(children) + | Inline::Emphasis(children) + | Inline::Underline(children) + | Inline::Strike(children) + | Inline::Link { + content: children, .. + } => children.iter().any(has_visible_text), + Inline::LineBreak => false, + } +} + fn normalize_inline_spaces(inlines: &mut Vec) { fn collapse(inlines: &mut Vec, preceding_space: &mut bool) { for inline in inlines.iter_mut() { @@ -380,7 +412,7 @@ fn normalize_inline_spaces(inlines: &mut Vec) { } fn trim_end(inlines: &mut [Inline]) { match inlines.last_mut() { - Some(Inline::Text(text)) => *text = text.trim_end_matches(' ').to_owned(), + Some(Inline::Text(text)) => text.truncate(text.trim_end_matches(' ').len()), Some(Inline::Strong(children)) | Some(Inline::Emphasis(children)) | Some(Inline::Underline(children)) @@ -394,3 +426,46 @@ fn normalize_inline_spaces(inlines: &mut Vec) { collapse(inlines, &mut true); trim_end(inlines); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_only_fast_path_agrees_with_the_html5_parser() { + for text in [ + "", + " ", + " A headline", + "a > b", + "x\t\n\u{c} y", + " ελληνικά 😸 ", + "\u{a0}", + "\u{a0}A\u{a0}", + "\u{feff}hello", + "a\0b", + "a\r\nb", + "HTML & formatting", + "İΟΣ—\u{2009}ñ", + ] { + for bytes in [0, 1, 3, text.len()] { + for nodes in [0, 1, 8] { + let options = ParseOptions { + max_input_bytes: bytes, + max_nodes: nodes, + max_depth: 0, + }; + let mut end = text.len().min(bytes); + while !text.is_char_boundary(end) { + end -= 1; + } + assert_eq!( + parse(text, &options), + parse_fragment(&text[..end], &options, end < text.len()), + "text={text:?}, bytes={bytes}, nodes={nodes}" + ); + } + } + } + } +} diff --git a/Ember.Content/renderer.rs b/Ember.Content/renderer.rs index 2dd1820..b83e0ec 100644 --- a/Ember.Content/renderer.rs +++ b/Ember.Content/renderer.rs @@ -1,3 +1,4 @@ +use crate::find::FindRanges; use crate::{Block, HtmlDocument, Inline}; use ferroui_base::input::InputElementImpl; use ferroui_base::interactivity::InteractiveImpl; @@ -51,7 +52,9 @@ impl Default for RenderOptions { link_brush: None, code_background: None, quote_brush: None, - code_font_family: FontFamily::new("Cascadia Code, SFMono-Regular, Consolas, monospace"), + code_font_family: FontFamily::new( + "Cascadia Code, SFMono-Regular, Menlo, Consolas, DejaVu Sans Mono, Liberation Mono, Droid Sans Mono, monospace", + ), link_min_height: 32.0, max_link_width: 320.0, max_code_height: 420.0, @@ -67,7 +70,82 @@ pub fn render( options: &RenderOptions, activate_link: Rc, ) -> Ref { - let root = render_blocks(&document.blocks, options, &activate_link); + render_document(document, options, activate_link, None, None) +} + +/// Native text colors for the matched characters. Both brushes are supplied by +/// the reader so highlighted code and links remain legible in custom themes. +pub struct FindHighlight<'a> { + pub query: &'a str, + pub foreground: Rc, + pub background: Rc, +} + +/// Render a single document with literal, case-insensitive find results. Only +/// the first 128 occurrences are decorated; all text and links remain present. +pub fn render_with_find( + document: &HtmlDocument, + options: &RenderOptions, + highlight: FindHighlight<'_>, + activate_link: Rc, +) -> Ref { + render_document(document, options, activate_link, Some(highlight), None) +} + +/// Builds a host-styled header for a preformatted block. Its callback switches only +/// visual wrapping, leaving the original, decoded source and selection intact. +pub type CodeToolbar<'a> = dyn Fn(&str, Rc) -> Ref + 'a; + +/// Render the same selectable document with host-provided code tools, including +/// inside quotations and lists. A toolbar is created only for a realized code block. +pub fn render_with_code_toolbar( + document: &HtmlDocument, + options: &RenderOptions, + highlight: Option>, + activate_link: Rc, + toolbar: &CodeToolbar<'_>, +) -> Ref { + render_document(document, options, activate_link, highlight, Some(toolbar)) +} + +struct FindPass<'a> { + highlight: Option>, + needle: String, + remaining: usize, +} + +impl FindPass<'_> { + fn active(&self) -> bool { + self.remaining > 0 && !self.needle.is_empty() + } + + fn ranges(&mut self, text: &str) -> FindRanges { + FindRanges::new(text, &self.needle, &mut self.remaining) + } +} + +fn render_document( + document: &HtmlDocument, + options: &RenderOptions, + activate_link: Rc, + highlight: Option>, + toolbar: Option<&CodeToolbar<'_>>, +) -> Ref { + let mut find = FindPass { + needle: highlight + .as_ref() + .map(|h| h.query.trim().to_lowercase()) + .unwrap_or_default(), + highlight, + remaining: 128, + }; + let root = render_blocks( + &document.blocks, + options, + &activate_link, + &mut find, + toolbar, + ); root.classes().add("ember-html"); if document.truncated { let notice = text_block(options); @@ -85,6 +163,8 @@ fn render_blocks( blocks: &[Block], options: &RenderOptions, activate_link: &Rc, + find: &mut FindPass<'_>, + toolbar: Option<&CodeToolbar<'_>>, ) -> Ref { let panel = StackPanel::new(); panel.set_spacing(finite(options.paragraph_spacing, 12.0).clamp(0.0, 64.0)); @@ -93,7 +173,7 @@ fn render_blocks( Block::Paragraph(inlines) => { panel .children() - .add(paragraph(inlines, options, activate_link)); + .add(paragraph(inlines, options, activate_link, find)); } Block::Heading { level, content } => { let scale = match level { @@ -106,8 +186,9 @@ fn render_blocks( heading_options.font_size = finite(options.font_size, 16.0).clamp(10.0, 48.0) * scale; heading_options.line_height = heading_options.font_size * 1.3; - let heading = paragraph(content, &heading_options, activate_link); + let heading = paragraph(content, &heading_options, activate_link, find); heading.set_font_weight(FontWeight::SemiBold); + AutomationProperties::set_heading_level(&heading, i32::from((*level).clamp(1, 6))); heading.classes().add("ember-html-heading"); panel.children().add(heading); } @@ -122,7 +203,13 @@ fn render_blocks( .or_else(|| Some(Brushes::gray())), ); border.set_padding(Thickness::new(14.0, 4.0, 0.0, 4.0)); - border.set_child(render_blocks(children, options, activate_link)); + border.set_child(render_blocks( + children, + options, + activate_link, + find, + toolbar, + )); panel.children().add(border); } Block::List { @@ -146,7 +233,7 @@ fn render_blocks( })); marker.set_min_width(22.0); marker.set_margin(Thickness::new(0.0, 0.0, 8.0, 0.0)); - let content = render_blocks(item, options, activate_link); + let content = render_blocks(item, options, activate_link, find, toolbar); Grid::set_column(&content, 1); row.children().add(marker); row.children().add(content); @@ -156,7 +243,14 @@ fn render_blocks( } Block::Code(code) => { let text = text_block(options); - text.set_text(Some(code)); + let mut hits = find.ranges(code); + if hits.has_hits() { + let runs = InlineCollection::new(); + add_text(code, &runs, &mut hits, find.highlight.as_ref(), None); + text.set_inlines(Some(runs)); + } else { + text.set_text(Some(code)); + } text.set_text_wrapping(TextWrapping::NoWrap); text.set_font_family(options.code_font_family.clone()); text.set_font_size((finite(options.font_size, 16.0) * 0.9).clamp(10.0, 36.0)); @@ -176,7 +270,37 @@ fn render_blocks( border.set_corner_radius(CornerRadius::uniform(8.0)); border.set_padding(Thickness::uniform(12.0)); border.set_background(options.code_background.clone()); - border.set_child(scroll); + if let Some(header) = toolbar { + border.set_padding(Thickness::new(10.0, 0.0, 8.0, 9.0)); + let text = text.downgrade(); + let viewport = scroll.downgrade(); + let wrap = Rc::new(move |enabled| { + if let (Some(text), Some(viewport)) = (text.upgrade(), viewport.upgrade()) { + text.set_text_wrapping(if enabled { + TextWrapping::Wrap + } else { + TextWrapping::NoWrap + }); + viewport.set_horizontal_scroll_bar_visibility(if enabled { + ScrollBarVisibility::Disabled + } else { + ScrollBarVisibility::Auto + }); + if enabled { + viewport.set_offset(ferroui_base::Vector::new( + 0.0, + viewport.offset().y, + )); + } + } + }); + let panel = StackPanel::new(); + panel.children().add(header(code, wrap)); + panel.children().add(scroll); + border.set_child(panel); + } else { + border.set_child(scroll); + } panel.children().add(border); } Block::Rule => { @@ -200,6 +324,7 @@ fn paragraph( inlines: &[Inline], options: &RenderOptions, activate_link: &Rc, + find: &mut FindPass<'_>, ) -> Ref { let text = text_block(options); if contains_link(inlines) { @@ -211,7 +336,19 @@ fn paragraph( text.set_line_spacing(extra_spacing); } let collection = InlineCollection::new(); - add_inlines(inlines, &collection, options, activate_link); + let mut hits = if find.active() { + find.ranges(&Inline::plain_text(inlines)) + } else { + FindRanges::default() + }; + add_inlines( + inlines, + &collection, + options, + Some(activate_link), + &mut hits, + find.highlight.as_ref(), + ); text.set_inlines(Some(collection)); text } @@ -249,44 +386,76 @@ fn add_inlines( inlines: &[Inline], output: &InlineCollection, options: &RenderOptions, - activate_link: &Rc, + activate_link: Option<&Rc>, + hits: &mut FindRanges, + paint: Option<&FindHighlight<'_>>, ) { for inline in inlines { match inline { - Inline::Text(text) => output.add(Run::with_text(Some(text))), - Inline::LineBreak => output.add(LineBreak::new()), + Inline::Text(text) => add_text(text, output, hits, paint, None), + Inline::LineBreak => { + hits.segments("\n", |_, _| {}); + output.add(LineBreak::new()); + } Inline::Strong(children) => { let span = Bold::new(); - add_inlines(children, &span.inlines(), options, activate_link); + add_inlines( + children, + &span.inlines(), + options, + activate_link, + hits, + paint, + ); output.add(span); } Inline::Emphasis(children) => { let span = Italic::new(); - add_inlines(children, &span.inlines(), options, activate_link); + add_inlines( + children, + &span.inlines(), + options, + activate_link, + hits, + paint, + ); output.add(span); } Inline::Underline(children) => { let span = Underline::new(); - add_inlines(children, &span.inlines(), options, activate_link); + add_inlines( + children, + &span.inlines(), + options, + activate_link, + hits, + paint, + ); output.add(span); } Inline::Strike(children) => { let span = Span::new(); span.set_text_decorations(Some(TextDecorations::strikethrough())); - add_inlines(children, &span.inlines(), options, activate_link); + add_inlines( + children, + &span.inlines(), + options, + activate_link, + hits, + paint, + ); output.add(span); } - Inline::Code(code) => { - let run = Run::with_text(Some(code)); - run.set_font_family(options.code_font_family.clone()); - run.set_font_size((finite(options.font_size, 16.0) * 0.9).clamp(10.0, 36.0)); - run.set_background(options.code_background.clone()); - output.add(run); - } + Inline::Code(code) => add_text(code, output, hits, paint, Some(options)), Inline::Link { url, content } => { - // Defense in depth: the public document model can be constructed manually. + // A caption keeps its native bold, italic and monospace runs, but + // never nests another interactive control inside its hyperlink. + let Some(activate) = activate_link else { + add_inlines(content, output, options, None, hits, paint); + continue; + }; let Some(destination) = crate::safe_link(url) else { - add_inlines(content, output, options, activate_link); + add_inlines(content, output, options, activate_link, hits, paint); continue; }; let link = InlineLink::new(); @@ -302,7 +471,17 @@ fn add_inlines( } let label = Inline::plain_text(content); let caption = &link.caption; - caption.set_text(Some(&label)); + if !hits.has_hits() + && content + .iter() + .all(|inline| matches!(inline, Inline::Text(_))) + { + caption.set_text(Some(&label)); + } else { + let formatted = InlineCollection::new(); + add_inlines(content, &formatted, options, None, hits, paint); + caption.set_inlines(Some(formatted)); + } caption.set_text_wrapping(TextWrapping::Wrap); caption.set_text_decorations(Some(TextDecorations::underline())); if let Some(brush) = &options.link_brush { @@ -318,7 +497,7 @@ fn add_inlines( ); // Leave NavigateUri unset: only the host decides how to launch, route internal // HN links, or copy a destination. There is never a second implicit launch. - let activate = activate_link.clone(); + let activate = activate.clone(); link.click(move |_, _| activate(&destination)); output.add_control(link); } @@ -326,6 +505,28 @@ fn add_inlines( } } +fn add_text( + text: &str, + output: &InlineCollection, + hits: &mut FindRanges, + paint: Option<&FindHighlight<'_>>, + code: Option<&RenderOptions>, +) { + hits.segments(text, |part, marked| { + let run = Run::with_text(Some(part)); + if let Some(options) = code { + run.set_font_family(options.code_font_family.clone()); + run.set_font_size((finite(options.font_size, 16.0) * 0.9).clamp(10.0, 36.0)); + run.set_background(options.code_background.clone()); + } + if let Some(style) = paint.filter(|_| marked) { + run.set_background(Some(style.background.clone())); + run.set_foreground(Some(style.foreground.clone())); + } + output.add(run); + }); +} + /// An embedded control otherwise uses its bottom edge as a text baseline. Publish the /// caption's measured baseline instead, so a centered touch target stays aligned with the /// surrounding sentence, including when its caption wraps over several lines. diff --git a/Ember.Content/tests.rs b/Ember.Content/tests.rs index 264f2a3..9672863 100644 --- a/Ember.Content/tests.rs +++ b/Ember.Content/tests.rs @@ -236,6 +236,121 @@ mod render { assert_eq!(*calls.borrow(), vec!["https://example.com/"]); } + #[test] + fn headings_and_formatted_link_captions_keep_their_native_structure() { + use ferroui_controls::automation::AutomationProperties; + use ferroui_controls::documents::Bold; + + let clock = Rc::new(TestGlobalClock::default()); + let _app = UnitTestApplication::start(sample_testing::services(clock)); + let document = HtmlDocument::parse( + "

Release notes

Important changes

" + ); + let control = crate::render(&document, &crate::RenderOptions::default(), Rc::new(|_| {})); + let heading = control + .get_logical_descendants() + .find(|node| node.classes().contains("ember-html-heading")) + .expect("document heading"); + assert_eq!(AutomationProperties::get_heading_level(&heading), 3); + let link = control + .get_logical_descendants() + .find_map(|node| node.cast::()) + .unwrap(); + assert_eq!( + AutomationProperties::get_name(&link).as_deref(), + Some("Important changes") + ); + assert!(link.get_logical_descendants().any(|node| node.is::())); + } + + #[test] + fn find_paints_decoded_text_across_native_runs_links_and_code() { + use ferroui_base::media::Brushes; + use ferroui_controls::documents::{Italic, Run}; + + let clock = Rc::new(TestGlobalClock::default()); + let _app = UnitTestApplication::start(sample_testing::services(clock)); + let calls = Rc::new(RefCell::new(Vec::new())); + let observed = calls.clone(); + let doc = HtmlDocument::parse("

before Bold & link() after

example Bold & link() code
"); + let bg: Rc = Brushes::yellow(); + let control = crate::render_with_find( + &doc, + &crate::RenderOptions::default(), + crate::FindHighlight { + query: "bOlD & liNk()", + foreground: Brushes::black(), + background: bg.clone(), + }, + Rc::new(move |url| observed.borrow_mut().push(url.to_owned())), + ); + let runs: Vec<_> = control + .get_logical_descendants() + .filter_map(|node| node.cast::()) + .collect(); + let painted: Vec<_> = runs + .iter() + .filter(|r| r.background().is_some_and(|b| Rc::ptr_eq(&b, &bg))) + .collect(); + assert_eq!( + painted.iter().filter_map(|r| r.text()).collect::(), + "Bold & link()Bold & link()" + ); + assert_eq!( + runs.iter().filter_map(|r| r.text()).collect::(), + "before Bold & link() afterexample Bold & link() code" + ); + let link = control + .get_logical_descendants() + .find_map(|node| node.cast::()) + .unwrap(); + assert!(link + .get_logical_descendants() + .any(|node| node.is::())); + assert!(link.navigate_uri().is_none()); + link.raise_event(&RoutedEventArgs::with_event(Button::click_event())); + assert_eq!(*calls.borrow(), vec!["https://example.com/"]); + } + + #[test] + fn repetitive_search_terms_bound_styling_without_truncating_the_document() { + use ferroui_base::media::Brushes; + use ferroui_controls::documents::Run; + + let clock = Rc::new(TestGlobalClock::default()); + let _app = UnitTestApplication::start(sample_testing::services(clock)); + let line = "a ".repeat(200); + let doc = HtmlDocument { + blocks: vec![Block::Paragraph(vec![Inline::Text(line.clone())]); 2], + truncated: false, + }; + let bg: Rc = Brushes::yellow(); + let control = crate::render_with_find( + &doc, + &crate::RenderOptions::default(), + crate::FindHighlight { + query: "a", + foreground: Brushes::black(), + background: bg.clone(), + }, + Rc::new(|_| {}), + ); + let runs: Vec<_> = control + .get_logical_descendants() + .filter_map(|node| node.cast::()) + .collect(); + assert_eq!( + runs.iter() + .filter(|r| r.background().is_some_and(|b| Rc::ptr_eq(&b, &bg))) + .count(), + 128 + ); + assert_eq!( + runs.iter().filter_map(|r| r.text()).collect::(), + line.repeat(2) + ); + } + #[test] fn manually_constructed_model_cannot_bypass_url_check() { let clock = Rc::new(TestGlobalClock::default()); @@ -257,3 +372,30 @@ mod render { .any(|node| node.is::())); } } + +#[test] +#[ignore = "plain and formatted content parsing timing workload"] +fn measure_content_parse_and_extract() { + let inputs = (0..4096) + .map(|id| format!("GPU text layout {id}: a scoped experiment — Ελληνικά")) + .collect::>(); + let rich = inputs + .iter() + .map(|s| format!("

{s} on a phone.

")) + .collect::>(); + for (label, inputs) in [("plain", inputs), ("formatted", rich)] { + let mut times = Vec::new(); + for _ in 0..7 { + let start = std::time::Instant::now(); + for input in &inputs { + std::hint::black_box(HtmlDocument::parse(input).plain_text()); + } + times.push(start.elapsed().as_micros()); + } + times.sort_unstable(); + eprintln!( + "4096 {label} parse + text extractions; median {} us; samples {times:?}", + times[3] + ); + } +} diff --git a/Ember.Core/examples/state_bench.rs b/Ember.Core/examples/state_bench.rs new file mode 100644 index 0000000..6c7cd5c --- /dev/null +++ b/Ember.Core/examples/state_bench.rs @@ -0,0 +1,44 @@ +//! cargo run --release --locked -p ember-core --example state_bench +use ember_core::{BoxFuture, HttpClient, MemoryStore, ReaderError, ReaderState, Repository, Store}; +use futures_executor::block_on; +use std::{hint::black_box, rc::Rc, time::Instant}; + +struct Offline; +impl HttpClient for Offline { + fn get<'a>(&'a self, _: &'a str) -> BoxFuture<'a, Result> { + unreachable!("reader state uses only local storage") + } +} + +fn main() { + let mut state = ReaderState::default(); + for id in 1..=10_000 { + state.mark_read(id, 1_700_000_000 + id); + if id % 20 == 0 { + state.toggle_saved(id, 1_700_000_000 + id); + } + if id % 10 == 0 { + state + .items + .get_mut(&id) + .unwrap() + .seen_comments + .extend(1..=100); + } + } + let store = Rc::new(MemoryStore::default()); + let repository = Repository::new(Rc::new(Offline), store.clone()); + block_on(async { + repository.save_state(&state).await.unwrap(); + let mut times = Vec::new(); + for _ in 0..25 { + let start = Instant::now(); + repository.save_state(black_box(&state)).await.unwrap(); + times.push(start.elapsed()); + } + times.sort(); + let bytes = store.get("reader:state").await.unwrap().unwrap().len(); + assert_eq!(repository.load_state().await.unwrap(), state); + println!("10000 stories, 100000 seen replies: {bytes} stored bytes, {:.2} ms median repository save (25 samples)", times[12].as_secs_f64() * 1000.0); + }); +} diff --git a/Ember.Core/model.rs b/Ember.Core/model.rs index b44748b..b67c942 100644 --- a/Ember.Core/model.rs +++ b/Ember.Core/model.rs @@ -102,6 +102,16 @@ impl HnItem { pub fn article_url(&self) -> Option { self.url.as_deref().and_then(safe_web_url) } + pub fn content_visible(&self, show_dead: bool) -> bool { + !self.deleted && (!self.dead || show_dead) + } + pub fn visible_article_url(&self, show_dead: bool) -> Option { + if self.content_visible(show_dead) { + self.article_url() + } else { + None + } + } pub fn domain(&self) -> String { self.url .as_deref() @@ -316,15 +326,29 @@ impl Preferences { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ItemState { + #[serde(skip_serializing_if = "Option::is_none")] pub saved_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub read_at: Option, + #[serde(skip_serializing_if = "std::ops::Not::not")] pub hidden: bool, + #[serde(skip_serializing_if = "Option::is_none")] pub last_opened_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub scroll_anchor: Option, + /// Logical pixels already read within the anchored story header or comment. + #[serde(skip_serializing_if = "is_zero")] + pub scroll_inset: u32, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] pub collapsed: BTreeSet, + #[serde(skip_serializing_if = "BTreeSet::is_empty")] pub seen_comments: BTreeSet, } +fn is_zero(value: &u32) -> bool { + *value == 0 +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct MuteRules { @@ -334,17 +358,74 @@ pub struct MuteRules { } impl MuteRules { + /// Extract the hostname used by feed matching. Pasted article links may have + /// paths and ports; plain entries must be hostnames, optionally with a port. + pub fn domain_from_input(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.chars().any(char::is_control) || value.contains('\\') { + return None; + } + let parsed = if let Some((_, rest)) = value.split_once("://") { + if rest.is_empty() || rest.starts_with('/') { + return None; + } + Url::parse(value).ok()? + } else { + let host = value.trim_end_matches('/'); + if host.contains(['/', '?', '#', '@']) || host.ends_with(':') { + return None; + } + Url::parse(&format!("https://{host}")).ok()? + }; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return None; + } + let host = parsed.host_str()?.trim_end_matches('.'); + if let Some(url::Host::Domain(_)) = parsed.host() { + if host.len() > 253 + || host.split('.').any(|part| { + part.is_empty() + || part.len() > 63 + || part.starts_with('-') + || part.ends_with('-') + || !part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + { + return None; + } + } + Some(host.trim_start_matches("www.").to_owned()) + } + pub fn matches(&self, item: &HnItem) -> bool { - let domain = item.domain().to_ascii_lowercase(); + if self.users.contains(item.author()) { + return true; + } + if !self.domains.is_empty() { + let domain = item.domain().to_ascii_lowercase(); + let domain = domain.trim_end_matches('.'); + if self.domains.iter().any(|value| { + let value = value.trim().to_ascii_lowercase(); + let value = value.trim_end_matches('.').trim_start_matches("www."); + !value.is_empty() + && (domain == value + || domain + .strip_suffix(value) + .is_some_and(|prefix| prefix.ends_with('.'))) + }) { + return true; + } + } + if self.keywords.is_empty() { + return false; + } let title = item.title_text().to_lowercase(); - self.users.contains(item.author()) - || self.domains.iter().any(|value| { - let value = value.trim().trim_start_matches("www.").to_ascii_lowercase(); - !value.is_empty() && (domain == value || domain.ends_with(&format!(".{value}"))) - }) - || self.keywords.iter().any(|value| { - !value.trim().is_empty() && title.contains(&value.trim().to_lowercase()) - }) + self.keywords + .iter() + .any(|value| !value.trim().is_empty() && title.contains(&value.trim().to_lowercase())) } } @@ -400,13 +481,13 @@ impl ReaderState { self.items.get(&id).is_some_and(|s| s.read_at.is_some()) } pub fn is_visible(&self, item: &HnItem) -> bool { + self.passes_content_filters(item) && !(self.preferences.hide_read && self.is_read(item.id)) + } + pub fn passes_content_filters(&self, item: &HnItem) -> bool { !item.deleted && (!item.dead || self.preferences.show_dead) && !self.mutes.matches(item) - && !self - .items - .get(&item.id) - .is_some_and(|s| s.hidden || (self.preferences.hide_read && s.read_at.is_some())) + && !self.items.get(&item.id).is_some_and(|s| s.hidden) } pub fn saved_ids(&self) -> Vec { let mut values: Vec<_> = self @@ -417,6 +498,32 @@ impl ReaderState { values.sort_by(|a, b| b.cmp(a)); values.into_iter().map(|(_, id)| id).collect() } + /// Downloads remain discoverable after a bookmark is removed or a backup is imported. + pub fn downloaded_ids(&self) -> Vec { + let mut ids: Vec<_> = self.downloaded_threads.keys().copied().collect(); + ids.sort_unstable_by_key(|id| { + let time = self + .items + .get(id) + .and_then(|item| item.saved_at.or(item.last_opened_at)); + std::cmp::Reverse((time, *id)) + }); + ids + } + + pub(crate) fn pinned_items(&self) -> BTreeSet { + let mut pinned: BTreeSet<_> = self + .items + .iter() + .filter_map(|(&id, item)| item.saved_at.map(|_| id)) + .collect(); + for (&root, items) in &self.downloaded_threads { + pinned.insert(root); + pinned.extend(items.iter().copied()); + } + pinned + } + pub fn history_ids(&self) -> Vec { let mut values: Vec<_> = self .items @@ -496,6 +603,43 @@ pub fn user_url(username: &str) -> String { url.into() } +/// A public HN permalink that can be read inside Ember. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HnDestination { + Discussion(ItemId), + Profile(String), +} + +impl HnDestination { + pub fn from_link(input: &str) -> Option { + let value = input.trim(); + if value.chars().any(char::is_whitespace) || value.contains('\\') { + return None; + } + let url = Url::parse(value).ok()?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str() != Some("news.ycombinator.com") + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + { + return None; + } + let mut ids = url.query_pairs().filter(|(key, _)| key == "id"); + let (_, id) = ids.next()?; + if ids.next().is_some() { + return None; + } + match url.path() { + "/item" if id.bytes().all(|byte| byte.is_ascii_digit()) => { + id.parse().ok().filter(|id| *id > 0).map(Self::Discussion) + } + "/user" if validate_username(&id).is_ok() => Some(Self::Profile(id.into_owned())), + _ => None, + } + } +} + pub fn validate_username(username: &str) -> Result<(), crate::ReaderError> { if username.is_empty() || username.len() > 64 diff --git a/Ember.Core/native.rs b/Ember.Core/native.rs index d2c4d74..834a508 100644 --- a/Ember.Core/native.rs +++ b/Ember.Core/native.rs @@ -216,20 +216,136 @@ impl Store for SqliteStore { Ok(()) }) } + fn get_many<'a>( + &'a self, + keys: &'a [String], + ) -> BoxFuture<'a, Result>, ReaderError>> { + let keys = keys.to_vec(); + self.execute(move |db| { + let tx = db.transaction().map_err(storage_error)?; + let values = { + let mut stmt = tx + .prepare_cached("SELECT value FROM ember_records WHERE key=?1") + .map_err(storage_error)?; + keys.iter() + .map(|key| stmt.query_row([key], |row| row.get(0)).optional()) + .collect::, _>>() + .map_err(storage_error)? + }; + tx.commit().map_err(storage_error)?; + Ok(values) + }) + } + + fn remove_many<'a>(&'a self, keys: &'a [String]) -> BoxFuture<'a, Result<(), ReaderError>> { + let keys = keys.to_vec(); + self.execute(move |db| { + let tx = db.transaction().map_err(storage_error)?; + { + let mut stmt = tx + .prepare_cached("DELETE FROM ember_records WHERE key=?1") + .map_err(storage_error)?; + for key in keys { + stmt.execute([key]).map_err(storage_error)?; + } + } + tx.commit().map_err(storage_error) + }) + } + + fn remove_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<(), ReaderError>> { + let prefix = prefix.to_owned(); + self.execute(move |db| { + if let Some(end) = prefix_end(&prefix) { + db.execute( + "DELETE FROM ember_records WHERE key>=?1 AND key=?1", [&prefix]) + } + .map(drop) + .map_err(storage_error) + }) + } + + fn count_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result> { + let prefix = prefix.to_owned(); + self.execute(move |db| { + if let Some(end) = prefix_end(&prefix) { + db.query_row( + "SELECT count(*) FROM ember_records WHERE key>=?1 AND key=?1", + [&prefix], + |r| r.get(0), + ) + .map_err(storage_error) + } + }) + } + fn scan_prefix<'a>( &'a self, prefix: &'a str, ) -> BoxFuture<'a, Result, ReaderError>> { let prefix = prefix.to_owned(); self.execute(move |db| { - // SQLite LIKE is ASCII case-insensitive by default. Record prefixes - // are exact and case-sensitive, including usernames and literal %/_ . - let mut statement = db.prepare("SELECT key,value FROM ember_records WHERE key>=?1 AND substr(key,1,length(?1))=?1 COLLATE BINARY ORDER BY key") - .map_err(storage_error)?; - let rows = statement.query_map([prefix], |row| Ok((row.get(0)?, row.get(1)?))).map_err(storage_error)?; - rows.collect::, _>>().map_err(storage_error) + scan_values( + db, + &prefix, + "SELECT key,value FROM ember_records WHERE key>=?1 ORDER BY key", + ) }) } + + fn scan_sizes<'a>( + &'a self, + prefix: &'a str, + ) -> BoxFuture<'a, Result, ReaderError>> { + let prefix = prefix.to_owned(); + self.execute(move |db| scan_values(db, &prefix, "SELECT key,length(CAST(value AS BLOB)) FROM ember_records WHERE key>=?1 ORDER BY key")) + } +} + +fn scan_values( + db: &Connection, + prefix: &str, + query: &str, +) -> Result, ReaderError> { + let mut stmt = db.prepare_cached(query).map_err(storage_error)?; + let mut rows = stmt.query([prefix]).map_err(storage_error)?; + let mut records = Vec::new(); + while let Some(row) = rows.next().map_err(storage_error)? { + let key: String = row.get(0).map_err(storage_error)?; + if !key.starts_with(prefix) { + break; + } + records.push((key, row.get(1).map_err(storage_error)?)); + } + Ok(records) +} + +/// SQLite's default BINARY collation orders UTF-8 keys by Unicode scalar. +fn prefix_end(prefix: &str) -> Option { + for (offset, ch) in prefix.char_indices().rev() { + if ch != char::MAX { + let next = if ch == '\u{d7ff}' { + '\u{e000}' + } else { + char::from_u32(ch as u32 + 1)? + }; + let mut end = prefix[..offset].to_owned(); + end.push(next); + return Some(end); + } + } + None } fn storage_error(error: impl std::fmt::Display) -> ReaderError { diff --git a/Ember.Core/repository.rs b/Ember.Core/repository.rs index ce9ec6b..e094985 100644 --- a/Ember.Core/repository.rs +++ b/Ember.Core/repository.rs @@ -21,6 +21,9 @@ pub type BoxFuture<'a, T> = Pin + 'a>>; /// are separate, and saved/downloaded item pinning does not use this budget. pub const METADATA_CACHE_LIMIT: usize = 256; +// Bound transient payloads, storage requests per transaction and cancellation latency. +const STORE_BATCH: usize = 32; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum ReaderError { Network(String), @@ -55,6 +58,58 @@ pub trait Store { &'a self, prefix: &'a str, ) -> BoxFuture<'a, Result, ReaderError>>; + + /// Key and stored UTF-8 value length, without copying payloads into the caller. + fn scan_sizes<'a>( + &'a self, + prefix: &'a str, + ) -> BoxFuture<'a, Result, ReaderError>> { + Box::pin(async move { + Ok(self + .scan_prefix(prefix) + .await? + .into_iter() + .map(|(key, value)| (key, value.len())) + .collect()) + }) + } + + /// Return one slot per input key, including missing records and duplicates. + fn get_many<'a>( + &'a self, + keys: &'a [String], + ) -> BoxFuture<'a, Result>, ReaderError>> { + Box::pin(async move { + let mut values = Vec::with_capacity(keys.len()); + for key in keys { + values.push(self.get(key).await?); + } + Ok(values) + }) + } + + fn remove_many<'a>(&'a self, keys: &'a [String]) -> BoxFuture<'a, Result<(), ReaderError>> { + Box::pin(async move { + for key in keys { + self.remove(key).await?; + } + Ok(()) + }) + } + + /// Clear only records in the requested namespace. + fn remove_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<(), ReaderError>> { + Box::pin(async move { + for (key, _) in self.scan_prefix(prefix).await? { + self.remove(&key).await?; + } + Ok(()) + }) + } + + fn count_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(self.scan_prefix(prefix).await?.len()) }) + } } #[derive(Default)] @@ -91,6 +146,31 @@ impl Store for MemoryStore { .collect()) }) } + fn scan_sizes<'a>( + &'a self, + prefix: &'a str, + ) -> BoxFuture<'a, Result, ReaderError>> { + Box::pin(async move { + Ok(self + .entries + .borrow() + .range(prefix.to_owned()..) + .take_while(|(k, _)| k.starts_with(prefix)) + .map(|(k, v)| (k.clone(), v.len())) + .collect()) + }) + } + + fn count_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(self + .entries + .borrow() + .range(prefix.to_owned()..) + .take_while(|(key, _)| key.starts_with(prefix)) + .count()) + }) + } } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -101,6 +181,13 @@ pub enum FetchMode { Offline, } +/// Counts include the root story, comments and poll options. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DownloadProgress { + Collecting { loaded: usize, pending: usize }, + Verifying { checked: usize, total: usize }, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum DataSource { Network, @@ -148,18 +235,35 @@ pub struct FeedPage { pub offset: usize, pub next_offset: Option, pub missing_ids: Vec, + /// Transient failures and the number of successful rows preceding each one. + /// An explicit null response is unavailable, but does not require another request. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_slots: Vec<(usize, ItemId)>, + /// The ranked ID list can fall back to cache while individual stories load online. + #[serde(default)] + pub ranking_source: DataSource, pub source: DataSource, pub stale: bool, pub warning: Option, + /// True when the general warning describes a row already reported in failed_slots. + #[serde(default)] + pub warning_from_failed_item: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct ItemBatch { pub items: Vec, pub missing_ids: Vec, + /// Transient failures and the number of successful rows preceding each one. + /// An explicit null response is unavailable, but does not require another request. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_slots: Vec<(usize, ItemId)>, pub source: DataSource, pub stale: bool, pub warning: Option, + /// True when the general warning describes a row already reported in failed_slots. + #[serde(default)] + pub warning_from_failed_item: bool, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -290,17 +394,31 @@ impl Repository { &self, key: &str, ) -> Result>, ReaderError> { - self.store - .get(key) - .await? - .map(|json| { - serde_json::from_str(&json) - .map_err(|e| ReaderError::InvalidData(format!("Invalid cached record: {e}"))) - }) - .transpose() + decode_record(self.store.get(key).await) + } + + // Retry an exceptional batch failure individually: one corrupt adapter record + // must not make its healthy neighbors unavailable offline. + async fn read_item_records( + &self, + keys: &[String], + ) -> Vec>>, ReaderError>> { + if let Ok(values) = self.store.get_many(keys).await { + if values.len() == keys.len() { + return values + .into_iter() + .map(|value| decode_record(Ok(value))) + .collect(); + } + } + let mut records = Vec::with_capacity(keys.len()); + for key in keys { + records.push(self.cached(key).await); + } + records } - async fn load( + async fn load( &self, key: &str, url: &str, @@ -308,15 +426,27 @@ impl Repository { ttl: u64, mode: FetchMode, ) -> Result, ReaderError> { - let cached = self.cached::(key).await; + let cached = self.cached(key).await; + self.resolve(key, || url.to_owned(), now, ttl, mode, cached) + .await + } + + async fn resolve( + &self, + key: &str, + url: impl FnOnce() -> String, + now: u64, + ttl: u64, + mode: FetchMode, + cached: Result>, ReaderError>, + ) -> Result, ReaderError> { let storage_warning = cached.as_ref().err().map(ToString::to_string); - let cached = cached.ok().flatten(); - if let Some(record) = &cached { + let cached = if let Some(record) = cached.ok().flatten() { // Future-dated records can follow a wall-clock correction; refresh them. let fresh = record.fetched_at <= now && now - record.fetched_at < ttl; if mode == FetchMode::Offline || (mode == FetchMode::CacheFirst && fresh) { return Ok(Loaded { - value: record.value.clone(), + value: record.value, source: if fresh { DataSource::Cache } else { @@ -327,13 +457,16 @@ impl Repository { warning: storage_warning, }); } - } + Some(record) + } else { + None + }; if mode == FetchMode::Offline { return Err(ReaderError::Storage( storage_warning.unwrap_or_else(|| "This content has not been downloaded".into()), )); } - let loaded = self.request(url).await.and_then(|body| { + let loaded = self.request(&url()).await.and_then(|body| { serde_json::from_str::(&body) .map_err(|e| ReaderError::InvalidData(format!("Invalid response: {e}"))) }); @@ -341,7 +474,7 @@ impl Repository { Ok(value) => { let record = CacheRecord { fetched_at: now, - value: value.clone(), + value: &value, }; let json = serde_json::to_string(&record) .map_err(|e| ReaderError::InvalidData(e.to_string()))?; @@ -386,6 +519,24 @@ impl Repository { id: ItemId, now: u64, mode: FetchMode, + ) -> Result>, ReaderError> { + let key = format!("cache:item:{id}"); + if id == 0 { + return Err(ReaderError::InvalidInput( + "An item ID must be positive".into(), + )); + } + let cached = self.cached(&key).await; + self.resolve_item(id, &key, now, mode, cached).await + } + + async fn resolve_item( + &self, + id: ItemId, + key: &str, + now: u64, + mode: FetchMode, + cached: Result>>, ReaderError>, ) -> Result>, ReaderError> { if id == 0 { return Err(ReaderError::InvalidInput( @@ -393,16 +544,17 @@ impl Repository { )); } let loaded = self - .load::>( - &format!("cache:item:{id}"), - &format!("{HN_API}/item/{id}.json"), + .resolve( + key, + || format!("{HN_API}/item/{id}.json"), now, self.policy.item_ttl, mode, + cached, ) .await?; if loaded.value.as_ref().is_some_and(|item| item.id != id) { - let _ = self.store.remove(&format!("cache:item:{id}")).await; + let _ = self.store.remove(key).await; return Err(ReaderError::InvalidData( "Response item ID does not match the requested item".into(), )); @@ -411,32 +563,45 @@ impl Repository { } pub async fn load_items(&self, ids: &[ItemId], now: u64, mode: FetchMode) -> ItemBatch { - let results = stream::iter( - ids.iter() - .copied() - .map(|id| async move { (id, self.load_item(id, now, mode).await) }), - ) - .buffered(self.policy.concurrency) - .collect::>() - .await; let mut batch = ItemBatch::default(); - for (id, result) in results { - match result { - Ok(loaded) => { - combine_source(&mut batch.source, loaded.source); - batch.stale |= loaded.stale; - if batch.warning.is_none() { - batch.warning = loaded.warning; - } - match loaded.value { - Some(item) => batch.items.push(item), - None => batch.missing_ids.push(id), + for ids in ids.chunks(STORE_BATCH) { + let keys: Vec<_> = ids.iter().map(|id| format!("cache:item:{id}")).collect(); + let cached = self.read_item_records(&keys).await; + let mut results = stream::iter(ids.iter().zip(&keys).zip(cached).enumerate().map( + |(index, ((&id, key), record))| async move { + // A preceding occurrence may have populated the cache after + // this page was read. Let it share that request or fresh value. + let result = if mode != FetchMode::Offline && ids[..index].contains(&id) { + self.load_item(id, now, mode).await + } else { + self.resolve_item(id, key, now, mode, record).await + }; + (id, result) + }, + )) + .buffered(self.policy.concurrency); + while let Some((id, result)) = results.next().await { + match result { + Ok(loaded) => { + combine_source(&mut batch.source, loaded.source); + batch.stale |= loaded.stale; + if loaded.warning.is_some() && (batch.warning.is_none() || batch.warning_from_failed_item) { + // A storage or fallback warning on a readable row needs its own feedback. + batch.warning = loaded.warning; + batch.warning_from_failed_item = false; + } + match loaded.value { + Some(item) => batch.items.push(item), + None => batch.missing_ids.push(id), + } } - } - Err(error) => { - batch.missing_ids.push(id); - if batch.warning.is_none() { - batch.warning = Some(error.to_string()); + Err(error) => { + batch.failed_slots.push((batch.items.len(), id)); + batch.missing_ids.push(id); + if batch.warning.is_none() { + batch.warning = Some(error.to_string()); + batch.warning_from_failed_item = true; + } } } } @@ -475,9 +640,12 @@ impl Repository { offset, next_offset: (end < ids.value.len()).then_some(end), missing_ids: batch.missing_ids, + failed_slots: batch.failed_slots, + ranking_source: ids.source, source, stale: ids.stale || batch.stale, - warning: batch.warning.or(ids.warning), + warning_from_failed_item: ids.warning.is_none() && batch.warning_from_failed_item, + warning: ids.warning.or(batch.warning), }) } @@ -516,18 +684,31 @@ impl Repository { budget: usize, now: u64, mode: FetchMode, + ) -> Result { + self.load_thread_reporting(id, budget, now, mode, |_, _| {}) + .await + } + + async fn load_thread_reporting( + &self, + id: ItemId, + budget: usize, + now: u64, + mode: FetchMode, + report: impl Fn(usize, usize), ) -> Result { let loaded = self.load_item(id, now, mode).await?; let root = loaded .value .ok_or_else(|| ReaderError::InvalidData("This story is unavailable".into()))?; let mut snapshot = ThreadSnapshot { - root: root.clone(), + root, source: loaded.source, stale: loaded.stale, warning: loaded.warning, ..ThreadSnapshot::default() }; + let root = &snapshot.root; snapshot.items.insert(id, root.clone()); let budget = budget.clamp(1, 20_000); let mut seen = BTreeSet::from([id]); @@ -539,10 +720,15 @@ impl Repository { .filter(|id| seen.insert(*id)) .collect(); let mut requested = 1; + report(snapshot.items.len(), pending.len()); while !pending.is_empty() && requested < budget { let count = pending .len() - .min(self.policy.concurrency) + .min(if mode == FetchMode::Offline { + STORE_BATCH + } else { + self.policy.concurrency + }) .min(budget - requested); let ids: Vec<_> = pending.drain(..count).collect(); requested += ids.len(); @@ -561,6 +747,7 @@ impl Repository { } snapshot.items.insert(item.id, item); } + report(snapshot.items.len(), pending.len()); } snapshot.pending_ids = pending.into(); snapshot.complete = snapshot.missing_ids.is_empty() && snapshot.pending_ids.is_empty(); @@ -574,23 +761,51 @@ impl Repository { id: ItemId, budget: usize, now: u64, + ) -> Result { + self.download_thread_with_progress(id, budget, now, FetchMode::CacheFirst, |_| {}) + .await + } + + /// Use Offline to check an existing copy without contacting the provider. + /// Dropping this future stops scheduling requests and verification reads. + pub async fn download_thread_with_progress( + &self, + id: ItemId, + budget: usize, + now: u64, + mode: FetchMode, + report: impl Fn(DownloadProgress), ) -> Result { let mut snapshot = self - .load_thread(id, budget, now, FetchMode::CacheFirst) + .load_thread_reporting(id, budget, now, mode, |loaded, pending| { + report(DownloadProgress::Collecting { loaded, pending }); + }) .await?; - for id in snapshot.items.keys().copied().collect::>() { - let cached = self - .cached::>(&format!("cache:item:{id}")) - .await; - if !matches!(cached, Ok(Some(CacheRecord { value: Some(ref item), .. })) if item.id == id) - { - if !snapshot.missing_ids.contains(&id) { + let total = snapshot.items.len(); + report(DownloadProgress::Verifying { checked: 0, total }); + let mut items = snapshot.items.iter(); + let mut checked = 0; + while checked < total { + let group: Vec<_> = items.by_ref().take(STORE_BATCH).collect(); + let keys: Vec<_> = group + .iter() + .map(|(id, _)| format!("cache:item:{id}")) + .collect(); + let records = self.read_item_records(&keys).await; + for ((&id, expected), record) in group.into_iter().zip(records) { + if !matches!(record, Ok(Some(CacheRecord { value: Some(ref item), .. })) if item == expected) + { + // Traversal visits each ID once. A successfully loaded item + // cannot also be in the traversal's missing-ID collection. snapshot.missing_ids.push(id); + snapshot.warning = Some( + "Some content could not be saved. Free storage and retry the download." + .into(), + ); } - snapshot.warning = Some( - "Some content could not be saved. Free storage and retry the download.".into(), - ); } + checked += keys.len(); + report(DownloadProgress::Verifying { checked, total }); } snapshot.complete = snapshot.missing_ids.is_empty() && snapshot.pending_ids.is_empty(); Ok(snapshot) @@ -658,19 +873,28 @@ impl Repository { None => Ok(ReaderState::default()), } } - pub async fn save_state(&self, state: &ReaderState) -> Result<(), ReaderError> { - self.store.put("reader:state", &state.export_json()?).await + /// Encode a compact snapshot before returning the write future. The caller can + /// release its UI-state borrow while the storage transaction is in flight. + pub fn save_state<'a>( + &'a self, + state: &ReaderState, + ) -> impl Future> + use<'a> { + let json = serde_json::to_string(state) + .map_err(|error| ReaderError::InvalidData(error.to_string())); + async move { self.store.put("reader:state", &json?).await } } pub async fn cached_items(&self, ids: &[ItemId]) -> Vec { let mut items = Vec::new(); - for id in ids { - if let Ok(Some(record)) = self - .cached::>(&format!("cache:item:{id}")) + for group in ids.chunks(STORE_BATCH) { + let keys: Vec<_> = group.iter().map(|id| format!("cache:item:{id}")).collect(); + for record in self + .read_item_records(&keys) .await + .into_iter() + .flatten() + .flatten() { - if let Some(item) = record.value { - items.push(item); - } + items.extend(record.value); } } items @@ -678,71 +902,101 @@ impl Repository { /// Evicts oldest unpinned items and excess search/profile records. Reading /// state, feed lists and saved/downloaded content survive. Returns removals. - pub async fn prune_cache(&self, state: &ReaderState) -> Result { - let records = self.store.scan_prefix("cache:item:").await?; - let mut pinned: BTreeSet<_> = state.saved_ids().into_iter().collect(); - for (&root, ids) in &state.downloaded_threads { - pinned.insert(root); - pinned.extend(ids.iter().copied()); - } - let mut candidates = Vec::new(); - for (key, json) in &records { - let id = key.trim_start_matches("cache:item:").parse::().ok(); - if id.is_some_and(|id| pinned.contains(&id)) { - continue; + pub fn prune_cache<'a>( + &'a self, + state: &ReaderState, + ) -> BoxFuture<'a, Result> { + let limit = state.preferences.cache_max_items; + let pinned = state.pinned_items(); + Box::pin(async move { + if self.store.count_prefix("cache:item:").await? <= limit { + return self.prune_metadata_cache().await; } - let fetched_at = cache_record_time(json); - candidates.push((fetched_at, key)); - } - candidates.sort(); - let remove = records - .len() - .saturating_sub(state.preferences.cache_max_items); - let mut removed = 0; - for (_, key) in candidates.into_iter().take(remove) { - self.store.remove(key).await?; - removed += 1; + let records = self.store.scan_prefix("cache:item:").await?; + let mut candidates = Vec::new(); + let remove = records.len().saturating_sub(limit); + for (key, json) in records { + let id = key.trim_start_matches("cache:item:").parse::().ok(); + if id.is_some_and(|id| pinned.contains(&id)) { + continue; + } + let fetched_at = cache_record_time(&json); + candidates.push((fetched_at, key)); + } + let keys = oldest_keys(candidates, remove); + self.remove_keys(&keys).await?; + Ok(keys.len() + self.prune_metadata_cache().await?) + }) + } + + async fn remove_keys(&self, keys: &[String]) -> Result<(), ReaderError> { + for batch in keys.chunks(STORE_BATCH) { + self.store.remove_many(batch).await?; } - Ok(removed + self.prune_metadata_cache().await?) + Ok(()) } async fn prune_metadata_cache(&self) -> Result { - // Scan only these two small namespaces, not thousands of item payloads. - // The six feed lists and versioned reader state cannot be evicted here. + let count = self.store.count_prefix("cache:user:").await? + + self.store.count_prefix("cache:search:").await?; + if count <= METADATA_CACHE_LIMIT { + return Ok(0); + } let mut records = self.store.scan_prefix("cache:user:").await?; records.extend(self.store.scan_prefix("cache:search:").await?); let remove = records.len().saturating_sub(METADATA_CACHE_LIMIT); - if remove == 0 { - return Ok(0); - } - let mut candidates: Vec<_> = records - .iter() - .map(|(key, json)| (cache_record_time(json), key)) + let candidates = records + .into_iter() + .map(|(key, json)| (cache_record_time(&json), key)) .collect(); - candidates.sort(); - for (_, key) in candidates.into_iter().take(remove) { - self.store.remove(key).await?; - } - Ok(remove) + let keys = oldest_keys(candidates, remove); + self.remove_keys(&keys).await?; + Ok(keys.len()) } pub async fn cache_stats(&self) -> Result { - let entries = self.store.scan_prefix("cache:").await?; + let entries = self.store.scan_sizes("cache:").await?; Ok(CacheStats { records: entries.len(), - bytes: entries.iter().map(|(k, v)| k.len() + v.len()).sum(), + bytes: entries.iter().map(|(k, v)| k.len() + v).sum(), items: entries .iter() .filter(|(k, _)| k.starts_with("cache:item:")) .count(), }) } + /// Release browsing cache while retaining feed lists, bookmarks and every verified + /// download. Protect both the active library and any state still durable on disk. + pub fn clean_temporary_cache<'a>( + &'a self, + state: &ReaderState, + ) -> BoxFuture<'a, Result> { + let mut pinned = state.pinned_items(); + Box::pin(async move { + pinned.extend(self.load_state().await?.pinned_items()); + let mut removed = CacheStats::default(); + let mut keys = Vec::new(); + for (key, value_bytes) in self.store.scan_sizes("cache:").await? { + let item = key.strip_prefix("cache:item:"); + let temporary = match item { + Some(id) => !id.parse::().is_ok_and(|id| pinned.contains(&id)), + None => key.starts_with("cache:user:") || key.starts_with("cache:search:"), + }; + if temporary { + removed.records += 1; + removed.items += usize::from(item.is_some()); + removed.bytes += key.len() + value_bytes; + keys.push(key); + } + } + self.remove_keys(&keys).await?; + Ok(removed) + }) + } + /// Deletes public cached payloads only. Preferences, bookmarks, history and /// collapse state remain intact; callers may explicitly clear state separately. pub async fn clear_cache(&self) -> Result<(), ReaderError> { - for (key, _) in self.store.scan_prefix("cache:").await? { - self.store.remove(&key).await?; - } - Ok(()) + self.store.remove_prefix("cache:").await } } @@ -753,10 +1007,34 @@ pub struct CacheStats { pub bytes: usize, } +/// Select only the records above the limit. The usual eviction removes one +/// page from a much larger cache. Order that subset for deterministic retries. +fn oldest_keys(mut records: Vec<(u64, String)>, count: usize) -> Vec { + if count < records.len() { + records.select_nth_unstable(count); + records.truncate(count); + } + records.sort_unstable(); + records.into_iter().map(|(_, key)| key).collect() +} + +fn decode_record( + raw: Result, ReaderError>, +) -> Result>, ReaderError> { + raw?.map(|json| { + serde_json::from_str(&json) + .map_err(|e| ReaderError::InvalidData(format!("Invalid cached record: {e}"))) + }) + .transpose() +} + fn cache_record_time(json: &str) -> u64 { - serde_json::from_str::(json) - .ok() - .and_then(|value| value.get("fetched_at").and_then(|value| value.as_u64())) + #[derive(Deserialize)] + struct Timestamp { + fetched_at: u64, + } + serde_json::from_str::(json) + .map(|r| r.fetched_at) .unwrap_or(0) } diff --git a/Ember.Core/tests.rs b/Ember.Core/tests.rs index 30dfd24..9cfcbe8 100644 --- a/Ember.Core/tests.rs +++ b/Ember.Core/tests.rs @@ -578,6 +578,74 @@ fn cache_write_failure_allows_reading_but_never_claims_offline_success() { }); } +#[test] +fn download_progress_reports_discovery_and_verifies_a_stale_copy_without_network() { + block_on(async { + let (repo, http, _) = repository(); + http.item(1, r#"{"id":1,"kids":[2]}"#); + http.item(2, r#"{"id":2,"kids":[3]}"#); + http.item(3, r#"{"id":3}"#); + assert!(repo.download_thread(1, 10, 1).await.unwrap().complete); + let requests = http.calls.get(); + let events = RefCell::new(Vec::new()); + let snapshot = repo + .download_thread_with_progress(1, 10, 90_000, FetchMode::Offline, |progress| { + events.borrow_mut().push(progress) + }) + .await + .unwrap(); + assert!(snapshot.complete); + assert_eq!(http.calls.get(), requests); + assert_eq!( + *events.borrow(), + [ + DownloadProgress::Collecting { + loaded: 1, + pending: 1 + }, + DownloadProgress::Collecting { + loaded: 2, + pending: 1 + }, + DownloadProgress::Collecting { + loaded: 3, + pending: 0 + }, + DownloadProgress::Verifying { + checked: 0, + total: 3 + }, + DownloadProgress::Verifying { + checked: 3, + total: 3 + }, + ] + ); + }); +} + +#[test] +fn stale_payload_with_the_right_id_cannot_certify_a_failed_refresh() { + block_on(async { + let store = Rc::new(UnwritableStore(MemoryStore::default())); + store + .0 + .put( + "cache:item:1", + r#"{"fetched_at":0,"value":{"id":1,"text":"old"}}"#, + ) + .await + .unwrap(); + let http = Rc::new(FakeHttp::default()); + http.item(1, r#"{"id":1,"text":"updated"}"#); + let repo = Repository::new(http, store); + let result = repo.download_thread(1, 10, 86_400).await.unwrap(); + assert_eq!(result.root.text.as_deref(), Some("updated")); + assert_eq!(result.missing_ids, [1]); + assert!(!result.complete); + }); +} + #[test] fn completed_download_can_reopen_fully_offline() { block_on(async { @@ -619,6 +687,34 @@ fn sqlite_store_persists_across_connections_and_scans_literal_prefixes() { store.scan_prefix("user:a_").await.unwrap(), [("user:a_%".into(), "saved".into())] ); + store.put("bytes:unicode", "Ł😀").await.unwrap(); + assert_eq!( + store.scan_sizes("bytes:").await.unwrap(), + [("bytes:unicode".into(), 6)] + ); + let keys = vec!["user:abc".into(), "missing".into(), "user:abc".into()]; + assert_eq!( + store.get_many(&keys).await.unwrap(), + [Some("other".into()), None, Some("other".into())] + ); + for key in [ + "x\u{d7ff}", + "x\u{d7ff}\u{10ffff}", + "x\u{e000}", + "\u{10ffff}z", + ] { + store.put(key, "utf8").await.unwrap(); + } + assert_eq!(store.count_prefix("x\u{d7ff}").await.unwrap(), 2); + assert_eq!(store.count_prefix("\u{10ffff}").await.unwrap(), 1); + store.remove_prefix("x\u{d7ff}").await.unwrap(); + assert_eq!( + store.get("x\u{e000}").await.unwrap().as_deref(), + Some("utf8") + ); + assert_eq!(store.count_prefix("x\u{d7ff}").await.unwrap(), 0); + store.remove_many(&keys).await.unwrap(); + assert_eq!(store.get("user:abc").await.unwrap(), None); } { let reopened = native::SqliteStore::open(&path).unwrap(); @@ -634,3 +730,188 @@ fn sqlite_store_persists_across_connections_and_scans_literal_prefixes() { let _ = std::fs::remove_file(format!("{}-shm", path.display())); }); } + +#[test] +fn mute_domains_accept_article_links_and_match_host_boundaries() { + let mut rules = MuteRules::default(); + for (input, expected) in [ + ("https://WWW.Example.COM:443/news?q=1", "example.com"), + ("docs.example.com:8080", "docs.example.com"), + ("Example.com./", "example.com"), + ("https://[::1]:8080/read", "[::1]"), + ("https://münchen.de/", "xn--mnchen-3ya.de"), + ] { + assert_eq!( + MuteRules::domain_from_input(input).as_deref(), + Some(expected) + ); + } + for bad in [ + "", + ":8080", + "https:/news", + "ftp://example.org", + "https:////example.org", + "https://person@example.org/news", + "bad domain", + "-bad.org", + "foo..bar", + "*.org", + ] { + assert!( + MuteRules::domain_from_input(bad).is_none(), + "accepted {bad}" + ); + } + rules + .domains + .insert(MuteRules::domain_from_input("https://www.example.com/post").unwrap()); + for (url, muted) in [ + ("https://news.example.com./x", true), + ("https://notexample.com/x", false), + ] { + assert_eq!( + rules.matches(&HnItem { + url: Some(url.into()), + ..Default::default() + }), + muted + ); + } +} + +#[test] +fn hn_permalinks_accept_only_unambiguous_public_destinations() { + assert_eq!( + HnDestination::from_link(" https://NEWS.ycombinator.com/item?from=test&id=42#42 "), + Some(HnDestination::Discussion(42)) + ); + assert_eq!( + HnDestination::from_link("http://news.ycombinator.com/user?id=aDa-1"), + Some(HnDestination::Profile("aDa-1".into())) + ); + for link in [ + "http://example.com/item?id=42", + "https://news.ycombinator.com.example.org/item?id=42", + "https://person@news.ycombinator.com/item?id=42", + "https://news.ycombinator.com:8080/item?id=42", + "https://news.ycombinator.com/item?id=0", + "https://news.ycombinator.com/item?id=1&id=2", + "https://news.ycombinator.com/item?id=18446744073709551616", + "https://news.ycombinator.com/user?id=a%2Fb", + "https://news.ycombinator.com/reply?id=42", + "not a link", + ] { + assert_eq!(HnDestination::from_link(link), None, "{link}"); + } +} + +#[test] +fn temporary_cleanup_keeps_shared_downloads_bookmarks_and_feed_lists() { + block_on(async { + let (repo, _, store) = repository(); + for id in 1..=8 { + store.put(&format!("cache:item:{id}"), "{}").await.unwrap(); + } + for key in [ + "cache:user:ada", + "cache:search:query", + "cache:feed:topstories", + "other:record", + ] { + store.put(key, "{}").await.unwrap(); + } + let mut durable = ReaderState::default(); + durable.toggle_saved(1, 9); + durable.downloaded_threads.insert(2, vec![3, 4]); + repo.save_state(&durable).await.unwrap(); + let mut current = ReaderState::default(); + current.toggle_saved(5, 10); + current.downloaded_threads.insert(6, vec![4, 7]); + let result = repo.clean_temporary_cache(¤t).await.unwrap(); + assert_eq!((result.records, result.items), (3, 1)); + assert!(result.bytes > 0); + for id in 1..=7 { + assert!( + store + .get(&format!("cache:item:{id}")) + .await + .unwrap() + .is_some() + ); + } + assert!(store.get("cache:item:8").await.unwrap().is_none()); + assert!(store.get("cache:feed:topstories").await.unwrap().is_some()); + assert!(store.get("other:record").await.unwrap().is_some()); + assert!(repo.load_state().await.unwrap().is_saved(1)); + assert_eq!(current.downloaded_ids(), vec![6]); + assert_eq!( + repo.clean_temporary_cache(¤t).await.unwrap().records, + 0 + ); + }); +} + +#[cfg(not(target_arch = "wasm32"))] +/// Run with --ignored --nocapture in the same Cargo profile on both revisions. +#[test] +#[ignore = "timed SQLite workload"] +fn sqlite_offline_workload() { + use std::time::Instant; + + block_on(async { + let store = Rc::new(native::SqliteStore::in_memory().unwrap()); + let http = Rc::new(FakeHttp::default()); + let repo = Repository::new(http.clone(), store.clone()); + let children: Vec = (2..=1_001).collect(); + let body = "x".repeat(1024); + for id in 1..=1001 { + let mut item = item(id, if id == 1 { &children } else { &[] }); + item.text = Some(body.clone()); + let json = serde_json::json!({ "fetched_at": 1, "value": item }).to_string(); + store.put(&format!("cache:item:{id}"), &json).await.unwrap(); + } + let mut reads = Vec::new(); + let mut prunes = Vec::new(); + for _ in 0..9 { + let start = Instant::now(); + let snapshot = repo + .download_thread_with_progress(1, 1001, 2, FetchMode::Offline, |_| {}) + .await + .unwrap(); + reads.push(start.elapsed()); + assert!(snapshot.complete && snapshot.items.len() == 1001); + let start = Instant::now(); + assert_eq!(repo.prune_cache(&ReaderState::default()).await.unwrap(), 0); + prunes.push(start.elapsed()); + } + reads.sort(); + prunes.sort(); + println!( + "1001 cached items, 1 KiB each, 9 samples; median verification {:?}, under-limit prune {:?}", + reads[4], prunes[4] + ); + assert_eq!(http.calls.get(), 0); + }); +} + +#[test] +fn compact_state_writes_snapshot_pending_edits_and_read_existing_backups() { + let (repo, _, store) = repository(); + let old = r#"{ + "version":1, + "items":{"7":{"saved_at":null,"read_at":31,"hidden":false,"last_opened_at":31,"scroll_anchor":null,"scroll_inset":0,"collapsed":[],"seen_comments":[]}} + }"#; + block_on(store.put("reader:state", old)).unwrap(); + let mut state = block_on(repo.load_state()).unwrap(); + let write = repo.save_state(&state); + state.toggle_hidden(7); + block_on(write).unwrap(); + let saved = block_on(repo.load_state()).unwrap(); + assert!(saved.is_read(7)); + assert!(!saved.items[&7].hidden); + let raw = block_on(store.get("reader:state")).unwrap().unwrap(); + assert!(!raw.contains("seen_comments")); + assert!(!raw.contains('\n')); + assert_eq!(ReaderState::import_json(&saved.export_json().unwrap()).unwrap(), saved); +} diff --git a/Ember.Core/thread.rs b/Ember.Core/thread.rs index e273865..72e03c0 100644 --- a/Ember.Core/thread.rs +++ b/Ember.Core/thread.rs @@ -1,8 +1,9 @@ use crate::{DataSource, HnItem, ItemId}; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ThreadSnapshot { pub root: HnItem, pub items: BTreeMap, @@ -43,16 +44,31 @@ impl Default for ThreadOptions { } #[derive(Clone, Debug)] -pub struct ThreadRow { +pub struct ThreadRow> { pub id: ItemId, pub parent_id: ItemId, pub depth: usize, - pub item: Option, + pub item: T, pub collapsed: bool, pub child_count: usize, pub matches_query: bool, } +impl ThreadRow { + /// Preserve display identity and flags while selecting an owned, borrowed or deferred payload. + pub fn with_item(&self, item: U) -> ThreadRow { + ThreadRow { + id: self.id, + parent_id: self.parent_id, + depth: self.depth, + item, + collapsed: self.collapsed, + child_count: self.child_count, + matches_query: self.matches_query, + } + } +} + /// Iterative, cycle-safe traversal. Search includes ancestor context and searches /// collapsed branches without altering their persisted collapse state. Deleted /// and dead parents remain structural placeholders so descendants stay reachable. @@ -75,30 +91,84 @@ pub fn flatten_thread(snapshot: &ThreadSnapshot, options: &ThreadOptions) -> Vec pub fn flatten_thread_with_match( snapshot: &ThreadSnapshot, options: &ThreadOptions, - mut matches: impl FnMut(&HnItem, &str) -> bool, + matches: impl FnMut(&HnItem, &str) -> bool, ) -> Vec { + project_thread_with_match(snapshot, options, matches) + .into_iter() + .map(|row| row.with_item(row.item.cloned())) + .collect() +} + +/// Borrow payloads directly from the immutable snapshot. Virtualized views can keep only +/// the display metadata and resolve the item by ID when a row is realized. +pub fn project_thread_with_match<'a>( + snapshot: &'a ThreadSnapshot, + options: &ThreadOptions, + mut matches: impl FnMut(&HnItem, &str) -> bool, +) -> Vec>> { let query = options.query.trim().to_lowercase(); - let mut rows = Vec::new(); + collect_rows(snapshot, options, !query.is_empty(), true, |item| { + matches(item, &query) + }) +} + +/// Return new replies and their ancestry, across collapsed branches, in the current sort order. +/// The baseline contains the items loaded on previous visits. Unavailable records and hidden +/// text cannot qualify as new replies, but their children remain reachable. +pub fn flatten_thread_since( + snapshot: &ThreadSnapshot, + options: &ThreadOptions, + previous: &BTreeSet, +) -> Vec { + project_thread_since(snapshot, options, previous) + .into_iter() + .map(|row| row.with_item(row.item.cloned())) + .collect() +} + +/// Borrowed projection of the previous-visit reply filter. +pub fn project_thread_since<'a>( + snapshot: &'a ThreadSnapshot, + options: &ThreadOptions, + previous: &BTreeSet, +) -> Vec>> { + collect_rows(snapshot, options, true, false, |item| { + !previous.contains(&item.id) + }) +} + +fn collect_rows<'a>( + snapshot: &'a ThreadSnapshot, + options: &ThreadOptions, + filtered: bool, + text_search: bool, + mut matches: impl FnMut(&HnItem) -> bool, +) -> Vec>> { + let mut rows: Vec>> = Vec::new(); + let mut retained: Vec<(Option, bool)> = Vec::new(); let mut seen = BTreeSet::from([snapshot.root.id]); - let mut stack = Vec::new(); - let children = ordered_children(&snapshot.root.kids, snapshot, options.sort); - for id in children.into_iter().rev() { - stack.push((id, snapshot.root.id, 0)); - } - while let Some((id, parent_id, depth)) = stack.pop() { + let mut stack: Vec<(_, Option, _)> = + ordered_children(&snapshot.root.kids, snapshot, options.sort) + .iter() + .rev() + .map(|&id| (id, None, 0)) + .collect(); + while let Some((id, parent, depth)) = stack.pop() { if !seen.insert(id) { continue; } - let item = snapshot.items.get(&id).cloned(); + let item = snapshot.items.get(&id); let collapsed = options.collapsed.contains(&id); - let matches_query = !query.is_empty() - && item.as_ref().is_some_and(|item| { - (!item.dead || options.show_dead) && !item.deleted && matches(item, &query) + let matched = filtered + && item.is_some_and(|item| { + (!item.dead || options.show_dead) && !item.deleted && matches(item) }); - let children = item - .as_ref() - .map(|item| ordered_children(&item.kids, snapshot, options.sort)) - .unwrap_or_default(); + if filtered { + retained.push((parent, matched)); + } + let index = rows.len(); + let parent_id = parent.map_or(snapshot.root.id, |i| rows[i].id); + let children = item.map_or(&[][..], |item| item.kids.as_slice()); rows.push(ThreadRow { id, parent_id, @@ -106,47 +176,95 @@ pub fn flatten_thread_with_match( item, collapsed, child_count: children.len(), - matches_query, + matches_query: text_search && matched, }); - if depth < options.max_depth.min(256) && (!collapsed || !query.is_empty()) { - for child in children.into_iter().rev() { - stack.push((child, id, depth + 1)); - } + if depth < options.max_depth.min(256) && (!collapsed || filtered) && !children.is_empty() { + let children = ordered_children(children, snapshot, options.sort); + stack.extend( + children + .iter() + .rev() + .map(|&child| (child, Some(index), depth + 1)), + ); } } - if !query.is_empty() { - let parents: BTreeMap<_, _> = rows.iter().map(|row| (row.id, row.parent_id)).collect(); - let mut retained = BTreeSet::new(); - for row in rows.iter().filter(|row| row.matches_query) { - let mut current = row.id; - while retained.insert(current) { - let Some(parent) = parents.get(¤t) else { - break; - }; - current = *parent; + if filtered { + // DFS emits every parent before its children. Propagate membership once per + // edge in reverse, using the actual display parent even for repeated/cyclic IDs. + for index in (0..retained.len()).rev() { + if let (Some(parent), true) = retained[index] { + retained[parent].1 = true; } } - rows.retain(|row| retained.contains(&row.id)); + let mut flags = retained.into_iter(); + rows.retain(|_| flags.next().expect("one flag per visited row").1); } rows } -fn ordered_children(ids: &[ItemId], snapshot: &ThreadSnapshot, sort: ThreadSort) -> Vec { - let mut ids = ids.to_vec(); - if sort != ThreadSort::Ranked { - ids.sort_by_key(|id| { - ( - snapshot - .items - .get(id) - .and_then(|item| item.time) - .unwrap_or(0), - *id, - ) - }); - if sort == ThreadSort::Newest { - ids.reverse(); +fn ordered_children<'a>( + ids: &'a [ItemId], + snapshot: &ThreadSnapshot, + sort: ThreadSort, +) -> Cow<'a, [ItemId]> { + if sort == ThreadSort::Ranked || ids.len() < 2 { + return Cow::Borrowed(ids); + } + let mut sorted = ids.to_vec(); + sorted.sort_by_cached_key(|id| { + ( + snapshot + .items + .get(id) + .and_then(|item| item.time) + .unwrap_or(0), + *id, + ) + }); + if sort == ThreadSort::Newest { + sorted.reverse(); + } + Cow::Owned(sorted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projected_rows_borrow_original_bodies_and_keep_the_first_display_parent() { + let mut snapshot = ThreadSnapshot { + root: HnItem { + id: 1, + kids: vec![2, 3], + ..Default::default() + }, + ..ThreadSnapshot::default() + }; + for (id, kids) in [(2, vec![4]), (3, vec![4]), (4, vec![2, 1])] { + snapshot.items.insert( + id, + HnItem { + id, + kids, + text: Some(format!("Reply {id}")), + ..Default::default() + }, + ); } + let options = ThreadOptions { + query: "4".into(), + ..Default::default() + }; + let rows = project_thread_with_match(&snapshot, &options, |item, _| item.id == 4); + assert_eq!( + rows.iter().map(|r| (r.id, r.parent_id)).collect::>(), + [(2, 1), (4, 2)] + ); + assert!(std::ptr::eq(rows[1].item.unwrap(), &snapshot.items[&4])); + assert_eq!( + flatten_thread(&snapshot, &options)[1].item.as_ref(), + rows[1].item + ); } - ids } diff --git a/Ember/App.xaml b/Ember/App.xaml index fef6416..f73072b 100644 --- a/Ember/App.xaml +++ b/Ember/App.xaml @@ -6,100 +6,254 @@ - - + + + + + + + + + - - - - - + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + - - + - - - + - + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - + + + + + + + + + + + - - - - - - + + + + + + Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" + HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + RecognizesAccessKey="True" /> + + + + + + + + + + + + + + + - - - - + + - - + + + + + + + + - + + diff --git a/Ember/Assets/Fluent/NOTICE.md b/Ember/Assets/Fluent/NOTICE.md index 92402ef..60f3143 100644 --- a/Ember/Assets/Fluent/NOTICE.md +++ b/Ember/Assets/Fluent/NOTICE.md @@ -3,9 +3,9 @@ These SVGs and their extracted `d` path data in `../../icons.rs` are from [Microsoft Fluent System Icons](https://github.com/microsoft/fluentui-system-icons), 24 px regular variants. Copyright (c) 2020 Microsoft Corporation; MIT License (see -[LICENSE](LICENSE)). Downloaded on 2026-10-21 from the paths listed below. Ember -scales the vectors to 20 logical pixels and uses the inherited FerroUI foreground. -No icon font or platform-specific glyph mapping is required. +[LICENSE](LICENSE)). Sources and checksums are listed below. Ember +renders the vectors at 18 logical pixels using the inherited FerroUI foreground. +The disclosure action uses Chevron Up rotated 180 degrees when its contents are collapsed. | Icon | Upstream source | SHA-256 of retained SVG | |---|---|---| @@ -36,3 +36,5 @@ No icon font or platform-specific glyph mapping is required. | Text Font Size | [SVG](https://github.com/microsoft/fluentui-system-icons/blob/main/assets/Text%20Font%20Size/SVG/ic_fluent_text_font_size_24_regular.svg) | `7400ff999b5e806b9b0392c6acc4fc0960586957e17cafe35191ab22d8984920` | | Weather Moon | [SVG](https://github.com/microsoft/fluentui-system-icons/blob/main/assets/Weather%20Moon/SVG/ic_fluent_weather_moon_24_regular.svg) | `7b8c77347c704ca3f76a5fed4865f9011caf4663d011850043067ca8ee490ee8` | | Weather Sunny | [SVG](https://github.com/microsoft/fluentui-system-icons/blob/main/assets/Weather%20Sunny/SVG/ic_fluent_weather_sunny_24_regular.svg) | `0ab79963a8c16c623980da51a0b2623be939b9ee167958123c11240adfcdbbaa` | +| Full Screen Maximize | [SVG](https://github.com/microsoft/fluentui-system-icons/blob/main/assets/Full%20Screen%20Maximize/SVG/ic_fluent_full_screen_maximize_24_regular.svg) | `53b2e6174212279aca7172bad8813b060767ed553bdca00abc8cce824e6dee97` | +| Full Screen Minimize | [SVG](https://github.com/microsoft/fluentui-system-icons/blob/main/assets/Full%20Screen%20Minimize/SVG/ic_fluent_full_screen_minimize_24_regular.svg) | `41dc7d7ddd0f65e9cf9ba0e2003782cdac4bc597795833695ebf6ebee7a66d69` | diff --git a/Ember/Assets/Fluent/ic_fluent_full_screen_maximize_24_regular.svg b/Ember/Assets/Fluent/ic_fluent_full_screen_maximize_24_regular.svg new file mode 100644 index 0000000..3e2fd16 --- /dev/null +++ b/Ember/Assets/Fluent/ic_fluent_full_screen_maximize_24_regular.svg @@ -0,0 +1,3 @@ + + + diff --git a/Ember/Assets/Fluent/ic_fluent_full_screen_minimize_24_regular.svg b/Ember/Assets/Fluent/ic_fluent_full_screen_minimize_24_regular.svg new file mode 100644 index 0000000..eaea99a --- /dev/null +++ b/Ember/Assets/Fluent/ic_fluent_full_screen_minimize_24_regular.svg @@ -0,0 +1,3 @@ + + + diff --git a/Ember/Assets/Fluent/sources.json b/Ember/Assets/Fluent/sources.json index 6bc355a..5721927 100644 --- a/Ember/Assets/Fluent/sources.json +++ b/Ember/Assets/Fluent/sources.json @@ -83,6 +83,18 @@ "assets/Eye Off/SVG/ic_fluent_eye_off_24_regular.svg", "M2.21967 2.21967C1.9534 2.48594 1.9292 2.9026 2.14705 3.19621L2.21967 3.28033L6.25424 7.3149C4.33225 8.66437 2.89577 10.6799 2.29888 13.0644C2.1983 13.4662 2.4425 13.8735 2.84431 13.9741C3.24613 14.0746 3.6534 13.8305 3.75399 13.4286C4.28346 11.3135 5.59112 9.53947 7.33416 8.39452L9.14379 10.2043C8.43628 10.9258 8 11.9143 8 13.0046C8 15.2138 9.79086 17.0046 12 17.0046C13.0904 17.0046 14.0788 16.5683 14.8004 15.8608L20.7197 21.7803C21.0126 22.0732 21.4874 22.0732 21.7803 21.7803C22.0466 21.5141 22.0708 21.0974 21.8529 20.8038L21.7803 20.7197L15.6668 14.6055L15.668 14.604L14.4679 13.4061L11.598 10.5368L11.6 10.536L8.71877 7.65782L8.72 7.656L7.58672 6.52549L3.28033 2.21967C2.98744 1.92678 2.51256 1.92678 2.21967 2.21967ZM10.2041 11.2655L13.7392 14.8006C13.2892 15.2364 12.6759 15.5046 12 15.5046C10.6193 15.5046 9.5 14.3853 9.5 13.0046C9.5 12.3287 9.76824 11.7154 10.2041 11.2655ZM12 5.5C10.9997 5.5 10.0291 5.64807 9.11109 5.925L10.3481 7.16119C10.8839 7.05532 11.4364 7 12 7C15.9231 7 19.3099 9.68026 20.2471 13.4332C20.3475 13.835 20.7546 14.0794 21.1565 13.9791C21.5584 13.8787 21.8028 13.4716 21.7024 13.0697C20.5994 8.65272 16.6155 5.5 12 5.5ZM12.1947 9.00928L15.996 12.81C15.8942 10.7531 14.2472 9.10764 12.1947 9.00928Z" ], + [ + "Full Screen Maximize", + "ic_fluent_full_screen_maximize_24_regular.svg", + "assets/Full Screen Maximize/SVG/ic_fluent_full_screen_maximize_24_regular.svg", + "M4.5 5.75C4.5 5.05964 5.05964 4.5 5.75 4.5H7.75C8.16421 4.5 8.5 4.16421 8.5 3.75C8.5 3.33579 8.16421 3 7.75 3H5.75C4.23122 3 3 4.23122 3 5.75V7.75C3 8.16421 3.33579 8.5 3.75 8.5C4.16421 8.5 4.5 8.16421 4.5 7.75V5.75ZM4.5 18.25C4.5 18.9404 5.05964 19.5 5.75 19.5H7.75C8.16421 19.5 8.5 19.8358 8.5 20.25C8.5 20.6642 8.16421 21 7.75 21H5.75C4.23122 21 3 19.7688 3 18.25V16.25C3 15.8358 3.33579 15.5 3.75 15.5C4.16421 15.5 4.5 15.8358 4.5 16.25V18.25ZM18.25 4.5C18.9404 4.5 19.5 5.05964 19.5 5.75V7.75C19.5 8.16421 19.8358 8.5 20.25 8.5C20.6642 8.5 21 8.16421 21 7.75V5.75C21 4.23122 19.7688 3 18.25 3H16.25C15.8358 3 15.5 3.33579 15.5 3.75C15.5 4.16421 15.8358 4.5 16.25 4.5H18.25ZM19.5 18.25C19.5 18.9404 18.9404 19.5 18.25 19.5H16.25C15.8358 19.5 15.5 19.8358 15.5 20.25C15.5 20.6642 15.8358 21 16.25 21H18.25C19.7688 21 21 19.7688 21 18.25V16.25C21 15.8358 20.6642 15.5 20.25 15.5C19.8358 15.5 19.5 15.8358 19.5 16.25V18.25Z" + ], + [ + "Full Screen Minimize", + "ic_fluent_full_screen_minimize_24_regular.svg", + "assets/Full Screen Minimize/SVG/ic_fluent_full_screen_minimize_24_regular.svg", + "M8.5 3.75C8.5 3.33579 8.16421 3 7.75 3C7.33579 3 7 3.33579 7 3.75V6.25C7 6.66421 6.66421 7 6.25 7H3.75C3.33579 7 3 7.33579 3 7.75C3 8.16421 3.33579 8.5 3.75 8.5H6.25C7.49264 8.5 8.5 7.49264 8.5 6.25V3.75ZM8.5 20.25C8.5 20.6642 8.16421 21 7.75 21C7.33579 21 7 20.6642 7 20.25V17.75C7 17.3358 6.66421 17 6.25 17H3.75C3.33579 17 3 16.6642 3 16.25C3 15.8358 3.33579 15.5 3.75 15.5H6.25C7.49264 15.5 8.5 16.5074 8.5 17.75V20.25ZM16.25 3C15.8358 3 15.5 3.33579 15.5 3.75V6.25C15.5 7.49264 16.5074 8.5 17.75 8.5H20.25C20.6642 8.5 21 8.16421 21 7.75C21 7.33579 20.6642 7 20.25 7H17.75C17.3358 7 17 6.66421 17 6.25V3.75C17 3.33579 16.6642 3 16.25 3ZM15.5 20.25C15.5 20.6642 15.8358 21 16.25 21C16.6642 21 17 20.6642 17 20.25V17.75C17 17.3358 17.3358 17 17.75 17H20.25C20.6642 17 21 16.6642 21 16.25C21 15.8358 20.6642 15.5 20.25 15.5H17.75C16.5074 15.5 15.5 16.5074 15.5 17.75V20.25Z" + ], [ "History", "ic_fluent_history_24_regular.svg", diff --git a/Ember/Cargo.toml b/Ember/Cargo.toml index b8ab7c7..395bf49 100644 --- a/Ember/Cargo.toml +++ b/Ember/Cargo.toml @@ -16,6 +16,7 @@ build = "build.rs" path = "lib.rs" [dependencies] +futures-util = { workspace = true } ember-core = { path = "../Ember.Core" } ember-content = { path = "../Ember.Content" } ferroui-base = { workspace = true, features = ["markup-functions"] } @@ -24,6 +25,7 @@ ferroui-markup = { workspace = true } ferroui-markup-xaml = { workspace = true } ferroui-themes-winui = { workspace = true } sample-support = { path = "../FerroUI/samples/SampleSupport" } +url = { workspace = true } [build-dependencies] sample-build = { path = "../FerroUI/samples/SampleBuild" } diff --git a/Ember/ReaderView.xaml b/Ember/ReaderView.xaml index 5b591e1..a137439 100644 --- a/Ember/ReaderView.xaml +++ b/Ember/ReaderView.xaml @@ -2,115 +2,159 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" x:Class="Ember.ReaderView" Background="{DynamicResource EmberBackground}" Foreground="{DynamicResource EmberText}"> - - - - - - - - - - - - - - - -