| | | 1 | | // SPDX-FileCopyrightText: 2026 Alper Çelik |
| | | 2 | | // SPDX-License-Identifier: MIT OR Apache-2.0 |
| | | 3 | | |
| | | 4 | | //! Cookie mirror (owned by the cookies worker). |
| | | 5 | | //! |
| | | 6 | | //! Why a copy instead of asking Qt: `QWebEngineCookieStore` can only enumerate |
| | | 7 | | //! asynchronously (`loadAllCookies()`) and **never signals completion** — it |
| | | 8 | | //! just emits one `cookieAdded` per cookie. The trait's `cookies()` is |
| | | 9 | | //! synchronous and our scheme handler runs synchronously on the GUI thread, so |
| | | 10 | | //! blocking on a nested event loop is not an option (re-entrancy + deadlock). |
| | | 11 | | //! Instead we subscribe to `cookieAdded` / `cookieRemoved` (Qt does the |
| | | 12 | | //! decryption and normalisation for us) and answer reads from this mirror. |
| | | 13 | | //! |
| | | 14 | | //! Semantics for callers: the mirror is an **observed snapshot**. |
| | | 15 | | //! * An empty mirror is a legitimate "no cookies observed" answer, not an error. |
| | | 16 | | //! * Cookies whose only change was server-side expiry, or cookies that were in |
| | | 17 | | //! the store before `qt_cookies_watch` ran (i.e. before the webview was |
| | | 18 | | //! created), appear only after the initial `loadAllCookies()` / navigation. |
| | | 19 | | //! * `set_cookie` / `delete_cookie` update the mirror optimistically so a write |
| | | 20 | | //! is visible to the very next read. The authoritative `cookieAdded` / |
| | | 21 | | //! `cookieRemoved` event for the same name/domain/path then overwrites or |
| | | 22 | | //! removes that entry (idempotent upsert/remove, so event order does not |
| | | 23 | | //! matter). A write Chromium rejects outright (e.g. `SameSite=None` without |
| | | 24 | | //! `Secure`, or a domain that cannot be set from the synthesised origin) fires |
| | | 25 | | //! no event, so its provisional entry stays until something replaces it. |
| | | 26 | | //! * Events for a webview that was never registered (or whose view was already |
| | | 27 | | //! destroyed) are ignored. |
| | | 28 | | //! |
| | | 29 | | //! What the Qt layer can and cannot carry (verified against Qt 6.11.2's |
| | | 30 | | //! `QNetworkCookie` / cookie-store code): |
| | | 31 | | //! * The domain of an event is Chromium's canonical form: a bare host for a |
| | | 32 | | //! host-only cookie, a dot-prefixed domain (".example.com") for a domain |
| | | 33 | | //! cookie, and the lowercased host for IP literals. |
| | | 34 | | //! `tauri-runtime-qt-core`'s `canonical_domain` reproduces that rule for |
| | | 35 | | //! optimistic writes. |
| | | 36 | | //! * `same_site` is lossy in **both** directions through Qt: the |
| | | 37 | | //! `CanonicalCookie` -> `QNetworkCookie` conversion used for the signals never |
| | | 38 | | //! fills `sameSitePolicy()` (so events always report `Default`), and a write |
| | | 39 | | //! that carries a policy is overwritten by that same Default value as soon as |
| | | 40 | | //! the event arrives. The mirror therefore reports what Qt reports. |
| | | 41 | | //! * CHIPS (`Cookie::partitioned()`) has no equivalent in the Qt API: the flag |
| | | 42 | | //! is dropped and the cookie is stored unpartitioned. |
| | | 43 | | //! * `clear_all_browsing_data` is broader than cookies (HTTP cache, and by |
| | | 44 | | //! contract tauri's "all browsing data"); `clear_all_browsing_data()` here |
| | | 45 | | //! clears cookies + HTTP cache and documents the rest. |
| | | 46 | | |
| | | 47 | | use std::collections::HashMap; |
| | | 48 | | use std::sync::LazyLock; |
| | | 49 | | use std::time::{SystemTime, UNIX_EPOCH}; |
| | | 50 | | |
| | | 51 | | use parking_lot::Mutex; |
| | | 52 | | use tauri_runtime::{Cookie, Error, Result}; |
| | | 53 | | use tauri_runtime_qt_core as qtcore; |
| | | 54 | | use tauri_runtime_qt_core::cookies::{Key, Snapshot}; |
| | | 55 | | |
| | | 56 | | use crate::bridge::cookies::ffi; |
| | | 57 | | |
| | | 58 | | // `Snapshot` (one cookie as the Qt store reported it, or as a write asked for |
| | | 59 | | // it) and `Key` (name + domain + path — Chromium's identity for a cookie) live |
| | | 60 | | // in the core crate, together with every matching rule used below. |
| | | 61 | | |
| | | 62 | | /// The per-process mirror. |
| | | 63 | | #[derive(Default)] |
| | | 64 | | struct Mirror { |
| | | 65 | | /// webview id -> observed cookies. |
| | | 66 | | cookies: HashMap<u64, HashMap<Key, Snapshot>>, |
| | | 67 | | /// webview id -> native `QWebEngineView*`, for the FFI calls. |
| | | 68 | | view_ptrs: HashMap<u64, u64>, |
| | | 69 | | /// native pointer -> webview id; the only handle Qt's signals carry. |
| | | 70 | | ptr_to_id: HashMap<u64, u64>, |
| | | 71 | | } |
| | | 72 | | |
| | 21 | 73 | | static MIRROR: LazyLock<Mutex<Mirror>> = LazyLock::new(|| Mutex::new(Mirror::default())); |
| | | 74 | | |
| | 126 | 75 | | fn now_ms() -> i64 { |
| | 126 | 76 | | SystemTime::now() |
| | 126 | 77 | | .duration_since(UNIX_EPOCH) |
| | 126 | 78 | | .map(|since_epoch| since_epoch.as_millis() as i64) |
| | 126 | 79 | | .unwrap_or(0) |
| | 126 | 80 | | } |
| | | 81 | | |
| | | 82 | | /// The webview's current URL host, via the existing webview bridge ("" when the |
| | | 83 | | /// webview has no page URL yet, e.g. before its first navigation). |
| | 63 | 84 | | fn current_host(webview_id: u64) -> String { |
| | 63 | 85 | | qtcore::cookies::host_of_url(&crate::bridge::webview::ffi::qt_webview_url(webview_id)) |
| | 63 | 86 | | } |
| | | 87 | | |
| | | 88 | | /// Runs `f` on the mirror of `webview_id`, or fails when the webview was never |
| | | 89 | | /// registered (unknown handle -> `WindowNotFound`, like the other ops). |
| | 252 | 90 | | fn with_webview<T>(webview_id: u64, f: impl FnOnce(&mut HashMap<Key, Snapshot>) -> T) -> Result<T> { |
| | 252 | 91 | | let mut mirror = MIRROR.lock(); |
| | 252 | 92 | | match mirror.cookies.get_mut(&webview_id) { |
| | 252 | 93 | | Some(cookies) => Ok(f(cookies)), |
| | 0 | 94 | | None => Err(Error::WindowNotFound), |
| | | 95 | | } |
| | 252 | 96 | | } |
| | | 97 | | |
| | | 98 | | /// The native view pointer of a registered webview. |
| | 84 | 99 | | fn view_ptr_of(webview_id: u64) -> Result<u64> { |
| | 84 | 100 | | MIRROR |
| | 84 | 101 | | .lock() |
| | 84 | 102 | | .view_ptrs |
| | 84 | 103 | | .get(&webview_id) |
| | 84 | 104 | | .copied() |
| | 84 | 105 | | .ok_or(Error::WindowNotFound) |
| | 84 | 106 | | } |
| | | 107 | | |
| | | 108 | | /// Starts mirroring the cookie store of the given webview. |
| | | 109 | | /// |
| | | 110 | | /// Never returns an error because it runs inside webview creation: the view is |
| | | 111 | | /// recorded either way, so later reads answer "no cookies observed" instead of |
| | | 112 | | /// failing window creation. The C++ side logs nothing; a failure to watch |
| | | 113 | | /// (unknown pointer, no cookie store) simply leaves the mirror empty. |
| | 21 | 114 | | pub(crate) fn register_webview(webview_id: u64, webview_ptr: u64) { |
| | 21 | 115 | | { |
| | 21 | 116 | | let mut mirror = MIRROR.lock(); |
| | 21 | 117 | | mirror.view_ptrs.insert(webview_id, webview_ptr); |
| | 21 | 118 | | mirror.ptr_to_id.insert(webview_ptr, webview_id); |
| | 21 | 119 | | mirror.cookies.insert(webview_id, HashMap::new()); |
| | 21 | 120 | | } |
| | 21 | 121 | | ffi::qt_cookies_watch(webview_ptr); |
| | 21 | 122 | | crate::qtrace!( |
| | | 123 | | "[tauri-runtime-qt] cookie mirror watching webview {webview_id} (view {webview_ptr:#x})" |
| | | 124 | | ); |
| | 21 | 125 | | } |
| | | 126 | | |
| | | 127 | | /// A cookie entered the store (including the initial load). Called from the Qt |
| | | 128 | | /// signal; the entry is upserted under Chromium's canonical key, so a repeated |
| | | 129 | | /// `loadAllCookies()` or an event after an optimistic write is idempotent. |
| | 84 | 130 | | pub(crate) fn on_cookie_added( |
| | 84 | 131 | | webview_ptr: u64, |
| | 84 | 132 | | name: &str, |
| | 84 | 133 | | value: &str, |
| | 84 | 134 | | domain: &str, |
| | 84 | 135 | | path: &str, |
| | 84 | 136 | | secure: bool, |
| | 84 | 137 | | http_only: bool, |
| | 84 | 138 | | same_site: i32, |
| | 84 | 139 | | expires_ms: i64, |
| | 84 | 140 | | ) { |
| | 84 | 141 | | let mut mirror = MIRROR.lock(); |
| | 84 | 142 | | let Some(&webview_id) = mirror.ptr_to_id.get(&webview_ptr) else { |
| | 0 | 143 | | return; // view never registered, or already destroyed |
| | | 144 | | }; |
| | 84 | 145 | | let snapshot = Snapshot { |
| | 84 | 146 | | name: name.to_string(), |
| | 84 | 147 | | value: value.to_string(), |
| | 84 | 148 | | domain: domain.to_ascii_lowercase(), |
| | 84 | 149 | | path: path.to_string(), |
| | 84 | 150 | | secure, |
| | 84 | 151 | | http_only, |
| | 84 | 152 | | same_site: qtcore::cookies::same_site_from_code(same_site), |
| | 84 | 153 | | expires_ms: (expires_ms >= 0).then_some(expires_ms), |
| | 84 | 154 | | }; |
| | 84 | 155 | | let key = snapshot.key(); |
| | 84 | 156 | | let Some(cookies) = mirror.cookies.get_mut(&webview_id) else { |
| | 0 | 157 | | return; |
| | | 158 | | }; |
| | 84 | 159 | | cookies.insert(key, snapshot); |
| | 84 | 160 | | } |
| | | 161 | | |
| | | 162 | | /// A cookie left the store; removes the canonical key. |
| | 84 | 163 | | pub(crate) fn on_cookie_removed(webview_ptr: u64, name: &str, domain: &str, path: &str) { |
| | 84 | 164 | | let mut mirror = MIRROR.lock(); |
| | 84 | 165 | | let Some(&webview_id) = mirror.ptr_to_id.get(&webview_ptr) else { |
| | 0 | 166 | | return; |
| | | 167 | | }; |
| | 84 | 168 | | if let Some(cookies) = mirror.cookies.get_mut(&webview_id) { |
| | 84 | 169 | | cookies.remove(&qtcore::cookies::key_of(name, domain, path)); |
| | 84 | 170 | | } |
| | 84 | 171 | | } |
| | | 172 | | |
| | | 173 | | // ---- trait surface ---- |
| | | 174 | | |
| | | 175 | | /// Every cookie the mirror has observed for this webview, sorted by |
| | | 176 | | /// (domain, path, name) for deterministic output. Expired entries can appear |
| | | 177 | | /// here (an expiry-only change emits no event); `cookies_for_url` filters them. |
| | 147 | 178 | | pub(crate) fn cookies(webview_id: u64) -> Result<Vec<Cookie<'static>>> { |
| | 147 | 179 | | with_webview(webview_id, |cookies| { |
| | 147 | 180 | | let mut snapshots: Vec<&Snapshot> = cookies.values().collect(); |
| | 147 | 181 | | qtcore::cookies::sort_by_identity(&mut snapshots); |
| | 147 | 182 | | snapshots |
| | 147 | 183 | | .into_iter() |
| | 147 | 184 | | .map(qtcore::cookies::to_public_cookie) |
| | 147 | 185 | | .collect() |
| | 147 | 186 | | }) |
| | 147 | 187 | | } |
| | | 188 | | |
| | | 189 | | /// The cookies a request to `url` would carry, matching Chromium's rules: |
| | | 190 | | /// |
| | | 191 | | /// * a host-only cookie (domain without a leading dot) matches the exact host; |
| | | 192 | | /// * a domain cookie (leading dot) matches that host and every subdomain; |
| | | 193 | | /// * the cookie's path must be a prefix of the request path (empty -> "/"); |
| | | 194 | | /// * `Secure` cookies are returned for `https`/`wss` requests only; |
| | | 195 | | /// * expired cookies are skipped. |
| | | 196 | | /// |
| | | 197 | | /// Deliberate difference from Chromium: the scheme gate (Chromium only answers |
| | | 198 | | /// for `http`/`https`/`ws`/`wss`) is not applied, so callers can ask about the |
| | | 199 | | /// runtime's own `tauri://` pages; host and path still decide the match. |
| | 84 | 200 | | pub(crate) fn cookies_for_url(webview_id: u64, url: &url::Url) -> Result<Vec<Cookie<'static>>> { |
| | 84 | 201 | | let now = now_ms(); |
| | 84 | 202 | | with_webview(webview_id, |cookies| { |
| | 84 | 203 | | let snapshots: Vec<&Snapshot> = cookies.values().collect(); |
| | 84 | 204 | | qtcore::cookies::cookies_for_url(&snapshots, url, now) |
| | 84 | 205 | | }) |
| | 84 | 206 | | } |
| | | 207 | | |
| | | 208 | | /// Writes a cookie through Qt and records it optimistically. |
| | | 209 | | /// |
| | | 210 | | /// A cookie without a domain becomes host-only for the webview's current page |
| | | 211 | | /// host; before the first navigation there is no host to attach it to, which is |
| | | 212 | | /// an error rather than a silently dropped write. |
| | 42 | 213 | | pub(crate) fn set_cookie(webview_id: u64, cookie: Cookie<'_>) -> Result<()> { |
| | 42 | 214 | | let view_ptr = view_ptr_of(webview_id)?; |
| | 42 | 215 | | let host = current_host(webview_id); |
| | 42 | 216 | | let domain = qtcore::cookies::canonical_domain(cookie.domain(), &host); |
| | 42 | 217 | | if domain.is_empty() { |
| | 0 | 218 | | return Err(Error::CreateWebview( |
| | 0 | 219 | | qtcore::cookies::missing_domain_message("set").into(), |
| | 0 | 220 | | )); |
| | 42 | 221 | | } |
| | 42 | 222 | | let name = cookie.name().to_string(); |
| | 42 | 223 | | let value = cookie.value().to_string(); |
| | 42 | 224 | | let path = qtcore::cookies::request_path_of(&cookie); |
| | 42 | 225 | | let secure = cookie.secure().unwrap_or(false); |
| | 42 | 226 | | let http_only = cookie.http_only().unwrap_or(false); |
| | 42 | 227 | | let same_site = cookie.same_site(); |
| | 42 | 228 | | let expires_ms = qtcore::cookies::expires_ms_of(&cookie, now_ms()); |
| | | 229 | | |
| | 42 | 230 | | crate::qtrace!( |
| | | 231 | | "[tauri-runtime-qt] set_cookie -> view {view_ptr:#x} name={name:?} value={value:?} domain={domain:?} path={path: |
| | 42 | 232 | | qtcore::cookies::same_site_code(same_site) |
| | | 233 | | ); |
| | 42 | 234 | | ffi::qt_cookies_set( |
| | 42 | 235 | | view_ptr, |
| | 42 | 236 | | &name, |
| | 42 | 237 | | &value, |
| | 42 | 238 | | &domain, |
| | 42 | 239 | | &path, |
| | 42 | 240 | | secure, |
| | 42 | 241 | | http_only, |
| | 42 | 242 | | qtcore::cookies::same_site_code(same_site), |
| | 42 | 243 | | expires_ms, |
| | | 244 | | ); |
| | | 245 | | |
| | 42 | 246 | | let snapshot = Snapshot { |
| | 42 | 247 | | name: name.clone(), |
| | 42 | 248 | | value, |
| | 42 | 249 | | domain: domain.clone(), |
| | 42 | 250 | | path: path.clone(), |
| | 42 | 251 | | secure, |
| | 42 | 252 | | http_only, |
| | 42 | 253 | | same_site, |
| | 42 | 254 | | expires_ms: (expires_ms >= 0).then_some(expires_ms), |
| | 42 | 255 | | }; |
| | 42 | 256 | | let mut mirror = MIRROR.lock(); |
| | 42 | 257 | | if let Some(cookies) = mirror.cookies.get_mut(&webview_id) { |
| | 42 | 258 | | cookies.insert(qtcore::cookies::key_of(&name, &domain, &path), snapshot); |
| | 42 | 259 | | } |
| | 42 | 260 | | Ok(()) |
| | 42 | 261 | | } |
| | | 262 | | |
| | | 263 | | /// Deletes cookies through Qt and removes them from the mirror optimistically. |
| | | 264 | | /// |
| | | 265 | | /// Qt builds Chromium's deletion filter from the cookie *name* plus a URL |
| | | 266 | | /// derived from the domain, so every path variant of that name for the host is |
| | | 267 | | /// deleted; the optimistic removal mirrors that (name + domain match + path |
| | | 268 | | /// prefix) rather than only the exact key. |
| | 21 | 269 | | pub(crate) fn delete_cookie(webview_id: u64, cookie: Cookie<'_>) -> Result<()> { |
| | 21 | 270 | | let view_ptr = view_ptr_of(webview_id)?; |
| | 21 | 271 | | let host = current_host(webview_id); |
| | 21 | 272 | | let domain = qtcore::cookies::canonical_domain(cookie.domain(), &host); |
| | 21 | 273 | | if domain.is_empty() { |
| | 0 | 274 | | return Err(Error::CreateWebview( |
| | 0 | 275 | | qtcore::cookies::missing_domain_message("delete").into(), |
| | 0 | 276 | | )); |
| | 21 | 277 | | } |
| | 21 | 278 | | let name = cookie.name().to_string(); |
| | 21 | 279 | | let request_path = qtcore::cookies::request_path_of(&cookie); |
| | | 280 | | |
| | 21 | 281 | | ffi::qt_cookies_delete(view_ptr, &name, &domain, &request_path); |
| | | 282 | | |
| | 21 | 283 | | let mut mirror = MIRROR.lock(); |
| | 21 | 284 | | if let Some(cookies) = mirror.cookies.get_mut(&webview_id) { |
| | 42 | 285 | | cookies.retain(|(key_name, key_domain, key_path), _| { |
| | 42 | 286 | | !(key_name == &name |
| | 21 | 287 | | && qtcore::cookies::domain_matches(key_domain.as_str(), &domain) |
| | 21 | 288 | | && qtcore::cookies::path_matches(key_path.as_str(), &request_path)) |
| | 42 | 289 | | }); |
| | 0 | 290 | | } |
| | 21 | 291 | | Ok(()) |
| | 21 | 292 | | } |
| | | 293 | | |
| | | 294 | | /// Clears the browsing data Qt 6.11 can clear for this webview: the HTTP cache |
| | | 295 | | /// and every cookie of the profile. The cookie mirror is emptied in the same |
| | | 296 | | /// call — Qt's `deleteAllCookies()` is asynchronous and has no completion |
| | | 297 | | /// signal, so waiting for the `cookieRemoved` events would race; the events |
| | | 298 | | /// that do arrive find nothing to remove. |
| | | 299 | | /// |
| | | 300 | | /// NOT cleared (Qt exposes no live-profile API for them): localStorage / |
| | | 301 | | /// sessionStorage, IndexedDB, service-worker registrations and Cache Storage. |
| | | 302 | | /// They live under the profile's storage directory, which this runtime does not |
| | | 303 | | /// delete while the profile is alive. |
| | 21 | 304 | | pub(crate) fn clear_all_browsing_data(webview_id: u64) -> Result<()> { |
| | 21 | 305 | | let view_ptr = view_ptr_of(webview_id)?; |
| | 21 | 306 | | ffi::qt_cookies_clear_all(view_ptr); |
| | 21 | 307 | | with_webview(webview_id, |cookies| cookies.clear()) |
| | 21 | 308 | | } |