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
+233
View File
@@ -20,6 +20,10 @@ pub struct AppState {
pub stage: Mutex<Stage>,
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
pub booted: Mutex<bool>,
/// What the last page probe reported, for the notification diagnostic.
pub diag: Mutex<String>,
/// The last notification a page raised, and what macOS did with it.
pub last_notification: Mutex<String>,
}
impl AppState {
@@ -30,6 +34,32 @@ impl AppState {
fn persist(&self) -> Result<(), String> {
config::save(&self.dir, &self.config.lock().unwrap())
}
/// Records a selector chosen by right-clicking it in the page.
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
{
let mut cfg = self.config.lock().unwrap();
let app = cfg
.apps
.iter_mut()
.find(|a| a.id == app_id)
.ok_or_else(|| format!("no app {app_id}"))?;
if app.hidden.iter().any(|s| s == selector) {
return Ok(());
}
app.hidden.push(selector.to_string());
}
self.persist()
}
/// Every host the import is allowed to bring cookies across for: the
/// configured apps, plus the sign-in hosts that vouch for them.
fn importable_hosts(&self) -> Vec<String> {
let cfg = self.config.lock().unwrap();
let mut hosts: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
hosts.extend(crate::routing::identity_providers().iter().map(|s| s.to_string()));
hosts
}
}
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
@@ -44,6 +74,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
active: Mutex::new(None),
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
booted: Mutex::new(false),
diag: Mutex::new(String::new()),
last_notification: Mutex::new(String::new()),
})
}
@@ -210,6 +242,7 @@ pub fn add_app(
scope: vec![scope],
group_id,
user_agent: None,
hidden: Vec::new(),
order,
};
cfg.apps.push(new.clone());
@@ -361,3 +394,203 @@ pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String
state.config.lock().unwrap().settings.theme = theme;
state.persist()
}
// ------------------------------------------------------- hidden elements
/// Replaces an app's hidden selectors and re-applies them without a reload.
#[tauri::command]
pub fn set_hidden(
app_id: String,
hidden: Vec<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let target = cfg
.apps
.iter_mut()
.find(|a| a.id == app_id)
.ok_or_else(|| format!("no app {app_id}"))?;
target.hidden = hidden.clone();
}
state.persist()?;
webviews::push_hidden(&app, &app_id, &hidden);
Ok(state.cfg())
}
/// Starts the in-page element picker, for reaching something a right-click
/// cannot land on cleanly.
#[tauri::command]
pub fn pick_hidden(app_id: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
wv.eval("window.__workAppPick && window.__workAppPick()")
.map_err(|e| e.to_string())
}
/// Permission state, and what a notification raised straight from Rust does.
///
/// Splits the chain in two: if this succeeds and the page test does not, the
/// shim is at fault; if this fails, macOS never granted permission.
#[tauri::command]
pub fn notification_status(app: AppHandle) -> String {
use tauri_plugin_notification::NotificationExt;
let state = match app.notification().permission_state() {
Ok(s) => format!("{s:?}"),
Err(e) => format!("unknown ({e})"),
};
let raised = match app
.notification()
.builder()
.title("Work")
.body("Notifications are working.")
.show()
{
Ok(()) => "raised".to_string(),
Err(e) => format!("failed: {e}"),
};
let app_state = app.state::<AppState>();
let diag = app_state.diag.lock().unwrap().clone();
let last = app_state.last_notification.lock().unwrap().clone();
let page = if diag.is_empty() { "not run yet".into() } else { diag };
let from_page = if last.is_empty() { "none yet".into() } else { last };
format!("permission: {state} · direct: {raised} · page: {page} · from page: {from_page}")
}
/// Asks macOS for notification permission, once, at startup.
pub fn ensure_notification_permission(app: &AppHandle) {
use tauri_plugin_notification::NotificationExt;
let granted = matches!(
app.notification().permission_state(),
Ok(tauri_plugin_notification::PermissionState::Granted)
);
if !granted {
if let Err(e) = app.notification().request_permission() {
eprintln!("notification permission was refused: {e}");
}
}
}
/// Fires a notification the way a page would.
///
/// Deliberately routed through the injected shim rather than raised directly:
/// the thing worth testing is the whole chain — page API, sentinel, Rust, and
/// macOS — not whether this process can show a notification.
#[tauri::command]
pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
// The probe reports what it found before raising anything, so a
// notification that never appears still says why.
wv.eval(
r#"(function () {
var kind = typeof window.Notification;
var shim = !!(window.Notification && window.Notification.__work);
var err = '';
try {
new Notification('Test notification',
{ body: 'If you can see this, pages can reach you.' });
} catch (e) { err = String(e); }
if (window.__workAppSend) {
window.__workAppSend('diag', { api: kind, shim: shim ? 'yes' : 'no', err: err });
}
})();"#,
)
.map_err(|e| e.to_string())
}
// ------------------------------------------------------- browser pairing
#[tauri::command]
pub fn list_browsers() -> Vec<crate::cookies::Browser> {
crate::cookies::list()
}
/// Imports the paired browser's cookies for the configured hosts.
///
/// Everything outside those hosts is dropped before anything is written: this
/// reaches into a browser's whole cookie store, and it must come back with the
/// sessions for the tools on the list and nothing else.
#[tauri::command]
pub fn pair_browser(
browser: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<crate::cookies::PairResult, String> {
let all = crate::cookies::read_all(&browser)?;
let scanned = all.len();
let wanted = crate::cookies::filter_to_scopes(all, &state.importable_hosts());
let mut domains: Vec<String> = wanted.iter().map(|c| c.domain.clone()).collect();
domains.sort();
domains.dedup();
let mut warnings = Vec::new();
if wanted.is_empty() {
warnings.push(format!(
"Read {scanned} cookies, none for the apps on your list. \
Sign in to them in that browser first."
));
}
let imported = crate::cookies::inject::install(&app, wanted)?;
{
let mut cfg = state.config.lock().unwrap();
cfg.settings.paired_browser = Some(browser);
cfg.settings.last_paired_at = Some(now_iso());
}
state.persist()?;
Ok(crate::cookies::PairResult {
imported,
domains: domains.len(),
domain_names: domains,
warnings,
})
}
/// A timestamp for "last paired", without pulling in a date library for it.
fn now_iso() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
let (y, mo, d) = civil_from_days(days as i64);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
/// Howard Hinnant's days-to-civil-date algorithm.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_from_days_matches_known_dates() {
// Cross-checked against Python:
// date(1970,1,1) + timedelta(days=n)
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(civil_from_days(20_697), (2026, 9, 1));
assert_eq!(civil_from_days(20_698), (2026, 9, 2));
}
}
+31 -13
View File
@@ -29,6 +29,10 @@ pub struct App {
pub group_id: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
/// CSS selectors this app hides on every page. Chosen by right-clicking
/// the thing you never want to see again.
#[serde(default)]
pub hidden: Vec<String>,
#[serde(default)]
pub order: i32,
}
@@ -167,8 +171,11 @@ pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// First-run contents: enough apps, across enough groups, that grouping and
/// cross-app link routing can both be seen working without typing anything.
/// First-run contents: the tools this was built for.
///
/// Each scope is the exact host, so a link from Gmail to Drive switches rather
/// than being swallowed by whichever Google app happens to be showing. The
/// `/u/N/` paths pin the second Google account, which is the one that matters.
pub fn seed() -> Config {
let mk = |name: &str, url: &str, group: &str, order: i32| App {
id: new_id(),
@@ -177,22 +184,20 @@ pub fn seed() -> Config {
scope: default_scope(url).into_iter().collect(),
group_id: Some(group.into()),
user_agent: None,
hidden: Vec::new(),
order,
};
Config {
version: 1,
groups: vec![
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
Group { id: "g-work".into(), name: "Work".into(), collapsed: false, order: 0 },
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 1 },
],
apps: vec![
mk("Google", "https://www.google.com", "g-google", 0),
mk("YouTube", "https://www.youtube.com", "g-google", 1),
mk("GitHub", "https://github.com", "g-dev", 0),
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
mk("Odoo", "https://example.odoo.com/web", "g-work", 0),
mk("Gmail", "https://mail.google.com/mail/u/N/", "g-google", 0),
mk("Drive", "https://drive.google.com/drive/u/N/my-drive", "g-google", 1),
mk("Chat", "https://chat.google.com/u/N/app/home", "g-google", 2),
],
settings: Settings::default(),
}
@@ -240,10 +245,23 @@ mod tests {
assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local");
}
#[test]
fn seeded_scopes_are_exact_hosts_that_cannot_swallow_each_other() {
// The whole point of exact hosts: a Drive link inside Gmail must match
// Drive, not Gmail. A shared `google.com` scope would break that.
let cfg = seed();
let scopes: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
assert!(scopes.contains(&"mail.google.com".to_string()));
assert!(scopes.contains(&"drive.google.com".to_string()));
assert!(scopes.contains(&"chat.google.com".to_string()));
assert!(scopes.contains(&"example.odoo.com".to_string()));
assert!(!scopes.contains(&"google.com".to_string()));
}
#[test]
fn seed_apps_all_carry_a_scope() {
let cfg = seed();
assert_eq!(cfg.apps.len(), 6);
assert_eq!(cfg.apps.len(), 4);
assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty()));
}
@@ -251,7 +269,7 @@ mod tests {
fn ordering_follows_group_then_position() {
let cfg = seed();
let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
assert_eq!(names, ["Odoo", "Gmail", "Drive", "Chat"]);
}
#[test]
+240
View File
@@ -0,0 +1,240 @@
//! Reading a Chromium browser's cookie jar on macOS.
//!
//! The values are AES-encrypted with a key that lives in the login Keychain, so
//! the first import raises a Keychain prompt. That prompt is the point: it is
//! macOS asking whether this app may read that key, and the honest answer has
//! to come from the user.
use aes::Aes128;
use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use rusqlite::{Connection, OpenFlags};
use sha2::{Digest, Sha256};
use super::Cookie;
type Decryptor = cbc::Decryptor<Aes128>;
/// Chromium's fixed KDF parameters on macOS. Not secrets — they are compiled
/// into every Chromium build, and the actual secret is the Keychain entry.
const SALT: &[u8] = b"saltysalt";
const ROUNDS: u32 = 1003;
const IV: [u8; 16] = [b' '; 16];
/// The profile most recently used, since that is the one you are signed into.
pub fn find_cookie_db(user_data: &std::path::Path) -> Option<std::path::PathBuf> {
if !user_data.exists() {
return None;
}
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
let entries = std::fs::read_dir(user_data).ok()?;
for entry in entries.flatten() {
// Chromium keeps cookies at <Profile>/Cookies, and newer builds at
// <Profile>/Network/Cookies.
for candidate in [entry.path().join("Cookies"), entry.path().join("Network/Cookies")] {
if !candidate.is_file() {
continue;
}
let Ok(modified) = candidate.metadata().and_then(|m| m.modified()) else {
continue;
};
if best.as_ref().is_none_or(|(t, _)| modified > *t) {
best = Some((modified, candidate));
}
}
}
best.map(|(_, p)| p)
}
/// SQLite will not open a file the browser holds a lock on, so it is copied
/// first. The `-wal` sidecar comes too, or recent writes are invisible.
pub fn copy_locked(db: &std::path::Path) -> Result<std::path::PathBuf, String> {
let tmp = std::env::temp_dir().join(format!("work-app-cookies-{}", std::process::id()));
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
let dest = tmp.join("Cookies");
std::fs::copy(db, &dest).map_err(|e| format!("could not read the cookie store: {e}"))?;
for suffix in ["-wal", "-shm"] {
let side = db.with_file_name(format!(
"{}{suffix}",
db.file_name().unwrap_or_default().to_string_lossy()
));
if side.exists() {
let _ = std::fs::copy(&side, dest.with_file_name(format!("Cookies{suffix}")));
}
}
Ok(dest)
}
/// The browser's encryption password, from the login Keychain.
///
/// Shelling out to `security` rather than binding the Security framework: it is
/// the same prompt either way, and this keeps a C API with a long history of
/// footguns out of the app.
fn keychain_password(service: &str) -> Result<String, String> {
let out = std::process::Command::new("/usr/bin/security")
.args([
"find-generic-password",
"-w",
"-s",
&format!("{service} Safe Storage"),
"-a",
service,
])
.output()
.map_err(|e| format!("could not run `security`: {e}"))?;
if !out.status.success() {
return Err(format!(
"no Keychain entry for \"{service} Safe Storage\". \
If a prompt appeared, it needs Allow — otherwise open {service} once and try again."
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn derive_key(password: &str) -> [u8; 16] {
let mut key = [0u8; 16];
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password.as_bytes(), SALT, ROUNDS, &mut key);
key
}
/// Decrypts one `v10` value.
///
/// Chromium 130 and later prepend the SHA-256 of the cookie's host to the
/// plaintext. It is stripped by comparing against the hash rather than by
/// guessing at the bytes, so a value that merely looks binary is left alone.
fn decrypt_value(encrypted: &[u8], key: &[u8; 16], host: &str) -> Option<String> {
if encrypted.len() < 4 || &encrypted[..3] != b"v10" {
return None;
}
let plain = Decryptor::new(key.into(), &IV.into())
.decrypt_padded_vec_mut::<Pkcs7>(&encrypted[3..])
.ok()?;
let expected: [u8; 32] = Sha256::digest(host.as_bytes()).into();
let body = if plain.len() >= 32 && plain[..32] == expected {
&plain[32..]
} else {
&plain[..]
};
String::from_utf8(body.to_vec()).ok()
}
pub fn read(user_data: &std::path::Path, service: &str) -> Result<Vec<Cookie>, String> {
let db = find_cookie_db(user_data).ok_or("no profile with a cookie store")?;
let key = derive_key(&keychain_password(service)?);
let copy = copy_locked(&db)?;
let conn = Connection::open_with_flags(&copy, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| format!("could not open the cookie store: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT host_key, name, value, encrypted_value, path, expires_utc, \
is_secure, is_httponly FROM cookies",
)
.map_err(|e| format!("unfamiliar cookie schema: {e}"))?;
let rows = stmt
.query_map([], |r| {
let host: String = r.get(0)?;
let plain: String = r.get(2).unwrap_or_default();
let enc: Vec<u8> = r.get(3).unwrap_or_default();
let value = if plain.is_empty() {
decrypt_value(&enc, &key, &host).unwrap_or_default()
} else {
plain
};
Ok(Cookie {
domain: host,
name: r.get(1)?,
value,
path: r.get(4).unwrap_or_else(|_| "/".into()),
expires: chromium_time(r.get::<_, i64>(5).unwrap_or(0)),
secure: r.get::<_, i64>(6).unwrap_or(0) != 0,
http_only: r.get::<_, i64>(7).unwrap_or(0) != 0,
})
})
.map_err(|e| e.to_string())?;
let cookies: Vec<Cookie> = rows
.filter_map(Result::ok)
// A value that would not decrypt is worse than useless: injecting an
// empty session cookie logs you out rather than in.
.filter(|c| !c.value.is_empty())
.collect();
let _ = std::fs::remove_dir_all(copy.parent().unwrap_or(&copy));
Ok(cookies)
}
/// Chromium counts microseconds from 1601-01-01; the rest of the world counts
/// seconds from 1970. Zero means a session cookie.
fn chromium_time(value: i64) -> Option<i64> {
if value <= 0 {
return None;
}
const EPOCH_DELTA: i64 = 11_644_473_600;
Some(value / 1_000_000 - EPOCH_DELTA)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chromium_time_converts_to_unix_seconds() {
// 13 Jan 2022 00:00:00 UTC in Chromium's epoch.
assert_eq!(chromium_time(13_287_916_800_000_000), Some(1_643_443_200));
assert_eq!(chromium_time(0), None);
assert_eq!(chromium_time(-5), None);
}
#[test]
fn key_derivation_matches_chromiums_published_parameters() {
// Cross-checked against an independent implementation:
// python3 -c "import hashlib; print(hashlib.pbkdf2_hmac(
// 'sha1', b'peanuts', b'saltysalt', 1003, 16).hex())"
// "peanuts" is Chromium's documented fallback password. If this ever
// fails, the KDF is wrong and every imported cookie will be garbage.
let key = derive_key("peanuts");
assert_eq!(
key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
"d9a09d499b4e1b7461f28e67972c6dbd"
);
}
#[test]
fn a_round_trip_decrypts_and_strips_the_host_hash() {
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
let key = derive_key("peanuts");
let host = "example.com";
let mut plain = Sha256::digest(host.as_bytes()).to_vec();
plain.extend_from_slice(b"session=abc123");
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
.encrypt_padded_vec_mut::<Pkcs7>(&plain);
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&ct);
assert_eq!(decrypt_value(&stored, &key, host).unwrap(), "session=abc123");
}
#[test]
fn a_value_without_the_hash_prefix_survives_intact() {
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
let key = derive_key("peanuts");
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
.encrypt_padded_vec_mut::<Pkcs7>(b"plain=1");
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&ct);
assert_eq!(decrypt_value(&stored, &key, "example.com").unwrap(), "plain=1");
}
#[test]
fn unversioned_values_are_refused() {
let key = derive_key("peanuts");
assert!(decrypt_value(b"not encrypted", &key, "example.com").is_none());
assert!(decrypt_value(b"", &key, "example.com").is_none());
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Putting imported cookies into WebKit's shared jar.
//!
//! Not `document.cookie`: the cookies that carry a session are almost always
//! HttpOnly, which is exactly the set script cannot write. Only
//! `WKHTTPCookieStore` can, so this drops to Objective-C.
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_foundation::{
MainThreadMarker, NSDate, NSDictionary, NSHTTPCookie, NSString, NSHTTPCookieDomain,
NSHTTPCookieExpires, NSHTTPCookieName, NSHTTPCookiePath, NSHTTPCookieSecure,
NSHTTPCookieValue,
};
use objc2_web_kit::WKWebsiteDataStore;
use tauri::AppHandle;
use super::Cookie;
fn build(c: &Cookie) -> Option<Retained<NSHTTPCookie>> {
let name = NSString::from_str(&c.name);
let value = NSString::from_str(&c.value);
let domain = NSString::from_str(&c.domain);
let path = NSString::from_str(if c.path.is_empty() { "/" } else { &c.path });
let mut keys: Vec<&NSString> = Vec::with_capacity(6);
let mut values: Vec<&AnyObject> = Vec::with_capacity(6);
unsafe {
keys.push(NSHTTPCookieName);
values.push(&*(Retained::as_ptr(&name) as *const AnyObject));
keys.push(NSHTTPCookieValue);
values.push(&*(Retained::as_ptr(&value) as *const AnyObject));
keys.push(NSHTTPCookieDomain);
values.push(&*(Retained::as_ptr(&domain) as *const AnyObject));
keys.push(NSHTTPCookiePath);
values.push(&*(Retained::as_ptr(&path) as *const AnyObject));
}
// Any non-nil value means secure; the key's absence means it is not.
let yes = NSString::from_str("TRUE");
if c.secure {
unsafe {
keys.push(NSHTTPCookieSecure);
values.push(&*(Retained::as_ptr(&yes) as *const AnyObject));
}
}
// Omitting the key is what makes a session cookie, which is the right
// shape for the ones that matter most here.
let expires = c.expires.map(|s| NSDate::dateWithTimeIntervalSince1970(s as f64));
if let Some(d) = &expires {
unsafe {
keys.push(NSHTTPCookieExpires);
values.push(&*(Retained::as_ptr(d) as *const AnyObject));
}
}
let props: Retained<NSDictionary<NSString, AnyObject>> =
NSDictionary::from_slices(&keys, &values);
unsafe { NSHTTPCookie::cookieWithProperties(std::mem::transmute(&*props)) }
}
/// Writes every cookie into the shared store the app's webviews read from.
///
/// Returns how many were accepted. Runs on the main thread because WebKit
/// insists, and blocks until done so the caller can report a real number.
pub fn install(app: &AppHandle, cookies: Vec<Cookie>) -> Result<usize, String> {
let (tx, rx) = std::sync::mpsc::channel();
app.run_on_main_thread(move || {
let Some(mtm) = MainThreadMarker::new() else {
let _ = tx.send(Err("not on the main thread".to_string()));
return;
};
let store = unsafe { WKWebsiteDataStore::defaultDataStore(mtm).httpCookieStore() };
let mut n = 0usize;
for c in &cookies {
if let Some(cookie) = build(c) {
unsafe { store.setCookie_completionHandler(&cookie, None) };
n += 1;
}
}
let _ = tx.send(Ok(n));
})
.map_err(|e| e.to_string())?;
rx.recv().map_err(|e| e.to_string())?
}
+28 -13
View File
@@ -40,6 +40,9 @@ pub struct Browser {
pub struct PairResult {
pub imported: usize,
pub domains: usize,
/// The hosts a session actually came across for. Shown because "4 domains"
/// does not tell you which tool is still going to ask you to sign in.
pub domain_names: Vec<String>,
/// Non-fatal problems worth showing: a profile that would not open, a
/// browser whose format is not supported.
pub warnings: Vec<String>,
@@ -64,25 +67,37 @@ pub fn chromium_browsers() -> Vec<(&'static str, &'static str, std::path::PathBu
]
}
/// The browsers this machine actually has, for the Settings picker.
/// When a cookie store was last written, as a stand-in for "last used".
fn last_used(db: Option<std::path::PathBuf>) -> Option<std::time::SystemTime> {
db.and_then(|p| p.metadata().ok()).and_then(|m| m.modified().ok())
}
/// The browsers this machine actually has, most recently used first.
///
/// Ordered rather than alphabetical because the first entry becomes the
/// default choice, and someone with four Chromium browsers installed wants the
/// one they actually browse in — not whichever happens to sort first.
pub fn list() -> Vec<Browser> {
let mut out: Vec<Browser> = chromium_browsers()
let mut found: Vec<(Browser, Option<std::time::SystemTime>)> = chromium_browsers()
.into_iter()
.map(|(id, label, dir, _)| Browser {
id: id.to_string(),
label: label.to_string(),
available: chrome::find_cookie_db(&dir).is_some(),
.map(|(id, label, dir, _)| {
let db = chrome::find_cookie_db(&dir);
let used = last_used(db.clone());
(
Browser { id: id.to_string(), label: label.to_string(), available: db.is_some() },
used,
)
})
.collect();
out.push(Browser {
id: "firefox".into(),
label: "Firefox".into(),
available: firefox::find_cookie_db().is_some(),
});
found.push((
Browser { id: "firefox".into(), label: "Firefox".into(), available: firefox::find_cookie_db().is_some() },
last_used(firefox::find_cookie_db()),
));
out.retain(|b| b.available);
out
found.retain(|(b, _)| b.available);
found.sort_by(|a, b| b.1.cmp(&a.1));
found.into_iter().map(|(b, _)| b).collect()
}
/// Reads every cookie the named browser holds.
+358
View File
@@ -0,0 +1,358 @@
/**
* Runs inside every app's webview, before the page does.
*
* Four jobs: route links by intent, hide elements the user has chosen to be
* rid of, replace WKWebView's inert Notification API, and put a right-click
* menu on the page. Configuration arrives as `window.__WORKAPP`, written
* immediately above this by Rust.
*
* Everything talks back over a made-up URL scheme that `on_navigation` answers
* and cancels. Deliberately not Tauri IPC, which would mean handing the page
* the ability to call into the app.
*/
(function () {
if (window.__workAppReady) return;
window.__workAppReady = true;
var CFG = window.__WORKAPP || { scopes: [], hidden: [], name: '' };
var queue = [];
var sending = false;
/* Assignments to location are cancelled by the navigation delegate, but two
in the same tick would lose one — so they go out one at a time. */
function drain() {
if (sending || !queue.length) return;
sending = true;
var url = queue.shift();
try { window.location.href = url; } catch (e) {}
setTimeout(function () { sending = false; drain(); }, 0);
}
function send(kind, data) {
var q = Object.keys(data)
.map(function (k) { return k + '=' + encodeURIComponent(data[k]); })
.join('&');
queue.push('workapp-' + kind + ':/?' + q);
drain();
}
/* ------------------------------------------------------------ routing */
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 CFG.scopes.some(function (s) {
s = String(s).replace(/^www\./, '').toLowerCase();
return h === s || h.endsWith('.' + s);
});
} catch (e) { return false; }
}
document.addEventListener('click', function (e) {
if (picking) return;
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)) {
/* 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();
send('route', { u: 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; }
send('route', { u: u });
return null;
};
void nativeOpen;
/* ------------------------------------------------------ hiding elements */
var STYLE_ID = '__workapp_hidden';
/* The injected configuration is a snapshot from when this view was built, so
a selector added since would vanish on reload. The page's own storage
carries the current list across loads, and Rust rewrites it on every
navigation — so the rule is in place before the first paint rather than
arriving after the thing has already flashed on screen. */
function stored() {
try {
var raw = localStorage.getItem(STYLE_ID);
var list = raw ? JSON.parse(raw) : null;
return Array.isArray(list) ? list : null;
} catch (e) { return null; }
}
var hidden = stored() || (CFG.hidden || []).slice();
function applyHidden() {
var css = hidden.length
? hidden.join(',\n') + ' { display: none !important; }'
: '';
var el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement('style');
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
if (el.textContent !== css) el.textContent = css;
try { localStorage.setItem(STYLE_ID, JSON.stringify(hidden)); } catch (e) {}
}
/* Single-page apps rewrite <head>, which takes the rule with it. */
function watch() {
applyHidden();
try {
new MutationObserver(function () {
if (!document.getElementById(STYLE_ID)) applyHidden();
}).observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
}
/**
* A selector for one element, preferring things likely to survive a reload.
*
* An id wins outright. Otherwise it climbs, taking up to two stable-looking
* classes per level and falling back to position. Framework classes that are
* hashed or numeric are skipped, since those change on every deploy.
*/
function selectorFor(el) {
function idOf(n) {
var id = n.getAttribute && n.getAttribute('id');
return id && /^[A-Za-z][\w-]*$/.test(id) ? '#' + CSS.escape(id) : null;
}
if (idOf(el)) return idOf(el);
var parts = [];
var node = el;
while (node && node.nodeType === 1 && parts.length < 5) {
if (node.tagName === 'BODY' || node.tagName === 'HTML') break;
var id = idOf(node);
if (id) { parts.unshift(id); break; }
var part = node.tagName.toLowerCase();
var classes = (node.getAttribute('class') || '')
.trim()
.split(/\s+/)
.filter(function (c) {
return c && c.length < 32 && /^[A-Za-z_-][\w-]*$/.test(c) && !/\d{3,}/.test(c);
})
.slice(0, 2);
if (classes.length) {
part += '.' + classes.map(function (c) { return CSS.escape(c); }).join('.');
} else {
var p = node.parentElement;
if (p) {
var same = Array.prototype.filter.call(p.children, function (c) {
return c.tagName === node.tagName;
});
if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
}
}
parts.unshift(part);
node = node.parentElement;
}
return parts.join(' ') || el.tagName.toLowerCase();
}
function hide(el) {
if (!el || el === document.body || el === document.documentElement) return;
var sel = selectorFor(el);
try { if (!document.querySelector(sel)) return; } catch (e) { return; }
if (hidden.indexOf(sel) === -1) hidden.push(sel);
applyHidden();
send('hide', { s: sel });
}
/* --------------------------------------------------------- element picker */
var picking = false;
var marker = null;
var lastTarget = null;
function ensureMarker() {
if (marker) return marker;
marker = document.createElement('div');
marker.style.cssText = [
'position:fixed', 'z-index:2147483646', 'pointer-events:none',
'border:2px solid #0ea5e9', 'background:rgba(14,165,233,0.18)',
'border-radius:3px', 'transition:all 60ms ease-out'
].join(';');
document.documentElement.appendChild(marker);
return marker;
}
function onPickMove(e) {
var el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === marker) return;
lastTarget = el;
var r = el.getBoundingClientRect();
var m = ensureMarker();
m.style.top = r.top + 'px';
m.style.left = r.left + 'px';
m.style.width = r.width + 'px';
m.style.height = r.height + 'px';
}
function onPickKey(e) {
if (e.key === 'Escape') { e.preventDefault(); stopPicking(); }
/* Arrow up widens the selection to the parent, for when the thing you
want is the container rather than the text you can point at. */
if (e.key === 'ArrowUp' && lastTarget && lastTarget.parentElement) {
e.preventDefault();
lastTarget = lastTarget.parentElement;
var r = lastTarget.getBoundingClientRect();
var m = ensureMarker();
m.style.top = r.top + 'px'; m.style.left = r.left + 'px';
m.style.width = r.width + 'px'; m.style.height = r.height + 'px';
}
}
function onPickClick(e) {
e.preventDefault();
e.stopPropagation();
var el = lastTarget || document.elementFromPoint(e.clientX, e.clientY);
stopPicking();
hide(el);
}
function startPicking() {
if (picking) return;
picking = true;
ensureMarker().style.display = 'block';
document.addEventListener('mousemove', onPickMove, true);
document.addEventListener('click', onPickClick, true);
document.addEventListener('keydown', onPickKey, true);
}
function stopPicking() {
picking = false;
if (marker) marker.style.display = 'none';
document.removeEventListener('mousemove', onPickMove, true);
document.removeEventListener('click', onPickClick, true);
document.removeEventListener('keydown', onPickKey, true);
}
/* Reachable from the shell: starting the picker, and reporting back. */
window.__workAppPick = startPicking;
window.__workAppSend = send;
/* ------------------------------------------------------- context menu */
var menu = null;
function closeMenu() {
if (menu) { menu.remove(); menu = null; }
}
function openMenu(x, y, target) {
closeMenu();
menu = document.createElement('div');
menu.style.cssText = [
'position:fixed', 'z-index:2147483647', 'min-width:210px',
'padding:5px', 'border-radius:10px',
'background:#ffffff', 'color:#1e293b',
'border:1px solid #cbd5e1',
'box-shadow:0 12px 28px rgba(2,6,23,0.22)',
'font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'left:' + x + 'px', 'top:' + y + 'px'
].join(';');
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
menu.style.background = '#0f172b';
menu.style.color = '#e2e8f0';
menu.style.borderColor = '#334155';
}
[
['Hide this element', function () { hide(target); }],
['Pick an element to hide…', startPicking],
['Manage hidden elements…', function () { send('manage', { x: '1' }); }]
].forEach(function (item) {
var b = document.createElement('div');
b.textContent = item[0];
b.style.cssText = 'padding:6px 10px;border-radius:6px;cursor:pointer;white-space:nowrap';
b.addEventListener('mouseenter', function () { b.style.background = 'rgba(14,165,233,0.15)'; });
b.addEventListener('mouseleave', function () { b.style.background = 'transparent'; });
b.addEventListener('click', function (ev) {
ev.preventDefault(); ev.stopPropagation();
closeMenu();
item[1]();
}, true);
menu.appendChild(b);
});
document.documentElement.appendChild(menu);
/* Keep it on screen when the click was near an edge. */
var r = menu.getBoundingClientRect();
if (r.right > innerWidth) menu.style.left = Math.max(4, innerWidth - r.width - 6) + 'px';
if (r.bottom > innerHeight) menu.style.top = Math.max(4, innerHeight - r.height - 6) + 'px';
}
document.addEventListener('contextmenu', function (e) {
e.preventDefault();
openMenu(e.clientX, e.clientY, e.target);
}, true);
document.addEventListener('mousedown', function (e) {
if (menu && !menu.contains(e.target)) closeMenu();
}, true);
/* ------------------------------------------------------- notifications */
/* WKWebView *does* define Notification — it just does nothing. Constructing
one throws no error and shows no banner, so Gmail believes it notified you
and you never hear about it. The native one is therefore replaced outright
rather than only filled in when missing. */
{
var WorkNotification = function (title, options) {
options = options || {};
this.title = title;
this.body = options.body || '';
send('notify', { t: String(title || ''), b: String(options.body || ''), a: CFG.name || '' });
};
WorkNotification.__work = true;
WorkNotification.permission = 'granted';
WorkNotification.requestPermission = function (cb) {
if (cb) cb('granted');
return Promise.resolve('granted');
};
WorkNotification.prototype.close = function () {};
WorkNotification.prototype.addEventListener = function () {};
WorkNotification.prototype.removeEventListener = function () {};
try {
Object.defineProperty(window, 'Notification', {
value: WorkNotification, writable: true, configurable: true
});
} catch (e) { window.Notification = WorkNotification; }
}
/* ------------------------------------------------------------- start */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', watch);
applyHidden();
} else {
watch();
}
})();
+12
View File
@@ -1,6 +1,7 @@
pub mod commands;
pub mod config;
pub mod routing;
pub mod cookies;
pub mod webviews;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
@@ -50,9 +51,14 @@ pub fn run() {
tauri::Builder::default()
.menu(build_menu)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
let state = commands::build_state(&app.handle().clone())?;
app.manage(state);
// Asked for at startup rather than at the first notification, so
// the prompt does not arrive attached to someone else's message.
commands::ensure_notification_permission(&app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -75,6 +81,12 @@ pub fn run() {
commands::delete_group,
commands::set_nav_collapsed,
commands::set_theme,
commands::set_hidden,
commands::pick_hidden,
commands::test_notification,
commands::notification_status,
commands::list_browsers,
commands::pair_browser,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+6
View File
@@ -65,6 +65,12 @@ pub fn host_matches(host: &str, scope: &str) -> bool {
host == scope || host.ends_with(&format!(".{scope}"))
}
/// The sign-in hosts, for the cookie import — a session for a tool is no use
/// without the identity provider's cookie that vouches for it.
pub fn identity_providers() -> &'static [&'static str] {
IDENTITY_PROVIDERS
}
fn is_identity_provider(host: &str) -> bool {
IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p))
}
+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"));
}
}