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.
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]}}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 874 B |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 12 KiB |
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(©, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| format!("could not open the cookie store: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT host_key, name, value, encrypted_value, path, expires_utc, \
|
||||
is_secure, is_httponly FROM cookies",
|
||||
)
|
||||
.map_err(|e| format!("unfamiliar cookie schema: {e}"))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let host: String = r.get(0)?;
|
||||
let plain: String = r.get(2).unwrap_or_default();
|
||||
let enc: Vec<u8> = r.get(3).unwrap_or_default();
|
||||
let value = if plain.is_empty() {
|
||||
decrypt_value(&enc, &key, &host).unwrap_or_default()
|
||||
} else {
|
||||
plain
|
||||
};
|
||||
Ok(Cookie {
|
||||
domain: host,
|
||||
name: r.get(1)?,
|
||||
value,
|
||||
path: r.get(4).unwrap_or_else(|_| "/".into()),
|
||||
expires: chromium_time(r.get::<_, i64>(5).unwrap_or(0)),
|
||||
secure: r.get::<_, i64>(6).unwrap_or(0) != 0,
|
||||
http_only: r.get::<_, i64>(7).unwrap_or(0) != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let cookies: Vec<Cookie> = rows
|
||||
.filter_map(Result::ok)
|
||||
// A value that would not decrypt is worse than useless: injecting an
|
||||
// empty session cookie logs you out rather than in.
|
||||
.filter(|c| !c.value.is_empty())
|
||||
.collect();
|
||||
|
||||
let _ = std::fs::remove_dir_all(copy.parent().unwrap_or(©));
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Chromium counts microseconds from 1601-01-01; the rest of the world counts
|
||||
/// seconds from 1970. Zero means a session cookie.
|
||||
fn chromium_time(value: i64) -> Option<i64> {
|
||||
if value <= 0 {
|
||||
return None;
|
||||
}
|
||||
const EPOCH_DELTA: i64 = 11_644_473_600;
|
||||
Some(value / 1_000_000 - EPOCH_DELTA)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chromium_time_converts_to_unix_seconds() {
|
||||
// 13 Jan 2022 00:00:00 UTC in Chromium's epoch.
|
||||
assert_eq!(chromium_time(13_287_916_800_000_000), Some(1_643_443_200));
|
||||
assert_eq!(chromium_time(0), None);
|
||||
assert_eq!(chromium_time(-5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_derivation_matches_chromiums_published_parameters() {
|
||||
// Cross-checked against an independent implementation:
|
||||
// python3 -c "import hashlib; print(hashlib.pbkdf2_hmac(
|
||||
// 'sha1', b'peanuts', b'saltysalt', 1003, 16).hex())"
|
||||
// "peanuts" is Chromium's documented fallback password. If this ever
|
||||
// fails, the KDF is wrong and every imported cookie will be garbage.
|
||||
let key = derive_key("peanuts");
|
||||
assert_eq!(
|
||||
key.iter().map(|b| format!("{b:02x}")).collect::<String>(),
|
||||
"d9a09d499b4e1b7461f28e67972c6dbd"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_decrypts_and_strips_the_host_hash() {
|
||||
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
|
||||
let key = derive_key("peanuts");
|
||||
let host = "example.com";
|
||||
|
||||
let mut plain = Sha256::digest(host.as_bytes()).to_vec();
|
||||
plain.extend_from_slice(b"session=abc123");
|
||||
|
||||
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(&plain);
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&ct);
|
||||
|
||||
assert_eq!(decrypt_value(&stored, &key, host).unwrap(), "session=abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_without_the_hash_prefix_survives_intact() {
|
||||
use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut};
|
||||
let key = derive_key("peanuts");
|
||||
let ct = cbc::Encryptor::<Aes128>::new(&key.into(), &IV.into())
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(b"plain=1");
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&ct);
|
||||
assert_eq!(decrypt_value(&stored, &key, "example.com").unwrap(), "plain=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unversioned_values_are_refused() {
|
||||
let key = derive_key("peanuts");
|
||||
assert!(decrypt_value(b"not encrypted", &key, "example.com").is_none());
|
||||
assert!(decrypt_value(b"", &key, "example.com").is_none());
|
||||
}
|
||||
}
|
||||
@@ -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())?
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||