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:
@@ -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
|
||||||
|
|||||||
Generated
+1
-181
@@ -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
@@ -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
@@ -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
@@ -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/"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(©, 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(©));
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(©, 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())
|
|
||||||
}
|
|
||||||
@@ -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())?
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user