Browser pairing, notifications, element hiding; drop the top bar

Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC) and injects the result into
WKHTTPCookieStore. Only the configured apps' hosts and their sign-in
hosts survive the filter. Browsers are offered most-recently-used first,
since the first entry becomes the default and someone with four
Chromium browsers installed wants the one they actually browse in.

WKWebView defines window.Notification but it does nothing: constructing
one throws no error and shows no banner, so a page believes it notified
you. Measured on the machine as `api=function shim=no` before the shim
was made unconditional; `from page: Odoo - Test notification -> raised`
after.

Anything on a page can be right-clicked away. The rule is re-asserted on
every navigation, because the injected script only carries a snapshot
from when the view was built and a selector added since would otherwise
come back on reload.

The top bar is gone. Navigation lives beside the cog, the nav carries
the traffic lights, and two-finger swipe goes back and forward.

The seed is now the real app list, scoped to exact hosts so a Drive link
inside Gmail switches rather than being swallowed.
This commit is contained in:
2026-09-01 12:17:31 +02:00
parent e369c82774
commit ff4a0c6bc4
44 changed files with 2492 additions and 419 deletions
+238 -109
View File
@@ -5,23 +5,25 @@
//! and anything the shell wants to draw over an app (a dialog) requires hiding
//! the app first.
use std::collections::HashMap;
use serde::Serialize;
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
webview::{NewWindowResponse, WebviewBuilder},
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
};
use url::Url;
use crate::config::{App, Config};
use crate::routing::{self, AppScope, Decision};
/// Scheme the injected interceptor uses to hand a URL back for a decision.
/// Scheme prefix the injected script uses to talk back.
///
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
/// granting google.com the ability to call into this app, and routing a link
/// does not need anything that dangerous. `on_navigation` sees this, answers
/// it, and cancels the navigation so nothing ever loads.
const ROUTE_SCHEME: &str = "workapp-route";
/// granting google.com the ability to call into this app, and none of these
/// messages need anything that dangerous. `on_navigation` answers them and
/// cancels the navigation, so nothing ever loads.
const SCHEME_PREFIX: &str = "workapp-";
pub fn label_for(app_id: &str) -> String {
format!("app-{app_id}")
@@ -41,86 +43,40 @@ pub struct UrlEvent {
pub url: String,
}
/// The click interceptor, specialised for one app's scope.
///
/// It only decides *intent*: a link the user clicked that leaves this app's
/// hosts is prevented and handed to Rust. Redirects, form posts and OAuth
/// bounces are untouched, which is what keeps sign-in flows alive.
fn interceptor_script(scopes: &[String]) -> String {
let json = serde_json::to_string(scopes).unwrap_or_else(|_| "[]".into());
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenEvent {
pub app_id: String,
pub selector: String,
}
/// The page script, with this app's configuration written above it.
fn script_for(app: &App) -> String {
let cfg = serde_json::json!({
"scopes": app.scopes(),
"hidden": app.hidden,
"name": app.name,
});
format!(
r#"(function () {{
if (window.__workAppRouter) return;
window.__workAppRouter = true;
var SCOPES = {json};
function abs(href) {{ try {{ return new URL(href, document.baseURI).href; }} catch (e) {{ return null; }} }}
function inScope(u) {{
try {{
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
return SCOPES.some(function (s) {{
s = String(s).replace(/^www\./, '').toLowerCase();
return h === s || h.endsWith('.' + s);
}});
}} catch (e) {{ return false; }}
}}
function ask(u) {{
try {{ window.location.href = '{scheme}:/?u=' + encodeURIComponent(u); }} catch (e) {{}}
}}
document.addEventListener('click', function (e) {{
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
var href = a.getAttribute('href');
if (!href || href.charAt(0) === '#') return;
if (/^(javascript|blob|data):/i.test(href)) return;
var u = abs(href);
if (!u) return;
if (inScope(u)) {{
// Our own host. A new-tab link has nowhere to go in a tabless app, so
// it takes over this view rather than being swallowed.
if (a.target === '_blank') {{ e.preventDefault(); window.location.href = u; }}
return;
}}
e.preventDefault();
e.stopPropagation();
ask(u);
}}, true);
var nativeOpen = window.open;
window.open = function (url) {{
if (!url) return null;
var u = abs(url);
if (!u) return null;
if (inScope(u)) {{ window.location.href = u; return null; }}
ask(u);
return null;
}};
void nativeOpen;
}})();"#,
json = json,
scheme = ROUTE_SCHEME
"window.__WORKAPP = {};\n{}",
cfg,
include_str!("inject.js")
)
}
/// Pulls the URL back out of a `workapp-route:/?u=…` sentinel.
pub fn route_target(url: &Url) -> Option<String> {
if url.scheme() != ROUTE_SCHEME {
return None;
}
url.query_pairs()
.find(|(k, _)| k == "u")
.map(|(_, v)| v.to_string())
/// Splits a sentinel URL into its kind and query parameters.
pub fn sentinel(url: &Url) -> Option<(String, HashMap<String, String>)> {
let kind = url.scheme().strip_prefix(SCHEME_PREFIX)?.to_string();
let params = url
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Some((kind, params))
}
/// Acts on a decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, from: &str, decision: Decision) {
/// Acts on a routing decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, decision: Decision) {
match decision {
// Only an identity provider reaches here, and the click that produced
// it was already cancelled — so this view has to be sent there.
Decision::Stay => {}
Decision::Switch { app_id, url } => {
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
@@ -129,31 +85,130 @@ fn apply(handle: &AppHandle, from: &str, decision: Decision) {
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
}
}
let _ = from;
}
/// Handles a sentinel URL: decide, then act without blocking the delegate.
fn handle_route(handle: &AppHandle, from: &str, target: String, scopes: Vec<AppScope>) {
/// Answers one message from the page, without blocking the delegate.
fn handle_sentinel(
handle: &AppHandle,
from: &str,
kind: String,
params: HashMap<String, String>,
scopes: Vec<AppScope>,
) {
let handle = handle.clone();
let from = from.to_string();
tauri::async_runtime::spawn(async move {
match routing::decide(&target, Some(&from), &scopes) {
Decision::Stay => {
// The click was cancelled to ask the question, so completing it
// is now this side's job.
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(&target) {
let _ = wv.navigate(u);
match kind.as_str() {
"route" => {
let Some(target) = params.get("u") else { return };
match routing::decide(target, Some(&from), &scopes) {
// Only an identity provider reaches here, and the click was
// already cancelled to ask the question — so completing it
// is now this side's job.
Decision::Stay => {
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(target) {
let _ = wv.navigate(u);
}
}
}
other => apply(&handle, other),
}
}
other => apply(&handle, &from, other),
"hide" => {
let Some(selector) = params.get("s") else { return };
let state = handle.state::<crate::commands::AppState>();
if state.add_hidden(&from, selector).is_ok() {
let _ = handle.emit(
"hidden-added",
HiddenEvent { app_id: from.clone(), selector: selector.clone() },
);
}
}
"notify" => {
use tauri_plugin_notification::NotificationExt;
let title = params.get("t").cloned().unwrap_or_default();
let body = params.get("b").cloned().unwrap_or_default();
let app_name = params.get("a").cloned().unwrap_or_default();
// The app's own name leads, or a notification from four tools
// in one window says nothing about which one wants you.
let heading = if app_name.is_empty() {
title.clone()
} else {
format!("{app_name}{title}")
};
let outcome = match handle
.notification()
.builder()
.title(heading.clone())
.body(body)
.show()
{
Ok(()) => "raised".to_string(),
Err(e) => {
eprintln!("could not raise a notification: {e}");
format!("failed: {e}")
}
};
// Recorded so the diagnostic can show that a page's notification
// actually reached macOS, not merely that the shim ran.
let state = handle.state::<crate::commands::AppState>();
*state.last_notification.lock().unwrap() = format!("{heading}{outcome}");
}
// Reports what the page found, over the same channel a real
// notification uses — so a silent failure says which half broke.
"diag" => {
let state = handle.state::<crate::commands::AppState>();
let mut parts: Vec<String> = params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
parts.sort();
*state.diag.lock().unwrap() = parts.join(" ");
}
"manage" => {
let _ = handle.emit("manage-hidden", from.clone());
}
_ => {}
}
});
}
/// Turns on WKWebView's two-finger back and forward swipes.
///
/// wry supports it but Tauri does not expose it, so it is set on the native
/// view after the fact. It is the only navigation gesture the app has, now
/// that there is no toolbar carrying arrows.
#[cfg(target_os = "macos")]
fn enable_swipe_navigation(handle: &AppHandle, app_id: &str) {
use objc2_web_kit::WKWebView;
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
let _ = wv.with_webview(|platform| unsafe {
let ptr = platform.inner() as *const WKWebView;
if ptr.is_null() {
return;
}
(*ptr).setAllowsBackForwardNavigationGestures(true);
});
}
}
#[cfg(not(target_os = "macos"))]
fn enable_swipe_navigation(_: &AppHandle, _: &str) {}
/// Builds the child webview for one app and parks it in the stage rect.
pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64, f64)) -> Result<(), String> {
pub fn create(
handle: &AppHandle,
app: &App,
cfg: &Config,
stage: (f64, f64, f64, f64),
) -> Result<(), String> {
let window = handle
.get_window("main")
.ok_or_else(|| "main window is gone".to_string())?;
@@ -170,15 +225,18 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
let win_id = id.clone();
let win_scopes = scopes.clone();
let load_handle = handle.clone();
let load_id = id.clone();
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
.user_agent(&app.ua())
.initialization_script(interceptor_script(&app.scopes()))
.initialization_script(script_for(app))
.on_navigation(move |url| {
// The sentinel is a question, not a destination: answer it and
// A sentinel is a question, not a destination: answer it and
// cancel. Everything else is allowed — a strict filter here would
// break every OAuth redirect chain.
if let Some(target) = route_target(url) {
handle_route(&nav_handle, &nav_id, target, nav_scopes.clone());
if let Some((kind, params)) = sentinel(url) {
handle_sentinel(&nav_handle, &nav_id, kind, params, nav_scopes.clone());
return false;
}
let _ = nav_handle.emit(
@@ -189,16 +247,27 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
})
.on_new_window(move |url, _features| {
// Nothing may open a window of its own; the same rules apply.
let decision = routing::decide(url.as_str(), Some(&win_id), &win_scopes);
match decision {
match routing::decide(url.as_str(), Some(&win_id), &win_scopes) {
Decision::Stay => {
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
let _ = wv.navigate(url);
}
}
other => apply(&win_handle, &win_id, other),
other => apply(&win_handle, other),
}
NewWindowResponse::Deny
})
// The script carries a snapshot of the hidden list from when the view
// was built, so anything chosen since would come back on reload. This
// re-asserts the real list on every navigation.
.on_page_load(move |_wv, _payload| {
let state = load_handle.state::<crate::commands::AppState>();
let hidden = state
.cfg()
.app(&load_id)
.map(|a| a.hidden.clone())
.unwrap_or_default();
push_hidden(&load_handle, &load_id, &hidden);
});
window
@@ -209,8 +278,10 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
)
.map_err(|e| e.to_string())?;
// Created hidden. `show` is what puts one on screen, so startup does not
// flash every app in turn as they are built.
enable_swipe_navigation(handle, &id);
// Created hidden. `show_only` is what puts one on screen, so startup does
// not flash every app in turn as they are built.
if let Some(wv) = handle.get_webview(&label_for(&id)) {
let _ = wv.hide();
}
@@ -218,7 +289,12 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
}
/// Shows one app and hides the rest, sizing it to the stage.
pub fn show_only(handle: &AppHandle, app_id: Option<&str>, cfg: &Config, stage: (f64, f64, f64, f64)) {
pub fn show_only(
handle: &AppHandle,
app_id: Option<&str>,
cfg: &Config,
stage: (f64, f64, f64, f64),
) {
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
if Some(app.id.as_str()) == app_id {
@@ -254,26 +330,79 @@ pub fn destroy(handle: &AppHandle, app_id: &str) {
}
}
/// Re-applies an app's hidden selectors without a reload.
///
/// The script owns the stylesheet, so changing the list from Settings is a
/// message to the page rather than a rebuild of the view.
pub fn push_hidden(handle: &AppHandle, app_id: &str, hidden: &[String]) {
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let json = serde_json::to_string(hidden).unwrap_or_else(|_| "[]".into());
let script = format!(
r#"(function(){{
var css = {json}.length ? {json}.join(',\n') + ' {{ display: none !important; }}' : '';
var el = document.getElementById('__workapp_hidden');
if (!el) {{
el = document.createElement('style');
el.id = '__workapp_hidden';
(document.head || document.documentElement).appendChild(el);
}}
el.textContent = css;
try {{ localStorage.setItem('__workapp_hidden', JSON.stringify({json})); }} catch (e) {{}}
}})();"#
);
let _ = wv.eval(&script);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_target_round_trips_a_url_with_a_query() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1%26c%3D2").unwrap();
assert_eq!(route_target(&u).unwrap(), "https://x.com/a?b=1&c=2");
fn sentinel_splits_kind_from_parameters() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "route");
assert_eq!(params["u"], "https://x.com/a?b=1");
}
#[test]
fn sentinel_reads_a_notification() {
let u = Url::parse("workapp-notify:/?t=New%20mail&b=From%20Sam&a=Gmail").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "notify");
assert_eq!(params["t"], "New mail");
assert_eq!(params["b"], "From Sam");
}
#[test]
fn sentinel_reads_a_hide_selector() {
let u = Url::parse("workapp-hide:/?s=%23promo%20.banner").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "hide");
assert_eq!(params["s"], "#promo .banner");
}
#[test]
fn ordinary_urls_are_not_sentinels() {
let u = Url::parse("https://github.com/?u=x").unwrap();
assert!(route_target(&u).is_none());
assert!(sentinel(&Url::parse("https://github.com/?u=x").unwrap()).is_none());
assert!(sentinel(&Url::parse("mailto:a@b.com").unwrap()).is_none());
}
#[test]
fn the_script_carries_the_apps_own_scope() {
let s = interceptor_script(&["mail.google.com".into()]);
fn the_script_carries_this_apps_own_configuration() {
let app = App {
id: "a".into(),
name: "Gmail".into(),
url: "https://mail.google.com".into(),
scope: vec!["mail.google.com".into()],
group_id: None,
user_agent: None,
hidden: vec![".ad".into()],
order: 0,
};
let s = script_for(&app);
assert!(s.contains("mail.google.com"));
assert!(s.contains("workapp-route:/?u="));
assert!(s.contains(".ad"));
assert!(s.contains("__workAppReady"));
}
}