diff --git a/README.md b/README.md index a40f2e5..a471f36 100644 --- a/README.md +++ b/README.md @@ -39,24 +39,27 @@ sentinel, because a strict navigation filter breaks every OAuth chain the moment through `accounts.google.com`. **Sessions persist.** Each webview keeps its cookies across restarts, so you log into a -tool once. Every app also claims a real Chrome user agent by default, because Google +tool once and it sticks. Every app also claims a real Chrome user agent by default, because Google refuses logins from anything it identifies as an embedded webview. -**Pairing imports a browser's cookies.** Settings lists the browsers installed, most -recently used first, and pairing decrypts that browser's cookie store — PBKDF2-HMAC-SHA1 -against its Keychain key, then AES-128-CBC — and injects the result into WebKit's shared -jar. Only cookies for the apps on your list and their sign-in hosts are read; everything -else is dropped before anything is written. macOS asks for Keychain permission the first -time, which is the consent gate and cannot be skipped. -Google and Microsoft increasingly tie a session to the browser that created it, so those -may still ask you to sign in once. After that the persistent jar keeps it. +**Notifications work, and clicking one lands on the message.** WKWebView *defines* +`window.Notification` but it is inert: constructing one throws nothing and shows nothing, +so a page believes it notified you and you never hear about it. It is replaced with a shim +that forwards to a real macOS notification carrying the app's name. -**Notifications work, with one gap.** WKWebView *defines* `window.Notification` but it is -inert: constructing one throws nothing and shows nothing, so a page believes it notified -you and you never hear about it. It is replaced with a shim that forwards to a real macOS -notification carrying the app's name. Service-worker push in the background is not -covered — only notifications a page raises while it is open. +Clicking the banner switches to that app and then runs **the page's own click handler**, +which is the only thing that knows which message it was about — Gmail opens the thread, +Chat opens the conversation. This is why notifications are raised directly through +`mac-notification-sys` rather than Tauri's notification plugin: the plugin has no way to +report that a notification was clicked. + +Service-worker push in the background is not covered — only notifications a page raises +while it is open. + +**Zoom is per app.** ⌘+ and ⌘− step a ladder that always returns to exactly 100% with ⌘0, +and each app remembers its own size. The shortcuts are menu-bar accelerators rather than a +key listener, because they have to work while a remote page has focus. **Anything on a page can be hidden.** Right-click it and choose *Hide this element*, or use the eye button in the nav to point at one (arrow-up widens the selection to the diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index 018d570..2d11e08 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -21,7 +21,7 @@ Settled during brainstorming. Not open questions. |---|---|---| | Shell | Tauri 2.11 · React 19 · Vite 7 · Tailwind 4 · TypeScript | Matches FlightTube, the reference app on this machine | | App rendering | One child webview per app (`add_child`) | Iframes are impossible: Google, Microsoft and most SaaS send `X-Frame-Options: DENY` | -| Sessions | Persistent per-app cookie jar, plus opt-in import from a paired browser | The jar is mandatory either way; the import only saves first logins | +| Sessions | Persistent per-app cookie jar | You sign in once per tool and it sticks | | Link routing | Injected JS click interceptor; permissive `on_navigation` | A strict navigation filter breaks every OAuth redirect chain | | Tab memory | Every app is a live webview, hidden when inactive | Keeps scroll position, drafts and timers across switches | | Collapsed nav | ~52px icon rail | Still clickable when collapsed | @@ -32,9 +32,11 @@ Settled during brainstorming. Not open questions. - **Multi-webview is `unstable` in Tauri.** The API can change between minor versions, so `tauri` is pinned to `=2.11.5`. -- **Device-bound sessions defeat cookie import.** Google and Microsoft increasingly bind a - session to the browser that created it. Imported cookies will sometimes be rejected and - the tool asks for a real login once. The persistent jar keeps it from then on. +- **Browser cookie import was built, measured, and removed.** 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 both simpler and more reliable, so + the whole import path was deleted rather than kept as a feature that mostly fails. - **Bounds sync trails layout by a frame.** A native webview is positioned from measurements the shell reports, so during a window resize it can lag. Every app in this class does. - **The Chrome user agent is a lie.** Sites that sniff deeply may behave oddly. It is on by @@ -137,30 +139,6 @@ for the top bar. Redirects, meta-refreshes and OAuth bounces are never blocked. `window.open` cannot escape into a stray window. A `_blank` link within the same app navigates that app's webview in place. -### Browser pairing - -Settings lists the browsers actually installed. Pairing with a Chromium browser: - -1. Copy the profile's `Cookies` SQLite file — Chrome holds a lock on the original — and - read it with `rusqlite`. -2. Read the `Chrome Safe Storage` key from the login Keychain, derive AES-128 with - PBKDF2-HMAC-SHA1 (salt `saltysalt`, 1003 iterations), and decrypt the `v10` values. -3. Keep only cookies whose domain matches a configured app's scope or the identity-provider - list. Nothing else is read out of the browser. -4. Inject them into `WKHTTPCookieStore`. - -**Verified on the machine.** Pairing against Arc imported 43 cookies across 9 domains, -and a probe from inside the page then read back `host=example.odoo.com -names=tz,cids,frontend_lang visible=3` — `tz` existing only in Arc's store, which is what -proves the import landed rather than merely being handed over. `setCookie` is -fire-and-forget, so the import's own count could never have shown this. - -The sites still presented sign-in pages. That is the documented limitation, not a broken -import: the cookies are in the store and visible to the page, and the session is being -refused at the far end. Settings keeps the probe as **Check cookies**, so the same -question can be answered again without guessing. - -Pairing is a button, not a background job. Cookies rotate; a silent task that periodically reaches into the Keychain is worse than one the user presses when something logs them out. ## UI @@ -201,8 +179,20 @@ Verified on the machine: `permission: Granted · direct: raised · page: api=fun was the reading that showed the native API existed and the shim had therefore never installed. The shim now replaces it unconditionally. +A click on the banner switches to the app that raised it and then runs the page's own +click handler, which is the only thing that knows which message the notification was +about. This is why notifications are raised through `mac-notification-sys` rather than +Tauri's notification plugin: the plugin cannot report a click. + Service-worker push is **not** covered — only notifications a page raises while it is open. +## Zoom + +Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a +repeated multiplier happened to leave behind. The shortcuts are menu-bar accelerators, not +a key listener: 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. + ## Hiding elements Anything on a page can be right-clicked and hidden. The injected script owns a stylesheet diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2fe31dd..bf00f3e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b63fd1b..00288e9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index af2e212..c81e532 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { - let cfg = self.config.lock().unwrap(); - let mut hosts: Vec = 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 { @@ -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) { + let state = app.state::(); + 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 { + { + 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::(); + 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::().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::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 { - 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 = 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); } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index a6d863d..97ef2fb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -33,10 +33,18 @@ pub struct App { /// the thing you never want to see again. #[serde(default)] pub hidden: Vec, + /// 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 { 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, - #[serde(default)] - pub last_paired_at: Option, } 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/")); } } diff --git a/src-tauri/src/cookies/chrome.rs b/src-tauri/src/cookies/chrome.rs deleted file mode 100644 index dfd41ed..0000000 --- a/src-tauri/src/cookies/chrome.rs +++ /dev/null @@ -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; - -/// 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 { - 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 /Cookies, and newer builds at - // /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 { - 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 { - 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::(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 { - if encrypted.len() < 4 || &encrypted[..3] != b"v10" { - return None; - } - let plain = Decryptor::new(key.into(), &IV.into()) - .decrypt_padded_vec_mut::(&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, 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(©, 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 = 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 = 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(©)); - 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 { - 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::(), - "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::::new(&key.into(), &IV.into()) - .encrypt_padded_vec_mut::(&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::::new(&key.into(), &IV.into()) - .encrypt_padded_vec_mut::(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()); - } -} diff --git a/src-tauri/src/cookies/firefox.rs b/src-tauri/src/cookies/firefox.rs deleted file mode 100644 index fd822a1..0000000 --- a/src-tauri/src/cookies/firefox.rs +++ /dev/null @@ -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 { - 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, 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(©, 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()) -} diff --git a/src-tauri/src/cookies/inject.rs b/src-tauri/src/cookies/inject.rs deleted file mode 100644 index 09a6789..0000000 --- a/src-tauri/src/cookies/inject.rs +++ /dev/null @@ -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> { - 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::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) -> Result { - 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())? -} diff --git a/src-tauri/src/cookies/mod.rs b/src-tauri/src/cookies/mod.rs deleted file mode 100644 index f702b11..0000000 --- a/src-tauri/src/cookies/mod.rs +++ /dev/null @@ -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, - 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, - /// Non-fatal problems worth showing: a profile that would not open, a - /// browser whose format is not supported. - pub warnings: Vec, -} - -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) -> Option { - 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 { - let mut found: Vec<(Browser, Option)> = 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, 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, scopes: &[String]) -> Vec { - 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()); - } -} diff --git a/src-tauri/src/inject.js b/src-tauri/src/inject.js index 2d7a7f8..dc7eb09 100644 --- a/src-tauri/src/inject.js +++ b/src-tauri/src/inject.js @@ -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') { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5972779..9561532 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::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"); diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index bdc164d..3511adb 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -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::(); - *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::(); + 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); diff --git a/src/App.tsx b/src/App.tsx index 3af2709..cc100f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import Nav from "./components/Nav"; import Settings from "./components/Settings"; import { BTN_PRIMARY } from "./components/ui"; import { useAppearance, type Theme } from "./hooks/useAppearance"; -import type { Config, Group, HiddenEvent, SwitchEvent } from "./types"; +import type { Config, Group, HiddenEvent, NotificationClick, SwitchEvent } from "./types"; export default function App() { const [config, setConfig] = useState(null); @@ -89,6 +89,20 @@ export default function App() { setFocusHidden(e.payload); setSettingsOpen(true); }), + // A macOS banner was clicked. Switch to the app that raised it, then let + // that page's own handler run — it is the only thing that knows which + // message the notification was about. + listen("notification-clicked", async (e) => { + await api.focusWindow(); + setSettingsOpen(false); + setActiveId(e.payload.appId); + await api.setActive(e.payload.appId); + await api.notificationClick(e.payload.appId, e.payload.notificationId); + }), + // Zoom changed from the menu bar; keep Settings' sliders honest. + listen<[string, number]>("zoom-changed", () => { + void api.getConfig().then(setConfig); + }), ]; return () => { unlisten.forEach((p) => p.then((f) => f())); diff --git a/src/api.ts b/src/api.ts index e8407f5..018719c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,7 @@ /** Every call into Rust, in one place. */ import { invoke } from "@tauri-apps/api/core"; -import type { Browser, Config, Group, PairResult, WorkApp } from "./types"; +import type { Config, Group, WorkApp } from "./types"; export const getConfig = () => invoke("get_config"); export const bootstrap = () => invoke("bootstrap"); @@ -40,11 +40,12 @@ export const setHidden = (appId: string, hidden: string[]) => invoke("set_hidden", { appId, hidden }); export const pickHidden = (appId: string) => invoke("pick_hidden", { appId }); -export const listBrowsers = () => invoke("list_browsers"); -export const pairBrowser = (browser: string) => - invoke("pair_browser", { browser }); export const testNotification = (appId: string) => invoke("test_notification", { appId }); export const notificationStatus = () => invoke("notification_status"); -export const probeCookies = (appId: string) => invoke("probe_cookies", { appId }); -export const cookieProbe = () => invoke("cookie_probe"); + +export const setZoom = (appId: string, zoom: number) => + invoke("set_zoom", { appId, zoom }); +export const notificationClick = (appId: string, notificationId: string) => + invoke("notification_click", { appId, notificationId }); +export const focusWindow = () => invoke("focus_window"); diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 1938e68..07cd90b 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -137,14 +137,6 @@ export default function Nav({ > {app.name} - {app.hidden.length > 0 && ( - - )} ); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 64fa6f3..b21ff8e 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import * as api from "../api"; import type { Theme } from "../hooks/useAppearance"; -import type { Browser, Config, Group, PairResult, WorkApp } from "../types"; +import type { Config, Group, WorkApp } from "../types"; import { Trash } from "./icons"; import { BTN, @@ -17,7 +17,6 @@ import { SUBPANEL, SectionHeading, Segmented, - Spinner, } from "./ui"; interface Props { @@ -42,12 +41,7 @@ export default function Settings({ const [error, setError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); - const [browsers, setBrowsers] = useState([]); - const [browser, setBrowser] = useState(""); - const [pairing, setPairing] = useState(false); const [notifyStatus, setNotifyStatus] = useState(null); - const [cookieStatus, setCookieStatus] = useState(null); - const [paired, setPaired] = useState(null); const groups = [...config.groups].sort((a, b) => a.order - b.order); @@ -58,12 +52,6 @@ export default function Settings({ (a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order, ); - useEffect(() => { - api.listBrowsers().then((b) => { - setBrowsers(b); - setBrowser(config.settings.pairedBrowser ?? b[0]?.id ?? ""); - }); - }, [config.settings.pairedBrowser]); useEffect(() => { if (!focusHiddenFor) return; @@ -95,20 +83,6 @@ export default function Settings({ const patch = (app: WorkApp, fields: Partial) => run(() => api.updateApp({ ...app, ...fields })); - const pair = async () => { - if (!browser) return; - setPairing(true); - setPaired(null); - try { - setPaired(await api.pairBrowser(browser)); - onConfig(await api.getConfig()); - setError(null); - } catch (e) { - setError(String(e)); - } finally { - setPairing(false); - } - }; const withHidden = orderedApps.filter((a) => a.hidden.length > 0); @@ -247,75 +221,38 @@ export default function Settings({

- {/* -------------------------------------------------- pairing */} + {/* ------------------------------------------------------ zoom */}
- Browser pairing - {browsers.length === 0 ? ( -

No browser with a readable cookie store was found.

- ) : ( - <> -
- - - + Zoom +
+ {orderedApps.map((app) => ( +
+ + {app.name} + + run(() => api.setZoom(app.id, Number(e.target.value) / 100)) + } + className="min-w-0 flex-1 cursor-pointer accent-sky-500" + /> + + {Math.round((app.zoom ?? 1) * 100)}% +
- - {cookieStatus && ( -

{cookieStatus}

- )} - - {config.settings.lastPairedAt && !paired && ( -

Last paired {config.settings.lastPairedAt}.

- )} - - {paired && ( -
-

- Imported {paired.imported} cookie{paired.imported === 1 ? "" : "s"} across{" "} - {paired.domains} domain{paired.domains === 1 ? "" : "s"}. -

- {paired.domainNames.length > 0 && ( -

- {paired.domainNames.join(" ")} -

- )} - {paired.warnings.map((w, i) => ( -

{w}

- ))} -
- )} - -

- Only cookies for the apps above and their sign-in hosts are read — nothing - else leaves the browser. macOS asks for Keychain permission the first time. - Google and Microsoft tie sessions to the browser that made them, so those two - may still ask you to sign in once; after that it sticks. -

- - )} + ))} +
+

+ ⌘+ and ⌘− zoom the app you are in; ⌘0 puts it back to 100%. Each app keeps + its own size. +

{/* --------------------------------------------- notifications */} @@ -335,6 +272,7 @@ export default function Settings({ > Check permission + Clicking one opens the message it is about. {notifyStatus && (

{notifyStatus}

diff --git a/src/types.ts b/src/types.ts index fa9cae5..d8761f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,8 @@ export interface WorkApp { userAgent: string | null; /** CSS selectors this app hides on every page. */ hidden: string[]; + /** Page zoom, remembered per app. */ + zoom: number; order: number; } @@ -20,8 +22,6 @@ export interface Group { export interface Settings { navCollapsed: boolean; theme: "system" | "light" | "dark"; - pairedBrowser: string | null; - lastPairedAt: string | null; } export interface Config { @@ -42,21 +42,16 @@ export interface UrlEvent { url: string; } -export interface Browser { - id: string; - label: string; - available: boolean; -} -export interface PairResult { - imported: number; - domains: number; - domainNames: string[]; - warnings: string[]; -} /** Rust recorded a selector the user right-clicked away. */ export interface HiddenEvent { appId: string; selector: string; } + +/** The user clicked a macOS banner an app raised. */ +export interface NotificationClick { + appId: string; + notificationId: string; +}