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
+17 -14
View File
@@ -39,24 +39,27 @@ sentinel, because a strict navigation filter breaks every OAuth chain the moment
through `accounts.google.com`. through `accounts.google.com`.
**Sessions persist.** Each webview keeps its cookies across restarts, so you log into a **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. 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 **Notifications work, and clicking one lands on the message.** WKWebView *defines*
may still ask you to sign in once. After that the persistent jar keeps it. `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 Clicking the banner switches to that app and then runs **the page's own click handler**,
inert: constructing one throws nothing and shows nothing, so a page believes it notified which is the only thing that knows which message it was about — Gmail opens the thread,
you and you never hear about it. It is replaced with a shim that forwards to a real macOS Chat opens the conversation. This is why notifications are raised directly through
notification carrying the app's name. Service-worker push in the background is not `mac-notification-sys` rather than Tauri's notification plugin: the plugin has no way to
covered — only notifications a page raises while it is open. 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 **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 use the eye button in the nav to point at one (arrow-up widens the selection to the
@@ -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 | | 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` | | 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 | | 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 | | 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 | | 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 - **Multi-webview is `unstable` in Tauri.** The API can change between minor versions, so
`tauri` is pinned to `=2.11.5`. `tauri` is pinned to `=2.11.5`.
- **Device-bound sessions defeat cookie import.** Google and Microsoft increasingly bind a - **Browser cookie import was built, measured, and removed.** It worked mechanically —
session to the browser that created it. Imported cookies will sometimes be rejected and 43 cookies decrypted from Arc and verifiably visible to the page — but Google, Microsoft
the tool asks for a real login once. The persistent jar keeps it from then on. 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 - **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 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 - **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 `window.open` cannot escape into a stray window. A `_blank` link within the same app
navigates that app's webview in place. 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. reaches into the Keychain is worse than one the user presses when something logs them out.
## UI ## 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 was the reading that showed the native API existed and the shim had therefore never
installed. The shim now replaces it unconditionally. 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. 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 ## Hiding elements
Anything on a page can be right-clicked and hidden. The injected script owns a stylesheet Anything on a page can be right-clicked and hidden. The injected script owns a stylesheet
+1 -181
View File
@@ -8,17 +8,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@@ -275,15 +264,6 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "block2" name = "block2"
version = "0.6.2" version = "0.6.2"
@@ -430,15 +410,6 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "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]] [[package]]
name = "cc" name = "cc"
version = "1.4.4" version = "1.4.4"
@@ -494,16 +465,6 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "combine" name = "combine"
version = "4.6.8" version = "4.6.8"
@@ -769,7 +730,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -999,18 +959,6 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -1482,32 +1430,11 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "heck" name = "heck"
@@ -1533,15 +1460,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.38.0" version = "0.38.0"
@@ -1810,16 +1728,6 @@ dependencies = [
"cfb", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.1" version = "2.12.1"
@@ -2073,17 +1981,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -2596,16 +2493,6 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -3016,31 +2903,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3329,17 +3191,6 @@ dependencies = [
"stable_deref_trait", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -3449,18 +3300,6 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -3497,12 +3336,6 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.8" version = "1.0.8"
@@ -4404,12 +4237,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@@ -5041,18 +4868,11 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
name = "work-app" name = "work-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes", "mac-notification-sys",
"cbc",
"hmac",
"objc2", "objc2",
"objc2-foundation",
"objc2-web-kit", "objc2-web-kit",
"pbkdf2",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"sha1",
"sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-notification", "tauri-plugin-notification",
+4 -11
View File
@@ -23,16 +23,9 @@ tokio = { version = "1", features = ["time"] }
url = "2" url = "2"
uuid = { version = "1", features = ["v4"] } 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] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6" 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"] }
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKWebsiteDataStore", "WKHTTPCookieStore"] } # 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::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use url::Url; use url::Url;
use crate::config::{self, App, Config, Group}; use crate::config::{self, App, Config, Group};
@@ -51,15 +51,6 @@ impl AppState {
} }
self.persist() 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> { pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
@@ -243,6 +234,7 @@ pub fn add_app(
group_id, group_id,
user_agent: None, user_agent: None,
hidden: Vec::new(), hidden: Vec::new(),
zoom: 1.0,
order, order,
}; };
cfg.apps.push(new.clone()); 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()) .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 /// Per app, because a dense ERP and a mail client do not want the same size.
/// WebKit, not what WebKit kept. This reads the other end. HttpOnly cookies are pub fn adjust_zoom(app: &AppHandle, direction: Option<i32>) {
/// invisible to script by design, so the answer is a floor, not a total — but a let state = app.state::<AppState>();
/// zero here means the injection never landed at all. 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] #[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 let wv = app
.get_webview(&webviews::label_for(&app_id)) .get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?; .ok_or_else(|| format!("{app_id} has no webview"))?;
wv.eval( let escaped = notification_id.replace('\\', "\\\\").replace('\'', "\\'");
r#"(function () { wv.eval(&format!(
var names = document.cookie "window.__workAppNotifyClick && window.__workAppNotifyClick('{escaped}')"
? 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(',')
});
}
})();"#,
)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// What the last cookie probe saw. /// Brings the window forward when a notification is clicked.
#[tauri::command] #[tauri::command]
pub fn cookie_probe(app: AppHandle) -> String { pub fn focus_window(app: AppHandle) {
let diag = app.state::<AppState>().diag.lock().unwrap().clone(); if let Some(w) = app.get_window("main") {
if diag.is_empty() { "no answer from the page".into() } else { diag } let _ = w.unminimize();
} let _ = w.show();
let _ = w.set_focus();
// ------------------------------------------------------- browser pairing
#[tauri::command]
pub fn list_browsers() -> Vec<crate::cookies::Browser> {
crate::cookies::list()
}
/// Imports the paired browser's cookies for the configured hosts.
///
/// Everything outside those hosts is dropped before anything is written: this
/// reaches into a browser's whole cookie store, and it must come back with the
/// sessions for the tools on the list and nothing else.
#[tauri::command]
pub fn pair_browser(
browser: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<crate::cookies::PairResult, String> {
let all = crate::cookies::read_all(&browser)?;
let scanned = all.len();
let wanted = crate::cookies::filter_to_scopes(all, &state.importable_hosts());
let mut domains: Vec<String> = wanted.iter().map(|c| c.domain.clone()).collect();
domains.sort();
domains.dedup();
let mut warnings = Vec::new();
if wanted.is_empty() {
warnings.push(format!(
"Read {scanned} cookies, none for the apps on your list. \
Sign in to them in that browser first."
));
} }
let imported = crate::cookies::inject::install(&app, wanted)?;
{
let mut cfg = state.config.lock().unwrap();
cfg.settings.paired_browser = Some(browser);
cfg.settings.last_paired_at = Some(now_iso());
}
state.persist()?;
Ok(crate::cookies::PairResult {
imported,
domains: domains.len(),
domain_names: domains,
warnings,
})
}
/// A timestamp for "last paired", without pulling in a date library for it.
fn now_iso() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
let (y, mo, d) = civil_from_days(days as i64);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
/// Howard Hinnant's days-to-civil-date algorithm.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
} }
#[cfg(test)] #[cfg(test)]
@@ -618,12 +600,22 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn civil_from_days_matches_known_dates() { fn zoom_steps_up_and_down_the_ladder() {
// Cross-checked against Python: assert_eq!(step_zoom(1.0, 1), 1.1);
// date(1970,1,1) + timedelta(days=n) assert_eq!(step_zoom(1.0, -1), 0.9);
assert_eq!(civil_from_days(0), (1970, 1, 1)); assert_eq!(step_zoom(1.25, 1), 1.5);
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)); #[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. /// the thing you never want to see again.
#[serde(default)] #[serde(default)]
pub hidden: Vec<String>, 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)] #[serde(default)]
pub order: i32, pub order: i32,
} }
fn default_zoom() -> f64 {
1.0
}
impl App { impl App {
pub fn scopes(&self) -> Vec<String> { pub fn scopes(&self) -> Vec<String> {
if self.scope.is_empty() { if self.scope.is_empty() {
@@ -69,10 +77,6 @@ pub struct Settings {
pub nav_collapsed: bool, pub nav_collapsed: bool,
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
pub theme: String, pub theme: String,
#[serde(default)]
pub paired_browser: Option<String>,
#[serde(default)]
pub last_paired_at: Option<String>,
} }
fn default_theme() -> String { fn default_theme() -> String {
@@ -84,8 +88,6 @@ impl Default for Settings {
Self { Self {
nav_collapsed: false, nav_collapsed: false,
theme: default_theme(), theme: default_theme(),
paired_browser: None,
last_paired_at: None,
} }
} }
} }
@@ -185,6 +187,7 @@ pub fn seed() -> Config {
group_id: Some(group.into()), group_id: Some(group.into()),
user_agent: None, user_agent: None,
hidden: Vec::new(), hidden: Vec::new(),
zoom: 1.0,
order, order,
}; };
Config { Config {
@@ -291,6 +294,8 @@ mod tests {
assert_eq!(cfg.version, 1); assert_eq!(cfg.version, 1);
assert_eq!(cfg.settings.theme, "system"); assert_eq!(cfg.settings.theme, "system");
assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]); 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/")); 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());
}
}
+84 -9
View File
@@ -2,7 +2,8 @@
* Runs inside every app's webview, before the page does. * Runs inside every app's webview, before the page does.
* *
* Four jobs: route links by intent, hide elements the user has chosen to be * 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 * menu on the page. Configuration arrives as `window.__WORKAPP`, written
* immediately above this by Rust. * immediately above this by Rust.
* *
@@ -323,29 +324,103 @@
/* WKWebView *does* define Notification — it just does nothing. Constructing /* WKWebView *does* define Notification — it just does nothing. Constructing
one throws no error and shows no banner, so Gmail believes it notified you 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 and you never hear about it. The native one is therefore replaced outright
rather than only filled in when missing. */ rather than only filled in when missing.
{
var WorkNotification = function (title, options) { 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 || {}; options = options || {};
var self = this;
this.title = title; this.title = title;
this.body = options.body || ''; this.body = options.body || '';
send('notify', { t: String(title || ''), b: String(options.body || ''), a: CFG.name || '' }); 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.__work = true;
WorkNotification.permission = 'granted'; WorkNotification.permission = 'granted';
WorkNotification.maxActions = 0;
WorkNotification.requestPermission = function (cb) { WorkNotification.requestPermission = function (cb) {
if (cb) cb('granted'); if (cb) cb('granted');
return Promise.resolve('granted'); return Promise.resolve('granted');
}; };
WorkNotification.prototype.close = function () {};
WorkNotification.prototype.addEventListener = function () {}; /* Called from Rust when the macOS banner is clicked. */
WorkNotification.prototype.removeEventListener = function () {}; window.__workAppNotifyClick = function (id) {
var n = notifications[id];
if (!n) return false;
n._fire('click');
return true;
};
try { try {
Object.defineProperty(window, 'Notification', { Object.defineProperty(window, 'Notification', {
value: WorkNotification, writable: true, configurable: true value: WorkNotification, writable: true, configurable: true
}); });
} catch (e) { window.Notification = WorkNotification; } } catch (e) { window.Notification = WorkNotification; }
}
/* ------------------------------------------------------------- start */ /* ------------------------------------------------------------- start */
+28 -7
View File
@@ -1,10 +1,9 @@
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod routing; pub mod routing;
pub mod cookies;
pub mod webviews; pub mod webviews;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu}; use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::Manager; use tauri::Manager;
/// The macOS menu bar. Edit has no entry of its own, but its items live under /// 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.menu(build_menu) .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_opener::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
@@ -81,14 +103,13 @@ pub fn run() {
commands::delete_group, commands::delete_group,
commands::set_nav_collapsed, commands::set_nav_collapsed,
commands::set_theme, commands::set_theme,
commands::focus_window,
commands::notification_click,
commands::set_zoom,
commands::set_hidden, commands::set_hidden,
commands::pick_hidden, commands::pick_hidden,
commands::test_notification, commands::test_notification,
commands::notification_status, commands::notification_status,
commands::probe_cookies,
commands::cookie_probe,
commands::list_browsers,
commands::pair_browser,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+68 -25
View File
@@ -43,6 +43,14 @@ pub struct UrlEvent {
pub url: String, 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)] #[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct HiddenEvent { pub struct HiddenEvent {
@@ -129,34 +137,11 @@ fn handle_sentinel(
} }
"notify" => { "notify" => {
use tauri_plugin_notification::NotificationExt;
let title = params.get("t").cloned().unwrap_or_default(); let title = params.get("t").cloned().unwrap_or_default();
let body = params.get("b").cloned().unwrap_or_default(); let body = params.get("b").cloned().unwrap_or_default();
let app_name = params.get("a").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 let notification_id = params.get("id").cloned().unwrap_or_default();
// in one window says nothing about which one wants you. notify(&handle, &from, &app_name, &title, &body, notification_id);
let heading = if app_name.is_empty() {
title.clone()
} else {
format!("{app_name}{title}")
};
let outcome = match handle
.notification()
.builder()
.title(heading.clone())
.body(body)
.show()
{
Ok(()) => "raised".to_string(),
Err(e) => {
eprintln!("could not raise a notification: {e}");
format!("failed: {e}")
}
};
// Recorded so the diagnostic can show that a page's notification
// actually reached macOS, not merely that the shim ran.
let state = handle.state::<crate::commands::AppState>();
*state.last_notification.lock().unwrap() = format!("{heading}{outcome}");
} }
// Reports what the page found, over the same channel a real // 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. /// 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 /// 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); 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 // Created hidden. `show_only` is what puts one on screen, so startup does
// not flash every app in turn as they are built. // not flash every app in turn as they are built.
if let Some(wv) = handle.get_webview(&label_for(&id)) { 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 { if Some(app.id.as_str()) == app_id {
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1)); let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3)); let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.set_zoom(app.zoom);
let _ = wv.show(); let _ = wv.show();
} else { } else {
let _ = wv.hide(); let _ = wv.hide();
@@ -398,6 +440,7 @@ mod tests {
group_id: None, group_id: None,
user_agent: None, user_agent: None,
hidden: vec![".ad".into()], hidden: vec![".ad".into()],
zoom: 1.0,
order: 0, order: 0,
}; };
let s = script_for(&app); let s = script_for(&app);
+15 -1
View File
@@ -6,7 +6,7 @@ import Nav from "./components/Nav";
import Settings from "./components/Settings"; import Settings from "./components/Settings";
import { BTN_PRIMARY } from "./components/ui"; import { BTN_PRIMARY } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance"; 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() { export default function App() {
const [config, setConfig] = useState<Config | null>(null); const [config, setConfig] = useState<Config | null>(null);
@@ -89,6 +89,20 @@ export default function App() {
setFocusHidden(e.payload); setFocusHidden(e.payload);
setSettingsOpen(true); 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<NotificationClick>("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 () => { return () => {
unlisten.forEach((p) => p.then((f) => f())); unlisten.forEach((p) => p.then((f) => f()));
+7 -6
View File
@@ -1,7 +1,7 @@
/** Every call into Rust, in one place. */ /** Every call into Rust, in one place. */
import { invoke } from "@tauri-apps/api/core"; 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<Config>("get_config"); export const getConfig = () => invoke<Config>("get_config");
export const bootstrap = () => invoke<void>("bootstrap"); export const bootstrap = () => invoke<void>("bootstrap");
@@ -40,11 +40,12 @@ export const setHidden = (appId: string, hidden: string[]) =>
invoke<Config>("set_hidden", { appId, hidden }); invoke<Config>("set_hidden", { appId, hidden });
export const pickHidden = (appId: string) => invoke<void>("pick_hidden", { appId }); export const pickHidden = (appId: string) => invoke<void>("pick_hidden", { appId });
export const listBrowsers = () => invoke<Browser[]>("list_browsers");
export const pairBrowser = (browser: string) =>
invoke<PairResult>("pair_browser", { browser });
export const testNotification = (appId: string) => export const testNotification = (appId: string) =>
invoke<void>("test_notification", { appId }); invoke<void>("test_notification", { appId });
export const notificationStatus = () => invoke<string>("notification_status"); export const notificationStatus = () => invoke<string>("notification_status");
export const probeCookies = (appId: string) => invoke<void>("probe_cookies", { appId });
export const cookieProbe = () => invoke<string>("cookie_probe"); export const setZoom = (appId: string, zoom: number) =>
invoke<Config>("set_zoom", { appId, zoom });
export const notificationClick = (appId: string, notificationId: string) =>
invoke<void>("notification_click", { appId, notificationId });
export const focusWindow = () => invoke<void>("focus_window");
-8
View File
@@ -137,14 +137,6 @@ export default function Nav({
> >
<Favicon url={app.url} name={app.name} /> <Favicon url={app.url} name={app.name} />
<span className="truncate">{app.name}</span> <span className="truncate">{app.name}</span>
{app.hidden.length > 0 && (
<span
title={`${app.hidden.length} element${app.hidden.length === 1 ? "" : "s"} hidden here`}
className="ml-auto font-mono text-[10px] opacity-50"
>
{app.hidden.length}
</span>
)}
</button> </button>
</li> </li>
); );
+27 -89
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import * as api from "../api"; import * as api from "../api";
import type { Theme } from "../hooks/useAppearance"; 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 { Trash } from "./icons";
import { import {
BTN, BTN,
@@ -17,7 +17,6 @@ import {
SUBPANEL, SUBPANEL,
SectionHeading, SectionHeading,
Segmented, Segmented,
Spinner,
} from "./ui"; } from "./ui";
interface Props { interface Props {
@@ -42,12 +41,7 @@ export default function Settings({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null); const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const [browsers, setBrowsers] = useState<Browser[]>([]);
const [browser, setBrowser] = useState("");
const [pairing, setPairing] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null); const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [cookieStatus, setCookieStatus] = useState<string | null>(null);
const [paired, setPaired] = useState<PairResult | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order); 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, (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(() => { useEffect(() => {
if (!focusHiddenFor) return; if (!focusHiddenFor) return;
@@ -95,20 +83,6 @@ export default function Settings({
const patch = (app: WorkApp, fields: Partial<WorkApp>) => const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
run(() => api.updateApp({ ...app, ...fields })); 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); const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
@@ -247,75 +221,38 @@ export default function Settings({
</p> </p>
</section> </section>
{/* -------------------------------------------------- pairing */} {/* ------------------------------------------------------ zoom */}
<section className="space-y-2"> <section className="space-y-2">
<SectionHeading>Browser pairing</SectionHeading> <SectionHeading>Zoom</SectionHeading>
{browsers.length === 0 ? ( <div className="space-y-1.5">
<p className={HELP}>No browser with a readable cookie store was found.</p> {orderedApps.map((app) => (
) : ( <div
<> key={app.id}
<div className={`${SUBPANEL} flex items-end gap-2`}> className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
<label className="flex-1 space-y-1">
<span className={LABEL}>Import cookies from</span>
<select value={browser} onChange={(e) => setBrowser(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
{browsers.map((b) => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</label>
<button
onClick={async () => {
if (!activeId) return;
setCookieStatus("checking…");
await api.probeCookies(activeId);
await new Promise((r) => setTimeout(r, 400));
setCookieStatus(await api.cookieProbe());
}}
disabled={!activeId}
title="What the current app can actually see"
className={BTN}
> >
Check cookies <Favicon url={app.url} name={app.name} />
</button> <span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
<button onClick={pair} disabled={pairing || !browser} className={BTN_PRIMARY}> <input
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"} type="range"
</button> min={50}
max={200}
step={5}
value={Math.round((app.zoom ?? 1) * 100)}
onChange={(e) =>
run(() => api.setZoom(app.id, Number(e.target.value) / 100))
}
className="min-w-0 flex-1 cursor-pointer accent-sky-500"
/>
<span className="w-12 shrink-0 text-right font-mono text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
{Math.round((app.zoom ?? 1) * 100)}%
</span>
</div> </div>
{cookieStatus && (
<p className={`${HELP} font-mono`}>{cookieStatus}</p>
)}
{config.settings.lastPairedAt && !paired && (
<p className={HELP}>Last paired {config.settings.lastPairedAt}.</p>
)}
{paired && (
<div className="space-y-1 rounded-lg bg-sky-500/10 px-3 py-2">
<p className="text-[12px] text-sky-700 dark:text-sky-300">
Imported {paired.imported} cookie{paired.imported === 1 ? "" : "s"} across{" "}
{paired.domains} domain{paired.domains === 1 ? "" : "s"}.
</p>
{paired.domainNames.length > 0 && (
<p className="font-mono text-[11px] leading-relaxed text-slate-600 dark:text-slate-300">
{paired.domainNames.join(" ")}
</p>
)}
{paired.warnings.map((w, i) => (
<p key={i} className="text-[11px] text-slate-600 dark:text-slate-300">{w}</p>
))} ))}
</div> </div>
)}
<p className={HELP}> <p className={HELP}>
Only cookies for the apps above and their sign-in hosts are read nothing + and zoom the app you are in; 0 puts it back to 100%. Each app keeps
else leaves the browser. macOS asks for Keychain permission the first time. its own size.
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.
</p> </p>
</>
)}
</section> </section>
{/* --------------------------------------------- notifications */} {/* --------------------------------------------- notifications */}
@@ -335,6 +272,7 @@ export default function Settings({
> >
Check permission Check permission
</button> </button>
<span className={HELP}>Clicking one opens the message it is about.</span>
</div> </div>
{notifyStatus && ( {notifyStatus && (
<p className={`${HELP} font-mono`}>{notifyStatus}</p> <p className={`${HELP} font-mono`}>{notifyStatus}</p>
+8 -13
View File
@@ -7,6 +7,8 @@ export interface WorkApp {
userAgent: string | null; userAgent: string | null;
/** CSS selectors this app hides on every page. */ /** CSS selectors this app hides on every page. */
hidden: string[]; hidden: string[];
/** Page zoom, remembered per app. */
zoom: number;
order: number; order: number;
} }
@@ -20,8 +22,6 @@ export interface Group {
export interface Settings { export interface Settings {
navCollapsed: boolean; navCollapsed: boolean;
theme: "system" | "light" | "dark"; theme: "system" | "light" | "dark";
pairedBrowser: string | null;
lastPairedAt: string | null;
} }
export interface Config { export interface Config {
@@ -42,21 +42,16 @@ export interface UrlEvent {
url: string; 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. */ /** Rust recorded a selector the user right-clicked away. */
export interface HiddenEvent { export interface HiddenEvent {
appId: string; appId: string;
selector: string; selector: string;
} }
/** The user clicked a macOS banner an app raised. */
export interface NotificationClick {
appId: string;
notificationId: string;
}