145 lines
7.2 KiB
Rust
145 lines
7.2 KiB
Rust
//! 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<I, S>(args: I) -> bool
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<str>,
|
|
{
|
|
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<I, S>(args: I, shift_held: bool) -> bool
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: AsRef<str>,
|
|
{
|
|
shift_held || asked_by_args(args)
|
|
}
|
|
|
|
/// One of the app's page layers: `normal` normally, `safe` (usually nothing) in safe mode.
|
|
pub fn layer<T>(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::<String>::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::<Vec<_>>();"));
|
|
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)?");
|
|
}
|
|
}
|
|
}
|