From ec560c9e0d378783ab254a9e193ce5a3cc945e12 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:49:18 +0200 Subject: [PATCH 01/15] wip --- crates/re_viewer/src/web.rs | 121 ++++++++---------------------- crates/re_viewer/src/web_tools.rs | 113 ++++++++++++++++------------ web_viewer/index.html | 75 ++++++++++++++++-- 3 files changed, 163 insertions(+), 146 deletions(-) diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index b96351ee08be..d54988959208 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -9,9 +9,9 @@ use wasm_bindgen::prelude::*; use re_log::ResultExt as _; use re_memory::AccountingAllocator; -use re_viewer_context::CommandSender; +use re_viewer_context::{SystemCommand, SystemCommandSender}; -use crate::web_tools::{string_from_js_value, translate_query_into_commands, url_to_receiver}; +use crate::web_tools::{url_to_receiver, Callback, StringOrStringArray}; #[global_allocator] static GLOBAL: AccountingAllocator = @@ -153,7 +153,7 @@ impl WebHandle { return; }; let follow_if_http = follow_if_http.unwrap_or(false); - let rx = url_to_receiver(app.egui_ctx.clone(), follow_if_http, url); + let rx = url_to_receiver(app.egui_ctx.clone(), follow_if_http, url.to_owned()); if let Some(rx) = rx.ok_or_log_error() { app.add_receiver(rx); } @@ -296,12 +296,16 @@ impl From for re_types::blueprint::components::PanelState { // Keep in sync with the `AppOptions` typedef in `rerun_js/web-viewer/index.js` #[derive(Clone, Default, Deserialize)] pub struct AppOptions { - url: Option, + app_id: Option, + url: Option, manifest_url: Option, render_backend: Option, hide_welcome_screen: Option, panel_state_overrides: Option, fullscreen: Option, + + notebook: Option, + persist: Option, } // Keep in sync with the `FullscreenOptions` typedef in `rerun_js/web-viewer/index.js` @@ -333,17 +337,6 @@ impl From for crate::app_blueprint::PanelStateOverrides { } } -// Can't deserialize `Option` directly, so newtype it is. -#[derive(Clone, Deserialize)] -#[repr(transparent)] -pub struct Callback(#[serde(with = "serde_wasm_bindgen::preserve")] js_sys::Function); - -impl Callback { - pub fn call(&self) -> Result { - self.0.call0(&web_sys::window().unwrap()) - } -} - fn create_app( cc: &eframe::CreationContext<'_>, app_options: AppOptions, @@ -358,8 +351,8 @@ fn create_app( max_bytes: Some(2_500_000_000), }, location: Some(cc.integration_info.web_info.location.clone()), - persist_state: get_persist_state(&cc.integration_info), - is_in_notebook: is_in_notebook(&cc.integration_info), + persist_state: app_options.persist.unwrap_or(true), + is_in_notebook: app_options.notebook.unwrap_or(false), expect_data_soon: None, force_wgpu_backend: None, hide_welcome_screen: app_options.hide_welcome_screen.unwrap_or(false), @@ -376,53 +369,38 @@ fn create_app( cc.storage, ); - let query_map = &cc.integration_info.web_info.location.query_map; + if let Some(app_id) = app_options.app_id { + app.command_sender + .send_system(SystemCommand::ActivateApp(re_log_types::ApplicationId( + app_id, + ))); + } - if let Some(manifest_url) = &app_options.manifest_url { - app.set_examples_manifest_url(manifest_url.into()); - } else { - for url in query_map.get("manifest_url").into_iter().flatten() { - app.set_examples_manifest_url(url.clone()); - } + if let Some(manifest_url) = app_options.manifest_url { + app.set_examples_manifest_url(manifest_url); } - if let Some(url) = &app_options.url { + if let Some(urls) = app_options.url { let follow_if_http = false; - if let Some(receiver) = - url_to_receiver(cc.egui_ctx.clone(), follow_if_http, url).ok_or_log_error() - { - app.add_receiver(receiver); + for url in urls.into_inner() { + if let Some(receiver) = + url_to_receiver(cc.egui_ctx.clone(), follow_if_http, url).ok_or_log_error() + { + // We may be here because the user clicked Back/Forward in the browser while trying + // out examples. If we re-download the same file we should clear out the old data first. + app.command_sender + .send_system(SystemCommand::ClearSourceAndItsStores( + receiver.source().clone(), + )); + app.command_sender + .send_system(SystemCommand::AddReceiver(receiver)); + } } - } else { - translate_query_into_commands(&cc.egui_ctx, &app.command_sender); } - install_popstate_listener(cc.egui_ctx.clone(), app.command_sender.clone()); - Ok(app) } -/// Listen for `popstate` event, which comes when the user hits the back/forward buttons. -/// -/// -fn install_popstate_listener(egui_ctx: egui::Context, command_sender: CommandSender) -> Option<()> { - let window = web_sys::window()?; - let closure = Closure::wrap(Box::new(move |_: web_sys::Event| { - translate_query_into_commands(&egui_ctx, &command_sender); - }) as Box); - window - .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) - .map_err(|err| { - format!( - "Failed to add popstate event listener: {}", - string_from_js_value(err) - ) - }) - .ok_or_log_error()?; - closure.forget(); - Some(()) -} - /// Used to set the "email" property in the analytics config, /// in the same way as `rerun analytics email YOURNAME@rerun.io`. /// @@ -435,38 +413,3 @@ pub fn set_email(email: String) { config.opt_in_metadata.insert("email".into(), email.into()); config.save().unwrap(); } - -fn is_in_notebook(info: &eframe::IntegrationInfo) -> bool { - get_query_bool(info, "notebook", false) -} - -fn get_persist_state(info: &eframe::IntegrationInfo) -> bool { - get_query_bool(info, "persist", true) -} - -fn get_query_bool(info: &eframe::IntegrationInfo, key: &str, default: bool) -> bool { - let default_int = default as i32; - - if let Some(values) = info.web_info.location.query_map.get(key) { - if values.len() == 1 { - match values[0].as_str() { - "0" => false, - "1" => true, - other => { - re_log::warn!( - "Unexpected value for '{key}' query: {other:?}. Expected either '0' or '1'. Defaulting to '{default_int}'." - ); - default - } - } - } else { - re_log::warn!( - "Found {} values for '{key}' query. Expected one or none. Defaulting to '{default_int}'.", - values.len() - ); - default - } - } else { - default - } -} diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index 1a1ec1efcb33..92ce63dd2e9b 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,11 +1,11 @@ use std::{ops::ControlFlow, sync::Arc}; use anyhow::Context as _; +use serde::Deserialize; use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use re_log::ResultExt as _; -use re_viewer_context::CommandSender; /// Web-specific tools used by various parts of the application. @@ -134,46 +134,6 @@ pub fn replace_history(new_relative_url: &str) -> Option<()> { .ok_or_log_error() } -/// Parse the `?query` parst of the url, and translate it into commands to control the application. -pub fn translate_query_into_commands(egui_ctx: &egui::Context, command_sender: &CommandSender) { - use re_viewer_context::{SystemCommand, SystemCommandSender as _}; - - let location = eframe::web::web_location(); - - if let Some(app_ids) = location.query_map.get("app_id") { - if let Some(app_id) = app_ids.last() { - let app_id = re_log_types::ApplicationId::from(app_id.as_str()); - command_sender.send_system(SystemCommand::ActivateApp(app_id)); - } - } - - // NOTE: we support passing in multiple urls to multiple different recorording, blueprints, etc - let urls: Vec<&String> = location - .query_map - .get("url") - .into_iter() - .flatten() - .collect(); - if !urls.is_empty() { - let follow_if_http = false; - for url in urls { - if let Some(receiver) = - url_to_receiver(egui_ctx.clone(), follow_if_http, url).ok_or_log_error() - { - // We may be here because the user clicked Back/Forward in the browser while trying - // out examples. If we re-download the same file we should clear out the old data first. - command_sender.send_system(SystemCommand::ClearSourceAndItsStores( - receiver.source().clone(), - )); - - command_sender.send_system(SystemCommand::AddReceiver(receiver)); - } - } - } - - egui_ctx.request_repaint(); // wake up to receive the messages -} - enum EndpointCategory { /// Could be a local path (`/foo.rrd`) or a remote url (`http://foo.com/bar.rrd`). /// @@ -184,23 +144,23 @@ enum EndpointCategory { WebSocket(String), /// An eventListener for rrd posted from containing html - WebEventListener, + WebEventListener(String), } impl EndpointCategory { - fn categorize_uri(uri: &str) -> Self { + fn categorize_uri(uri: String) -> Self { if uri.starts_with("http") || uri.ends_with(".rrd") || uri.ends_with(".rbl") { - Self::HttpRrd(uri.into()) + Self::HttpRrd(uri) } else if uri.starts_with("ws:") || uri.starts_with("wss:") { - Self::WebSocket(uri.into()) + Self::WebSocket(uri) } else if uri.starts_with("web_event:") { - Self::WebEventListener + Self::WebEventListener(uri) } else { // If this is something like `foo.com` we can't know what it is until we connect to it. // We could/should connect and see what it is, but for now we just take a wild guess instead: re_log::info!("Assuming WebSocket endpoint"); if uri.contains("://") { - Self::WebSocket(uri.into()) + Self::WebSocket(uri) } else { Self::WebSocket(format!("{}://{uri}", re_ws_comms::PROTOCOL)) } @@ -212,7 +172,7 @@ impl EndpointCategory { pub fn url_to_receiver( egui_ctx: egui::Context, follow_if_http: bool, - url: &str, + url: String, ) -> anyhow::Result> { let ui_waker = Box::new(move || { // Spend a few more milliseconds decoding incoming messages, @@ -227,13 +187,12 @@ pub fn url_to_receiver( Some(ui_waker), ), ), - EndpointCategory::WebEventListener => { + EndpointCategory::WebEventListener(url) => { // Process an rrd when it's posted via `window.postMessage` let (tx, rx) = re_smart_channel::smart_channel( re_smart_channel::SmartMessageSource::RrdWebEventCallback, re_smart_channel::SmartChannelSource::RrdWebEventListener, ); - let url = url.to_owned(); re_log_encoding::stream_rrd_from_http::stream_rrd_from_event_listener(Arc::new({ move |msg| { ui_waker(); @@ -265,3 +224,57 @@ pub fn url_to_receiver( .with_context(|| format!("Failed to connect to WebSocket server at {url}.")), } } + +// Can't deserialize `Option` directly, so newtype it is. +#[derive(Clone, Deserialize)] +#[repr(transparent)] +pub struct Callback(#[serde(with = "serde_wasm_bindgen::preserve")] js_sys::Function); + +impl Callback { + pub fn call(&self) -> Result { + self.0.call0(&web_sys::window().unwrap()) + } +} + +// Deserializes from JS string or array of strings. +#[derive(Clone)] +pub struct StringOrStringArray(Vec); + +impl StringOrStringArray { + pub fn into_inner(self) -> Vec { + self.0 + } +} + +impl std::ops::Deref for StringOrStringArray { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de> Deserialize<'de> for StringOrStringArray { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + fn from_value(value: JsValue) -> Option> { + if let Some(value) = value.as_string() { + return Some(vec![value]); + } + + let array = value.dyn_into::().ok()?; + let mut out = Vec::with_capacity(array.length() as usize); + for item in array.into_iter() { + out.push(item.as_string()?); + } + Some(out) + } + + let value = serde_wasm_bindgen::preserve::deserialize(deserializer)?; + from_value(value) + .map(Self) + .ok_or_else(|| serde::de::Error::custom("value is not a string or array of strings")) + } +} diff --git a/web_viewer/index.html b/web_viewer/index.html index 58dc70aa513b..1373784b7b21 100644 --- a/web_viewer/index.html +++ b/web_viewer/index.html @@ -326,7 +326,27 @@ console.debug("Wasm loaded. Starting app…"); - let handle = new wasm_bindgen.WebHandle(); + const query = new URLSearchParams(window.location.search); + /* + url: Option, + manifest_url: Option, + render_backend: Option, + hide_welcome_screen: Option, + panel_state_overrides: Option, + fullscreen: Option, + */ + const options = { + app_id: query.get("app_id"), + url: query.get("url"), + manifest_url: query.get("manifest_url"), + render_backend: query.get("renderer"), + hide_welcome_screen: query.has("hide_welcome_screen"), + + notebook: get_query_bool(query, "notebook", false), + persist: get_query_bool(query, "persist", true), + }; + + let handle = new wasm_bindgen.WebHandle(options); function check_for_panic() { if (handle.has_panicked()) { @@ -356,13 +376,54 @@ check_for_panic(); - const urlParams = new URLSearchParams(window.location.search); - const forceWgpuBackend = urlParams.get("renderer"); + handle.start("the_canvas_id").then(on_app_started).catch(on_wasm_error); + } - handle - .start("the_canvas_id", null, null, forceWgpuBackend) - .then(on_app_started) - .catch(on_wasm_error); + function get_query_bool( + /** @type {URLSearchParams} */ query, + /** @type {string} */ key, + /** @type {boolean} */ fallback, + ) { + /* + if values.len() == 1 { + match values[0].as_str() { + "0" => false, + "1" => true, + other => { + re_log::warn!( + "Unexpected value for '{key}' query: {other:?}. Expected either '0' or '1'. Defaulting to '{default_int}'." + ); + default + } + } + } else { + re_log::warn!( + "Found {} values for '{key}' query. Expected one or none. Defaulting to '{default_int}'.", + values.len() + ); + default + }*/ + const values = query.getAll(key); + if (values.length !== 1) { + console.warn( + `found ${values.length} values for "${key}" query. Expected one or none. Defaulting to "${fallback}".`, + ); + return fallback; + } + + // prettier-ignore + switch (values[0]) { + case "0": /* fallthrough */ + case "false": return false; + + case "": /* fallthrough */ + case "1": /* fallthrough */ + case "true": return true; + + default: + console.warn(`unexpected value for "${key}" query. Expected one of: 0, 1, false, true, or no value. Defaulting to "${fallback}".`); + return fallback; + } } function on_app_started(handle) { From e574ca5f1309ccda2dc70946cf00dfb89bae6245 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:52:17 +0200 Subject: [PATCH 02/15] update AppOptions type --- rerun_js/web-viewer/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rerun_js/web-viewer/index.ts b/rerun_js/web-viewer/index.ts index 5013b2e120c4..d25a77d8bc54 100644 --- a/rerun_js/web-viewer/index.ts +++ b/rerun_js/web-viewer/index.ts @@ -1,6 +1,7 @@ import type { WebHandle } from "./re_viewer.js"; interface AppOptions { + app_id?: string; url?: string; manifest_url?: string; render_backend?: Backend; @@ -9,6 +10,9 @@ interface AppOptions { [K in Panel]: PanelState; }>; fullscreen?: FullscreenOptions; + + persist?: boolean; + notebook?: boolean; } type WebHandleConstructor = { From d9e0a9bee3a3ad760f06aba8002f2c34977b9d7e Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 18 Jun 2024 14:27:17 +0200 Subject: [PATCH 03/15] wip --- .vscode/settings.json | 3 +- crates/re_viewer/src/web.rs | 6 -- crates/re_viewer/src/web_tools.rs | 148 +++++++++++++++++++++++++++++- 3 files changed, 148 insertions(+), 9 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 72885b57a97c..f6dd83e7d777 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -66,5 +66,6 @@ }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "rust-analyzer.cargo.target": "wasm32-unknown-unknown" } diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index d54988959208..69153b55f6b3 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -386,12 +386,6 @@ fn create_app( if let Some(receiver) = url_to_receiver(cc.egui_ctx.clone(), follow_if_http, url).ok_or_log_error() { - // We may be here because the user clicked Back/Forward in the browser while trying - // out examples. If we re-download the same file we should clear out the old data first. - app.command_sender - .send_system(SystemCommand::ClearSourceAndItsStores( - receiver.source().clone(), - )); app.command_sender .send_system(SystemCommand::AddReceiver(receiver)); } diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index 92ce63dd2e9b..ef5da9a27beb 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -6,6 +6,8 @@ use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use re_log::ResultExt as _; +use web_sys::History; +use web_sys::UrlSearchParams; /// Web-specific tools used by various parts of the application. @@ -83,7 +85,7 @@ pub fn current_url_suffix() -> Option { /// ``` /// push_history("foo/bar?baz=qux#fragment"); /// ``` -pub fn push_history(new_relative_url: &str) -> Option<()> { +pub fn push_history(entry: HistoryEntry) -> Option<()> { let current_relative_url = current_url_suffix().unwrap_or_default(); if current_relative_url == new_relative_url { @@ -97,6 +99,7 @@ pub fn push_history(new_relative_url: &str) -> Option<()> { .history() .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) .ok_or_log_error()?; + // Instead of setting state to `null`, try to preserve existing state. // This helps with ensuring JS frameworks can perform client-side routing. // If we ever need to store anything in `state`, we should rethink how @@ -116,7 +119,7 @@ pub fn push_history(new_relative_url: &str) -> Option<()> { } /// Replace the current relative url with an new one. -pub fn replace_history(new_relative_url: &str) -> Option<()> { +pub fn replace_history(entry: HistoryEntry) -> Option<()> { let history = web_sys::window()? .history() .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) @@ -134,6 +137,147 @@ pub fn replace_history(new_relative_url: &str) -> Option<()> { .ok_or_log_error() } +/// A history entry is actually stored in two places: +/// - State object +/// - URL +/// +/// Ideally we wouldn't have to, but we want two things: +/// - Listen to `popstate` events and handle navigations client-side, +/// so that the forward/back buttons can be used to navigate between +/// examples and the welcome screen. +/// - Add a `?url` query param to the address bar when navigating to +/// an example, so that examples can be shared directly by just +/// copying the link. +#[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize)] +pub struct HistoryEntry { + /// Data source URL + /// + /// We support loading multiple URLs at the same time + pub url: Vec, + + /// Active app id + pub app_id: Option, +} + +// Builder methods +impl HistoryEntry { + pub fn new() -> Self { + Self { + url: Vec::new(), + app_id: None, + } + } + + pub fn url(mut self, url: String) -> Self { + self.url.push(url); + self + } + + pub fn app_id(mut self, app_id: Option) -> Self { + self.app_id = app_id; + self + } +} + +// Serialization +impl HistoryEntry { + fn to_search_params(&self) -> Option { + let params = UrlSearchParams::new().ok()?; + for url in &self.url { + params.append("url", url); + } + if let Some(app_id) = &self.app_id { + params.append("app_id", app_id); + } + Some(params) + } +} + +pub fn history() -> Option { + web_sys::window()? + .history() + .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) + .ok_or_log_error() +} + +pub trait HistoryExt { + fn current(&self) -> Option; + fn push(&self, entry: HistoryEntry); + fn replace(&self, entry: HistoryEntry); +} + +const HISTORY_ENTRY_KEY: &str = "__rerun"; + +extern "C" { + #[wasm_bindgen(js_namespace = "window", js_name = structuredClone)] + /// The `structuredClone()` method. + /// + /// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) + pub fn structured_clone(value: &JsValue) -> Result; +} + +/// Get the current raw history state. +/// +/// The return value is an object which may contain properties +/// added by other JS code. We need to be careful about not +/// trampling over those. +/// +/// The returned object has been shallow-cloned, so it is safe +/// to add our own keys to the object, as it won't update the +/// current browser history. +fn get_state(history: &History) -> Option { + let state = self.state().unwrap_or(JsValue::UNDEFINED); + if state.is_object() { + Some(structured_clone(&state)) + } else { + None + } +} + +fn get_current_history_entry(history: &History) -> Option { + let state = get_state(history)?; + + let key = JsValue::from_str(HISTORY_ENTRY_KEY); + + // let entry = serde_wasm_bindgen::to_value(&entry).ok()?; + // js_sys::Reflect::set(&state, &key, &entry).ok()?; +} + +fn get_state_with(history: &History, entry: HistoryEntry) -> Option { + let state = history.state().unwrap_or(JsValue::UNDEFINED); + if !state.is_object() { + return None; + } + + let key = JsValue::from_str(Self::KEY); + let entry = serde_wasm_bindgen::to_value(&entry).ok()?; + js_sys::Reflect::set(&state, &key, &entry).ok()?; + + Some(state) +} + +impl HistoryExt for History { + fn push(&self, entry: HistoryEntry) { + fn try_push(history: &History, entry: HistoryEntry) -> Option<()> { + let key = JsValue::from_str(Self::KEY); + let entry = serde_wasm_bindgen::to_value(&entry).ok()?; + history.push_state_with_url(data, title, url) + } + + try_push(self, entry); + } + + fn replace(&self, entry: HistoryEntry) { + fn try_replace(history: &History, entry: HistoryEntry) -> Option<()> { + let history = history()?; + + todo!() + } + + try_replace(self, entry); + } +} + enum EndpointCategory { /// Could be a local path (`/foo.rrd`) or a remote url (`http://foo.com/bar.rrd`). /// From 7b67c74c23ccf86744b45035d46cc15635eb162d Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Wed, 26 Jun 2024 13:09:44 +0200 Subject: [PATCH 04/15] refactor history api --- crates/re_viewer/src/app.rs | 14 +- crates/re_viewer/src/app_state.rs | 3 +- .../src/ui/welcome_screen/example_section.rs | 56 +-- crates/re_viewer/src/ui/welcome_screen/mod.rs | 9 +- crates/re_viewer/src/web.rs | 19 +- crates/re_viewer/src/web_tools.rs | 343 +++++++++--------- rerun_js/web-viewer/index.ts | 6 +- 7 files changed, 219 insertions(+), 231 deletions(-) diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index 33a6e7c3a47c..8c4b9ec63c22 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -80,6 +80,9 @@ pub struct StartupOptions { /// Default overrides for state of top/side/bottom panels. pub panel_state_overrides: PanelStateOverrides, + + #[cfg(target_arch = "wasm32")] + pub enable_history: bool, } impl Default for StartupOptions { @@ -107,6 +110,9 @@ impl Default for StartupOptions { fullscreen_options: Default::default(), panel_state_overrides: Default::default(), + + #[cfg(target_arch = "wasm32")] + enable_history: false, } } } @@ -945,6 +951,11 @@ impl App { if let Some(store_view) = store_context { let entity_db = store_view.recording; + #[cfg(target_arch = "wasm32")] + let is_history_enabled = self.startup_options.enable_history; + #[cfg(not(target_arch = "wasm32"))] + let is_history_enabled = false; + self.state.show( app_blueprint, ui, @@ -960,6 +971,7 @@ impl App { hide: self.startup_options.hide_welcome_screen, opacity: self.welcome_screen_opacity(egui_ctx), }, + is_history_enabled, ); } render_ctx.before_submit(); @@ -1462,7 +1474,7 @@ impl eframe::App for App { } #[cfg(target_arch = "wasm32")] - { + if self.startup_options.enable_history { // Handle pressing the back/forward mouse buttons explicitly, since eframe catches those. let back_pressed = egui_ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Extra1)); diff --git a/crates/re_viewer/src/app_state.rs b/crates/re_viewer/src/app_state.rs index c38e400eb3a8..d6ac7d5097d7 100644 --- a/crates/re_viewer/src/app_state.rs +++ b/crates/re_viewer/src/app_state.rs @@ -132,6 +132,7 @@ impl AppState { rx: &ReceiveSet, command_sender: &CommandSender, welcome_screen_state: &WelcomeScreenState, + is_history_enabled: bool, ) { re_tracing::profile_function!(); @@ -417,7 +418,7 @@ impl AppState { .frame(viewport_frame) .show_inside(ui, |ui| { if show_welcome { - welcome_screen.ui(ui, command_sender, welcome_screen_state); + welcome_screen.ui(ui, command_sender, welcome_screen_state, is_history_enabled); } else { viewport.viewport_ui(ui, &ctx, view_states); } diff --git a/crates/re_viewer/src/ui/welcome_screen/example_section.rs b/crates/re_viewer/src/ui/welcome_screen/example_section.rs index 860e657760a1..29fd00415732 100644 --- a/crates/re_viewer/src/ui/welcome_screen/example_section.rs +++ b/crates/re_viewer/src/ui/welcome_screen/example_section.rs @@ -246,6 +246,7 @@ impl ExampleSection { ui: &mut egui::Ui, command_sender: &CommandSender, header_ui: &impl Fn(&mut Ui), + is_history_enabled: bool, ) { let examples = self .examples @@ -352,19 +353,11 @@ impl ExampleSection { // panel to quit auto-zoom mode. ui.input_mut(|i| i.pointer = Default::default()); - let open_in_new_tab = ui.input(|i| i.modifiers.any()); open_example_url( ui.ctx(), command_sender, &example.desc.rrd_url, - open_in_new_tab, - ); - } else if response.middle_clicked() { - open_example_url( - ui.ctx(), - command_sender, - &example.desc.rrd_url, - true, + is_history_enabled, ); } @@ -433,28 +426,12 @@ impl ExampleSection { } } -#[cfg(target_arch = "wasm32")] -fn open_in_background_tab(egui_ctx: &egui::Context, rrd_url: &str) { - egui_ctx.open_url(egui::output::OpenUrl { - url: format!("/?url={}", crate::web_tools::percent_encode(rrd_url)), - new_tab: true, - }); -} - fn open_example_url( _egui_ctx: &egui::Context, command_sender: &CommandSender, rrd_url: &str, - _open_in_new_tab: bool, + _is_history_enabled: bool, ) { - #[cfg(target_arch = "wasm32")] - { - if _open_in_new_tab { - open_in_background_tab(_egui_ctx, rrd_url); - return; - } - } - let data_source = re_data_source::DataSource::RrdHttpUrl { url: rrd_url.to_owned(), follow: false, @@ -471,30 +448,13 @@ fn open_example_url( command_sender.send_system(SystemCommand::LoadDataSource(data_source)); #[cfg(target_arch = "wasm32")] - { - // Ensure that the user returns to the welcome page after navigating to an example. - use crate::web_tools; - - // So we know where to return to - let welcome_screen_app_id = re_viewer_context::StoreHub::welcome_screen_app_id(); - let welcome_screen_url = format!( - "?app_id={}", - web_tools::percent_encode(&welcome_screen_app_id.to_string()) - ); + if _is_history_enabled { + use crate::web_tools::{history, HistoryEntry, HistoryExt, JsResultExt}; - if web_tools::current_url_suffix() - .unwrap_or_default() - .is_empty() - { - // Replace, otherwise the user would need to hit back twice to return to - // whatever linked them to `https://www.rerun.io/viewer` in the first place. - web_tools::replace_history(&welcome_screen_url); - } else { - web_tools::push_history(&welcome_screen_url); + if let Some(history) = history().ok_or_log_js_error() { + let entry = HistoryEntry::new().rrd_url(rrd_url.to_owned()); + history.push_entry(entry).ok_or_log_js_error(); } - - // Where we're going: - web_tools::push_history(&format!("?url={}", web_tools::percent_encode(rrd_url))); } } diff --git a/crates/re_viewer/src/ui/welcome_screen/mod.rs b/crates/re_viewer/src/ui/welcome_screen/mod.rs index 791982ce8047..ec0da35cff4c 100644 --- a/crates/re_viewer/src/ui/welcome_screen/mod.rs +++ b/crates/re_viewer/src/ui/welcome_screen/mod.rs @@ -23,6 +23,7 @@ impl WelcomeScreen { ui: &mut egui::Ui, command_sender: &re_viewer_context::CommandSender, welcome_screen_state: &WelcomeScreenState, + is_history_enabled: bool, ) { if welcome_screen_state.opacity <= 0.0 { return; @@ -51,8 +52,12 @@ impl WelcomeScreen { if welcome_screen_state.hide { no_data_ui::no_data_ui(ui); } else { - self.example_page - .ui(ui, command_sender, &welcome_section_ui); + self.example_page.ui( + ui, + command_sender, + &welcome_section_ui, + is_history_enabled, + ); } }); }); diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index 69153b55f6b3..87536915546b 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -11,7 +11,8 @@ use re_log::ResultExt as _; use re_memory::AccountingAllocator; use re_viewer_context::{SystemCommand, SystemCommandSender}; -use crate::web_tools::{url_to_receiver, Callback, StringOrStringArray}; +use crate::web_tools::JsResultExt; +use crate::web_tools::{install_popstate_listener, url_to_receiver, Callback, StringOrStringArray}; #[global_allocator] static GLOBAL: AccountingAllocator = @@ -293,22 +294,22 @@ impl From for re_types::blueprint::components::PanelState { } } -// Keep in sync with the `AppOptions` typedef in `rerun_js/web-viewer/index.js` +// Keep in sync with the `AppOptions` interface in `rerun_js/web-viewer/index.ts` #[derive(Clone, Default, Deserialize)] pub struct AppOptions { - app_id: Option, url: Option, manifest_url: Option, render_backend: Option, hide_welcome_screen: Option, panel_state_overrides: Option, fullscreen: Option, + enable_history: Option, notebook: Option, persist: Option, } -// Keep in sync with the `FullscreenOptions` typedef in `rerun_js/web-viewer/index.js` +// Keep in sync with the `FullscreenOptions` interface in `rerun_js/web-viewer/index.ts` #[derive(Clone, Deserialize)] pub struct FullscreenOptions { /// This returns the current fullscreen state, which is a boolean representing on/off. @@ -345,6 +346,7 @@ fn create_app( let app_env = crate::AppEnvironment::Web { url: cc.integration_info.web_info.location.url.clone(), }; + let enable_history = app_options.enable_history.unwrap_or(false); let startup_options = crate::StartupOptions { memory_limit: re_memory::MemoryLimit { // On wasm32 we only have 4GB of memory to play around with. @@ -358,6 +360,7 @@ fn create_app( hide_welcome_screen: app_options.hide_welcome_screen.unwrap_or(false), fullscreen_options: app_options.fullscreen.clone(), panel_state_overrides: app_options.panel_state_overrides.unwrap_or_default().into(), + enable_history, }; crate::customize_eframe_and_setup_renderer(cc)?; @@ -369,11 +372,9 @@ fn create_app( cc.storage, ); - if let Some(app_id) = app_options.app_id { - app.command_sender - .send_system(SystemCommand::ActivateApp(re_log_types::ApplicationId( - app_id, - ))); + if enable_history { + install_popstate_listener(cc.egui_ctx.clone(), app.command_sender.clone()) + .ok_or_log_js_error(); } if let Some(manifest_url) = app_options.manifest_url { diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index ef5da9a27beb..55c18665bb16 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,15 +1,52 @@ -use std::{ops::ControlFlow, sync::Arc}; +//! Web-specific tools used by various parts of the application. use anyhow::Context as _; +use re_log::ResultExt; +use re_viewer_context::StoreHub; +use re_viewer_context::{CommandSender, SystemCommand, SystemCommandSender as _}; use serde::Deserialize; +use std::{ops::ControlFlow, sync::Arc}; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsCast as _; +use wasm_bindgen::JsError; use wasm_bindgen::JsValue; - -use re_log::ResultExt as _; use web_sys::History; use web_sys::UrlSearchParams; +use web_sys::Window; + +pub trait JsResultExt { + /// Logs an error if the result is an error and returns the result. + fn ok_or_log_js_error(self) -> Option; + + /// Logs an error if the result is an error and returns the result, but only once. + fn ok_or_log_js_error_once(self) -> Option; -/// Web-specific tools used by various parts of the application. + /// Log a warning if there is an `Err`, but only log the exact same message once. + fn warn_on_js_err_once(self, msg: impl std::fmt::Display) -> Option; + + /// Unwraps in debug builds otherwise logs an error if the result is an error and returns the result. + fn unwrap_debug_or_log_js_error(self) -> Option; +} + +impl JsResultExt for Result { + fn ok_or_log_js_error(self) -> Option { + self.map_err(string_from_js_value).ok_or_log_error() + } + + fn ok_or_log_js_error_once(self) -> Option { + self.map_err(string_from_js_value).ok_or_log_error_once() + } + + fn warn_on_js_err_once(self, msg: impl std::fmt::Display) -> Option { + self.map_err(string_from_js_value).warn_on_err_once(msg) + } + + fn unwrap_debug_or_log_js_error(self) -> Option { + self.map_err(string_from_js_value) + .unwrap_debug_or_log_error() + } +} /// Useful in error handlers #[allow(clippy::needless_pass_by_value)] @@ -27,10 +64,12 @@ pub fn string_from_js_value(s: wasm_bindgen::JsValue) -> String { format!("{s:#?}") } +pub fn js_error(msg: impl std::fmt::Display) -> JsValue { + JsError::new(&msg.to_string()).into() +} + pub fn set_url_parameter_and_refresh(key: &str, value: &str) -> Result<(), wasm_bindgen::JsValue> { - let Some(window) = web_sys::window() else { - return Err("Failed to get window".into()); - }; + let window = window()?; let location = window.location(); let url = web_sys::Url::new(&location.href()?)?; @@ -39,102 +78,76 @@ pub fn set_url_parameter_and_refresh(key: &str, value: &str) -> Result<(), wasm_ location.assign(&url.href()) } -/// Percent-encode the given string so you can put it in a URL. -pub fn percent_encode(s: &str) -> String { - format!("{}", js_sys::encode_uri_component(s)) -} +/// Listen for `popstate` event, which comes when the user hits the back/forward buttons. +/// +/// +pub fn install_popstate_listener( + egui_ctx: egui::Context, + command_sender: CommandSender, +) -> Result<(), JsValue> { + let closure = Closure::wrap(Box::new({ + let mut prev = history()?.current_entry()?; + move |_: web_sys::Event| { + handle_popstate(&mut prev, &egui_ctx, &command_sender).ok_or_log_js_error(); + } + }) as Box); -pub fn go_back() -> Option<()> { - let history = web_sys::window()? - .history() - .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) - .ok_or_log_error()?; - history - .back() - .map_err(|err| format!("Failed to go back: {}", string_from_js_value(err))) - .ok_or_log_error() + window()? + .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) + .ok_or_log_js_error(); + closure.forget(); + Ok(()) } -pub fn go_forward() -> Option<()> { - let history = web_sys::window()? - .history() - .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) - .ok_or_log_error()?; - history - .forward() - .map_err(|err| format!("Failed to go forward: {}", string_from_js_value(err))) - .ok_or_log_error() -} +fn handle_popstate( + prev: &mut Option, + egui_ctx: &egui::Context, + command_sender: &CommandSender, +) -> Result<(), JsValue> { + let current = history()?.current_entry()?; + if ¤t == prev { + return Ok(()); + } -/// The current percent-encoded URL suffix, e.g. "?foo=bar#baz". -pub fn current_url_suffix() -> Option { - let location = web_sys::window()?.location(); - let search = location.search().unwrap_or_default(); - let hash = location.hash().unwrap_or_default(); - Some(format!("{search}{hash}")) -} + let Some(entry) = current else { + // the user navigated back to the history entry where the viewer was initially opened + // in that case they likely expect to land back at the welcome screen: + command_sender.send_system(SystemCommand::ActivateApp(StoreHub::welcome_screen_app_id())); -/// Push a relative url on the web `History`, -/// so that the user can use the back button to navigate to it. -/// -/// If this is already the current url, nothing happens. -/// -/// The url must be percent encoded. -/// -/// Example: -/// ``` -/// push_history("foo/bar?baz=qux#fragment"); -/// ``` -pub fn push_history(entry: HistoryEntry) -> Option<()> { - let current_relative_url = current_url_suffix().unwrap_or_default(); - - if current_relative_url == new_relative_url { - re_log::debug!("Ignoring navigation to {new_relative_url:?} as we're already there"); - } else { - re_log::debug!( - "Existing url is {current_relative_url:?}; navigating to {new_relative_url:?}" - ); - - let history = web_sys::window()? - .history() - .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) - .ok_or_log_error()?; - - // Instead of setting state to `null`, try to preserve existing state. - // This helps with ensuring JS frameworks can perform client-side routing. - // If we ever need to store anything in `state`, we should rethink how - // we handle this. - let existing_state = history.state().unwrap_or(JsValue::NULL); - history - .push_state_with_url(&existing_state, "", Some(new_relative_url)) - .map_err(|err| { - format!( - "Failed to push history state: {}", - string_from_js_value(err) - ) - }) - .ok_or_log_error()?; + return Ok(()); + }; + + let follow_if_http = false; + for url in &entry.urls { + // we continue in case of errors because some receivers may be valid + let Some(receiver) = + url_to_receiver(egui_ctx.clone(), follow_if_http, url.clone()).ok_or_log_error() + else { + continue; + }; + + // We may be here because the user clicked Back/Forward in the browser while trying + // out examples. If we re-download the same file we should clear out the old data first. + command_sender.send_system(SystemCommand::ClearSourceAndItsStores( + receiver.source().clone(), + )); + command_sender.send_system(SystemCommand::AddReceiver(receiver)); } - Some(()) + + *prev = Some(entry); + egui_ctx.request_repaint(); + + Ok(()) } -/// Replace the current relative url with an new one. -pub fn replace_history(entry: HistoryEntry) -> Option<()> { - let history = web_sys::window()? - .history() - .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) - .ok_or_log_error()?; - // NOTE: See `existing_state` in `push_history` above for info on why this is here. - let existing_state = history.state().unwrap_or(JsValue::NULL); - history - .replace_state_with_url(&existing_state, "", Some(new_relative_url)) - .map_err(|err| { - format!( - "Failed to push history state: {}", - string_from_js_value(err) - ) - }) - .ok_or_log_error() +pub fn go_back() -> Option<()> { + let history = history().ok_or_log_js_error()?; + history.back().ok_or_log_js_error() +} + +pub fn go_forward() -> Option<()> { + let history = history().ok_or_log_js_error()?; + history.forward().ok_or_log_js_error() } /// A history entry is actually stored in two places: @@ -148,68 +161,69 @@ pub fn replace_history(entry: HistoryEntry) -> Option<()> { /// - Add a `?url` query param to the address bar when navigating to /// an example, so that examples can be shared directly by just /// copying the link. -#[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Default, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct HistoryEntry { /// Data source URL /// /// We support loading multiple URLs at the same time - pub url: Vec, - - /// Active app id - pub app_id: Option, + /// + /// `?url=` + pub urls: Vec, } // Builder methods impl HistoryEntry { - pub fn new() -> Self { - Self { - url: Vec::new(), - app_id: None, - } - } + pub const KEY: &'static str = "__rerun"; - pub fn url(mut self, url: String) -> Self { - self.url.push(url); - self + pub fn new() -> Self { + Self { urls: Vec::new() } } - pub fn app_id(mut self, app_id: Option) -> Self { - self.app_id = app_id; + /// Set the URL of the RRD to load when using this entry. + pub fn rrd_url(mut self, url: String) -> Self { + self.urls.push(url); self } } // Serialization impl HistoryEntry { - fn to_search_params(&self) -> Option { - let params = UrlSearchParams::new().ok()?; - for url in &self.url { + pub fn to_query_string(&self) -> Result { + use std::fmt::Write; + + let params = UrlSearchParams::new()?; + for url in &self.urls { params.append("url", url); } - if let Some(app_id) = &self.app_id { - params.append("app_id", app_id); - } - Some(params) + let mut out = "?".to_owned(); + write!(&mut out, "{}", params.to_string()).ok(); + + Ok(out) } } -pub fn history() -> Option { - web_sys::window()? - .history() - .map_err(|err| format!("Failed to get History API: {}", string_from_js_value(err))) - .ok_or_log_error() +pub fn window() -> Result { + web_sys::window().ok_or_else(|| js_error("failed to get window object")) } -pub trait HistoryExt { - fn current(&self) -> Option; - fn push(&self, entry: HistoryEntry); - fn replace(&self, entry: HistoryEntry); +pub fn history() -> Result { + window()?.history() } -const HISTORY_ENTRY_KEY: &str = "__rerun"; +pub trait HistoryExt { + /// Push a history entry onto the stack, which becomes the latest entry. + fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>; + + /// Replace the latest entry. + fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>; + + /// Get the latest entry. + fn current_entry(&self) -> Result, JsValue>; +} +#[wasm_bindgen] extern "C" { - #[wasm_bindgen(js_namespace = "window", js_name = structuredClone)] + #[wasm_bindgen(catch, js_namespace = ["window"], js_name = structuredClone)] /// The `structuredClone()` method. /// /// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) @@ -222,59 +236,51 @@ extern "C" { /// added by other JS code. We need to be careful about not /// trampling over those. /// -/// The returned object has been shallow-cloned, so it is safe +/// The returned object has been deeply cloned, so it is safe /// to add our own keys to the object, as it won't update the /// current browser history. -fn get_state(history: &History) -> Option { - let state = self.state().unwrap_or(JsValue::UNDEFINED); - if state.is_object() { - Some(structured_clone(&state)) - } else { - None - } -} - -fn get_current_history_entry(history: &History) -> Option { - let state = get_state(history)?; - - let key = JsValue::from_str(HISTORY_ENTRY_KEY); - - // let entry = serde_wasm_bindgen::to_value(&entry).ok()?; - // js_sys::Reflect::set(&state, &key, &entry).ok()?; -} - -fn get_state_with(history: &History, entry: HistoryEntry) -> Option { +fn get_raw_state(history: &History) -> Result { let state = history.state().unwrap_or(JsValue::UNDEFINED); if !state.is_object() { - return None; + return Err(JsError::new("history state is not an object").into()); } - let key = JsValue::from_str(Self::KEY); - let entry = serde_wasm_bindgen::to_value(&entry).ok()?; - js_sys::Reflect::set(&state, &key, &entry).ok()?; + structured_clone(&state) +} - Some(state) +/// Get the state from `history`, deeply-cloned, and return it with updated values from the given `entry`. +/// +/// This does _not_ mutate the browser history. +fn get_updated_state(history: &History, entry: &HistoryEntry) -> Result { + let state = get_raw_state(history)?; + let key = JsValue::from_str(HistoryEntry::KEY); + let entry = serde_wasm_bindgen::to_value(entry)?; + js_sys::Reflect::set(&state, &key, &entry)?; + Ok(state) } impl HistoryExt for History { - fn push(&self, entry: HistoryEntry) { - fn try_push(history: &History, entry: HistoryEntry) -> Option<()> { - let key = JsValue::from_str(Self::KEY); - let entry = serde_wasm_bindgen::to_value(&entry).ok()?; - history.push_state_with_url(data, title, url) - } - - try_push(self, entry); + fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { + let state = get_updated_state(self, &entry)?; + let url = entry.to_query_string()?; + self.push_state_with_url(&state, "", Some(&url)) } - fn replace(&self, entry: HistoryEntry) { - fn try_replace(history: &History, entry: HistoryEntry) -> Option<()> { - let history = history()?; + fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { + let state = get_updated_state(self, &entry)?; + let url = entry.to_query_string()?; + self.replace_state_with_url(&state, "", Some(&url)) + } - todo!() + fn current_entry(&self) -> Result, JsValue> { + let state = get_raw_state(self)?; + let key = JsValue::from_str(HistoryEntry::KEY); + let value = js_sys::Reflect::get(&state, &key)?; + if value.is_undefined() || value.is_null() { + return Ok(None); } - try_replace(self, entry); + Ok(Some(serde_wasm_bindgen::from_value(value)?)) } } @@ -376,7 +382,8 @@ pub struct Callback(#[serde(with = "serde_wasm_bindgen::preserve")] js_sys::Func impl Callback { pub fn call(&self) -> Result { - self.0.call0(&web_sys::window().unwrap()) + let window: JsValue = window()?.into(); + self.0.call0(&window) } } diff --git a/rerun_js/web-viewer/index.ts b/rerun_js/web-viewer/index.ts index f1eee75f5a08..5845bb868362 100644 --- a/rerun_js/web-viewer/index.ts +++ b/rerun_js/web-viewer/index.ts @@ -1,6 +1,6 @@ import type { WebHandle } from "./re_viewer.js"; -interface AppOptions { +export interface AppOptions { app_id?: string; url?: string; manifest_url?: string; @@ -10,6 +10,7 @@ interface AppOptions { [K in Panel]: PanelState; }>; fullscreen?: FullscreenOptions; + enable_history?: boolean; persist?: boolean; notebook?: boolean; @@ -43,11 +44,12 @@ export type Panel = "top" | "blueprint" | "selection" | "time"; export type PanelState = "hidden" | "collapsed" | "expanded"; export type Backend = "webgpu" | "webgl"; -interface WebViewerOptions { +export interface WebViewerOptions { manifest_url?: string; render_backend?: Backend; hide_welcome_screen?: boolean; allow_fullscreen?: boolean; + enable_history?: boolean; } interface FullscreenOptions { From 9e2c932ba7489176cee6c0d0c3184ae8426acf04 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Mon, 1 Jul 2024 12:04:29 +0200 Subject: [PATCH 05/15] remove settings.json change --- .vscode/settings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f6dd83e7d777..72885b57a97c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -66,6 +66,5 @@ }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "rust-analyzer.cargo.target": "wasm32-unknown-unknown" + } } From 1adc95c528936b4475fc4142aca87acc74c370c8 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:34:09 +0200 Subject: [PATCH 06/15] ignore generated files in lint --- scripts/lint.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/lint.py b/scripts/lint.py index f15d696e84d9..45660af35a1e 100755 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -1120,6 +1120,19 @@ def main() -> None: "./scripts/zombie_todos.py", "./tests/python/release_checklist/main.py", "./web_viewer/re_viewer.js", # auto-generated by wasm_bindgen + # TODO(emilk): remove after parsing all .gitignore files + # without this running it locally is very noisy + # node_modules + "./rerun_notebook/node_modules", + "./rerun_js/node_modules", + "./rerun_js/node_modules", + # JS files generated during build + "./rerun_notebook/src/rerun_notebook/static", + "./rerun_js/web-viewer/inlined.js", + "./rerun_js/web-viewer/index.js", + "./rerun_js/web-viewer/re_viewer.js", + "./rerun_js/web-viewer/node_modules", + "./rerun_js/web-viewer-react/node_modules", ) should_ignore = parse_gitignore(".gitignore") # TODO(emilk): parse all .gitignore files, not just top-level From 0923c20c903669f2ae8e3ba945b224145164ab0e Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 12:35:20 +0200 Subject: [PATCH 07/15] fix lints --- crates/re_viewer/src/web_tools.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index 55c18665bb16..e08618cccb2b 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -161,7 +161,7 @@ pub fn go_forward() -> Option<()> { /// - Add a `?url` query param to the address bar when navigating to /// an example, so that examples can be shared directly by just /// copying the link. -#[derive(Clone, Default, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct HistoryEntry { /// Data source URL /// @@ -180,6 +180,7 @@ impl HistoryEntry { } /// Set the URL of the RRD to load when using this entry. + #[inline] pub fn rrd_url(mut self, url: String) -> Self { self.urls.push(url); self @@ -381,6 +382,7 @@ pub fn url_to_receiver( pub struct Callback(#[serde(with = "serde_wasm_bindgen::preserve")] js_sys::Function); impl Callback { + #[inline] pub fn call(&self) -> Result { let window: JsValue = window()?.into(); self.0.call0(&window) @@ -400,6 +402,7 @@ impl StringOrStringArray { impl std::ops::Deref for StringOrStringArray { type Target = Vec; + #[inline] fn deref(&self) -> &Self::Target { &self.0 } @@ -417,7 +420,7 @@ impl<'de> Deserialize<'de> for StringOrStringArray { let array = value.dyn_into::().ok()?; let mut out = Vec::with_capacity(array.length() as usize); - for item in array.into_iter() { + for item in array { out.push(item.as_string()?); } Some(out) From 81f797d54cf37eabfc059bc003c11aed8c56a332 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:27:47 +0200 Subject: [PATCH 08/15] improve popstate event behavior --- .vscode/settings.json | 3 +- Cargo.lock | 1 + crates/re_viewer/Cargo.toml | 2 + crates/re_viewer/src/app.rs | 6 ++ crates/re_viewer/src/web.rs | 3 +- crates/re_viewer/src/web_tools.rs | 125 +++++++++++++++++++++++------- 6 files changed, 109 insertions(+), 31 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 72885b57a97c..f6dd83e7d777 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -66,5 +66,6 @@ }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "rust-analyzer.cargo.target": "wasm32-unknown-unknown" } diff --git a/Cargo.lock b/Cargo.lock index b7c2136f61da..4024c566d809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5199,6 +5199,7 @@ dependencies = [ "image", "itertools 0.13.0", "js-sys", + "parking_lot", "poll-promise", "re_analytics", "re_blueprint_tree", diff --git a/crates/re_viewer/Cargo.toml b/crates/re_viewer/Cargo.toml index 9a2ed7d43150..4d35b9f66f7d 100644 --- a/crates/re_viewer/Cargo.toml +++ b/crates/re_viewer/Cargo.toml @@ -100,6 +100,7 @@ egui.workspace = true ehttp.workspace = true image = { workspace = true, default-features = false, features = ["png"] } itertools = { workspace = true } +parking_lot.workspace = true poll-promise = { workspace = true, features = ["web"] } rfd.workspace = true ron.workspace = true @@ -121,6 +122,7 @@ wasm-bindgen.workspace = true web-sys = { workspace = true, features = [ "History", "Location", + "PopStateEvent", "Storage", "Url", "UrlSearchParams", diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index 8c4b9ec63c22..fa7b0013cdd4 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -133,6 +133,9 @@ pub struct App { pub(crate) egui_ctx: egui::Context, screenshotter: crate::screenshotter::Screenshotter, + #[cfg(target_arch = "wasm32")] + pub(crate) popstate_listener: Option, + #[cfg(not(target_arch = "wasm32"))] profiler: re_tracing::Profiler, @@ -278,6 +281,9 @@ impl App { egui_ctx, screenshotter, + #[cfg(target_arch = "wasm32")] + popstate_listener: None, + #[cfg(not(target_arch = "wasm32"))] profiler: Default::default(), diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index 87536915546b..82b5f660e533 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -373,8 +373,7 @@ fn create_app( ); if enable_history { - install_popstate_listener(cc.egui_ctx.clone(), app.command_sender.clone()) - .ok_or_log_js_error(); + install_popstate_listener(&mut app).ok_or_log_js_error(); } if let Some(manifest_url) = app_options.manifest_url { diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index e08618cccb2b..c4d763ca1e76 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,10 +1,12 @@ //! Web-specific tools used by various parts of the application. use anyhow::Context as _; +use parking_lot::Mutex; use re_log::ResultExt; use re_viewer_context::StoreHub; use re_viewer_context::{CommandSender, SystemCommand, SystemCommandSender as _}; use serde::Deserialize; +use std::sync::OnceLock; use std::{ops::ControlFlow, sync::Arc}; use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::wasm_bindgen; @@ -78,43 +80,95 @@ pub fn set_url_parameter_and_refresh(key: &str, value: &str) -> Result<(), wasm_ location.assign(&url.href()) } +static STORED_HISTORY_ENTRY: OnceLock>>> = OnceLock::new(); + +fn stored_history_entry() -> &'static Arc>> { + STORED_HISTORY_ENTRY.get_or_init(|| Arc::new(Mutex::new(None))) +} + +fn get_stored_history_entry() -> Option { + stored_history_entry().lock().clone() +} + +fn set_stored_history_entry(entry: Option) { + *stored_history_entry().lock() = entry; +} + +type EventListener = dyn FnMut(Event) -> Result<(), JsValue>; + /// Listen for `popstate` event, which comes when the user hits the back/forward buttons. /// /// -pub fn install_popstate_listener( - egui_ctx: egui::Context, - command_sender: CommandSender, -) -> Result<(), JsValue> { +pub fn install_popstate_listener(app: &mut crate::App) -> Result<(), JsValue> { + let egui_ctx = app.egui_ctx.clone(); + let command_sender = app.command_sender.clone(); + let closure = Closure::wrap(Box::new({ - let mut prev = history()?.current_entry()?; - move |_: web_sys::Event| { - handle_popstate(&mut prev, &egui_ctx, &command_sender).ok_or_log_js_error(); + move |event: web_sys::PopStateEvent| { + let new_state = deserialize_from_state(event.state())?; + handle_popstate(&egui_ctx, &command_sender, new_state)?; + Ok(()) } - }) as Box); + }) as Box>); + + set_stored_history_entry(history()?.current_entry()?); window()? .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) .ok_or_log_js_error(); - closure.forget(); + + app.popstate_listener = Some(PopstateListener(Some(closure))); + Ok(()) } +pub struct PopstateListener(Option>>); + +impl Drop for PopstateListener { + fn drop(&mut self) { + let Some(window) = window().ok_or_log_js_error() else { + return; + }; + + let closure = self.0.take().unwrap(); + window + .remove_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) + .ok_or_log_js_error(); + drop(closure); + } +} + fn handle_popstate( - prev: &mut Option, egui_ctx: &egui::Context, command_sender: &CommandSender, + new_state: Option, ) -> Result<(), JsValue> { - let current = history()?.current_entry()?; - if ¤t == prev { + let prev_state = get_stored_history_entry(); + + re_log::debug!("popstate: prev={prev_state:?} new={new_state:?}"); + + if prev_state == new_state { + re_log::debug!("popstate: no change"); + return Ok(()); } - let Some(entry) = current else { + if new_state.is_none() || new_state.as_ref().is_some_and(|v| v.urls.is_empty()) { + re_log::debug!("popstate: clear recordings + go to welcome screen"); + // the user navigated back to the history entry where the viewer was initially opened // in that case they likely expect to land back at the welcome screen: + command_sender.send_system(SystemCommand::CloseAllRecordings); command_sender.send_system(SystemCommand::ActivateApp(StoreHub::welcome_screen_app_id())); + set_stored_history_entry(new_state); + egui_ctx.request_repaint(); + return Ok(()); + } + + let Some(entry) = new_state else { + unreachable!(); }; let follow_if_http = false; @@ -126,15 +180,12 @@ fn handle_popstate( continue; }; - // We may be here because the user clicked Back/Forward in the browser while trying - // out examples. If we re-download the same file we should clear out the old data first. - command_sender.send_system(SystemCommand::ClearSourceAndItsStores( - receiver.source().clone(), - )); command_sender.send_system(SystemCommand::AddReceiver(receiver)); + + re_log::debug!("popstate: add receiver {url:?}"); } - *prev = Some(entry); + set_stored_history_entry(Some(entry)); egui_ctx.request_repaint(); Ok(()) @@ -242,13 +293,31 @@ extern "C" { /// current browser history. fn get_raw_state(history: &History) -> Result { let state = history.state().unwrap_or(JsValue::UNDEFINED); + + if state.is_undefined() || state.is_null() { + // no state - return empty object + return Ok(js_sys::Object::new().into()); + } + if !state.is_object() { + // invalid state return Err(JsError::new("history state is not an object").into()); } + // deeply clone state structured_clone(&state) } +fn deserialize_from_state(state: JsValue) -> Result, JsValue> { + let key = JsValue::from_str(HistoryEntry::KEY); + let value = js_sys::Reflect::get(&state, &key)?; + if value.is_undefined() || value.is_null() { + return Ok(None); + } + let entry = serde_wasm_bindgen::from_value(value)?; + Ok(Some(entry)) +} + /// Get the state from `history`, deeply-cloned, and return it with updated values from the given `entry`. /// /// This does _not_ mutate the browser history. @@ -264,24 +333,24 @@ impl HistoryExt for History { fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { let state = get_updated_state(self, &entry)?; let url = entry.to_query_string()?; - self.push_state_with_url(&state, "", Some(&url)) + self.push_state_with_url(&state, "", Some(&url))?; + set_stored_history_entry(Some(entry)); + + Ok(()) } fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { let state = get_updated_state(self, &entry)?; let url = entry.to_query_string()?; - self.replace_state_with_url(&state, "", Some(&url)) + self.replace_state_with_url(&state, "", Some(&url))?; + set_stored_history_entry(Some(entry)); + + Ok(()) } fn current_entry(&self) -> Result, JsValue> { let state = get_raw_state(self)?; - let key = JsValue::from_str(HistoryEntry::KEY); - let value = js_sys::Reflect::get(&state, &key)?; - if value.is_undefined() || value.is_null() { - return Ok(None); - } - - Ok(Some(serde_wasm_bindgen::from_value(value)?)) + deserialize_from_state(state) } } From 1eeea449eba3a8ccfd8592399b69112fa8796786 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 21:56:48 +0200 Subject: [PATCH 09/15] move history into its own file --- crates/re_viewer/src/history.rs | 290 ++++++++++++++++++++++++++++++ crates/re_viewer/src/lib.rs | 3 + crates/re_viewer/src/web.rs | 6 +- crates/re_viewer/src/web_tools.rs | 278 ---------------------------- 4 files changed, 296 insertions(+), 281 deletions(-) create mode 100644 crates/re_viewer/src/history.rs diff --git a/crates/re_viewer/src/history.rs b/crates/re_viewer/src/history.rs new file mode 100644 index 000000000000..c91570162d2f --- /dev/null +++ b/crates/re_viewer/src/history.rs @@ -0,0 +1,290 @@ +//! Rerun's usage of the history API on web lives here. +//! +//! A history entry is stored in two places: +//! - State object +//! - URL +//! +//! Ideally we wouldn't have to, but we want two things: +//! - Listen to `popstate` events and handle navigations client-side, +//! so that the forward/back buttons can be used to navigate between +//! examples and the welcome screen. +//! - Add a `?url` query param to the address bar when navigating to +//! an example, so that examples can be shared directly by just +//! copying the link. + +use crate::web_tools::{url_to_receiver, window, JsResultExt as _}; +use js_sys::wasm_bindgen; +use parking_lot::Mutex; +use re_log::ResultExt as _; +use re_viewer_context::StoreHub; +use re_viewer_context::{CommandSender, SystemCommand, SystemCommandSender as _}; +use std::sync::Arc; +use std::sync::OnceLock; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsCast as _; +use wasm_bindgen::JsError; +use wasm_bindgen::JsValue; +use web_sys::History; +use web_sys::UrlSearchParams; + +#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HistoryEntry { + /// Data source URL + /// + /// We support loading multiple URLs at the same time + /// + /// `?url=` + pub urls: Vec, +} + +// Builder methods +impl HistoryEntry { + pub const KEY: &'static str = "__rerun"; + + pub fn new() -> Self { + Self { urls: Vec::new() } + } + + /// Set the URL of the RRD to load when using this entry. + #[inline] + pub fn rrd_url(mut self, url: String) -> Self { + self.urls.push(url); + self + } +} + +// Serialization +impl HistoryEntry { + pub fn to_query_string(&self) -> Result { + use std::fmt::Write; + + let params = UrlSearchParams::new()?; + for url in &self.urls { + params.append("url", url); + } + let mut out = "?".to_owned(); + write!(&mut out, "{}", params.to_string()).ok(); + + Ok(out) + } +} + +fn stored_history_entry() -> &'static Arc>> { + static STORED_HISTORY_ENTRY: OnceLock>>> = OnceLock::new(); + STORED_HISTORY_ENTRY.get_or_init(|| Arc::new(Mutex::new(None))) +} + +fn get_stored_history_entry() -> Option { + stored_history_entry().lock().clone() +} + +fn set_stored_history_entry(entry: Option) { + *stored_history_entry().lock() = entry; +} + +type EventListener = dyn FnMut(Event) -> Result<(), JsValue>; + +/// Listen for `popstate` event, which comes when the user hits the back/forward buttons. +/// +/// +pub fn install_popstate_listener(app: &mut crate::App) -> Result<(), JsValue> { + let egui_ctx = app.egui_ctx.clone(); + let command_sender = app.command_sender.clone(); + + let closure = Closure::wrap(Box::new({ + move |event: web_sys::PopStateEvent| { + let new_state = deserialize_from_state(&event.state())?; + handle_popstate(&egui_ctx, &command_sender, new_state); + Ok(()) + } + }) as Box>); + + set_stored_history_entry(history()?.current_entry()?); + + window()? + .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) + .ok_or_log_js_error(); + + app.popstate_listener = Some(PopstateListener(Some(closure))); + + Ok(()) +} + +pub struct PopstateListener(Option>>); + +impl Drop for PopstateListener { + fn drop(&mut self) { + let Some(window) = window().ok_or_log_js_error() else { + return; + }; + + let closure = self.0.take().unwrap(); + window + .remove_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) + .ok_or_log_js_error(); + drop(closure); + } +} + +fn handle_popstate( + egui_ctx: &egui::Context, + command_sender: &CommandSender, + new_state: Option, +) { + let prev_state = get_stored_history_entry(); + + re_log::debug!("popstate: prev={prev_state:?} new={new_state:?}"); + + if prev_state == new_state { + re_log::debug!("popstate: no change"); + + return; + } + + if new_state.is_none() || new_state.as_ref().is_some_and(|v| v.urls.is_empty()) { + re_log::debug!("popstate: clear recordings + go to welcome screen"); + + // the user navigated back to the history entry where the viewer was initially opened + // in that case they likely expect to land back at the welcome screen: + command_sender.send_system(SystemCommand::CloseAllRecordings); + command_sender.send_system(SystemCommand::ActivateApp(StoreHub::welcome_screen_app_id())); + + set_stored_history_entry(new_state); + egui_ctx.request_repaint(); + + return; + } + + let Some(entry) = new_state else { + unreachable!(); + }; + + let follow_if_http = false; + for url in &entry.urls { + // we continue in case of errors because some receivers may be valid + let Some(receiver) = + url_to_receiver(egui_ctx.clone(), follow_if_http, url.clone()).ok_or_log_error() + else { + continue; + }; + + command_sender.send_system(SystemCommand::AddReceiver(receiver)); + + re_log::debug!("popstate: add receiver {url:?}"); + } + + set_stored_history_entry(Some(entry)); + egui_ctx.request_repaint(); +} + +pub fn go_back() -> Option<()> { + let history = history().ok_or_log_js_error()?; + history.back().ok_or_log_js_error() +} + +pub fn go_forward() -> Option<()> { + let history = history().ok_or_log_js_error()?; + history.forward().ok_or_log_js_error() +} + +pub fn history() -> Result { + window()?.history() +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(catch, js_namespace = ["window"], js_name = structuredClone)] + /// The `structuredClone()` method. + /// + /// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) + pub fn structured_clone(value: &JsValue) -> Result; +} + +/// Get the current raw history state. +/// +/// The return value is an object which may contain properties +/// added by other JS code. We need to be careful about not +/// trampling over those. +/// +/// The returned object has been deeply cloned, so it is safe +/// to add our own keys to the object, as it won't update the +/// current browser history. +fn get_raw_state(history: &History) -> Result { + let state = history.state().unwrap_or(JsValue::UNDEFINED); + + if state.is_undefined() || state.is_null() { + // no state - return empty object + return Ok(js_sys::Object::new().into()); + } + + if !state.is_object() { + // invalid state + return Err(JsError::new("history state is not an object").into()); + } + + // deeply clone state + structured_clone(&state) +} + +fn deserialize_from_state(state: &JsValue) -> Result, JsValue> { + let key = JsValue::from_str(HistoryEntry::KEY); + let value = js_sys::Reflect::get(state, &key)?; + if value.is_undefined() || value.is_null() { + return Ok(None); + } + let entry = serde_wasm_bindgen::from_value(value)?; + Ok(Some(entry)) +} + +/// Get the state from `history`, deeply-cloned, and return it with updated values from the given `entry`. +/// +/// This does _not_ mutate the browser history. +fn get_updated_state(history: &History, entry: &HistoryEntry) -> Result { + let state = get_raw_state(history)?; + let key = JsValue::from_str(HistoryEntry::KEY); + let entry = serde_wasm_bindgen::to_value(entry)?; + js_sys::Reflect::set(&state, &key, &entry)?; + Ok(state) +} + +pub trait HistoryExt: private::Sealed { + /// Push a history entry onto the stack, which becomes the latest entry. + fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>; + + /// Replace the latest entry. + fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>; + + /// Get the latest entry. + fn current_entry(&self) -> Result, JsValue>; +} + +impl private::Sealed for History {} +impl HistoryExt for History { + fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { + let state = get_updated_state(self, &entry)?; + let url = entry.to_query_string()?; + self.push_state_with_url(&state, "", Some(&url))?; + set_stored_history_entry(Some(entry)); + + Ok(()) + } + + fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { + let state = get_updated_state(self, &entry)?; + let url = entry.to_query_string()?; + self.replace_state_with_url(&state, "", Some(&url))?; + set_stored_history_entry(Some(entry)); + + Ok(()) + } + + fn current_entry(&self) -> Result, JsValue> { + let state = get_raw_state(self)?; + deserialize_from_state(&state) + } +} + +mod private { + pub trait Sealed {} +} diff --git a/crates/re_viewer/src/lib.rs b/crates/re_viewer/src/lib.rs index 0fd5e4da045e..8757bc7699f6 100644 --- a/crates/re_viewer/src/lib.rs +++ b/crates/re_viewer/src/lib.rs @@ -57,6 +57,9 @@ mod web; #[cfg(target_arch = "wasm32")] mod web_tools; +#[cfg(target_arch = "wasm32")] +mod history; + // --------------------------------------------------------------------------- /// Information about this version of the crate. diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index 82b5f660e533..0064d42ab646 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -11,8 +11,8 @@ use re_log::ResultExt as _; use re_memory::AccountingAllocator; use re_viewer_context::{SystemCommand, SystemCommandSender}; -use crate::web_tools::JsResultExt; -use crate::web_tools::{install_popstate_listener, url_to_receiver, Callback, StringOrStringArray}; +use crate::history::install_popstate_listener; +use crate::web_tools::{url_to_receiver, Callback, JsResultExt as _, StringOrStringArray}; #[global_allocator] static GLOBAL: AccountingAllocator = @@ -294,7 +294,7 @@ impl From for re_types::blueprint::components::PanelState { } } -// Keep in sync with the `AppOptions` interface in `rerun_js/web-viewer/index.ts` +// Keep in sync with the `AppOptions` interface in `rerun_js/web-viewer/index.ts`. #[derive(Clone, Default, Deserialize)] pub struct AppOptions { url: Option, diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index c4d763ca1e76..c34af5733cd8 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,20 +1,12 @@ //! Web-specific tools used by various parts of the application. use anyhow::Context as _; -use parking_lot::Mutex; use re_log::ResultExt; -use re_viewer_context::StoreHub; -use re_viewer_context::{CommandSender, SystemCommand, SystemCommandSender as _}; use serde::Deserialize; -use std::sync::OnceLock; use std::{ops::ControlFlow, sync::Arc}; -use wasm_bindgen::closure::Closure; -use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsCast as _; use wasm_bindgen::JsError; use wasm_bindgen::JsValue; -use web_sys::History; -use web_sys::UrlSearchParams; use web_sys::Window; pub trait JsResultExt { @@ -80,280 +72,10 @@ pub fn set_url_parameter_and_refresh(key: &str, value: &str) -> Result<(), wasm_ location.assign(&url.href()) } -static STORED_HISTORY_ENTRY: OnceLock>>> = OnceLock::new(); - -fn stored_history_entry() -> &'static Arc>> { - STORED_HISTORY_ENTRY.get_or_init(|| Arc::new(Mutex::new(None))) -} - -fn get_stored_history_entry() -> Option { - stored_history_entry().lock().clone() -} - -fn set_stored_history_entry(entry: Option) { - *stored_history_entry().lock() = entry; -} - -type EventListener = dyn FnMut(Event) -> Result<(), JsValue>; - -/// Listen for `popstate` event, which comes when the user hits the back/forward buttons. -/// -/// -pub fn install_popstate_listener(app: &mut crate::App) -> Result<(), JsValue> { - let egui_ctx = app.egui_ctx.clone(); - let command_sender = app.command_sender.clone(); - - let closure = Closure::wrap(Box::new({ - move |event: web_sys::PopStateEvent| { - let new_state = deserialize_from_state(event.state())?; - handle_popstate(&egui_ctx, &command_sender, new_state)?; - Ok(()) - } - }) as Box>); - - set_stored_history_entry(history()?.current_entry()?); - - window()? - .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) - .ok_or_log_js_error(); - - app.popstate_listener = Some(PopstateListener(Some(closure))); - - Ok(()) -} - -pub struct PopstateListener(Option>>); - -impl Drop for PopstateListener { - fn drop(&mut self) { - let Some(window) = window().ok_or_log_js_error() else { - return; - }; - - let closure = self.0.take().unwrap(); - window - .remove_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) - .ok_or_log_js_error(); - drop(closure); - } -} - -fn handle_popstate( - egui_ctx: &egui::Context, - command_sender: &CommandSender, - new_state: Option, -) -> Result<(), JsValue> { - let prev_state = get_stored_history_entry(); - - re_log::debug!("popstate: prev={prev_state:?} new={new_state:?}"); - - if prev_state == new_state { - re_log::debug!("popstate: no change"); - - return Ok(()); - } - - if new_state.is_none() || new_state.as_ref().is_some_and(|v| v.urls.is_empty()) { - re_log::debug!("popstate: clear recordings + go to welcome screen"); - - // the user navigated back to the history entry where the viewer was initially opened - // in that case they likely expect to land back at the welcome screen: - command_sender.send_system(SystemCommand::CloseAllRecordings); - command_sender.send_system(SystemCommand::ActivateApp(StoreHub::welcome_screen_app_id())); - - set_stored_history_entry(new_state); - egui_ctx.request_repaint(); - - return Ok(()); - } - - let Some(entry) = new_state else { - unreachable!(); - }; - - let follow_if_http = false; - for url in &entry.urls { - // we continue in case of errors because some receivers may be valid - let Some(receiver) = - url_to_receiver(egui_ctx.clone(), follow_if_http, url.clone()).ok_or_log_error() - else { - continue; - }; - - command_sender.send_system(SystemCommand::AddReceiver(receiver)); - - re_log::debug!("popstate: add receiver {url:?}"); - } - - set_stored_history_entry(Some(entry)); - egui_ctx.request_repaint(); - - Ok(()) -} - -pub fn go_back() -> Option<()> { - let history = history().ok_or_log_js_error()?; - history.back().ok_or_log_js_error() -} - -pub fn go_forward() -> Option<()> { - let history = history().ok_or_log_js_error()?; - history.forward().ok_or_log_js_error() -} - -/// A history entry is actually stored in two places: -/// - State object -/// - URL -/// -/// Ideally we wouldn't have to, but we want two things: -/// - Listen to `popstate` events and handle navigations client-side, -/// so that the forward/back buttons can be used to navigate between -/// examples and the welcome screen. -/// - Add a `?url` query param to the address bar when navigating to -/// an example, so that examples can be shared directly by just -/// copying the link. -#[derive(Clone, Default, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct HistoryEntry { - /// Data source URL - /// - /// We support loading multiple URLs at the same time - /// - /// `?url=` - pub urls: Vec, -} - -// Builder methods -impl HistoryEntry { - pub const KEY: &'static str = "__rerun"; - - pub fn new() -> Self { - Self { urls: Vec::new() } - } - - /// Set the URL of the RRD to load when using this entry. - #[inline] - pub fn rrd_url(mut self, url: String) -> Self { - self.urls.push(url); - self - } -} - -// Serialization -impl HistoryEntry { - pub fn to_query_string(&self) -> Result { - use std::fmt::Write; - - let params = UrlSearchParams::new()?; - for url in &self.urls { - params.append("url", url); - } - let mut out = "?".to_owned(); - write!(&mut out, "{}", params.to_string()).ok(); - - Ok(out) - } -} - pub fn window() -> Result { web_sys::window().ok_or_else(|| js_error("failed to get window object")) } -pub fn history() -> Result { - window()?.history() -} - -pub trait HistoryExt { - /// Push a history entry onto the stack, which becomes the latest entry. - fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>; - - /// Replace the latest entry. - fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue>; - - /// Get the latest entry. - fn current_entry(&self) -> Result, JsValue>; -} - -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(catch, js_namespace = ["window"], js_name = structuredClone)] - /// The `structuredClone()` method. - /// - /// [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) - pub fn structured_clone(value: &JsValue) -> Result; -} - -/// Get the current raw history state. -/// -/// The return value is an object which may contain properties -/// added by other JS code. We need to be careful about not -/// trampling over those. -/// -/// The returned object has been deeply cloned, so it is safe -/// to add our own keys to the object, as it won't update the -/// current browser history. -fn get_raw_state(history: &History) -> Result { - let state = history.state().unwrap_or(JsValue::UNDEFINED); - - if state.is_undefined() || state.is_null() { - // no state - return empty object - return Ok(js_sys::Object::new().into()); - } - - if !state.is_object() { - // invalid state - return Err(JsError::new("history state is not an object").into()); - } - - // deeply clone state - structured_clone(&state) -} - -fn deserialize_from_state(state: JsValue) -> Result, JsValue> { - let key = JsValue::from_str(HistoryEntry::KEY); - let value = js_sys::Reflect::get(&state, &key)?; - if value.is_undefined() || value.is_null() { - return Ok(None); - } - let entry = serde_wasm_bindgen::from_value(value)?; - Ok(Some(entry)) -} - -/// Get the state from `history`, deeply-cloned, and return it with updated values from the given `entry`. -/// -/// This does _not_ mutate the browser history. -fn get_updated_state(history: &History, entry: &HistoryEntry) -> Result { - let state = get_raw_state(history)?; - let key = JsValue::from_str(HistoryEntry::KEY); - let entry = serde_wasm_bindgen::to_value(entry)?; - js_sys::Reflect::set(&state, &key, &entry)?; - Ok(state) -} - -impl HistoryExt for History { - fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { - let state = get_updated_state(self, &entry)?; - let url = entry.to_query_string()?; - self.push_state_with_url(&state, "", Some(&url))?; - set_stored_history_entry(Some(entry)); - - Ok(()) - } - - fn replace_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { - let state = get_updated_state(self, &entry)?; - let url = entry.to_query_string()?; - self.replace_state_with_url(&state, "", Some(&url))?; - set_stored_history_entry(Some(entry)); - - Ok(()) - } - - fn current_entry(&self) -> Result, JsValue> { - let state = get_raw_state(self)?; - deserialize_from_state(state) - } -} - enum EndpointCategory { /// Could be a local path (`/foo.rrd`) or a remote url (`http://foo.com/bar.rrd`). /// From 5d9d19f0265e68b74671d476a004c4f6166b3494 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 21:57:02 +0200 Subject: [PATCH 10/15] add some comments --- crates/re_viewer/src/app.rs | 19 ++++++++++++++++--- .../src/ui/welcome_screen/example_section.rs | 3 ++- web_viewer/index.html | 13 +++++++++---- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index fa7b0013cdd4..66645114b28e 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -81,6 +81,19 @@ pub struct StartupOptions { /// Default overrides for state of top/side/bottom panels. pub panel_state_overrides: PanelStateOverrides, + /// Whether or not to enable usage of the `History` API on web. + /// + /// It is disabled by default. + /// + /// This should only be enabled when it is acceptable for `rerun` + /// to push its own entries into browser history. + /// + /// That only makes sense if it has "taken over" a page, and is + /// the only thing on that page. If you are embedding multiple + /// viewers onto the same page, then it's better to turn this off. + /// + /// We use browser history in a limited way to track the currently + /// open example recording, see [`crate::history`]. #[cfg(target_arch = "wasm32")] pub enable_history: bool, } @@ -134,7 +147,7 @@ pub struct App { screenshotter: crate::screenshotter::Screenshotter, #[cfg(target_arch = "wasm32")] - pub(crate) popstate_listener: Option, + pub(crate) popstate_listener: Option, #[cfg(not(target_arch = "wasm32"))] profiler: re_tracing::Profiler, @@ -1488,10 +1501,10 @@ impl eframe::App for App { egui_ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Extra2)); if back_pressed { - crate::web_tools::go_back(); + crate::history::go_back(); } if fwd_pressed { - crate::web_tools::go_forward(); + crate::history::go_forward(); } } diff --git a/crates/re_viewer/src/ui/welcome_screen/example_section.rs b/crates/re_viewer/src/ui/welcome_screen/example_section.rs index 29fd00415732..c4639a0f956f 100644 --- a/crates/re_viewer/src/ui/welcome_screen/example_section.rs +++ b/crates/re_viewer/src/ui/welcome_screen/example_section.rs @@ -449,7 +449,8 @@ fn open_example_url( #[cfg(target_arch = "wasm32")] if _is_history_enabled { - use crate::web_tools::{history, HistoryEntry, HistoryExt, JsResultExt}; + use crate::history::{history, HistoryEntry, HistoryExt as _}; + use crate::web_tools::JsResultExt as _; if let Some(history) = history().ok_or_log_js_error() { let entry = HistoryEntry::new().rrd_url(rrd_url.to_owned()); diff --git a/web_viewer/index.html b/web_viewer/index.html index f68494240f3a..fe948420db36 100644 --- a/web_viewer/index.html +++ b/web_viewer/index.html @@ -1,4 +1,4 @@ - + @@ -327,21 +327,26 @@ console.debug("Wasm loaded. Starting app…"); const query = new URLSearchParams(window.location.search); + /* - url: Option, + url: Option, manifest_url: Option, render_backend: Option, hide_welcome_screen: Option, panel_state_overrides: Option, fullscreen: Option, + enable_history: Option, + notebook: Option, + persist: Option, */ const options = { - app_id: query.get("app_id"), url: query.get("url"), manifest_url: query.get("manifest_url"), render_backend: query.get("renderer"), hide_welcome_screen: query.has("hide_welcome_screen"), - + // panel_state_overrides: undefined, + // fullscreen: undefined, + enable_history: true, notebook: get_query_bool(query, "notebook", false), persist: get_query_bool(query, "persist", true), }; From e7b9050a9170e6176c28d776c55f1e0588bdb6c1 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 21:57:25 +0200 Subject: [PATCH 11/15] rm dead code --- web_viewer/index.html | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/web_viewer/index.html b/web_viewer/index.html index fe948420db36..bb5191ed0671 100644 --- a/web_viewer/index.html +++ b/web_viewer/index.html @@ -389,25 +389,6 @@ /** @type {string} */ key, /** @type {boolean} */ fallback, ) { - /* - if values.len() == 1 { - match values[0].as_str() { - "0" => false, - "1" => true, - other => { - re_log::warn!( - "Unexpected value for '{key}' query: {other:?}. Expected either '0' or '1'. Defaulting to '{default_int}'." - ); - default - } - } - } else { - re_log::warn!( - "Found {} values for '{key}' query. Expected one or none. Defaulting to '{default_int}'.", - values.len() - ); - default - }*/ const values = query.getAll(key); if (values.length !== 1) { console.warn( From e3a6bc093a1f7e4d0397d340a8046bda0711b95b Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 22:25:39 +0200 Subject: [PATCH 12/15] link to issue --- scripts/lint.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/lint.py b/scripts/lint.py index 2e4ac74299ea..6e30adaa3463 100755 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -1120,13 +1120,11 @@ def main() -> None: "./scripts/zombie_todos.py", "./tests/python/release_checklist/main.py", "./web_viewer/re_viewer.js", # auto-generated by wasm_bindgen - # TODO(emilk): remove after parsing all .gitignore files - # without this running it locally is very noisy - # node_modules + # JS dependencies: "./rerun_notebook/node_modules", "./rerun_js/node_modules", "./rerun_js/node_modules", - # JS files generated during build + # JS files generated during build: "./rerun_notebook/src/rerun_notebook/static", "./rerun_js/web-viewer/inlined.js", "./rerun_js/web-viewer/index.js", @@ -1135,7 +1133,7 @@ def main() -> None: "./rerun_js/web-viewer-react/node_modules", ) - should_ignore = parse_gitignore(".gitignore") # TODO(emilk): parse all .gitignore files, not just top-level + should_ignore = parse_gitignore(".gitignore") # TODO(#6730): parse all .gitignore files, not just top-level script_dirpath = os.path.dirname(os.path.realpath(__file__)) root_dirpath = os.path.abspath(f"{script_dirpath}/..") From f2c89f3070ec78f457551b4db595fbcb63a43a30 Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 22:25:47 +0200 Subject: [PATCH 13/15] use default + remove unwrap --- crates/re_viewer/src/history.rs | 18 ++++++++++++------ .../src/ui/welcome_screen/example_section.rs | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/re_viewer/src/history.rs b/crates/re_viewer/src/history.rs index c91570162d2f..6767ba380863 100644 --- a/crates/re_viewer/src/history.rs +++ b/crates/re_viewer/src/history.rs @@ -42,10 +42,6 @@ pub struct HistoryEntry { impl HistoryEntry { pub const KEY: &'static str = "__rerun"; - pub fn new() -> Self { - Self { urls: Vec::new() } - } - /// Set the URL of the RRD to load when using this entry. #[inline] pub fn rrd_url(mut self, url: String) -> Self { @@ -106,20 +102,30 @@ pub fn install_popstate_listener(app: &mut crate::App) -> Result<(), JsValue> { .add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) .ok_or_log_js_error(); - app.popstate_listener = Some(PopstateListener(Some(closure))); + app.popstate_listener = Some(PopstateListener::new(closure)); Ok(()) } pub struct PopstateListener(Option>>); +impl PopstateListener { + fn new(closure: Closure>) -> Self { + Self(Some(closure)) + } +} + impl Drop for PopstateListener { fn drop(&mut self) { let Some(window) = window().ok_or_log_js_error() else { return; }; - let closure = self.0.take().unwrap(); + // The closure is guaranteed to be `Some`, because the field isn't + // accessed outside of the constructor. + let Some(closure) = self.0.take() else { + unreachable!(); + }; window .remove_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref()) .ok_or_log_js_error(); diff --git a/crates/re_viewer/src/ui/welcome_screen/example_section.rs b/crates/re_viewer/src/ui/welcome_screen/example_section.rs index c4639a0f956f..2a989383b7d1 100644 --- a/crates/re_viewer/src/ui/welcome_screen/example_section.rs +++ b/crates/re_viewer/src/ui/welcome_screen/example_section.rs @@ -453,7 +453,7 @@ fn open_example_url( use crate::web_tools::JsResultExt as _; if let Some(history) = history().ok_or_log_js_error() { - let entry = HistoryEntry::new().rrd_url(rrd_url.to_owned()); + let entry = HistoryEntry::default().rrd_url(rrd_url.to_owned()); history.push_entry(entry).ok_or_log_js_error(); } } From e4a1aec3eea308b91643bc2686f294f2f4e7fd5e Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 22:26:12 +0200 Subject: [PATCH 14/15] comment about target --- .vscode/settings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f6dd83e7d777..e3374f2c78c4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -67,5 +67,7 @@ "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "rust-analyzer.cargo.target": "wasm32-unknown-unknown" + // Uncomment the following option and restart rust-analyzer to get it to check code behind `cfg(target_arch=wasm32)`. + // Don't forget to put it in a comment again before committing. + // "rust-analyzer.cargo.target": "wasm32-unknown-unknown" } From 374945711c413fd291e9cb422964f1d2fd70b83f Mon Sep 17 00:00:00 2001 From: jprochazk <1665677+jprochazk@users.noreply.github.com> Date: Tue, 2 Jul 2024 22:47:00 +0200 Subject: [PATCH 15/15] fix lints --- crates/re_viewer/src/history.rs | 1 + web_viewer/index.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/re_viewer/src/history.rs b/crates/re_viewer/src/history.rs index 6767ba380863..ab853bd654a8 100644 --- a/crates/re_viewer/src/history.rs +++ b/crates/re_viewer/src/history.rs @@ -266,6 +266,7 @@ pub trait HistoryExt: private::Sealed { } impl private::Sealed for History {} + impl HistoryExt for History { fn push_entry(&self, entry: HistoryEntry) -> Result<(), JsValue> { let state = get_updated_state(self, &entry)?; diff --git a/web_viewer/index.html b/web_viewer/index.html index bb5191ed0671..6dc28b590766 100644 --- a/web_viewer/index.html +++ b/web_viewer/index.html @@ -1,4 +1,4 @@ - +