diff --git a/Cargo.lock b/Cargo.lock index bea4e0a2e543..d1da8653b931 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5053,6 +5053,7 @@ dependencies = [ "ehttp", "image", "itertools 0.12.0", + "js-sys", "once_cell", "poll-promise", "re_analytics", diff --git a/crates/re_data_source/src/web_sockets.rs b/crates/re_data_source/src/web_sockets.rs index d0d6542cae2f..c258aa8e76ac 100644 --- a/crates/re_data_source/src/web_sockets.rs +++ b/crates/re_data_source/src/web_sockets.rs @@ -17,22 +17,25 @@ pub fn connect_to_ws_url( re_log::info!("Connecting to WebSocket server at {url:?}…"); - let callback = move |binary: Vec| match re_ws_comms::decode_log_msg(&binary) { - Ok(log_msg) => { - if tx.send(log_msg).is_ok() { - if let Some(on_msg) = &on_msg { - on_msg(); + let callback = { + let url = url.to_owned(); + move |binary: Vec| match re_ws_comms::decode_log_msg(&binary) { + Ok(log_msg) => { + if tx.send(log_msg).is_ok() { + if let Some(on_msg) = &on_msg { + on_msg(); + } + std::ops::ControlFlow::Continue(()) + } else { + re_log::info_once!("Closing connection to {url}"); + std::ops::ControlFlow::Break(()) } - std::ops::ControlFlow::Continue(()) - } else { - re_log::info!("Failed to send log message to viewer - closing"); + } + Err(err) => { + re_log::error!("Failed to parse message: {err}"); std::ops::ControlFlow::Break(()) } } - Err(err) => { - re_log::error!("Failed to parse message: {err}"); - std::ops::ControlFlow::Break(()) - } }; re_ws_comms::viewer_to_server(url.to_owned(), callback)?; diff --git a/crates/re_log_encoding/src/stream_rrd_from_http.rs b/crates/re_log_encoding/src/stream_rrd_from_http.rs index dbee815c1208..7662678258a6 100644 --- a/crates/re_log_encoding/src/stream_rrd_from_http.rs +++ b/crates/re_log_encoding/src/stream_rrd_from_http.rs @@ -15,7 +15,7 @@ pub fn stream_rrd_from_http_to_channel( re_smart_channel::SmartChannelSource::RrdHttpStream { url: url.clone() }, ); stream_rrd_from_http( - url, + url.clone(), Arc::new(move |msg| { if let Some(on_msg) = &on_msg { on_msg(); @@ -25,17 +25,17 @@ pub fn stream_rrd_from_http_to_channel( if tx.send(msg).is_ok() { ControlFlow::Continue(()) } else { - re_log::info!("Failed to send log message to viewer - closing"); + re_log::info_once!("Closing connection to {url}"); ControlFlow::Break(()) } } HttpMessage::Success => { - tx.quit(None).warn_on_err_once("failed to send quit marker"); + tx.quit(None).warn_on_err_once("Failed to send quit marker"); ControlFlow::Break(()) } HttpMessage::Failure(err) => { tx.quit(Some(err)) - .warn_on_err_once("failed to send quit marker"); + .warn_on_err_once("Failed to send quit marker"); ControlFlow::Break(()) } } diff --git a/crates/re_smart_channel/src/receive_set.rs b/crates/re_smart_channel/src/receive_set.rs index f13f745fb565..1a9ecc6f0c91 100644 --- a/crates/re_smart_channel/src/receive_set.rs +++ b/crates/re_smart_channel/src/receive_set.rs @@ -36,6 +36,15 @@ impl ReceiveSet { self.receivers.lock().retain(|r| r.source() != source); } + pub fn retain(&self, mut f: impl FnMut(&Receiver) -> bool) { + self.receivers.lock().retain(|r| f(r)); + } + + /// Remove all receivers. + pub fn clear(&self) { + self.receivers.lock().clear(); + } + /// Disconnect from any channel with a source pointing at this `uri`. #[cfg(target_arch = "wasm32")] pub fn remove_by_uri(&self, uri: &str) { diff --git a/crates/re_ui/src/command.rs b/crates/re_ui/src/command.rs index 3f3753419614..cce3a35a4ce1 100644 --- a/crates/re_ui/src/command.rs +++ b/crates/re_ui/src/command.rs @@ -18,6 +18,8 @@ pub enum UICommand { SaveRecordingSelection, SaveBlueprint, CloseCurrentRecording, + CloseAllRecordings, + #[cfg(not(target_arch = "wasm32"))] Quit, @@ -111,6 +113,9 @@ impl UICommand { "Close the current recording (unsaved data will be lost)", ), + Self::CloseAllRecordings => ("Close all recordings", + "Close all open current recording (unsaved data will be lost)",), + #[cfg(not(target_arch = "wasm32"))] Self::Quit => ("Quit", "Close the Rerun Viewer"), @@ -258,6 +263,7 @@ impl UICommand { Self::SaveBlueprint => None, Self::Open => Some(cmd(Key::O)), Self::CloseCurrentRecording => None, + Self::CloseAllRecordings => None, #[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] Self::Quit => Some(KeyboardShortcut::new(Modifiers::ALT, Key::F4)), diff --git a/crates/re_viewer/Cargo.toml b/crates/re_viewer/Cargo.toml index 5eb8fb025b16..8a8553a02143 100644 --- a/crates/re_viewer/Cargo.toml +++ b/crates/re_viewer/Cargo.toml @@ -106,14 +106,16 @@ wgpu.workspace = true # web dependencies: [target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true web-sys = { workspace = true, features = [ - 'Location', - 'Url', - 'UrlSearchParams', - 'Window', + "History", + "Location", "Storage", + "Url", + "UrlSearchParams", + "Window", ] } diff --git a/crates/re_viewer/src/app.rs b/crates/re_viewer/src/app.rs index 9b0079e43f34..e85cd7ec3896 100644 --- a/crates/re_viewer/src/app.rs +++ b/crates/re_viewer/src/app.rs @@ -339,10 +339,35 @@ impl App { SystemCommand::ActivateRecording(store_id) => { store_hub.set_activate_recording(store_id); } + SystemCommand::CloseStore(store_id) => { store_hub.remove(&store_id); } + SystemCommand::CloseAllRecordings => { + store_hub.clear_recordings(); + + // Stop receiving into the old recordings. + // This is most important when going back to the example screen by using the "Back" + // button in the browser, and there is still a connection downloading an .rrd. + // That's the case of `SmartChannelSource::RrdHttpStream`. + // TODO(emilk): exactly what things get kept and what gets cleared? + self.rx.retain(|r| match r.source() { + SmartChannelSource::File(_) | SmartChannelSource::RrdHttpStream { .. } => false, + + SmartChannelSource::WsClient { .. } + | SmartChannelSource::RrdWebEventListener + | SmartChannelSource::Sdk + | SmartChannelSource::TcpServer { .. } + | SmartChannelSource::Stdin => true, + }); + } + + SystemCommand::AddReceiver(rx) => { + re_log::debug!("Received AddReceiver"); + self.add_receiver(rx); + } + SystemCommand::LoadDataSource(data_source) => { let egui_ctx = self.re_ui.egui_ctx.clone(); @@ -487,6 +512,10 @@ impl App { .send_system(SystemCommand::CloseStore(cur_rec.clone())); } } + UICommand::CloseAllRecordings => { + self.command_sender + .send_system(SystemCommand::CloseAllRecordings); + } #[cfg(not(target_arch = "wasm32"))] UICommand::Quit => { 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 3c70ef89b558..066f9ae6af83 100644 --- a/crates/re_viewer/src/ui/welcome_screen/example_section.rs +++ b/crates/re_viewer/src/ui/welcome_screen/example_section.rs @@ -2,7 +2,7 @@ use egui::{NumExt as _, Ui}; use ehttp::{fetch, Request}; use poll_promise::Promise; -use re_viewer_context::SystemCommandSender; +use re_viewer_context::{CommandSender, SystemCommandSender as _}; #[derive(Debug, serde::Deserialize)] struct ExampleThumbnail { @@ -269,7 +269,7 @@ impl ExampleSection { &mut self, ui: &mut egui::Ui, re_ui: &re_ui::ReUi, - command_sender: &re_viewer_context::CommandSender, + command_sender: &CommandSender, header_ui: &impl Fn(&mut Ui), ) { let examples = self @@ -414,11 +414,7 @@ impl ExampleSection { // panel to quit auto-zoom mode. ui.input_mut(|i| i.pointer = Default::default()); - let data_source = - re_data_source::DataSource::RrdHttpUrl(example.desc.rrd_url.clone()); - command_sender.send_system( - re_viewer_context::SystemCommand::LoadDataSource(data_source), - ); + open_example_url(command_sender, &example.desc.rrd_url); } } }); @@ -426,6 +422,25 @@ impl ExampleSection { } } +fn open_example_url(command_sender: &CommandSender, rrd_url: &str) { + let data_source = re_data_source::DataSource::RrdHttpUrl(rrd_url.to_owned()); + command_sender.send_system(re_viewer_context::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 + web_tools::push_history("?examples"); + + // Where we're going: + web_tools::push_history(&format!("?url={}", web_tools::percent_encode(rrd_url))); + } +} + fn example_thumbnail(ui: &mut Ui, example: &ExampleDesc, column_width: f32) { // dimensions of the source image to use as thumbnail let image_width = example.thumbnail.width as f32; diff --git a/crates/re_viewer/src/web.rs b/crates/re_viewer/src/web.rs index 0a0008aa92a3..85614f686c2a 100644 --- a/crates/re_viewer/src/web.rs +++ b/crates/re_viewer/src/web.rs @@ -1,13 +1,14 @@ -#![allow(clippy::mem_forget)] // False positives from #[wasm_bindgen] macro +//! Main entry-point of the web app. -use anyhow::Context as _; -use eframe::wasm_bindgen::{self, prelude::*}; +#![allow(clippy::mem_forget)] // False positives from #[wasm_bindgen] macro -use std::ops::ControlFlow; -use std::sync::Arc; +use wasm_bindgen::prelude::*; use re_log::ResultExt as _; use re_memory::AccountingAllocator; +use re_viewer_context::CommandSender; + +use crate::web_tools::{string_from_js_value, translate_query_into_commands, url_to_receiver}; #[global_allocator] static GLOBAL: AccountingAllocator = @@ -90,7 +91,7 @@ impl WebHandle { let Some(mut app) = self.runner.app_mut::() else { return; }; - let rx = url_to_receiver(url, app.re_ui.egui_ctx.clone()); + let rx = url_to_receiver(app.re_ui.egui_ctx.clone(), url); if let Some(rx) = rx.ok_or_log_error() { app.add_receiver(rx); } @@ -130,8 +131,6 @@ fn create_app( }; let re_ui = crate::customize_eframe(cc); - let egui_ctx = cc.egui_ctx.clone(); - let mut app = crate::App::new(build_info, &app_env, startup_options, re_ui, cc.storage); let query_map = &cc.integration_info.web_info.location.query_map; @@ -145,73 +144,37 @@ fn create_app( } if let Some(url) = url { - if let Some(receiver) = url_to_receiver(url, egui_ctx).ok_or_log_error() { + if let Some(receiver) = url_to_receiver(cc.egui_ctx.clone(), url).ok_or_log_error() { app.add_receiver(receiver); } } else { - // NOTE: we support passing in multiple urls to multiple different recorording, blueprints, etc - for url in query_map.get("url").into_iter().flatten() { - if let Some(receiver) = url_to_receiver(url, egui_ctx.clone()).ok_or_log_error() { - app.add_receiver(receiver); - } - } + translate_query_into_commands(&cc.egui_ctx, &app.command_sender); } + install_popstate_listener(cc.egui_ctx.clone(), app.command_sender.clone()); + app } -fn url_to_receiver( - url: &str, - egui_ctx: egui::Context, -) -> anyhow::Result> { - let ui_waker = Box::new(move || { - // Spend a few more milliseconds decoding incoming messages, - // then trigger a repaint (https://github.com/rerun-io/rerun/issues/963): - egui_ctx.request_repaint_after(std::time::Duration::from_millis(10)); - }); - match categorize_uri(url) { - EndpointCategory::HttpRrd(url) => Ok( - re_log_encoding::stream_rrd_from_http::stream_rrd_from_http_to_channel( - url, - Some(ui_waker), - ), - ), - EndpointCategory::WebEventListener => { - // 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, - ); - re_log_encoding::stream_rrd_from_http::stream_rrd_from_event_listener(Arc::new({ - move |msg| { - ui_waker(); - use re_log_encoding::stream_rrd_from_http::HttpMessage; - match msg { - HttpMessage::LogMsg(msg) => { - if tx.send(msg).is_ok() { - ControlFlow::Continue(()) - } else { - re_log::info!("Failed to send log message to viewer - closing"); - ControlFlow::Break(()) - } - } - HttpMessage::Success => { - tx.quit(None).warn_on_err_once("failed to send quit marker"); - ControlFlow::Break(()) - } - HttpMessage::Failure(err) => { - tx.quit(Some(err)) - .warn_on_err_once("failed to send quit marker"); - ControlFlow::Break(()) - } - } - } - })); - Ok(rx) - } - EndpointCategory::WebSocket(url) => re_data_source::connect_to_ws_url(&url, Some(ui_waker)) - .with_context(|| format!("Failed to connect to WebSocket server at {url}.")), - } +/// 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, @@ -227,38 +190,6 @@ pub fn set_email(email: String) { config.save().unwrap(); } -enum EndpointCategory { - /// Could be a local path (`/foo.rrd`) or a remote url (`http://foo.com/bar.rrd`). - /// - /// Could be a link to either an `.rrd` recording or a `.rbl` blueprint. - HttpRrd(String), - - /// A remote Rerun server. - WebSocket(String), - - /// An eventListener for rrd posted from containing html - WebEventListener, -} - -fn categorize_uri(uri: &str) -> EndpointCategory { - if uri.starts_with("http") || uri.ends_with(".rrd") || uri.ends_with(".rbl") { - EndpointCategory::HttpRrd(uri.into()) - } else if uri.starts_with("ws:") || uri.starts_with("wss:") { - EndpointCategory::WebSocket(uri.into()) - } else if uri.starts_with("web_event:") { - EndpointCategory::WebEventListener - } 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("://") { - EndpointCategory::WebSocket(uri.into()) - } else { - EndpointCategory::WebSocket(format!("{}://{uri}", re_ws_comms::PROTOCOL)) - } - } -} - fn is_in_notebook(info: &eframe::IntegrationInfo) -> bool { get_query_bool(info, "notebook", false) } diff --git a/crates/re_viewer/src/web_tools.rs b/crates/re_viewer/src/web_tools.rs index 2575a31a944e..790c56f0b5ac 100644 --- a/crates/re_viewer/src/web_tools.rs +++ b/crates/re_viewer/src/web_tools.rs @@ -1,3 +1,19 @@ +use std::{ops::ControlFlow, sync::Arc}; + +use anyhow::Context 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. + +/// Useful in error handlers +#[allow(clippy::needless_pass_by_value)] +pub fn string_from_js_value(s: wasm_bindgen::JsValue) -> String { + s.as_string().unwrap_or(format!("{s:#?}")) +} + 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()); @@ -9,3 +25,173 @@ 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)) +} + +/// 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 location = web_sys::window()?.location(); + + let search = location.search().unwrap_or_default(); + let hash = location.hash().unwrap_or_default(); + let current_relative_url = format!("{search}{hash}"); + + 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()?; + history + .push_state_with_url(&JsValue::NULL, "", Some(new_relative_url)) + .map_err(|err| { + format!( + "Failed to push history state: {}", + string_from_js_value(err) + ) + }) + .ok_or_log_error()?; + } + Some(()) +} + +/// 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(); + + // NOTE: it's unclear what to do if we find bout `examples` and `url` in the query. + + if location.query_map.get("examples").is_some() { + command_sender.send_system(SystemCommand::CloseAllRecordings); + } + + // 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() { + // Clear out any already open recordings to make room for the new ones. + command_sender.send_system(SystemCommand::CloseAllRecordings); + + for url in urls { + if let Some(receiver) = url_to_receiver(egui_ctx.clone(), url).ok_or_log_error() { + 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`). + /// + /// Could be a link to either an `.rrd` recording or a `.rbl` blueprint. + HttpRrd(String), + + /// A remote Rerun server. + WebSocket(String), + + /// An eventListener for rrd posted from containing html + WebEventListener, +} + +impl EndpointCategory { + fn categorize_uri(uri: &str) -> Self { + if uri.starts_with("http") || uri.ends_with(".rrd") || uri.ends_with(".rbl") { + Self::HttpRrd(uri.into()) + } else if uri.starts_with("ws:") || uri.starts_with("wss:") { + Self::WebSocket(uri.into()) + } else if uri.starts_with("web_event:") { + Self::WebEventListener + } 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()) + } else { + Self::WebSocket(format!("{}://{uri}", re_ws_comms::PROTOCOL)) + } + } + } +} + +/// Start receiving from the given url. +pub fn url_to_receiver( + egui_ctx: egui::Context, + url: &str, +) -> anyhow::Result> { + let ui_waker = Box::new(move || { + // Spend a few more milliseconds decoding incoming messages, + // then trigger a repaint (https://github.com/rerun-io/rerun/issues/963): + egui_ctx.request_repaint_after(std::time::Duration::from_millis(10)); + }); + match EndpointCategory::categorize_uri(url) { + EndpointCategory::HttpRrd(url) => Ok( + re_log_encoding::stream_rrd_from_http::stream_rrd_from_http_to_channel( + url, + Some(ui_waker), + ), + ), + EndpointCategory::WebEventListener => { + // 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(); + use re_log_encoding::stream_rrd_from_http::HttpMessage; + match msg { + HttpMessage::LogMsg(msg) => { + if tx.send(msg).is_ok() { + ControlFlow::Continue(()) + } else { + re_log::info_once!("Failed to send log message to viewer - closing connection to {url}"); + ControlFlow::Break(()) + } + } + HttpMessage::Success => { + tx.quit(None).warn_on_err_once("Failed to send quit marker"); + ControlFlow::Break(()) + } + HttpMessage::Failure(err) => { + tx.quit(Some(err)) + .warn_on_err_once("Failed to send quit marker"); + ControlFlow::Break(()) + } + } + } + })); + Ok(rx) + } + EndpointCategory::WebSocket(url) => re_data_source::connect_to_ws_url(&url, Some(ui_waker)) + .with_context(|| format!("Failed to connect to WebSocket server at {url}.")), + } +} diff --git a/crates/re_viewer_context/src/command_sender.rs b/crates/re_viewer_context/src/command_sender.rs index 53f1cc342e0d..e836e288e660 100644 --- a/crates/re_viewer_context/src/command_sender.rs +++ b/crates/re_viewer_context/src/command_sender.rs @@ -10,6 +10,8 @@ pub enum SystemCommand { /// Load some data. LoadDataSource(DataSource), + AddReceiver(re_smart_channel::Receiver), + /// Reset the `Viewer` to the default state ResetViewer, @@ -25,6 +27,9 @@ pub enum SystemCommand { /// Close a recording or blueprint (free its memory). CloseStore(StoreId), + /// Close all stores and show the welcome screen again. + CloseAllRecordings, + /// Update the blueprint with additional data /// /// The [`StoreId`] should generally be the currently selected blueprint @@ -65,6 +70,7 @@ pub trait SystemCommandSender { // ---------------------------------------------------------------------------- /// Sender that queues up the execution of commands. +#[derive(Clone)] pub struct CommandSender { system_sender: std::sync::mpsc::Sender, ui_sender: std::sync::mpsc::Sender, diff --git a/crates/re_viewer_context/src/store_hub.rs b/crates/re_viewer_context/src/store_hub.rs index 594fffbb3177..782a8ed02c8c 100644 --- a/crates/re_viewer_context/src/store_hub.rs +++ b/crates/re_viewer_context/src/store_hub.rs @@ -100,14 +100,14 @@ impl StoreHub { setup_welcome_screen_blueprint: &dyn Fn(&mut EntityDb), ) -> Self { re_tracing::profile_function!(); - let mut blueprint_by_app_id = HashMap::new(); + let mut active_blueprint_by_app_id = HashMap::new(); let mut store_bundle = StoreBundle::default(); let welcome_screen_store_id = StoreId::from_string( StoreKind::Blueprint, Self::welcome_screen_app_id().to_string(), ); - blueprint_by_app_id.insert( + active_blueprint_by_app_id.insert( Self::welcome_screen_app_id(), welcome_screen_store_id.clone(), ); @@ -121,7 +121,7 @@ impl StoreHub { active_rec_id: None, active_application_id: None, default_blueprint_by_app_id: Default::default(), - active_blueprint_by_app_id: blueprint_by_app_id, + active_blueprint_by_app_id, store_bundle, was_recording_active: false, @@ -213,6 +213,14 @@ impl StoreHub { self.store_bundle.remove(store_id); } + /// Remove all open recordings, and go to the welcome page. + pub fn clear_recordings(&mut self) { + self.store_bundle + .retain(|db| db.store_kind() != StoreKind::Recording); + self.active_rec_id = None; + self.active_application_id = Some(Self::welcome_screen_app_id()); + } + // --------------------- // Active app