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:
+238
-109
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user