Browser pairing, notifications, element hiding; drop the top bar

Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC) and injects the result into
WKHTTPCookieStore. Only the configured apps' hosts and their sign-in
hosts survive the filter. Browsers are offered most-recently-used first,
since the first entry becomes the default and someone with four
Chromium browsers installed wants the one they actually browse in.

WKWebView defines window.Notification but it does nothing: constructing
one throws no error and shows no banner, so a page believes it notified
you. Measured on the machine as `api=function shim=no` before the shim
was made unconditional; `from page: Odoo - Test notification -> raised`
after.

Anything on a page can be right-clicked away. The rule is re-asserted on
every navigation, because the injected script only carries a snapshot
from when the view was built and a selector added since would otherwise
come back on reload.

The top bar is gone. Navigation lives beside the cog, the nav carries
the traffic lights, and two-finger swipe goes back and forward.

The seed is now the real app list, scoped to exact hosts so a Drive link
inside Gmail switches rather than being swallowed.
This commit is contained in:
2026-09-01 12:17:31 +02:00
parent e369c82774
commit ff4a0c6bc4
44 changed files with 2492 additions and 419 deletions
+29 -2
View File
@@ -42,6 +42,29 @@ through `accounts.google.com`.
tool once. Every app also claims a real Chrome user agent by default, because Google
refuses logins from anything it identifies as an embedded webview.
**Pairing imports a browser's cookies.** Settings lists the browsers installed, most
recently used first, and pairing decrypts that browser's cookie store — PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC — and injects the result into WebKit's shared
jar. Only cookies for the apps on your list and their sign-in hosts are read; everything
else is dropped before anything is written. macOS asks for Keychain permission the first
time, which is the consent gate and cannot be skipped.
Google and Microsoft increasingly tie a session to the browser that created it, so those
may still ask you to sign in once. After that the persistent jar keeps it.
**Notifications work, with one gap.** WKWebView *defines* `window.Notification` but it is
inert: constructing one throws nothing and shows nothing, so a page believes it notified
you and you never hear about it. It is replaced with a shim that forwards to a real macOS
notification carrying the app's name. Service-worker push in the background is not
covered — only notifications a page raises while it is open.
**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
parent, Escape cancels). Selectors are stored per app, listed in Settings, editable, and
reversible. The rule is re-asserted on every navigation and re-added if a single-page app
rewrites `<head>`, and cached in the page's own storage so a reload hides it before the
first paint rather than after it has flashed on screen.
## Apps and groups
An app owns the **exact host** of its URL — `mail.google.com`, not `google.com` — or Gmail
@@ -51,8 +74,12 @@ Extra hosts can be added per app.
Groups are for the nav only. Deleting one keeps its apps, ungrouped: deleting a folder
should never be a way to lose the things inside it.
The nav collapses to a 52px icon rail that is still clickable, so switching apps never
requires expanding it first.
The nav collapses to a 72px icon rail that is still clickable, so switching apps never
requires expanding it first. It is that wide because the macOS traffic lights have to fit
inside it: there is no title bar and no toolbar, so the nav carries the drag strip.
Back, forward, reload and hide-an-element sit next to the cog at the top of the nav.
There is no toolbar — two-finger swipe goes back and forward.
## Running it
+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!--
The app drawn as itself: a dark window, a sky rail down the left with one
active dot, and the tool it is showing beside it. Legible at 32px, where
all that survives is a dark tile with a blue edge and a bright mark.
-->
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e293b"/>
<stop offset="100%" stop-color="#0b1220"/>
</linearGradient>
<linearGradient id="rail" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#38bdf8"/>
<stop offset="100%" stop-color="#0284c7"/>
</linearGradient>
<clipPath id="tileClip">
<rect x="64" y="64" width="896" height="896" rx="205"/>
</clipPath>
</defs>
<rect x="64" y="64" width="896" height="896" rx="205" fill="url(#tile)"/>
<g clip-path="url(#tileClip)">
<!-- The nav, running the full height the way it does in the window. -->
<rect x="64" y="64" width="236" height="896" fill="url(#rail)"/>
<!-- Three apps; the lit one is the app you are in. -->
<circle cx="182" cy="330" r="37" fill="#ffffff"/>
<circle cx="182" cy="512" r="30" fill="#ffffff" opacity="0.5"/>
<circle cx="182" cy="694" r="30" fill="#ffffff" opacity="0.5"/>
<!-- The tool on the right, in the same rhythm as the dots. -->
<rect x="392" y="304" width="430" height="52" rx="26" fill="#94a3b8"/>
<rect x="392" y="486" width="344" height="52" rx="26" fill="#64748b"/>
<rect x="392" y="668" width="268" height="52" rx="26" fill="#475569"/>
</g>
<!-- A hairline lift, so the tile does not sit flat on a dark background. -->
<rect x="64" y="64" width="896" height="896" rx="205" fill="none"
stroke="#ffffff" stroke-opacity="0.10" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -179,6 +179,29 @@ and seeding. Cookie decryption is tested against a fixture database with a known
Webview orchestration and bounds sync have no seam that a unit test can reach. They are
verified by running the app and driving it.
## Notifications
WKWebView defines `window.Notification`, but it is inert: constructing one throws nothing
and shows nothing, so a page believes it has notified you and you never hear about it. It
is replaced by a shim that forwards over the same sentinel channel as everything else, and
Rust raises a real macOS notification carrying the app's name.
Verified on the machine: `permission: Granted · direct: raised · page: api=function shim=no`
was the reading that showed the native API existed and the shim had therefore never
installed. The shim now replaces it unconditionally.
Service-worker push is **not** covered — only notifications a page raises while it is open.
## Hiding elements
Anything on a page can be right-clicked and hidden. The injected script owns a stylesheet
of `display: none` rules, re-added when a single-page app rewrites `<head>`. Selectors are
per app and stored in `apps.json`, editable and reversible in Settings.
The selector generator prefers an id, then up to two stable-looking classes per level,
then position — skipping classes that look hashed or numbered, because those change on
every deploy. A selector that stops matching hides nothing; it never hides the wrong thing.
## Out of scope
- Windows and Linux.
+324
View File
@@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -264,6 +275,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -410,6 +430,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.4.4"
@@ -465,6 +494,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.8"
@@ -730,6 +769,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -959,6 +999,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1430,11 +1482,32 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
@@ -1460,6 +1533,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "html5ever"
version = "0.38.0"
@@ -1728,6 +1810,16 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -1981,6 +2073,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2008,6 +2111,20 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@@ -2122,6 +2239,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2282,6 +2413,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2297,6 +2429,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2309,6 +2451,17 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.1",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2352,6 +2505,8 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -2441,6 +2596,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2606,6 +2771,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2704,6 +2878,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -2813,6 +3016,31 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3101,6 +3329,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3210,6 +3449,18 @@ dependencies = [
"system-deps",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3246,6 +3497,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.8"
@@ -3508,6 +3765,25 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1"
dependencies = [
"log",
"notify-rust",
"rand",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.5"
@@ -3630,6 +3906,17 @@ dependencies = [
"toml 1.1.4+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -4117,6 +4404,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4748,10 +5041,21 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
name = "work-app"
version = "0.1.0"
dependencies = [
"aes",
"cbc",
"hmac",
"objc2",
"objc2-foundation",
"objc2-web-kit",
"pbkdf2",
"rusqlite",
"serde",
"serde_json",
"sha1",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tokio",
"url",
@@ -4922,6 +5226,26 @@ dependencies = [
"serde",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
+15
View File
@@ -16,8 +16,23 @@ tauri-build = { version = "2", features = [] }
# Pinned: multi-webview lives behind `unstable`, whose API can move between minors.
tauri = { version = "=2.11.5", features = ["unstable"] }
tauri-plugin-opener = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["time"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
# Browser cookie import.
rusqlite = { version = "0.40.2", features = ["bundled"] }
aes = "0.8"
cbc = { version = "0.1", features = ["alloc"] }
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", features = ["NSHTTPCookie", "NSString", "NSDictionary", "NSArray", "NSDate", "NSValue", "NSURL"] }
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKWebsiteDataStore", "WKHTTPCookieStore"] }
+2 -1
View File
@@ -6,6 +6,7 @@
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"opener:default"
"opener:default",
"notification:default"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default"]}}
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default","notification:default"]}}
+198
View File
@@ -2360,6 +2360,204 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
+198
View File
@@ -2360,6 +2360,204 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+233
View File
@@ -20,6 +20,10 @@ pub struct AppState {
pub stage: Mutex<Stage>,
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
pub booted: Mutex<bool>,
/// What the last page probe reported, for the notification diagnostic.
pub diag: Mutex<String>,
/// The last notification a page raised, and what macOS did with it.
pub last_notification: Mutex<String>,
}
impl AppState {
@@ -30,6 +34,32 @@ impl AppState {
fn persist(&self) -> Result<(), String> {
config::save(&self.dir, &self.config.lock().unwrap())
}
/// Records a selector chosen by right-clicking it in the page.
pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> {
{
let mut cfg = self.config.lock().unwrap();
let app = cfg
.apps
.iter_mut()
.find(|a| a.id == app_id)
.ok_or_else(|| format!("no app {app_id}"))?;
if app.hidden.iter().any(|s| s == selector) {
return Ok(());
}
app.hidden.push(selector.to_string());
}
self.persist()
}
/// Every host the import is allowed to bring cookies across for: the
/// configured apps, plus the sign-in hosts that vouch for them.
fn importable_hosts(&self) -> Vec<String> {
let cfg = self.config.lock().unwrap();
let mut hosts: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
hosts.extend(crate::routing::identity_providers().iter().map(|s| s.to_string()));
hosts
}
}
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
@@ -44,6 +74,8 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
active: Mutex::new(None),
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
booted: Mutex::new(false),
diag: Mutex::new(String::new()),
last_notification: Mutex::new(String::new()),
})
}
@@ -210,6 +242,7 @@ pub fn add_app(
scope: vec![scope],
group_id,
user_agent: None,
hidden: Vec::new(),
order,
};
cfg.apps.push(new.clone());
@@ -361,3 +394,203 @@ pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String
state.config.lock().unwrap().settings.theme = theme;
state.persist()
}
// ------------------------------------------------------- hidden elements
/// Replaces an app's hidden selectors and re-applies them without a reload.
#[tauri::command]
pub fn set_hidden(
app_id: String,
hidden: Vec<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let target = cfg
.apps
.iter_mut()
.find(|a| a.id == app_id)
.ok_or_else(|| format!("no app {app_id}"))?;
target.hidden = hidden.clone();
}
state.persist()?;
webviews::push_hidden(&app, &app_id, &hidden);
Ok(state.cfg())
}
/// Starts the in-page element picker, for reaching something a right-click
/// cannot land on cleanly.
#[tauri::command]
pub fn pick_hidden(app_id: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
wv.eval("window.__workAppPick && window.__workAppPick()")
.map_err(|e| e.to_string())
}
/// Permission state, and what a notification raised straight from Rust does.
///
/// Splits the chain in two: if this succeeds and the page test does not, the
/// shim is at fault; if this fails, macOS never granted permission.
#[tauri::command]
pub fn notification_status(app: AppHandle) -> String {
use tauri_plugin_notification::NotificationExt;
let state = match app.notification().permission_state() {
Ok(s) => format!("{s:?}"),
Err(e) => format!("unknown ({e})"),
};
let raised = match app
.notification()
.builder()
.title("Work")
.body("Notifications are working.")
.show()
{
Ok(()) => "raised".to_string(),
Err(e) => format!("failed: {e}"),
};
let app_state = app.state::<AppState>();
let diag = app_state.diag.lock().unwrap().clone();
let last = app_state.last_notification.lock().unwrap().clone();
let page = if diag.is_empty() { "not run yet".into() } else { diag };
let from_page = if last.is_empty() { "none yet".into() } else { last };
format!("permission: {state} · direct: {raised} · page: {page} · from page: {from_page}")
}
/// Asks macOS for notification permission, once, at startup.
pub fn ensure_notification_permission(app: &AppHandle) {
use tauri_plugin_notification::NotificationExt;
let granted = matches!(
app.notification().permission_state(),
Ok(tauri_plugin_notification::PermissionState::Granted)
);
if !granted {
if let Err(e) = app.notification().request_permission() {
eprintln!("notification permission was refused: {e}");
}
}
}
/// Fires a notification the way a page would.
///
/// Deliberately routed through the injected shim rather than raised directly:
/// the thing worth testing is the whole chain — page API, sentinel, Rust, and
/// macOS — not whether this process can show a notification.
#[tauri::command]
pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
// The probe reports what it found before raising anything, so a
// notification that never appears still says why.
wv.eval(
r#"(function () {
var kind = typeof window.Notification;
var shim = !!(window.Notification && window.Notification.__work);
var err = '';
try {
new Notification('Test notification',
{ body: 'If you can see this, pages can reach you.' });
} catch (e) { err = String(e); }
if (window.__workAppSend) {
window.__workAppSend('diag', { api: kind, shim: shim ? 'yes' : 'no', err: err });
}
})();"#,
)
.map_err(|e| e.to_string())
}
// ------------------------------------------------------- browser pairing
#[tauri::command]
pub fn list_browsers() -> Vec<crate::cookies::Browser> {
crate::cookies::list()
}
/// Imports the paired browser's cookies for the configured hosts.
///
/// Everything outside those hosts is dropped before anything is written: this
/// reaches into a browser's whole cookie store, and it must come back with the
/// sessions for the tools on the list and nothing else.
#[tauri::command]
pub fn pair_browser(
browser: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<crate::cookies::PairResult, String> {
let all = crate::cookies::read_all(&browser)?;
let scanned = all.len();
let wanted = crate::cookies::filter_to_scopes(all, &state.importable_hosts());
let mut domains: Vec<String> = wanted.iter().map(|c| c.domain.clone()).collect();
domains.sort();
domains.dedup();
let mut warnings = Vec::new();
if wanted.is_empty() {
warnings.push(format!(
"Read {scanned} cookies, none for the apps on your list. \
Sign in to them in that browser first."
));
}
let imported = crate::cookies::inject::install(&app, wanted)?;
{
let mut cfg = state.config.lock().unwrap();
cfg.settings.paired_browser = Some(browser);
cfg.settings.last_paired_at = Some(now_iso());
}
state.persist()?;
Ok(crate::cookies::PairResult {
imported,
domains: domains.len(),
domain_names: domains,
warnings,
})
}
/// A timestamp for "last paired", without pulling in a date library for it.
fn now_iso() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
let (y, mo, d) = civil_from_days(days as i64);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
}
/// Howard Hinnant's days-to-civil-date algorithm.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_from_days_matches_known_dates() {
// Cross-checked against Python:
// date(1970,1,1) + timedelta(days=n)
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(civil_from_days(20_697), (2026, 9, 1));
assert_eq!(civil_from_days(20_698), (2026, 9, 2));
}
}
+31 -13
View File
@@ -29,6 +29,10 @@ pub struct App {
pub group_id: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
/// CSS selectors this app hides on every page. Chosen by right-clicking
/// the thing you never want to see again.
#[serde(default)]
pub hidden: Vec<String>,
#[serde(default)]
pub order: i32,
}
@@ -167,8 +171,11 @@ pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// First-run contents: enough apps, across enough groups, that grouping and
/// cross-app link routing can both be seen working without typing anything.
/// First-run contents: the tools this was built for.
///
/// Each scope is the exact host, so a link from Gmail to Drive switches rather
/// than being swallowed by whichever Google app happens to be showing. The
/// `/u/N/` paths pin the second Google account, which is the one that matters.
pub fn seed() -> Config {
let mk = |name: &str, url: &str, group: &str, order: i32| App {
id: new_id(),
@@ -177,22 +184,20 @@ pub fn seed() -> Config {
scope: default_scope(url).into_iter().collect(),
group_id: Some(group.into()),
user_agent: None,
hidden: Vec::new(),
order,
};
Config {
version: 1,
groups: vec![
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
Group { id: "g-work".into(), name: "Work".into(), collapsed: false, order: 0 },
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 1 },
],
apps: vec![
mk("Google", "https://www.google.com", "g-google", 0),
mk("YouTube", "https://www.youtube.com", "g-google", 1),
mk("GitHub", "https://github.com", "g-dev", 0),
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
mk("Odoo", "https://example.odoo.com/web", "g-work", 0),
mk("Gmail", "https://mail.google.com/mail/u/N/", "g-google", 0),
mk("Drive", "https://drive.google.com/drive/u/N/my-drive", "g-google", 1),
mk("Chat", "https://chat.google.com/u/N/app/home", "g-google", 2),
],
settings: Settings::default(),
}
@@ -240,10 +245,23 @@ mod tests {
assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local");
}
#[test]
fn seeded_scopes_are_exact_hosts_that_cannot_swallow_each_other() {
// The whole point of exact hosts: a Drive link inside Gmail must match
// Drive, not Gmail. A shared `google.com` scope would break that.
let cfg = seed();
let scopes: Vec<String> = cfg.apps.iter().flat_map(|a| a.scopes()).collect();
assert!(scopes.contains(&"mail.google.com".to_string()));
assert!(scopes.contains(&"drive.google.com".to_string()));
assert!(scopes.contains(&"chat.google.com".to_string()));
assert!(scopes.contains(&"example.odoo.com".to_string()));
assert!(!scopes.contains(&"google.com".to_string()));
}
#[test]
fn seed_apps_all_carry_a_scope() {
let cfg = seed();
assert_eq!(cfg.apps.len(), 6);
assert_eq!(cfg.apps.len(), 4);
assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty()));
}
@@ -251,7 +269,7 @@ mod tests {
fn ordering_follows_group_then_position() {
let cfg = seed();
let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
assert_eq!(names, ["Odoo", "Gmail", "Drive", "Chat"]);
}
#[test]
+240
View File
@@ -0,0 +1,240 @@
//! Reading a Chromium browser's cookie jar on macOS.
//!
//! The values are AES-encrypted with a key that lives in the login Keychain, so
//! the first import raises a Keychain prompt. That prompt is the point: it is
//! macOS asking whether this app may read that key, and the honest answer has
//! to come from the user.
use aes::Aes128;
use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use rusqlite::{Connection, OpenFlags};
use sha2::{Digest, Sha256};
use super::Cookie;
type Decryptor = cbc::Decryptor<Aes128>;
/// Chromium's fixed KDF parameters on macOS. Not secrets — they are compiled
/// into every Chromium build, and the actual secret is the Keychain entry.
const SALT: &[u8] = b"saltysalt";
const ROUNDS: u32 = 1003;
const IV: [u8; 16] = [b' '; 16];
/// The profile most recently used, since that is the one you are signed into.
pub fn find_cookie_db(user_data: &std::path::Path) -> Option<std::path::PathBuf> {
if !user_data.exists() {
return None;
}
let mut best: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
let entries = std::fs::read_dir(user_data).ok()?;
for entry in entries.flatten() {
// Chromium keeps cookies at <Profile>/Cookies, and newer builds at
// <Profile>/Network/Cookies.
for candidate in [entry.path().join("Cookies"), entry.path().join("Network/Cookies")] {
if !candidate.is_file() {
continue;
}
let Ok(modified) = candidate.metadata().and_then(|m| m.modified()) else {
continue;
};
if best.as_ref().is_none_or(|(t, _)| modified > *t) {
best = Some((modified, candidate));
}
}
}
best.map(|(_, p)| p)
}
/// SQLite will not open a file the browser holds a lock on, so it is copied
/// first. The `-wal` sidecar comes too, or recent writes are invisible.
pub fn copy_locked(db: &std::path::Path) -> Result<std::path::PathBuf, String> {
let tmp = std::env::temp_dir().join(format!("work-app-cookies-{}", std::process::id()));
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
let dest = tmp.join("Cookies");
std::fs::copy(db, &dest).map_err(|e| format!("could not read the cookie store: {e}"))?;
for suffix in ["-wal", "-shm"] {
let side = db.with_file_name(format!(
"{}{suffix}",
db.file_name().unwrap_or_default().to_string_lossy()
));
if side.exists() {
let _ = std::fs::copy(&side, dest.with_file_name(format!("Cookies{suffix}")));
}
}
Ok(dest)
}
/// The browser's encryption password, from the login Keychain.
///
/// Shelling out to `security` rather than binding the Security framework: it is
/// the same prompt either way, and this keeps a C API with a long history of
/// footguns out of the app.
fn keychain_password(service: &str) -> Result<String, String> {
let out = std::process::Command::new("/usr/bin/security")
.args([
"find-generic-password",
"-w",
"-s",
&format!("{service} Safe Storage"),
"-a",
service,
])
.output()
.map_err(|e| format!("could not run `security`: {e}"))?;
if !out.status.success() {
return Err(format!(
"no Keychain entry for \"{service} Safe Storage\". \
If a prompt appeared, it needs Allow — otherwise open {service} once and try again."
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn derive_key(password: &str) -> [u8; 16] {
let mut key = [0u8; 16];
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password.as_bytes(), SALT, ROUNDS, &mut key);
key
}
/// Decrypts one `v10` value.
///
/// Chromium 130 and later prepend the SHA-256 of the cookie's host to the
/// plaintext. It is stripped by comparing against the hash rather than by
/// guessing at the bytes, so a value that merely looks binary is left alone.
fn decrypt_value(encrypted: &[u8], key: &[u8; 16], host: &str) -> Option<String> {
if encrypted.len() < 4 || &encrypted[..3] != b"v10" {
return None;
}
let plain = Decryptor::new(key.into(), &IV.into())
.decrypt_padded_vec_mut::<Pkcs7>(&encrypted[3..])
.ok()?;
let expected: [u8; 32] = Sha256::digest(host.as_bytes()).into();
let body = if plain.len() >= 32 && plain[..32] == expected {
&plain[32..]
} else {
&plain[..]
};
String::from_utf8(body.to_vec()).ok()
}
pub fn read(user_data: &std::path::Path, service: &str) -> Result<Vec<Cookie>, String> {
let db = find_cookie_db(user_data).ok_or("no profile with a cookie store")?;
let key = derive_key(&keychain_password(service)?);
let copy = copy_locked(&db)?;
let conn = Connection::open_with_flags(&copy, OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|e| format!("could not open the cookie store: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT host_key, name, value, encrypted_value, path, expires_utc, \
is_secure, is_httponly FROM cookies",
)
.map_err(|e| format!("unfamiliar cookie schema: {e}"))?;
let rows = stmt
.query_map([], |r| {
let host: String = r.get(0)?;
let plain: String = r.get(2).unwrap_or_default();
let enc: Vec<u8> = r.get(3).unwrap_or_default();
let value = if plain.is_empty() {
decrypt_value(&enc, &key, &host).unwrap_or_default()
} else {
plain
};
Ok(Cookie {
domain: host,
name: r.get(1)?,
value,
path: r.get(4).unwrap_or_else(|_| "/".into()),
expires: chromium_time(r.get::<_, i64>(5).unwrap_or(0)),
secure: r.get::<_, i64>(6).unwrap_or(0) != 0,
http_only: r.get::<_, i64>(7).unwrap_or(0) != 0,
})
})
.map_err(|e| e.to_string())?;
let cookies: Vec<Cookie> = rows
.filter_map(Result::ok)
// A value that would not decrypt is worse than useless: injecting an
// empty session cookie logs you out rather than in.
.filter(|c| !c.value.is_empty())
.collect();
let _ = std::fs::remove_dir_all(copy.parent().unwrap_or(&copy));
Ok(cookies)
}
/// Chromium counts microseconds from 1601-01-01; the rest of the world counts
/// seconds from 1970. Zero means a session cookie.
fn chromium_time(value: i64) -> Option<i64> {
if value <= 0 {
return None;
}
const EPOCH_DELTA: i64 = 11_644_473_600;
Some(value / 1_000_000 - EPOCH_DELTA)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chromium_time_converts_to_unix_seconds() {
// 13 Jan 2022 00:00:00 UTC in Chromium's epoch.
assert_eq!(chromium_time(13_287_916_800_000_000), Some(1_643_443_200));
assert_eq!(chromium_time(0), None);
assert_eq!(chromium_time(-5), None);
}
#[test]
fn key_derivation_matches_chromiums_published_parameters() {
// Cross-checked against an independent implementation:
// python3 -c "import hashlib; print(hashlib.pbkdf2_hmac(
// 'sha1', b'peanuts', b'saltysalt', 1003, 16).hex())"
// "peanuts" is Chromium's documented fallback password. If this ever
// fails, the KDF is wrong and every imported cookie will be garbage.
let key = derive_key("peanuts");
assert_eq!(
key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
"d9a09d499b4e1b7461f28e67972c6dbd"
);
}
#[test]
fn a_round_trip_decrypts_and_strips_the_host_hash() {
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
let key = derive_key("peanuts");
let host = "example.com";
let mut plain = Sha256::digest(host.as_bytes()).to_vec();
plain.extend_from_slice(b"session=abc123");
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
.encrypt_padded_vec_mut::<Pkcs7>(&plain);
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&ct);
assert_eq!(decrypt_value(&stored, &key, host).unwrap(), "session=abc123");
}
#[test]
fn a_value_without_the_hash_prefix_survives_intact() {
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
let key = derive_key("peanuts");
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
.encrypt_padded_vec_mut::<Pkcs7>(b"plain=1");
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&ct);
assert_eq!(decrypt_value(&stored, &key, "example.com").unwrap(), "plain=1");
}
#[test]
fn unversioned_values_are_refused() {
let key = derive_key("peanuts");
assert!(decrypt_value(b"not encrypted", &key, "example.com").is_none());
assert!(decrypt_value(b"", &key, "example.com").is_none());
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Putting imported cookies into WebKit's shared jar.
//!
//! Not `document.cookie`: the cookies that carry a session are almost always
//! HttpOnly, which is exactly the set script cannot write. Only
//! `WKHTTPCookieStore` can, so this drops to Objective-C.
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_foundation::{
MainThreadMarker, NSDate, NSDictionary, NSHTTPCookie, NSString, NSHTTPCookieDomain,
NSHTTPCookieExpires, NSHTTPCookieName, NSHTTPCookiePath, NSHTTPCookieSecure,
NSHTTPCookieValue,
};
use objc2_web_kit::WKWebsiteDataStore;
use tauri::AppHandle;
use super::Cookie;
fn build(c: &Cookie) -> Option<Retained<NSHTTPCookie>> {
let name = NSString::from_str(&c.name);
let value = NSString::from_str(&c.value);
let domain = NSString::from_str(&c.domain);
let path = NSString::from_str(if c.path.is_empty() { "/" } else { &c.path });
let mut keys: Vec<&NSString> = Vec::with_capacity(6);
let mut values: Vec<&AnyObject> = Vec::with_capacity(6);
unsafe {
keys.push(NSHTTPCookieName);
values.push(&*(Retained::as_ptr(&name) as *const AnyObject));
keys.push(NSHTTPCookieValue);
values.push(&*(Retained::as_ptr(&value) as *const AnyObject));
keys.push(NSHTTPCookieDomain);
values.push(&*(Retained::as_ptr(&domain) as *const AnyObject));
keys.push(NSHTTPCookiePath);
values.push(&*(Retained::as_ptr(&path) as *const AnyObject));
}
// Any non-nil value means secure; the key's absence means it is not.
let yes = NSString::from_str("TRUE");
if c.secure {
unsafe {
keys.push(NSHTTPCookieSecure);
values.push(&*(Retained::as_ptr(&yes) as *const AnyObject));
}
}
// Omitting the key is what makes a session cookie, which is the right
// shape for the ones that matter most here.
let expires = c.expires.map(|s| NSDate::dateWithTimeIntervalSince1970(s as f64));
if let Some(d) = &expires {
unsafe {
keys.push(NSHTTPCookieExpires);
values.push(&*(Retained::as_ptr(d) as *const AnyObject));
}
}
let props: Retained<NSDictionary<NSString, AnyObject>> =
NSDictionary::from_slices(&keys, &values);
unsafe { NSHTTPCookie::cookieWithProperties(std::mem::transmute(&*props)) }
}
/// Writes every cookie into the shared store the app's webviews read from.
///
/// Returns how many were accepted. Runs on the main thread because WebKit
/// insists, and blocks until done so the caller can report a real number.
pub fn install(app: &AppHandle, cookies: Vec<Cookie>) -> Result<usize, String> {
let (tx, rx) = std::sync::mpsc::channel();
app.run_on_main_thread(move || {
let Some(mtm) = MainThreadMarker::new() else {
let _ = tx.send(Err("not on the main thread".to_string()));
return;
};
let store = unsafe { WKWebsiteDataStore::defaultDataStore(mtm).httpCookieStore() };
let mut n = 0usize;
for c in &cookies {
if let Some(cookie) = build(c) {
unsafe { store.setCookie_completionHandler(&cookie, None) };
n += 1;
}
}
let _ = tx.send(Ok(n));
})
.map_err(|e| e.to_string())?;
rx.recv().map_err(|e| e.to_string())?
}
+28 -13
View File
@@ -40,6 +40,9 @@ pub struct Browser {
pub struct PairResult {
pub imported: usize,
pub domains: usize,
/// The hosts a session actually came across for. Shown because "4 domains"
/// does not tell you which tool is still going to ask you to sign in.
pub domain_names: Vec<String>,
/// Non-fatal problems worth showing: a profile that would not open, a
/// browser whose format is not supported.
pub warnings: Vec<String>,
@@ -64,25 +67,37 @@ pub fn chromium_browsers() -> Vec<(&'static str, &'static str, std::path::PathBu
]
}
/// The browsers this machine actually has, for the Settings picker.
/// When a cookie store was last written, as a stand-in for "last used".
fn last_used(db: Option<std::path::PathBuf>) -> Option<std::time::SystemTime> {
db.and_then(|p| p.metadata().ok()).and_then(|m| m.modified().ok())
}
/// The browsers this machine actually has, most recently used first.
///
/// Ordered rather than alphabetical because the first entry becomes the
/// default choice, and someone with four Chromium browsers installed wants the
/// one they actually browse in — not whichever happens to sort first.
pub fn list() -> Vec<Browser> {
let mut out: Vec<Browser> = chromium_browsers()
let mut found: Vec<(Browser, Option<std::time::SystemTime>)> = chromium_browsers()
.into_iter()
.map(|(id, label, dir, _)| Browser {
id: id.to_string(),
label: label.to_string(),
available: chrome::find_cookie_db(&dir).is_some(),
.map(|(id, label, dir, _)| {
let db = chrome::find_cookie_db(&dir);
let used = last_used(db.clone());
(
Browser { id: id.to_string(), label: label.to_string(), available: db.is_some() },
used,
)
})
.collect();
out.push(Browser {
id: "firefox".into(),
label: "Firefox".into(),
available: firefox::find_cookie_db().is_some(),
});
found.push((
Browser { id: "firefox".into(), label: "Firefox".into(), available: firefox::find_cookie_db().is_some() },
last_used(firefox::find_cookie_db()),
));
out.retain(|b| b.available);
out
found.retain(|(b, _)| b.available);
found.sort_by(|a, b| b.1.cmp(&a.1));
found.into_iter().map(|(b, _)| b).collect()
}
/// Reads every cookie the named browser holds.
+358
View File
@@ -0,0 +1,358 @@
/**
* Runs inside every app's webview, before the page does.
*
* Four jobs: route links by intent, hide elements the user has chosen to be
* rid of, replace WKWebView's inert Notification API, and put a right-click
* menu on the page. Configuration arrives as `window.__WORKAPP`, written
* immediately above this by Rust.
*
* Everything talks back over a made-up URL scheme that `on_navigation` answers
* and cancels. Deliberately not Tauri IPC, which would mean handing the page
* the ability to call into the app.
*/
(function () {
if (window.__workAppReady) return;
window.__workAppReady = true;
var CFG = window.__WORKAPP || { scopes: [], hidden: [], name: '' };
var queue = [];
var sending = false;
/* Assignments to location are cancelled by the navigation delegate, but two
in the same tick would lose one — so they go out one at a time. */
function drain() {
if (sending || !queue.length) return;
sending = true;
var url = queue.shift();
try { window.location.href = url; } catch (e) {}
setTimeout(function () { sending = false; drain(); }, 0);
}
function send(kind, data) {
var q = Object.keys(data)
.map(function (k) { return k + '=' + encodeURIComponent(data[k]); })
.join('&');
queue.push('workapp-' + kind + ':/?' + q);
drain();
}
/* ------------------------------------------------------------ routing */
function abs(href) {
try { return new URL(href, document.baseURI).href; } catch (e) { return null; }
}
function inScope(u) {
try {
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
return CFG.scopes.some(function (s) {
s = String(s).replace(/^www\./, '').toLowerCase();
return h === s || h.endsWith('.' + s);
});
} catch (e) { return false; }
}
document.addEventListener('click', function (e) {
if (picking) return;
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
var href = a.getAttribute('href');
if (!href || href.charAt(0) === '#') return;
if (/^(javascript|blob|data):/i.test(href)) return;
var u = abs(href);
if (!u) return;
if (inScope(u)) {
/* A new-tab link has nowhere to go in a tabless app, so it takes over
this view rather than being swallowed. */
if (a.target === '_blank') { e.preventDefault(); window.location.href = u; }
return;
}
e.preventDefault();
e.stopPropagation();
send('route', { u: u });
}, true);
var nativeOpen = window.open;
window.open = function (url) {
if (!url) return null;
var u = abs(url);
if (!u) return null;
if (inScope(u)) { window.location.href = u; return null; }
send('route', { u: u });
return null;
};
void nativeOpen;
/* ------------------------------------------------------ hiding elements */
var STYLE_ID = '__workapp_hidden';
/* The injected configuration is a snapshot from when this view was built, so
a selector added since would vanish on reload. The page's own storage
carries the current list across loads, and Rust rewrites it on every
navigation — so the rule is in place before the first paint rather than
arriving after the thing has already flashed on screen. */
function stored() {
try {
var raw = localStorage.getItem(STYLE_ID);
var list = raw ? JSON.parse(raw) : null;
return Array.isArray(list) ? list : null;
} catch (e) { return null; }
}
var hidden = stored() || (CFG.hidden || []).slice();
function applyHidden() {
var css = hidden.length
? hidden.join(',\n') + ' { display: none !important; }'
: '';
var el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement('style');
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
if (el.textContent !== css) el.textContent = css;
try { localStorage.setItem(STYLE_ID, JSON.stringify(hidden)); } catch (e) {}
}
/* Single-page apps rewrite <head>, which takes the rule with it. */
function watch() {
applyHidden();
try {
new MutationObserver(function () {
if (!document.getElementById(STYLE_ID)) applyHidden();
}).observe(document.documentElement, { childList: true, subtree: true });
} catch (e) {}
}
/**
* A selector for one element, preferring things likely to survive a reload.
*
* An id wins outright. Otherwise it climbs, taking up to two stable-looking
* classes per level and falling back to position. Framework classes that are
* hashed or numeric are skipped, since those change on every deploy.
*/
function selectorFor(el) {
function idOf(n) {
var id = n.getAttribute && n.getAttribute('id');
return id && /^[A-Za-z][\w-]*$/.test(id) ? '#' + CSS.escape(id) : null;
}
if (idOf(el)) return idOf(el);
var parts = [];
var node = el;
while (node && node.nodeType === 1 && parts.length < 5) {
if (node.tagName === 'BODY' || node.tagName === 'HTML') break;
var id = idOf(node);
if (id) { parts.unshift(id); break; }
var part = node.tagName.toLowerCase();
var classes = (node.getAttribute('class') || '')
.trim()
.split(/\s+/)
.filter(function (c) {
return c && c.length < 32 && /^[A-Za-z_-][\w-]*$/.test(c) && !/\d{3,}/.test(c);
})
.slice(0, 2);
if (classes.length) {
part += '.' + classes.map(function (c) { return CSS.escape(c); }).join('.');
} else {
var p = node.parentElement;
if (p) {
var same = Array.prototype.filter.call(p.children, function (c) {
return c.tagName === node.tagName;
});
if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(node) + 1) + ')';
}
}
parts.unshift(part);
node = node.parentElement;
}
return parts.join(' ') || el.tagName.toLowerCase();
}
function hide(el) {
if (!el || el === document.body || el === document.documentElement) return;
var sel = selectorFor(el);
try { if (!document.querySelector(sel)) return; } catch (e) { return; }
if (hidden.indexOf(sel) === -1) hidden.push(sel);
applyHidden();
send('hide', { s: sel });
}
/* --------------------------------------------------------- element picker */
var picking = false;
var marker = null;
var lastTarget = null;
function ensureMarker() {
if (marker) return marker;
marker = document.createElement('div');
marker.style.cssText = [
'position:fixed', 'z-index:2147483646', 'pointer-events:none',
'border:2px solid #0ea5e9', 'background:rgba(14,165,233,0.18)',
'border-radius:3px', 'transition:all 60ms ease-out'
].join(';');
document.documentElement.appendChild(marker);
return marker;
}
function onPickMove(e) {
var el = document.elementFromPoint(e.clientX, e.clientY);
if (!el || el === marker) return;
lastTarget = el;
var r = el.getBoundingClientRect();
var m = ensureMarker();
m.style.top = r.top + 'px';
m.style.left = r.left + 'px';
m.style.width = r.width + 'px';
m.style.height = r.height + 'px';
}
function onPickKey(e) {
if (e.key === 'Escape') { e.preventDefault(); stopPicking(); }
/* Arrow up widens the selection to the parent, for when the thing you
want is the container rather than the text you can point at. */
if (e.key === 'ArrowUp' && lastTarget && lastTarget.parentElement) {
e.preventDefault();
lastTarget = lastTarget.parentElement;
var r = lastTarget.getBoundingClientRect();
var m = ensureMarker();
m.style.top = r.top + 'px'; m.style.left = r.left + 'px';
m.style.width = r.width + 'px'; m.style.height = r.height + 'px';
}
}
function onPickClick(e) {
e.preventDefault();
e.stopPropagation();
var el = lastTarget || document.elementFromPoint(e.clientX, e.clientY);
stopPicking();
hide(el);
}
function startPicking() {
if (picking) return;
picking = true;
ensureMarker().style.display = 'block';
document.addEventListener('mousemove', onPickMove, true);
document.addEventListener('click', onPickClick, true);
document.addEventListener('keydown', onPickKey, true);
}
function stopPicking() {
picking = false;
if (marker) marker.style.display = 'none';
document.removeEventListener('mousemove', onPickMove, true);
document.removeEventListener('click', onPickClick, true);
document.removeEventListener('keydown', onPickKey, true);
}
/* Reachable from the shell: starting the picker, and reporting back. */
window.__workAppPick = startPicking;
window.__workAppSend = send;
/* ------------------------------------------------------- context menu */
var menu = null;
function closeMenu() {
if (menu) { menu.remove(); menu = null; }
}
function openMenu(x, y, target) {
closeMenu();
menu = document.createElement('div');
menu.style.cssText = [
'position:fixed', 'z-index:2147483647', 'min-width:210px',
'padding:5px', 'border-radius:10px',
'background:#ffffff', 'color:#1e293b',
'border:1px solid #cbd5e1',
'box-shadow:0 12px 28px rgba(2,6,23,0.22)',
'font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'left:' + x + 'px', 'top:' + y + 'px'
].join(';');
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
menu.style.background = '#0f172b';
menu.style.color = '#e2e8f0';
menu.style.borderColor = '#334155';
}
[
['Hide this element', function () { hide(target); }],
['Pick an element to hide…', startPicking],
['Manage hidden elements…', function () { send('manage', { x: '1' }); }]
].forEach(function (item) {
var b = document.createElement('div');
b.textContent = item[0];
b.style.cssText = 'padding:6px 10px;border-radius:6px;cursor:pointer;white-space:nowrap';
b.addEventListener('mouseenter', function () { b.style.background = 'rgba(14,165,233,0.15)'; });
b.addEventListener('mouseleave', function () { b.style.background = 'transparent'; });
b.addEventListener('click', function (ev) {
ev.preventDefault(); ev.stopPropagation();
closeMenu();
item[1]();
}, true);
menu.appendChild(b);
});
document.documentElement.appendChild(menu);
/* Keep it on screen when the click was near an edge. */
var r = menu.getBoundingClientRect();
if (r.right > innerWidth) menu.style.left = Math.max(4, innerWidth - r.width - 6) + 'px';
if (r.bottom > innerHeight) menu.style.top = Math.max(4, innerHeight - r.height - 6) + 'px';
}
document.addEventListener('contextmenu', function (e) {
e.preventDefault();
openMenu(e.clientX, e.clientY, e.target);
}, true);
document.addEventListener('mousedown', function (e) {
if (menu && !menu.contains(e.target)) closeMenu();
}, true);
/* ------------------------------------------------------- notifications */
/* WKWebView *does* define Notification — it just does nothing. Constructing
one throws no error and shows no banner, so Gmail believes it notified you
and you never hear about it. The native one is therefore replaced outright
rather than only filled in when missing. */
{
var WorkNotification = function (title, options) {
options = options || {};
this.title = title;
this.body = options.body || '';
send('notify', { t: String(title || ''), b: String(options.body || ''), a: CFG.name || '' });
};
WorkNotification.__work = true;
WorkNotification.permission = 'granted';
WorkNotification.requestPermission = function (cb) {
if (cb) cb('granted');
return Promise.resolve('granted');
};
WorkNotification.prototype.close = function () {};
WorkNotification.prototype.addEventListener = function () {};
WorkNotification.prototype.removeEventListener = function () {};
try {
Object.defineProperty(window, 'Notification', {
value: WorkNotification, writable: true, configurable: true
});
} catch (e) { window.Notification = WorkNotification; }
}
/* ------------------------------------------------------------- start */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', watch);
applyHidden();
} else {
watch();
}
})();
+12
View File
@@ -1,6 +1,7 @@
pub mod commands;
pub mod config;
pub mod routing;
pub mod cookies;
pub mod webviews;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
@@ -50,9 +51,14 @@ pub fn run() {
tauri::Builder::default()
.menu(build_menu)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
let state = commands::build_state(&app.handle().clone())?;
app.manage(state);
// Asked for at startup rather than at the first notification, so
// the prompt does not arrive attached to someone else's message.
commands::ensure_notification_permission(&app.handle().clone());
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -75,6 +81,12 @@ pub fn run() {
commands::delete_group,
commands::set_nav_collapsed,
commands::set_theme,
commands::set_hidden,
commands::pick_hidden,
commands::test_notification,
commands::notification_status,
commands::list_browsers,
commands::pair_browser,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+6
View File
@@ -65,6 +65,12 @@ pub fn host_matches(host: &str, scope: &str) -> bool {
host == scope || host.ends_with(&format!(".{scope}"))
}
/// The sign-in hosts, for the cookie import — a session for a tool is no use
/// without the identity provider's cookie that vouches for it.
pub fn identity_providers() -> &'static [&'static str] {
IDENTITY_PROVIDERS
}
fn is_identity_provider(host: &str) -> bool {
IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p))
}
+238 -109
View File
@@ -5,23 +5,25 @@
//! and anything the shell wants to draw over an app (a dialog) requires hiding
//! the app first.
use std::collections::HashMap;
use serde::Serialize;
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
webview::{NewWindowResponse, WebviewBuilder},
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
};
use url::Url;
use crate::config::{App, Config};
use crate::routing::{self, AppScope, Decision};
/// Scheme the injected interceptor uses to hand a URL back for a decision.
/// Scheme prefix the injected script uses to talk back.
///
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
/// granting google.com the ability to call into this app, and routing a link
/// does not need anything that dangerous. `on_navigation` sees this, answers
/// it, and cancels the navigation so nothing ever loads.
const ROUTE_SCHEME: &str = "workapp-route";
/// granting google.com the ability to call into this app, and none of these
/// messages need anything that dangerous. `on_navigation` answers them and
/// cancels the navigation, so nothing ever loads.
const SCHEME_PREFIX: &str = "workapp-";
pub fn label_for(app_id: &str) -> String {
format!("app-{app_id}")
@@ -41,86 +43,40 @@ pub struct UrlEvent {
pub url: String,
}
/// The click interceptor, specialised for one app's scope.
///
/// It only decides *intent*: a link the user clicked that leaves this app's
/// hosts is prevented and handed to Rust. Redirects, form posts and OAuth
/// bounces are untouched, which is what keeps sign-in flows alive.
fn interceptor_script(scopes: &[String]) -> String {
let json = serde_json::to_string(scopes).unwrap_or_else(|_| "[]".into());
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenEvent {
pub app_id: String,
pub selector: String,
}
/// The page script, with this app's configuration written above it.
fn script_for(app: &App) -> String {
let cfg = serde_json::json!({
"scopes": app.scopes(),
"hidden": app.hidden,
"name": app.name,
});
format!(
r#"(function () {{
if (window.__workAppRouter) return;
window.__workAppRouter = true;
var SCOPES = {json};
function abs(href) {{ try {{ return new URL(href, document.baseURI).href; }} catch (e) {{ return null; }} }}
function inScope(u) {{
try {{
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
return SCOPES.some(function (s) {{
s = String(s).replace(/^www\./, '').toLowerCase();
return h === s || h.endsWith('.' + s);
}});
}} catch (e) {{ return false; }}
}}
function ask(u) {{
try {{ window.location.href = '{scheme}:/?u=' + encodeURIComponent(u); }} catch (e) {{}}
}}
document.addEventListener('click', function (e) {{
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
var href = a.getAttribute('href');
if (!href || href.charAt(0) === '#') return;
if (/^(javascript|blob|data):/i.test(href)) return;
var u = abs(href);
if (!u) return;
if (inScope(u)) {{
// Our own host. A new-tab link has nowhere to go in a tabless app, so
// it takes over this view rather than being swallowed.
if (a.target === '_blank') {{ e.preventDefault(); window.location.href = u; }}
return;
}}
e.preventDefault();
e.stopPropagation();
ask(u);
}}, true);
var nativeOpen = window.open;
window.open = function (url) {{
if (!url) return null;
var u = abs(url);
if (!u) return null;
if (inScope(u)) {{ window.location.href = u; return null; }}
ask(u);
return null;
}};
void nativeOpen;
}})();"#,
json = json,
scheme = ROUTE_SCHEME
"window.__WORKAPP = {};\n{}",
cfg,
include_str!("inject.js")
)
}
/// Pulls the URL back out of a `workapp-route:/?u=…` sentinel.
pub fn route_target(url: &Url) -> Option<String> {
if url.scheme() != ROUTE_SCHEME {
return None;
}
url.query_pairs()
.find(|(k, _)| k == "u")
.map(|(_, v)| v.to_string())
/// Splits a sentinel URL into its kind and query parameters.
pub fn sentinel(url: &Url) -> Option<(String, HashMap<String, String>)> {
let kind = url.scheme().strip_prefix(SCHEME_PREFIX)?.to_string();
let params = url
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
Some((kind, params))
}
/// Acts on a decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, from: &str, decision: Decision) {
/// Acts on a routing decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, decision: Decision) {
match decision {
// Only an identity provider reaches here, and the click that produced
// it was already cancelled — so this view has to be sent there.
Decision::Stay => {}
Decision::Switch { app_id, url } => {
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
@@ -129,31 +85,130 @@ fn apply(handle: &AppHandle, from: &str, decision: Decision) {
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
}
}
let _ = from;
}
/// Handles a sentinel URL: decide, then act without blocking the delegate.
fn handle_route(handle: &AppHandle, from: &str, target: String, scopes: Vec<AppScope>) {
/// Answers one message from the page, without blocking the delegate.
fn handle_sentinel(
handle: &AppHandle,
from: &str,
kind: String,
params: HashMap<String, String>,
scopes: Vec<AppScope>,
) {
let handle = handle.clone();
let from = from.to_string();
tauri::async_runtime::spawn(async move {
match routing::decide(&target, Some(&from), &scopes) {
Decision::Stay => {
// The click was cancelled to ask the question, so completing it
// is now this side's job.
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(&target) {
let _ = wv.navigate(u);
match kind.as_str() {
"route" => {
let Some(target) = params.get("u") else { return };
match routing::decide(target, Some(&from), &scopes) {
// Only an identity provider reaches here, and the click was
// already cancelled to ask the question — so completing it
// is now this side's job.
Decision::Stay => {
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(target) {
let _ = wv.navigate(u);
}
}
}
other => apply(&handle, other),
}
}
other => apply(&handle, &from, other),
"hide" => {
let Some(selector) = params.get("s") else { return };
let state = handle.state::<crate::commands::AppState>();
if state.add_hidden(&from, selector).is_ok() {
let _ = handle.emit(
"hidden-added",
HiddenEvent { app_id: from.clone(), selector: selector.clone() },
);
}
}
"notify" => {
use tauri_plugin_notification::NotificationExt;
let title = params.get("t").cloned().unwrap_or_default();
let body = params.get("b").cloned().unwrap_or_default();
let app_name = params.get("a").cloned().unwrap_or_default();
// The app's own name leads, or a notification from four tools
// in one window says nothing about which one wants you.
let heading = if app_name.is_empty() {
title.clone()
} else {
format!("{app_name}{title}")
};
let outcome = match handle
.notification()
.builder()
.title(heading.clone())
.body(body)
.show()
{
Ok(()) => "raised".to_string(),
Err(e) => {
eprintln!("could not raise a notification: {e}");
format!("failed: {e}")
}
};
// Recorded so the diagnostic can show that a page's notification
// actually reached macOS, not merely that the shim ran.
let state = handle.state::<crate::commands::AppState>();
*state.last_notification.lock().unwrap() = format!("{heading}{outcome}");
}
// Reports what the page found, over the same channel a real
// notification uses — so a silent failure says which half broke.
"diag" => {
let state = handle.state::<crate::commands::AppState>();
let mut parts: Vec<String> = params
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
parts.sort();
*state.diag.lock().unwrap() = parts.join(" ");
}
"manage" => {
let _ = handle.emit("manage-hidden", from.clone());
}
_ => {}
}
});
}
/// Turns on WKWebView's two-finger back and forward swipes.
///
/// wry supports it but Tauri does not expose it, so it is set on the native
/// view after the fact. It is the only navigation gesture the app has, now
/// that there is no toolbar carrying arrows.
#[cfg(target_os = "macos")]
fn enable_swipe_navigation(handle: &AppHandle, app_id: &str) {
use objc2_web_kit::WKWebView;
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
let _ = wv.with_webview(|platform| unsafe {
let ptr = platform.inner() as *const WKWebView;
if ptr.is_null() {
return;
}
(*ptr).setAllowsBackForwardNavigationGestures(true);
});
}
}
#[cfg(not(target_os = "macos"))]
fn enable_swipe_navigation(_: &AppHandle, _: &str) {}
/// Builds the child webview for one app and parks it in the stage rect.
pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64, f64)) -> Result<(), String> {
pub fn create(
handle: &AppHandle,
app: &App,
cfg: &Config,
stage: (f64, f64, f64, f64),
) -> Result<(), String> {
let window = handle
.get_window("main")
.ok_or_else(|| "main window is gone".to_string())?;
@@ -170,15 +225,18 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
let win_id = id.clone();
let win_scopes = scopes.clone();
let load_handle = handle.clone();
let load_id = id.clone();
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
.user_agent(&app.ua())
.initialization_script(interceptor_script(&app.scopes()))
.initialization_script(script_for(app))
.on_navigation(move |url| {
// The sentinel is a question, not a destination: answer it and
// A sentinel is a question, not a destination: answer it and
// cancel. Everything else is allowed — a strict filter here would
// break every OAuth redirect chain.
if let Some(target) = route_target(url) {
handle_route(&nav_handle, &nav_id, target, nav_scopes.clone());
if let Some((kind, params)) = sentinel(url) {
handle_sentinel(&nav_handle, &nav_id, kind, params, nav_scopes.clone());
return false;
}
let _ = nav_handle.emit(
@@ -189,16 +247,27 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
})
.on_new_window(move |url, _features| {
// Nothing may open a window of its own; the same rules apply.
let decision = routing::decide(url.as_str(), Some(&win_id), &win_scopes);
match decision {
match routing::decide(url.as_str(), Some(&win_id), &win_scopes) {
Decision::Stay => {
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
let _ = wv.navigate(url);
}
}
other => apply(&win_handle, &win_id, other),
other => apply(&win_handle, other),
}
NewWindowResponse::Deny
})
// The script carries a snapshot of the hidden list from when the view
// was built, so anything chosen since would come back on reload. This
// re-asserts the real list on every navigation.
.on_page_load(move |_wv, _payload| {
let state = load_handle.state::<crate::commands::AppState>();
let hidden = state
.cfg()
.app(&load_id)
.map(|a| a.hidden.clone())
.unwrap_or_default();
push_hidden(&load_handle, &load_id, &hidden);
});
window
@@ -209,8 +278,10 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
)
.map_err(|e| e.to_string())?;
// Created hidden. `show` is what puts one on screen, so startup does not
// flash every app in turn as they are built.
enable_swipe_navigation(handle, &id);
// Created hidden. `show_only` is what puts one on screen, so startup does
// not flash every app in turn as they are built.
if let Some(wv) = handle.get_webview(&label_for(&id)) {
let _ = wv.hide();
}
@@ -218,7 +289,12 @@ pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64
}
/// Shows one app and hides the rest, sizing it to the stage.
pub fn show_only(handle: &AppHandle, app_id: Option<&str>, cfg: &Config, stage: (f64, f64, f64, f64)) {
pub fn show_only(
handle: &AppHandle,
app_id: Option<&str>,
cfg: &Config,
stage: (f64, f64, f64, f64),
) {
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
if Some(app.id.as_str()) == app_id {
@@ -254,26 +330,79 @@ pub fn destroy(handle: &AppHandle, app_id: &str) {
}
}
/// Re-applies an app's hidden selectors without a reload.
///
/// The script owns the stylesheet, so changing the list from Settings is a
/// message to the page rather than a rebuild of the view.
pub fn push_hidden(handle: &AppHandle, app_id: &str, hidden: &[String]) {
let Some(wv) = handle.get_webview(&label_for(app_id)) else { return };
let json = serde_json::to_string(hidden).unwrap_or_else(|_| "[]".into());
let script = format!(
r#"(function(){{
var css = {json}.length ? {json}.join(',\n') + ' {{ display: none !important; }}' : '';
var el = document.getElementById('__workapp_hidden');
if (!el) {{
el = document.createElement('style');
el.id = '__workapp_hidden';
(document.head || document.documentElement).appendChild(el);
}}
el.textContent = css;
try {{ localStorage.setItem('__workapp_hidden', JSON.stringify({json})); }} catch (e) {{}}
}})();"#
);
let _ = wv.eval(&script);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_target_round_trips_a_url_with_a_query() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1%26c%3D2").unwrap();
assert_eq!(route_target(&u).unwrap(), "https://x.com/a?b=1&c=2");
fn sentinel_splits_kind_from_parameters() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "route");
assert_eq!(params["u"], "https://x.com/a?b=1");
}
#[test]
fn sentinel_reads_a_notification() {
let u = Url::parse("workapp-notify:/?t=New%20mail&b=From%20Sam&a=Gmail").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "notify");
assert_eq!(params["t"], "New mail");
assert_eq!(params["b"], "From Sam");
}
#[test]
fn sentinel_reads_a_hide_selector() {
let u = Url::parse("workapp-hide:/?s=%23promo%20.banner").unwrap();
let (kind, params) = sentinel(&u).unwrap();
assert_eq!(kind, "hide");
assert_eq!(params["s"], "#promo .banner");
}
#[test]
fn ordinary_urls_are_not_sentinels() {
let u = Url::parse("https://github.com/?u=x").unwrap();
assert!(route_target(&u).is_none());
assert!(sentinel(&Url::parse("https://github.com/?u=x").unwrap()).is_none());
assert!(sentinel(&Url::parse("mailto:a@b.com").unwrap()).is_none());
}
#[test]
fn the_script_carries_the_apps_own_scope() {
let s = interceptor_script(&["mail.google.com".into()]);
fn the_script_carries_this_apps_own_configuration() {
let app = App {
id: "a".into(),
name: "Gmail".into(),
url: "https://mail.google.com".into(),
scope: vec!["mail.google.com".into()],
group_id: None,
user_agent: None,
hidden: vec![".ad".into()],
order: 0,
};
let s = script_for(&app);
assert!(s.contains("mail.google.com"));
assert!(s.contains("workapp-route:/?u="));
assert!(s.contains(".ad"));
assert!(s.contains("__workAppReady"));
}
}
+40 -49
View File
@@ -2,18 +2,17 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import Nav, { navWidth } from "./components/Nav";
import Nav from "./components/Nav";
import Settings from "./components/Settings";
import TopBar from "./components/TopBar";
import { BTN_PRIMARY } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance";
import type { Config, Group, SwitchEvent, UrlEvent } from "./types";
import type { Config, Group, HiddenEvent, SwitchEvent } from "./types";
export default function App() {
const [config, setConfig] = useState<Config | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [focusHidden, setFocusHidden] = useState<string | null>(null);
const [theme, setTheme] = useAppearance("system");
const stageRef = useRef<HTMLDivElement>(null);
@@ -24,7 +23,6 @@ export default function App() {
activeRef.current = activeId;
const collapsed = config?.settings.navCollapsed ?? false;
const activeApp = config?.apps.find((a) => a.id === activeId) ?? null;
useEffect(() => {
api.getConfig().then((c) => {
@@ -72,20 +70,24 @@ export default function App() {
}, [config, report]);
useEffect(() => {
if (!activeId) return;
void api.setActive(activeId);
void api.currentUrl(activeId).then(setUrl);
if (activeId) void api.setActive(activeId);
}, [activeId]);
// A link in one app that points at another: Rust decided, the shell moves.
useEffect(() => {
const unlisten = [
// A link in one app that points at another: Rust decided, the shell moves.
listen<SwitchEvent>("switch-app", async (e) => {
setActiveId(e.payload.appId);
await api.navigateApp(e.payload.appId, e.payload.url);
}),
listen<UrlEvent>("url-changed", (e) => {
if (e.payload.appId === activeRef.current) setUrl(e.payload.url);
// Something was right-clicked away inside a page.
listen<HiddenEvent>("hidden-added", () => {
void api.getConfig().then(setConfig);
}),
// "Manage hidden elements…" from a page's context menu.
listen<string>("manage-hidden", (e) => {
setFocusHidden(e.payload);
setSettingsOpen(true);
}),
];
return () => {
@@ -110,10 +112,7 @@ export default function App() {
const toggleGroup = (g: Group) => {
if (!config) return;
const updated = { ...g, collapsed: !g.collapsed };
setConfig({
...config,
groups: config.groups.map((x) => (x.id === g.id ? updated : x)),
});
setConfig({ ...config, groups: config.groups.map((x) => (x.id === g.id ? updated : x)) });
void api.updateGroup(updated);
};
@@ -125,55 +124,47 @@ export default function App() {
if (!config) return null;
return (
<div className="flex h-screen flex-col">
<TopBar
url={url}
appName={activeApp?.name ?? null}
<div className="flex h-screen">
<Nav
config={config}
activeId={activeId}
collapsed={collapsed}
onSelect={setActiveId}
onToggleCollapse={toggleCollapse}
onOpenSettings={() => { setFocusHidden(null); setSettingsOpen(true); }}
onToggleGroup={toggleGroup}
onBack={() => activeId && api.historyGo(activeId, -1)}
onForward={() => activeId && api.historyGo(activeId, 1)}
onReload={() => activeId && api.historyGo(activeId, 0)}
onOpenExternal={() => url && api.openExternal(url)}
onPick={() => activeId && api.pickHidden(activeId)}
/>
<div className="flex min-h-0 flex-1">
<Nav
config={config}
activeId={activeId}
collapsed={collapsed}
onSelect={setActiveId}
onToggleCollapse={toggleCollapse}
onOpenSettings={() => setSettingsOpen(true)}
onToggleGroup={toggleGroup}
/>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
{config.apps.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-[13px] text-slate-500 dark:text-slate-400">
No apps yet. Add the tools you work in and they appear in the nav.
</p>
<button onClick={() => setSettingsOpen(true)} className={BTN_PRIMARY}>
Add your first app
</button>
</div>
)}
</div>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
{config.apps.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-[13px] text-slate-500 dark:text-slate-400">
No apps yet. Add the tools you work in and they appear in the nav.
</p>
<button onClick={() => setSettingsOpen(true)} className={BTN_PRIMARY}>
Add your first app
</button>
</div>
)}
</div>
{settingsOpen && (
<Settings
config={config}
theme={theme}
activeId={activeId}
focusHiddenFor={focusHidden}
onConfig={setConfig}
onTheme={onTheme}
onClose={() => setSettingsOpen(false)}
onClose={() => { setSettingsOpen(false); setFocusHidden(null); }}
/>
)}
{/* Keeps the nav width honest for the stage measurement above. */}
<span hidden>{navWidth(collapsed)}</span>
</div>
);
}
+12 -1
View File
@@ -1,7 +1,7 @@
/** Every call into Rust, in one place. */
import { invoke } from "@tauri-apps/api/core";
import type { Config, Group, WorkApp } from "./types";
import type { Browser, Config, Group, PairResult, WorkApp } from "./types";
export const getConfig = () => invoke<Config>("get_config");
export const bootstrap = () => invoke<void>("bootstrap");
@@ -35,3 +35,14 @@ export const deleteGroup = (groupId: string) => invoke<Config>("delete_group", {
export const setNavCollapsed = (collapsed: boolean) =>
invoke<void>("set_nav_collapsed", { collapsed });
export const setTheme = (theme: string) => invoke<void>("set_theme", { theme });
export const setHidden = (appId: string, hidden: string[]) =>
invoke<Config>("set_hidden", { appId, hidden });
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) =>
invoke<void>("test_notification", { appId });
export const notificationStatus = () => invoke<string>("notification_status");
+73 -55
View File
@@ -1,4 +1,5 @@
import type { Config, Group, WorkApp } from "../types";
import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons";
import { Favicon, HEADING, ICON_CHROME } from "./ui";
interface Props {
@@ -9,51 +10,65 @@ interface Props {
onToggleCollapse: () => void;
onOpenSettings: () => void;
onToggleGroup: (g: Group) => void;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onPick: () => void;
}
const RAIL = 52;
/**
* Wide enough that the macOS traffic lights fit inside the rail rather than
* spilling over the page. Everything else about the rail follows from that.
*/
const RAIL = 72;
const PANEL = 240;
/** The strip the traffic lights sit in. Draggable, since there is no title bar. */
const TITLEBAR = 36;
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
strokeLinecap="round"
strokeLinejoin="round"
d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
/>
</svg>
);
}
function Cog() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
);
}
export default function Nav({
config,
activeId,
collapsed,
onSelect,
onToggleCollapse,
onOpenSettings,
onToggleGroup,
config, activeId, collapsed,
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload, onPick,
}: Props) {
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) =>
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
const ungrouped = inGroup(null);
const disabled = !activeId;
const shell =
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
"dark:border-slate-800 dark:bg-slate-900";
/* Back, forward, reload, hide-an-element and settings, in that order —
navigation first because it is what gets reached for most. */
const controls = (
<>
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
<Back />
</button>
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_CHROME}>
<Forward />
</button>
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
<Reload />
</button>
<button
onClick={onPick}
disabled={disabled}
title="Hide an element on this page — or right-click it"
className={ICON_CHROME}
>
<EyeOff />
</button>
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
</>
);
// ------------------------------------------------------------ icon rail
if (collapsed) {
const railBtn = (app: WorkApp) => {
@@ -65,41 +80,38 @@ export default function Nav({
title={app.name}
aria-label={app.name}
className={
"relative grid size-9 cursor-pointer place-items-center rounded-lg transition-colors " +
"relative grid size-10 cursor-pointer place-items-center rounded-lg transition-colors " +
(active
? "bg-slate-100 dark:bg-slate-800"
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
}
>
{active && (
<span className="absolute -left-2 h-5 w-[3px] rounded-full bg-sky-500" />
)}
<Favicon url={app.url} name={app.name} size={18} />
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
<Favicon url={app.url} name={app.name} size={20} />
</button>
);
};
return (
<aside className={`${shell} w-[52px] items-center`} style={{ width: RAIL }}>
<div className="flex w-full flex-col items-center gap-1 border-b border-slate-200 py-2 dark:border-slate-800">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
<div className="flex flex-wrap justify-center gap-0.5 px-1">{controls}</div>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Chevron dir="right" />
<Collapse open={false} />
</button>
</div>
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
{ungrouped.map(railBtn)}
{ungrouped.length > 0 && groups.length > 0 && (
<span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />
<span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
)}
{groups.map((g, i) => {
const apps = inGroup(g.id);
if (apps.length === 0) return null;
return (
<div key={g.id} className="flex flex-col items-center gap-1">
{i > 0 && <span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />}
{i > 0 && <span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
{apps.map(railBtn)}
</div>
);
@@ -125,22 +137,30 @@ export default function Nav({
>
<Favicon url={app.url} name={app.name} />
<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>
</li>
);
return (
<aside className={shell} style={{ width: PANEL }}>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
<span className="truncate text-[13px] font-semibold tracking-tight">Apps</span>
<div className="flex shrink-0 items-center gap-1">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Collapse" className={ICON_CHROME}>
<Chevron dir="left" />
</button>
</div>
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="shrink-0" />
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
{controls}
<button
onClick={onToggleCollapse}
title="Collapse"
className={`${ICON_CHROME} ml-auto`}
>
<Collapse open />
</button>
</header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
@@ -158,9 +178,7 @@ export default function Nav({
<svg
viewBox="0 0 24 24"
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
fill="none" stroke="currentColor" strokeWidth="2.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
+234 -99
View File
@@ -1,49 +1,53 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import * as api from "../api";
import type { Config, Group, WorkApp } from "../types";
import type { Theme } from "../hooks/useAppearance";
import type { Browser, Config, Group, PairResult, WorkApp } from "../types";
import { Trash } from "./icons";
import {
BTN,
BTN_PRIMARY,
Badge,
Dialog,
Favicon,
HELP,
ICON_CHROME,
INPUT,
LABEL,
SUBPANEL,
SectionHeading,
Segmented,
SUBPANEL,
Spinner,
} from "./ui";
interface Props {
config: Config;
/** The app a test notification is fired from. */
activeId: string | null;
theme: Theme;
/** Opens straight onto the hidden-elements section when set. */
focusHiddenFor?: string | null;
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
}
export default function Settings({ config, theme, onConfig, onTheme, onClose }: Props) {
export default function Settings({
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
}: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [groupId, setGroupId] = useState<string>("");
const [groupId, setGroupId] = useState("");
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | 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 [paired, setPaired] = useState<PairResult | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
// Same order the nav shows, so the two lists never disagree about position.
@@ -53,6 +57,18 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
(a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order,
);
useEffect(() => {
api.listBrowsers().then((b) => {
setBrowsers(b);
setBrowser(config.settings.pairedBrowser ?? b[0]?.id ?? "");
});
}, [config.settings.pairedBrowser]);
useEffect(() => {
if (!focusHiddenFor) return;
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
}, [focusHiddenFor]);
const run = async (fn: () => Promise<Config>) => {
try {
onConfig(await fn());
@@ -78,6 +94,23 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
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);
return (
<>
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
@@ -92,87 +125,68 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && (
<p className={HELP}>Nothing yet. Add the first one below.</p>
)}
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
{orderedApps.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Odoo"
className={`${INPUT} w-full`}
/>
<input value={name} onChange={(e) => setName(e.target.value)}
placeholder="Odoo" className={`${INPUT} w-full`} />
</label>
<label className="flex-[1.4] space-y-1">
<span className={LABEL}>URL</span>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
<input value={url} onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addApp()}
placeholder="example.com"
className={`${INPUT} w-full font-mono text-[11px]`}
/>
placeholder="example.com" className={`${INPUT} w-full font-mono text-[11px]`} />
</label>
<label className="w-[110px] space-y-1">
<span className={LABEL}>Group</span>
<select
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}
>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} w-full cursor-pointer`}>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
<button onClick={addApp} className={BTN_PRIMARY}>
Add
</button>
<button onClick={addApp} className={BTN_PRIMARY}>Add</button>
</div>
<p className={HELP}>
An app owns its URL's host. Links to any other app on this list switch to it;
@@ -180,21 +194,150 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
</p>
</section>
{/* --------------------------------------------------- groups */}
{/* ------------------------------------------- hidden elements */}
<section id="hidden-elements" className="space-y-2">
<SectionHeading>Hidden elements</SectionHeading>
{withHidden.length === 0 ? (
<p className={HELP}>
Nothing hidden. Right-click anything in an app and choose <em>Hide this
element</em> or use the eye button in the nav to pick one.
</p>
) : (
<div className="space-y-3">
{withHidden.map((app) => (
<div key={app.id} className={SUBPANEL}>
<div className="mb-2 flex items-center gap-2">
<Favicon url={app.url} name={app.name} />
<span className="text-[12px] font-medium">{app.name}</span>
<Badge tone="accent">{app.hidden.length}</Badge>
</div>
<div className="space-y-1">
{app.hidden.map((sel, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={sel}
onChange={(e) => {
const next = [...app.hidden];
next[i] = e.target.value;
run(() => api.setHidden(app.id, next));
}}
title="Edit the selector to widen or narrow what it hides"
className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`}
/>
<button
onClick={() =>
run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
}
title="Show this again"
className={`${ICON_CHROME} hover:text-red-500!`}
>
<Trash />
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
<p className={HELP}>
Changes apply immediately, without a reload. A selector that stops matching
after the site changes simply hides nothing.
</p>
</section>
{/* -------------------------------------------------- pairing */}
<section className="space-y-2">
<SectionHeading>Browser pairing</SectionHeading>
{browsers.length === 0 ? (
<p className={HELP}>No browser with a readable cookie store was found.</p>
) : (
<>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<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={pair} disabled={pairing || !browser} className={BTN_PRIMARY}>
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"}
</button>
</div>
{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>
)}
<p className={HELP}>
Only cookies for the apps above and their sign-in hosts are read nothing
else leaves the browser. macOS asks for Keychain permission the first time.
Google and Microsoft tie sessions to the browser that made them, so those two
may still ask you to sign in once; after that it sticks.
</p>
</>
)}
</section>
{/* --------------------------------------------- notifications */}
<section className="space-y-2">
<SectionHeading>Notifications</SectionHeading>
<div className="flex items-center gap-2">
<button
onClick={() => activeId && api.testNotification(activeId)}
disabled={!activeId}
className={BTN}
>
Send a test notification
</button>
<button
onClick={async () => setNotifyStatus(await api.notificationStatus())}
className={BTN}
>
Check permission
</button>
</div>
{notifyStatus && (
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
)}
<p className={HELP}>
WKWebView defines a notification API that silently does nothing, so it is
replaced with one that forwards to macOS. Notifications raised by a service
worker in the background are not covered only those a page raises while open.
</p>
</section>
{/* ---------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.map((g: Group) => (
<div
key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<div key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800">
<input
value={g.name}
onChange={(e) =>
run(() => api.updateGroup({ ...g, name: e.target.value }))
}
onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value }))}
className={`${INPUT} h-[26px] w-full flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
@@ -205,7 +348,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
title={`Delete ${g.name} — its apps stay, ungrouped`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
<Trash />
</button>
</div>
))}
@@ -213,17 +356,11 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>New group</span>
<input
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance"
className={`${INPUT} w-full`}
/>
placeholder="Finance" className={`${INPUT} w-full`} />
</label>
<button onClick={addGroup} className={BTN}>
Add group
</button>
<button onClick={addGroup} className={BTN}>Add group</button>
</div>
</section>
@@ -242,9 +379,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<button onClick={onClose} className={BTN}>
Done
</button>
<button onClick={onClose} className={BTN}>Done</button>
</div>
</div>
</Dialog>
@@ -262,7 +397,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
>
It disappears from the nav and its view is closed. The session it holds for{" "}
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
adding it back does not mean logging in again.
adding it back does not mean signing in again.
</Dialog>
)}
</>
-75
View File
@@ -1,75 +0,0 @@
import { ICON_CHROME } from "./ui";
interface Props {
url: string | null;
appName: string | null;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onOpenExternal: () => void;
}
/** Reserves the strip the macOS traffic lights sit in. */
const TRAFFIC_LIGHTS = 78;
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
/**
* Browser chrome, kept to the four things a tabless app still needs. It spans
* the whole window rather than sitting inside the stage, so the traffic lights
* have somewhere to live no matter how narrow the nav gets.
*/
export default function TopBar({
url,
appName,
onBack,
onForward,
onReload,
onOpenExternal,
}: Props) {
return (
<header
data-tauri-drag-region
className="flex h-[38px] shrink-0 items-center gap-1 border-b border-slate-300
bg-white px-2 dark:border-slate-800 dark:bg-slate-900"
style={{ paddingLeft: TRAFFIC_LIGHTS }}
>
<button onClick={onBack} title="Back" className={ICON_CHROME}>
<Icon d="M15 6l-6 6 6 6" />
</button>
<button onClick={onForward} title="Forward" className={ICON_CHROME}>
<Icon d="M9 6l6 6-6 6" />
</button>
<button onClick={onReload} title="Reload" className={ICON_CHROME}>
<Icon d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</button>
<div
data-tauri-drag-region
className="mx-2 flex min-w-0 flex-1 items-center gap-2 text-[11px]"
>
{appName && (
<span className="shrink-0 font-medium text-slate-600 dark:text-slate-300">
{appName}
</span>
)}
<span className="truncate text-slate-400 dark:text-slate-500">{url ?? ""}</span>
</div>
<button
onClick={onOpenExternal}
title="Open this page in your browser"
className={ICON_CHROME}
disabled={!url}
>
<Icon d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" />
</button>
</header>
);
}
+36
View File
@@ -0,0 +1,36 @@
/** One stroke weight, one size, one place to find them. */
type Props = { className?: string };
const S = ({ d, className = "size-4" }: { d: string } & Props) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
export const Back = (p: Props) => <S d="M15 6l-6 6 6 6" {...p} />;
export const Forward = (p: Props) => <S d="M9 6l6 6-6 6" {...p} />;
export const Reload = (p: Props) => <S d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" {...p} />;
export const External = (p: Props) => (
<S d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" {...p} />
);
export const EyeOff = (p: Props) => (
<S d="M3 3l18 18M10.6 10.6a2 2 0 002.8 2.8M9.4 5.4A9.5 9.5 0 0112 5c5 0 9 4.5 9 7a11 11 0 01-2.4 3.5M6.2 6.9C4 8.3 3 10.4 3 12c0 2.5 4 7 9 7a9.6 9.6 0 004-.85" {...p} />
);
export const Trash = ({ className = "size-3.5" }: Props) => (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
export const Cog = (p: Props) => (
<svg viewBox="0 0 24 24" className={p.className ?? "size-4"} fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
);
export const Collapse = ({ open, className = "size-4" }: Props & { open: boolean }) => (
<S d={open ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"} className={className} />
);
+10
View File
@@ -56,6 +56,16 @@ export const ICON_CHROME =
`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 ` +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
/** The only spinning thing in the app; used where a wait has no known length. */
export function Spinner({ className = "size-4" }: { className?: string }) {
return (
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export function SectionHeading({ children }: { children: ReactNode }) {
return <h2 className={`flex items-center gap-2 ${HEADING}`}>{children}</h2>;
}
+21
View File
@@ -5,6 +5,8 @@ export interface WorkApp {
scope: string[];
groupId: string | null;
userAgent: string | null;
/** CSS selectors this app hides on every page. */
hidden: string[];
order: number;
}
@@ -39,3 +41,22 @@ export interface UrlEvent {
appId: 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. */
export interface HiddenEvent {
appId: string;
selector: string;
}