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:
CRTkafa 2026-09-30 23:13:03 +07:00
parent 08e2ca3998
commit 30cc85f3ff
19 changed files with 342 additions and 20 deletions

View file

@ -137,6 +137,13 @@ pub trait SiteHooks: Send + Sync + 'static {
/// (`waybetter_host::bridge`), then the app's page runtime.
fn init_scripts(&self, app: &AppHandle, tab: &NewTab) -> Vec<String>;
/// The document-start scripts of a tab in safe mode ([`crate::safe`]) instead of
/// [`Self::init_scripts`]: by default the host's tab bridge alone, so nothing of the app's page
/// layers runs. An app returns more here only for a layer it wants to survive safe mode.
fn safe_init_scripts(&self, _app: &AppHandle, _tab: &NewTab) -> Vec<String> {
vec![crate::bridge::SOURCE.to_owned()]
}
/// The new tab webview's background until the page paints.
fn tab_background(&self, _app: &AppHandle, _label: &str) -> Option<tauri::window::Color> {
None
@ -272,6 +279,10 @@ impl PageScripts {
/// The page state as scripts for a site document that just loaded (evaluate them natively, so the
/// page's CSP cannot block them). An app with scripts of its own places them between these.
pub fn page_scripts(app: &AppHandle) -> Result<PageScripts, String> {
// Safe mode: a site page gets none of them.
if crate::safe::active() {
return Ok(PageScripts { features: String::new(), links: String::new(), css: None, plugins: Vec::new() });
}
let site = config(app).site;
let page = page(app)?;
let css = page.plugins.css();
@ -484,7 +495,7 @@ pub(crate) async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec<TabLa
let new_tab = NewTab { id: &item.id, label: &label, profile: &item.profile, item: &item };
let builder = tab_builder(&app, &hooks, &new_tab, url, data_dir);
// The site's page color until it paints (never the WebView's white).
let builder = match hooks.tab_background(&app, &label) {
let builder = match crate::safe::layer(crate::safe::active(), || hooks.tab_background(&app, &label), || None) {
Some(color) => builder.background_color(color),
None => builder,
};
@ -506,7 +517,9 @@ pub(crate) async fn tabs_sync(webview: Webview, app: AppHandle, items: Vec<TabLa
}
}
};
hooks.tab_placed(&app, &tab, &item);
if !crate::safe::active() {
hooks.tab_placed(&app, &tab, &item);
}
host::place_tab(&tab, (x, y, width, height), visible, item.parked || rect.is_none());
placed.push((item.id.clone(), (x, y, width, height), visible));
}
@ -568,6 +581,38 @@ async fn tab_find(webview: Webview, app: AppHandle, id: String, action: String,
host::tab_find(&app, &id, &action, query.as_deref())
}
/// Whether safe mode is on ([`crate::safe`]): the shell shows it in the titlebar and drops its chrome theme.
#[tauri::command]
async fn safe_mode_get(webview: Webview) -> Result<crate::safe::SafeState, String> {
shell_only(&webview)?;
Ok(crate::safe::state())
}
/// Switches safe mode for this run (memory only, never saved) and rebuilds every tab webview, so
/// each page starts again with or without the app's page layers. The app menu's "Restart in safe
/// mode" and the titlebar's "Leave".
#[tauri::command]
async fn safe_mode_set(webview: Webview, app: AppHandle, on: bool) -> Result<crate::safe::SafeState, String> {
shell_only(&webview)?;
if crate::safe::set(on) {
emit_shell(&app, crate::safe::EVENT, crate::safe::state());
rebuild_tabs(&app);
}
Ok(crate::safe::state())
}
/// Every tab webview is built again on the shell's next layout: their recorded profiles are marked
/// stale (`tabs_sync` then closes and recreates each one with the document-start scripts of the
/// moment) and the shell is asked to send its layout.
pub fn rebuild_tabs(app: &AppHandle) {
if let Ok(mut data) = profiles(app) {
for profile in data.tab_profiles.values_mut() {
*profile = crate::safe::STALE_PROFILE.to_owned();
}
}
emit_shell(app, crate::safe::EVENT_REBUILD, ());
}
/// Gives keyboard focus back to the shell (e.g. Ctrl+L pressed inside a tab).
#[tauri::command]
async fn shell_focus(webview: Webview) -> Result<(), String> {
@ -848,7 +893,8 @@ fn tab_builder(app: &AppHandle, hooks: &Arc<dyn SiteHooks>, tab: &NewTab, url: U
builder = builder.data_store_identifier(profiles::store_identifier(tab.profile));
}
}
for script in hooks.init_scripts(app, tab) {
// Safe mode: the safe set (the tab bridge) instead of the app's page layers.
for script in crate::safe::layer(crate::safe::active(), || hooks.init_scripts(app, tab), || hooks.safe_init_scripts(app, tab)) {
builder = builder.initialization_script(script);
}
builder
@ -927,7 +973,7 @@ fn tab_builder(app: &AppHandle, hooks: &Arc<dyn SiteHooks>, tab: &NewTab, url: U
host::restore_tab_zoom(app, &tab);
}
emit_tab_state(app, TabState { id: load_id.clone(), url: Some(payload.url().to_string()), loading: Some(!finished), ..Default::default() });
if finished && site.is_origin(payload.url()) {
if finished && site.is_origin(payload.url()) && !crate::safe::active() {
load_hooks.site_page_loaded(app, &tab, payload.url());
}
})
@ -984,6 +1030,8 @@ fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 's
tab_action,
tab_zoom,
tab_find,
safe_mode_get,
safe_mode_set,
open_external_url,
shell_focus,
shell_ready,
@ -1118,6 +1166,8 @@ impl App {
});
tauri_app.manage(Page(Mutex::new(PageState { plugins, features, settings, modes })));
tauri_app.manage(Host { config, hooks });
// Safe mode for this run: the launch argument, or Shift held while starting (Windows).
crate::safe::set(crate::safe::at_launch(std::env::args(), host::shift_held()));
#[cfg(debug_assertions)]
crate::devloop::start(&handle);
if let Some(setup) = setup {