diff --git a/.vscode/settings.json b/.vscode/settings.json index 72885b57a97c..e3374f2c78c4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -66,5 +66,8 @@ }, "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + // 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" } diff --git a/Cargo.lock b/Cargo.lock index 068261274577..aa71f00cd0a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5200,6 +5200,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 33a6e7c3a47c..66645114b28e 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -80,6 +80,22 @@ 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, } impl Default for StartupOptions { @@ -107,6 +123,9 @@ impl Default for StartupOptions { fullscreen_options: Default::default(), panel_state_overrides: Default::default(), + + #[cfg(target_arch = "wasm32")] + enable_history: false, } } } @@ -127,6 +146,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, @@ -272,6 +294,9 @@ impl App { egui_ctx, screenshotter, + #[cfg(target_arch = "wasm32")] + popstate_listener: None, + #[cfg(not(target_arch = "wasm32"))] profiler: Default::default(), @@ -945,6 +970,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 +990,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 +1493,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)); @@ -1470,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/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/history.rs b/crates/re_viewer/src/history.rs new file mode 100644 index 000000000000..ab853bd654a8 --- /dev/null +++ b/crates/re_viewer/src/history.rs @@ -0,0 +1,297 @@ +//! 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"; + + /// 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::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; + }; + + // 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(); + 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/ui/welcome_screen/example_section.rs b/crates/re_viewer/src/ui/welcome_screen/example_section.rs index 860e657760a1..2a989383b7d1 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,14 @@ 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::history::{history, HistoryEntry, HistoryExt as _}; + use crate::web_tools::JsResultExt as _; - 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::default().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 b96351ee08be..0064d42ab646 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -9,9 +9,10 @@ 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::history::install_popstate_listener; +use crate::web_tools::{url_to_receiver, Callback, JsResultExt as _, StringOrStringArray}; #[global_allocator] static GLOBAL: AccountingAllocator = @@ -153,7 +154,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); } @@ -293,18 +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 { - 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, } -// 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. @@ -333,17 +338,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, @@ -352,19 +346,21 @@ 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. 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), 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)?; @@ -376,53 +372,29 @@ fn create_app( cc.storage, ); - let query_map = &cc.integration_info.web_info.location.query_map; + if enable_history { + install_popstate_listener(&mut app).ok_or_log_js_error(); + } - 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() + { + 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 +407,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..c34af5733cd8 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,13 +1,46 @@ -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 serde::Deserialize; +use std::{ops::ControlFlow, sync::Arc}; use wasm_bindgen::JsCast as _; +use wasm_bindgen::JsError; use wasm_bindgen::JsValue; +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; + + /// 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() + } -use re_log::ResultExt as _; -use re_viewer_context::CommandSender; + 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) + } -/// Web-specific tools used by various parts of the application. + 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)] @@ -25,10 +58,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()?)?; @@ -37,141 +72,8 @@ 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)) -} - -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() -} - -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() -} - -/// 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}")) -} - -/// 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(new_relative_url: &str) -> 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()?; - } - Some(()) -} - -/// Replace the current relative url with an new one. -pub fn replace_history(new_relative_url: &str) -> 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() -} - -/// 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 +pub fn window() -> Result { + web_sys::window().ok_or_else(|| js_error("failed to get window object")) } enum EndpointCategory { @@ -184,23 +86,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 +114,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 +129,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 +166,60 @@ 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 { + #[inline] + pub fn call(&self) -> Result { + let window: JsValue = window()?.into(); + self.0.call0(&window) + } +} + +// 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; + + #[inline] + 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 { + 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/rerun_js/web-viewer/index.ts b/rerun_js/web-viewer/index.ts index 62173a31ee22..17b3bc68b8f2 100644 --- a/rerun_js/web-viewer/index.ts +++ b/rerun_js/web-viewer/index.ts @@ -47,11 +47,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; width?: string; height?: string; @@ -59,7 +60,9 @@ interface WebViewerOptions { // `AppOptions` and `WebViewerOptions` must be compatible // otherwise we need to restructure how we pass options to the viewer -interface AppOptions extends WebViewerOptions { + +/** @private */ +export interface AppOptions extends WebViewerOptions { url?: string; manifest_url?: string; render_backend?: Backend; @@ -68,6 +71,7 @@ interface AppOptions extends WebViewerOptions { [K in Panel]: PanelState; }>; fullscreen?: FullscreenOptions; + enable_history?: boolean; } interface FullscreenOptions { @@ -98,7 +102,7 @@ type EventsWithoutValue = { type Cancel = () => void; function delay(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } export class WebViewer { diff --git a/scripts/lint.py b/scripts/lint.py index ae41003719fd..6e30adaa3463 100755 --- a/scripts/lint.py +++ b/scripts/lint.py @@ -1120,9 +1120,20 @@ def main() -> None: "./scripts/zombie_todos.py", "./tests/python/release_checklist/main.py", "./web_viewer/re_viewer.js", # auto-generated by wasm_bindgen + # JS dependencies: + "./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 + 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}/..") diff --git a/web_viewer/index.html b/web_viewer/index.html index 9d60722dacf7..6dc28b590766 100644 --- a/web_viewer/index.html +++ b/web_viewer/index.html @@ -326,7 +326,32 @@ 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, + enable_history: Option, + notebook: Option, + persist: Option, + */ + const options = { + 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), + }; + + let handle = new wasm_bindgen.WebHandle(options); function check_for_panic() { if (handle.has_panicked()) { @@ -356,13 +381,35 @@ 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); + } + + function get_query_bool( + /** @type {URLSearchParams} */ query, + /** @type {string} */ key, + /** @type {boolean} */ fallback, + ) { + 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; - handle - .start("the_canvas_id", null, null, forceWgpuBackend) - .then(on_app_started) - .catch(on_wasm_error); + 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) {