//! Safe mode: the app with only its shell working. While it is on, a site tab gets nothing of the //! app's page layers: no page runtime, no plugins, no theme, no custom CSS, no feature flags, no //! hotfix data, no words; only the host's tab bridge (so the shell still shows the tab's address and //! title and its keys still work). The site is its own, exactly as in a browser. //! //! It is a flag in this process's memory and nothing else: it is never written to disk, so the //! next normal start is back to the owner's setup. It is switched on by the launch argument //! [`ARG`] (for when the interface itself is broken), by holding Shift while the app starts //! (Windows), or from the shell (`safe_mode_set`, shell-only: the app menu's "Restart in safe //! mode" and the titlebar's "Leave"), which rebuilds every tab webview so document-start scripts //! change too. //! //! How the layers go ([`crate::app`]): a tab's document-start scripts are the app's //! `SiteHooks::safe_init_scripts` (by default the tab bridge alone) instead of `init_scripts`; //! `SiteHooks::site_page_loaded`, `tab_placed` and `tab_background` are not called; //! `app::page_scripts` is empty; and `host::eval_in_site_tabs`, the one way the host and an app //! push state into site pages later (flags, CSS, theme, words, hotfix), does nothing. An app that //! wants some layer of its own to survive safe mode says so in `safe_init_scripts`; by default //! nothing survives, fixes included: the point is to rule the app's own code out. use serde::Serialize; use std::sync::atomic::{AtomicBool, Ordering}; /// The launch argument that starts the app in safe mode. pub const ARG: &str = "--safe-mode"; /// Shell event: safe mode was switched (payload [`SafeState`]). pub const EVENT: &str = "safe-mode"; /// Shell event: every tab webview must be built again; the shell answers with its layout (`tabs_sync`). pub const EVENT_REBUILD: &str = "tabs-rebuild"; /// Marks a tab's recorded profile as stale, so the next `tabs_sync` closes its webview and creates /// it again with the document-start scripts of the moment (the profile-change path). pub const STALE_PROFILE: &str = "\u{0}rebuild"; static ACTIVE: AtomicBool = AtomicBool::new(false); /// What the shell hears about safe mode. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] pub struct SafeState { pub active: bool, } /// Whether safe mode is on in this process. pub fn active() -> bool { ACTIVE.load(Ordering::SeqCst) } /// Switches safe mode; true when that changed anything. pub fn set(on: bool) -> bool { ACTIVE.swap(on, Ordering::SeqCst) != on } pub fn state() -> SafeState { SafeState { active: active() } } /// Whether the launch arguments ask for safe mode (the first is the program itself). pub fn asked_by_args(args: I) -> bool where I: IntoIterator, S: AsRef, { args.into_iter().skip(1).any(|arg| arg.as_ref() == ARG) } /// Whether this launch starts in safe mode: the argument, or Shift held while the app starts. pub fn at_launch(args: I, shift_held: bool) -> bool where I: IntoIterator, S: AsRef, { shift_held || asked_by_args(args) } /// One of the app's page layers: `normal` normally, `safe` (usually nothing) in safe mode. pub fn layer(active: bool, normal: impl FnOnce() -> T, safe: impl FnOnce() -> T) -> T { if active { safe() } else { normal() } } #[cfg(test)] mod tests { use super::*; #[test] fn the_launch_argument_and_shift_ask_for_safe_mode() { assert!(asked_by_args(["app.exe", "--safe-mode"])); assert!(asked_by_args(["app.exe", "waybetter://install?kind=theme", "--safe-mode"])); assert!(!asked_by_args(["app.exe"])); assert!(!asked_by_args(["--safe-mode"]), "the first argument is the program"); assert!(!asked_by_args(["app.exe", "--safe-mode=1", "safe-mode", "--SAFE-MODE", "https://x.com/?--safe-mode"]), "exactly the argument"); assert!(at_launch(["app.exe"], true)); assert!(at_launch(["app.exe", "--safe-mode"], false)); assert!(!at_launch(["app.exe"], false)); assert!(!at_launch(Vec::::new(), false)); } #[test] fn the_flag_lives_in_memory_only_and_gates_every_layer() { // One test owns the process-wide flag (tests run in parallel). assert!(!active(), "off unless asked for"); assert!(set(true) && active()); assert!(!set(true), "switching to the same state changes nothing"); assert_eq!(serde_json::to_string(&state()).unwrap(), r#"{"active":true}"#); assert_eq!(layer(active(), || vec!["runtime.js", "plugins"], Vec::new), Vec::<&str>::new()); assert!(set(false) && !active()); assert_eq!(layer(active(), || vec!["runtime.js"], Vec::new), vec!["runtime.js"]); // Never persisted: this module touches no file, so the next start is a normal one. let source = include_str!("safe.rs"); let code = source.split("#[cfg(test)]").next().unwrap(); for forbidden in ["std::fs", "write_json", "read_json", "File::", "app_data_dir"] { assert!(!code.contains(forbidden), "{forbidden}"); } } #[test] fn the_host_skips_every_page_layer_in_safe_mode() { let app = include_str!("app.rs"); let host = include_str!("host.rs"); // Document-start scripts: the safe set instead of the app's. // (the normal layers are the string packs' delivery and then the app's own scripts). assert!(app.contains("let layers = || strings_layer(app).into_iter().chain(hooks.init_scripts(app, tab)).collect::>();")); assert!(app.contains("crate::safe::layer(crate::safe::active(), layers, || hooks.safe_init_scripts(app, tab))")); // The default safe set is the tab bridge alone. let default = &app[app.find("fn safe_init_scripts").expect("the hook")..]; let default = &default[..default.find("\n }").unwrap()]; assert!(default.contains("vec![crate::bridge::SOURCE.to_owned()]"), "{default}"); // A loaded site page gets nothing; placement and background hooks stay out; page_scripts is empty. assert!(app.contains("if finished && site.is_origin(payload.url()) && !crate::safe::active() {")); assert!(app.contains("if !crate::safe::active() {\n hooks.tab_placed(&app, &tab, &item);")); assert!(app.contains("crate::safe::layer(crate::safe::active(), || hooks.tab_background(&app, &label), || None)")); let page_scripts = &app[app.find("pub fn page_scripts").expect("page_scripts")..]; assert!(page_scripts[..page_scripts.find("let site").unwrap()].contains("if crate::safe::active()"), "page_scripts checks the flag first"); // The one way state is pushed into site pages later does nothing. let eval = &host[host.find("pub fn eval_in_site_tabs").expect("eval_in_site_tabs")..]; assert!(eval[..eval.find("for (label").unwrap()].contains("if crate::safe::active()"), "eval_in_site_tabs checks the flag first"); // Both commands are the shell's. for command in ["safe_mode_get", "safe_mode_set"] { assert!(crate::commands::SHELL.contains(&command), "{command}"); crate::testkit::assert_guarded(app, command, "shell_only(&webview)?"); } } }