Safe mode: one run with only the shell working (the app menu, --safe-mode, Shift at launch on Windows)
Host: `safe.rs` is a flag in this process's memory, never written to disk, so the next normal start is back to the owner's setup. While it is on a site tab gets the tab bridge and nothing of the app's page layers: its document-start scripts are `SiteHooks::safe_init_scripts` (default: the bridge alone) instead of `init_scripts`; `site_page_loaded`, `tab_placed` and `tab_background` are not called; `page_scripts` is empty; `eval_in_site_tabs` does nothing. So no page runtime, plugins, theme, custom CSS, feature flags, fixes, hotfix data or words reach a page: the site runs as in a browser, and only the shell is the app's. It starts from the launch argument `--safe-mode` (for when the interface itself is broken), from Shift held while the app starts (Windows), or from the shell: `safe_mode_get` / `safe_mode_set {on}` (shell-only), where a switch rebuilds every tab webview (profiles marked stale, `tabs-rebuild` asks the shell for its layout) so document-start scripts change too. Tests: the argument, the flag, and that app.rs and host.rs gate every layer.
Frame: the app menu's "Restart in safe mode…" says what it does before anything reloads; while on, the titlebar shows an amber "Safe mode | Leave" chip (one click leaves) and the row reads "Leave safe mode"; the chrome drops its theme (and pane themes) and takes it back on leaving, without touching the first-frame cache. `features.safeMode` (on by default).
This commit is contained in:
parent
08e2ca3998
commit
30cc85f3ff
19 changed files with 342 additions and 20 deletions
143
crates/waybetter-host/src/safe.rs
Normal file
143
crates/waybetter-host/src/safe.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! 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.
|
||||
assert!(app.contains("crate::safe::layer(crate::safe::active(), || hooks.init_scripts(app, tab), || 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)?");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue