Drop cookie import; click-through notifications; per-app zoom

The cookie import is gone. It worked mechanically - 43 cookies decrypted
from Arc and verifiably visible to the page - but Google, Microsoft and
Odoo all refused the imported sessions, because each binds a session to
the browser that created it. Signing in once inside the app is simpler
and actually works, so the whole path is deleted rather than kept as a
feature that mostly fails. That takes rusqlite, aes, cbc, pbkdf2, hmac,
sha1 and sha2 out of the build with it.

Notifications are now raised through mac-notification-sys rather than
Tauri's notification plugin, because the plugin cannot report that one
was clicked. A click switches to the app that raised it and then runs
the page's own click handler - the only thing that knows which message
the notification was about.

Zoom is per app, on a fixed ladder so Cmd+0 returns to exactly 100%.
The shortcuts are menu-bar accelerators rather than a key listener,
since the keystroke has to work while a remote page has focus.

The hidden-element count is off the nav rows.
This commit is contained in:
2026-09-01 12:53:52 +02:00
parent f057268103
commit 3525d454bf
18 changed files with 416 additions and 1073 deletions
+1 -181
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -275,15 +264,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -430,15 +410,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.4.4"
@@ -494,16 +465,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -769,7 +730,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -999,18 +959,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1482,32 +1430,11 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
@@ -1533,15 +1460,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -1810,16 +1728,6 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -2073,17 +1981,6 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2596,16 +2493,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3016,31 +2903,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3329,17 +3191,6 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3449,18 +3300,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3497,12 +3336,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
@@ -4404,12 +4237,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5041,18 +4868,11 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
name = "work-app"
version = "0.1.0"
dependencies = [
"aes",
"cbc",
"hmac",
"mac-notification-sys",
"objc2",
"objc2-foundation",
"objc2-web-kit",
"pbkdf2",
"rusqlite",
"serde",
"serde_json",
"sha1",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-notification",
+4 -11
View File
@@ -23,16 +23,9 @@ tokio = { version = "1", features = ["time"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
# Browser cookie import.
rusqlite = { version = "0.40.2", features = ["bundled"] }
aes = "0.8"
cbc = { version = "0.1", features = ["alloc"] }
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = ["NSHTTPCookie", "NSString", "NSDictionary", "NSArray", "NSDate", "NSValue", "NSURL"] }
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKWebsiteDataStore", "WKHTTPCookieStore"] }
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences"] }
# Notifications are raised here rather than through the plugin, which offers no
# way to learn that one was clicked.
mac-notification-sys = "0.6"
+109 -117
View File
@@ -4,7 +4,7 @@
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, State};
use tauri::{AppHandle, Emitter, Manager, State};
use url::Url;
use crate::config::{self, App, Config, Group};
@@ -51,15 +51,6 @@ impl AppState {
}
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> {
@@ -243,6 +234,7 @@ pub fn add_app(
group_id,
user_agent: None,
hidden: Vec::new(),
zoom: 1.0,
order,
};
cfg.apps.push(new.clone());
@@ -501,116 +493,106 @@ pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string())
}
/// Asks the page which cookies it can actually see.
// ----------------------------------------------------------------- zoom
/// The zoom ladder, so a keystroke lands on a sensible size rather than
/// drifting by a multiplier that never returns to exactly 100%.
const ZOOM_STEPS: [f64; 13] = [
0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0,
];
fn step_zoom(current: f64, direction: i32) -> f64 {
// The nearest rung, so a hand-edited value still moves somewhere sane.
let idx = ZOOM_STEPS
.iter()
.enumerate()
.min_by(|a, b| {
(a.1 - current).abs().partial_cmp(&(b.1 - current).abs()).unwrap()
})
.map(|(i, _)| i as i32)
.unwrap_or(5);
let next = (idx + direction).clamp(0, ZOOM_STEPS.len() as i32 - 1);
ZOOM_STEPS[next as usize]
}
/// Applies a zoom change to whichever app is showing, and remembers it.
///
/// `setCookie` is fire-and-forget, so the import's count is what was handed to
/// WebKit, not what WebKit kept. This reads the other end. HttpOnly cookies are
/// invisible to script by design, so the answer is a floor, not a total — but a
/// zero here means the injection never landed at all.
/// Per app, because a dense ERP and a mail client do not want the same size.
pub fn adjust_zoom(app: &AppHandle, direction: Option<i32>) {
let state = app.state::<AppState>();
let Some(id) = state.active.lock().unwrap().clone() else { return };
let zoom = {
let mut cfg = state.config.lock().unwrap();
let Some(target) = cfg.apps.iter_mut().find(|a| a.id == id) else { return };
target.zoom = match direction {
Some(d) => step_zoom(target.zoom, d),
None => 1.0,
};
target.zoom
};
let _ = state.persist();
if let Some(wv) = app.get_webview(&webviews::label_for(&id)) {
let _ = wv.set_zoom(zoom);
}
let _ = app.emit("zoom-changed", (id, zoom));
}
#[tauri::command]
pub fn probe_cookies(app_id: String, app: AppHandle) -> Result<(), String> {
pub fn set_zoom(app_id: String, zoom: f64, 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.zoom = zoom.clamp(0.25, 5.0);
}
state.persist()?;
if let Some(wv) = app.get_webview(&webviews::label_for(&app_id)) {
let _ = wv.set_zoom(zoom);
}
Ok(state.cfg())
}
/// Reloads whichever app is showing, for the menu bar's Reload item.
pub fn reload_active(app: &AppHandle) {
let state = app.state::<AppState>();
let Some(id) = state.active.lock().unwrap().clone() else { return };
if let Some(wv) = app.get_webview(&webviews::label_for(&id)) {
let _ = wv.eval("location.reload()");
}
}
// -------------------------------------------------- notification clicks
/// Runs the page's own click handler for a notification it raised.
///
/// This is what makes a click land on the message rather than merely on the
/// app: Gmail's handler knows which thread it was about, and this app does not
/// and should not.
#[tauri::command]
pub fn notification_click(app_id: String, notification_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(
r#"(function () {
var names = document.cookie
? document.cookie.split(';').map(function (c) { return c.split('=')[0].trim(); })
: [];
if (window.__workAppSend) {
window.__workAppSend('diag', {
host: location.hostname,
visible: String(names.length),
names: names.slice(0, 12).join(',')
});
}
})();"#,
)
let escaped = notification_id.replace('\\', "\\\\").replace('\'', "\\'");
wv.eval(&format!(
"window.__workAppNotifyClick && window.__workAppNotifyClick('{escaped}')"
))
.map_err(|e| e.to_string())
}
/// What the last cookie probe saw.
/// Brings the window forward when a notification is clicked.
#[tauri::command]
pub fn cookie_probe(app: AppHandle) -> String {
let diag = app.state::<AppState>().diag.lock().unwrap().clone();
if diag.is_empty() { "no answer from the page".into() } else { diag }
}
// ------------------------------------------------------- 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."
));
pub fn focus_window(app: AppHandle) {
if let Some(w) = app.get_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
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)]
@@ -618,12 +600,22 @@ 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));
fn zoom_steps_up_and_down_the_ladder() {
assert_eq!(step_zoom(1.0, 1), 1.1);
assert_eq!(step_zoom(1.0, -1), 0.9);
assert_eq!(step_zoom(1.25, 1), 1.5);
}
#[test]
fn zoom_stops_at_the_ends_rather_than_wrapping() {
assert_eq!(step_zoom(3.0, 1), 3.0);
assert_eq!(step_zoom(0.5, -1), 0.5);
}
#[test]
fn an_off_ladder_value_snaps_to_its_nearest_rung() {
// A hand-edited apps.json should still zoom somewhere sensible.
assert_eq!(step_zoom(1.04, 1), 1.1);
assert_eq!(step_zoom(1.04, -1), 0.9);
}
}
+11 -6
View File
@@ -33,10 +33,18 @@ pub struct App {
/// the thing you never want to see again.
#[serde(default)]
pub hidden: Vec<String>,
/// Page zoom, remembered per app: a dense ERP and a mail client do not
/// want the same size.
#[serde(default = "default_zoom")]
pub zoom: f64,
#[serde(default)]
pub order: i32,
}
fn default_zoom() -> f64 {
1.0
}
impl App {
pub fn scopes(&self) -> Vec<String> {
if self.scope.is_empty() {
@@ -69,10 +77,6 @@ pub struct Settings {
pub nav_collapsed: bool,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default)]
pub paired_browser: Option<String>,
#[serde(default)]
pub last_paired_at: Option<String>,
}
fn default_theme() -> String {
@@ -84,8 +88,6 @@ impl Default for Settings {
Self {
nav_collapsed: false,
theme: default_theme(),
paired_browser: None,
last_paired_at: None,
}
}
}
@@ -185,6 +187,7 @@ pub fn seed() -> Config {
group_id: Some(group.into()),
user_agent: None,
hidden: Vec::new(),
zoom: 1.0,
order,
};
Config {
@@ -291,6 +294,8 @@ mod tests {
assert_eq!(cfg.version, 1);
assert_eq!(cfg.settings.theme, "system");
assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]);
// A file written before zoom existed must not open every app at 0%.
assert_eq!(cfg.apps[0].zoom, 1.0);
assert!(cfg.apps[0].ua().contains("Chrome/"));
}
}
-240
View File
@@ -1,240 +0,0 @@
//! 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());
}
}
-49
View File
@@ -1,49 +0,0 @@
//! Firefox keeps `cookies.sqlite` unencrypted, which makes it the one browser
//! here that needs no Keychain access at all.
use rusqlite::{Connection, OpenFlags};
use super::Cookie;
pub fn find_cookie_db() -> Option<std::path::PathBuf> {
let root = super::home().join("Library/Application Support/Firefox/Profiles");
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
for entry in std::fs::read_dir(root).ok()?.flatten() {
let db = entry.path().join("cookies.sqlite");
if !db.exists() {
continue;
}
let modified = db.metadata().and_then(|m| m.modified()).ok()?;
if best.as_ref().is_none_or(|(t, _)| modified > *t) {
best = Some((modified, db));
}
}
best.map(|(_, p)| p)
}
pub fn read() -> Result<Vec<Cookie>, String> {
let db = find_cookie_db().ok_or("no Firefox profile with cookies")?;
let copy = super::chrome::copy_locked(&db)?;
let conn = Connection::open_with_flags(&copy, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT host, name, value, path, expiry, isSecure, isHttpOnly FROM moz_cookies")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| {
Ok(Cookie {
domain: r.get::<_, String>(0)?,
name: r.get::<_, String>(1)?,
value: r.get::<_, String>(2)?,
path: r.get::<_, String>(3)?,
expires: r.get::<_, i64>(4).ok().filter(|v| *v > 0),
secure: r.get::<_, i64>(5).unwrap_or(0) != 0,
http_only: r.get::<_, i64>(6).unwrap_or(0) != 0,
})
})
.map_err(|e| e.to_string())?;
Ok(rows.filter_map(Result::ok).collect())
}
-88
View File
@@ -1,88 +0,0 @@
//! 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())?
}
-162
View File
@@ -1,162 +0,0 @@
//! Importing a browser's cookies, so a tool you are already signed into in
//! Chrome does not ask again here.
//!
//! This is an accelerator, not the foundation. Each webview keeps its own
//! persistent jar regardless; the import only saves first logins, and it is
//! expected to fail against services that bind a session to the browser that
//! created it.
pub mod chrome;
pub mod firefox;
#[cfg(target_os = "macos")]
pub mod inject;
use serde::{Deserialize, Serialize};
/// One cookie, in the only shape the rest of the app cares about.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cookie {
pub domain: String,
pub name: String,
pub value: String,
pub path: String,
/// Unix seconds. `None` is a session cookie.
pub expires: Option<i64>,
pub secure: bool,
pub http_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Browser {
pub id: String,
pub label: String,
/// Whether a readable cookie store was actually found on disk.
pub available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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>,
}
fn home() -> std::path::PathBuf {
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default())
}
/// Chromium-family browsers, by profile directory and Keychain service name.
///
/// The Keychain entry is per-browser: Brave's key does not open Chrome's.
pub fn chromium_browsers() -> Vec<(&'static str, &'static str, std::path::PathBuf, &'static str)> {
let h = home();
vec![
("chrome", "Google Chrome", h.join("Library/Application Support/Google/Chrome"), "Chrome"),
("brave", "Brave", h.join("Library/Application Support/BraveSoftware/Brave-Browser"), "Brave"),
("edge", "Microsoft Edge", h.join("Library/Application Support/Microsoft Edge"), "Microsoft Edge"),
("vivaldi", "Vivaldi", h.join("Library/Application Support/Vivaldi"), "Vivaldi"),
("arc", "Arc", h.join("Library/Application Support/Arc/User Data"), "Arc"),
("chromium", "Chromium", h.join("Library/Application Support/Chromium"), "Chromium"),
]
}
/// 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 found: Vec<(Browser, Option<std::time::SystemTime>)> = chromium_browsers()
.into_iter()
.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();
found.push((
Browser { id: "firefox".into(), label: "Firefox".into(), available: firefox::find_cookie_db().is_some() },
last_used(firefox::find_cookie_db()),
));
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.
pub fn read_all(browser: &str) -> Result<Vec<Cookie>, String> {
if browser == "firefox" {
return firefox::read();
}
let (_, label, dir, service) = chromium_browsers()
.into_iter()
.find(|(id, ..)| *id == browser)
.ok_or_else(|| format!("{browser} is not a browser this can read"))?;
chrome::read(&dir, service).map_err(|e| format!("{label}: {e}"))
}
/// Keeps only what the configured apps and their sign-in hosts need.
///
/// The filter is the whole point: this reaches into a browser's cookie store,
/// and it should come back with the session for the tools on the list and
/// nothing else at all.
pub fn filter_to_scopes(cookies: Vec<Cookie>, scopes: &[String]) -> Vec<Cookie> {
cookies
.into_iter()
.filter(|c| {
let host = c.domain.trim_start_matches('.');
scopes.iter().any(|s| crate::routing::host_matches(host, s))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn c(domain: &str) -> Cookie {
Cookie {
domain: domain.into(),
name: "s".into(),
value: "1".into(),
path: "/".into(),
expires: None,
secure: true,
http_only: true,
}
}
#[test]
fn filter_keeps_scoped_hosts_and_their_subdomains() {
let got = filter_to_scopes(
vec![c("github.com"), c(".github.com"), c("gist.github.com"), c("example.net")],
&["github.com".to_string()],
);
assert_eq!(got.len(), 3);
}
#[test]
fn filter_drops_everything_unscoped() {
// A browser's cookie store holds the user's whole life. Only the apps
// on the list may come across.
let got = filter_to_scopes(vec![c("bank.example"), c("evilgithub.com")], &["github.com".into()]);
assert!(got.is_empty());
}
}
+98 -23
View File
@@ -2,7 +2,8 @@
* 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
* rid of, replace WKWebView's inert Notification API — carrying clicks back
* to the page that raised them — and put a right-click
* menu on the page. Configuration arrives as `window.__WORKAPP`, written
* immediately above this by Rust.
*
@@ -323,30 +324,104 @@
/* 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; }
rather than only filled in when missing.
Each one is kept so that clicking the macOS banner can run the page's own
click handler. That handler is the only thing that knows which message the
notification was about — this app does not, and should not have to. */
var notifications = {};
var notifySeq = 0;
function WorkNotification(title, options) {
options = options || {};
var self = this;
this.title = title;
this.body = options.body || '';
this.data = options.data;
this.tag = options.tag || '';
this.icon = options.icon || '';
this.onclick = null;
this.onclose = null;
this.onerror = null;
this.onshow = null;
this._listeners = { click: [], close: [], show: [], error: [] };
this.id = 'n' + (++notifySeq);
notifications[this.id] = this;
/* An app that raises hundreds in a session should not grow forever. */
var ids = Object.keys(notifications);
if (ids.length > 200) delete notifications[ids[0]];
send('notify', {
t: String(title == null ? '' : title),
b: String(this.body),
a: CFG.name || '',
id: this.id
});
setTimeout(function () { self._fire('show'); }, 0);
}
WorkNotification.prototype._fire = function (type) {
var ev;
try {
ev = new Event(type);
} catch (e) {
ev = { type: type };
}
try { Object.defineProperty(ev, 'target', { value: this }); } catch (e) {}
var handler = this['on' + type];
if (typeof handler === 'function') {
try { handler.call(this, ev); } catch (e) {}
}
(this._listeners[type] || []).forEach(function (fn) {
try { fn.call(this, ev); } catch (e) {}
}, this);
};
WorkNotification.prototype.close = function () {
delete notifications[this.id];
this._fire('close');
};
WorkNotification.prototype.addEventListener = function (type, fn) {
if (!this._listeners[type]) this._listeners[type] = [];
this._listeners[type].push(fn);
};
WorkNotification.prototype.removeEventListener = function (type, fn) {
var list = this._listeners[type];
if (!list) return;
var i = list.indexOf(fn);
if (i !== -1) list.splice(i, 1);
};
WorkNotification.prototype.dispatchEvent = function (ev) {
this._fire(ev && ev.type ? ev.type : 'click');
return true;
};
WorkNotification.__work = true;
WorkNotification.permission = 'granted';
WorkNotification.maxActions = 0;
WorkNotification.requestPermission = function (cb) {
if (cb) cb('granted');
return Promise.resolve('granted');
};
/* Called from Rust when the macOS banner is clicked. */
window.__workAppNotifyClick = function (id) {
var n = notifications[id];
if (!n) return false;
n._fire('click');
return true;
};
try {
Object.defineProperty(window, 'Notification', {
value: WorkNotification, writable: true, configurable: true
});
} catch (e) { window.Notification = WorkNotification; }
/* ------------------------------------------------------------- start */
if (document.readyState === 'loading') {
+28 -7
View File
@@ -1,10 +1,9 @@
pub mod commands;
pub mod config;
pub mod routing;
pub mod cookies;
pub mod webviews;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::Manager;
/// The macOS menu bar. Edit has no entry of its own, but its items live under
@@ -43,13 +42,36 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
],
)?;
Menu::with_items(app, &[&app_menu, &window_menu])
// Zoom lives in the menu bar rather than a key listener, because the
// keystroke has to work while an app's own webview has focus — and that
// webview is a remote page this app deliberately cannot script for input.
let view_menu = Submenu::with_items(
app,
"View",
true,
&[
&MenuItem::with_id(app, "zoom-in", "Zoom In", true, Some("CmdOrCtrl+="))?,
&MenuItem::with_id(app, "zoom-out", "Zoom Out", true, Some("CmdOrCtrl+-"))?,
&MenuItem::with_id(app, "zoom-reset", "Actual Size", true, Some("CmdOrCtrl+0"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?,
],
)?;
Menu::with_items(app, &[&app_menu, &view_menu, &window_menu])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.menu(build_menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"zoom-in" => commands::adjust_zoom(app, Some(1)),
"zoom-out" => commands::adjust_zoom(app, Some(-1)),
"zoom-reset" => commands::adjust_zoom(app, None),
"reload" => commands::reload_active(app),
_ => {}
})
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
@@ -81,14 +103,13 @@ pub fn run() {
commands::delete_group,
commands::set_nav_collapsed,
commands::set_theme,
commands::focus_window,
commands::notification_click,
commands::set_zoom,
commands::set_hidden,
commands::pick_hidden,
commands::test_notification,
commands::notification_status,
commands::probe_cookies,
commands::cookie_probe,
commands::list_browsers,
commands::pair_browser,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+68 -25
View File
@@ -43,6 +43,14 @@ pub struct UrlEvent {
pub url: String,
}
/// A notification the user clicked, and the page object that raised it.
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationClick {
pub app_id: String,
pub notification_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenEvent {
@@ -129,34 +137,11 @@ fn handle_sentinel(
}
"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}");
let notification_id = params.get("id").cloned().unwrap_or_default();
notify(&handle, &from, &app_name, &title, &body, notification_id);
}
// Reports what the page found, over the same channel a real
@@ -180,6 +165,58 @@ fn handle_sentinel(
});
}
/// Raises a macOS notification and waits, on its own thread, to see it clicked.
///
/// Not the notification plugin: that has no way to report a click, and a
/// notification you cannot click through to the message is barely a
/// notification. `send_notification` blocks until the user acts or it is
/// dismissed, which is why this gets a thread of its own.
fn notify(
handle: &AppHandle,
app_id: &str,
app_name: &str,
title: &str,
body: &str,
notification_id: String,
) {
// 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() { "Work".to_string() } else { app_name.to_string() };
let subtitle = title.to_string();
let message = body.to_string();
let handle = handle.clone();
let app_id = app_id.to_string();
std::thread::spawn(move || {
let state = handle.state::<crate::commands::AppState>();
let response = mac_notification_sys::send_notification(
&heading,
if subtitle.is_empty() { None } else { Some(&subtitle) },
&message,
None,
);
match response {
Ok(mac_notification_sys::NotificationResponse::Click) => {
*state.last_notification.lock().unwrap() =
format!("{heading} / {subtitle} → clicked");
let _ = handle.emit(
"notification-clicked",
NotificationClick { app_id, notification_id },
);
}
Ok(_) => {
*state.last_notification.lock().unwrap() =
format!("{heading} / {subtitle} → raised");
}
Err(e) => {
eprintln!("could not raise a notification: {e}");
*state.last_notification.lock().unwrap() = format!("failed: {e}");
}
}
});
}
/// 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
@@ -280,6 +317,10 @@ pub fn create(
enable_swipe_navigation(handle, &id);
if let Some(wv) = handle.get_webview(&label_for(&id)) {
let _ = wv.set_zoom(app.zoom);
}
// 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)) {
@@ -300,6 +341,7 @@ pub fn show_only(
if Some(app.id.as_str()) == app_id {
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.set_zoom(app.zoom);
let _ = wv.show();
} else {
let _ = wv.hide();
@@ -398,6 +440,7 @@ mod tests {
group_id: None,
user_agent: None,
hidden: vec![".ad".into()],
zoom: 1.0,
order: 0,
};
let s = script_for(&app);