Prove cookie injection lands, rather than assuming it

setCookie is fire-and-forget, so the import's count was what WebKit was
handed, not what it kept. A probe from inside the page reads back the
other end: pairing against Arc gave `names=tz,cids,frontend_lang` for
aputure.odoo.com, and `tz` exists only in Arc's store — so the import
demonstrably landed.

The sites still ask for sign-in. That is the far end refusing the
session, not a broken import, and the two are now distinguishable
instead of being guessed at.
This commit is contained in:
2026-09-01 12:21:41 +02:00
parent ff4a0c6bc4
commit f057268103
5 changed files with 70 additions and 3 deletions
@@ -149,6 +149,17 @@ Settings lists the browsers actually installed. Pairing with a Chromium browser:
list. Nothing else is read out of the browser.
4. Inject them into `WKHTTPCookieStore`.
**Verified on the machine.** Pairing against Arc imported 43 cookies across 9 domains,
and a probe from inside the page then read back `host=example.odoo.com
names=tz,cids,frontend_lang visible=3``tz` existing only in Arc's store, which is what
proves the import landed rather than merely being handed over. `setCookie` is
fire-and-forget, so the import's own count could never have shown this.
The sites still presented sign-in pages. That is the documented limitation, not a broken
import: the cookies are in the store and visible to the page, and the session is being
refused at the far end. Settings keeps the probe as **Check cookies**, so the same
question can be answered again without guessing.
Pairing is a button, not a background job. Cookies rotate; a silent task that periodically
reaches into the Keychain is worse than one the user presses when something logs them out.
+36 -3
View File
@@ -453,11 +453,9 @@ pub fn notification_status(app: AppHandle) -> 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}")
format!("permission: {state} · direct: {raised} · from page: {from_page}")
}
/// Asks macOS for notification permission, once, at startup.
@@ -503,6 +501,41 @@ pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string())
}
/// Asks the page which cookies it can actually see.
///
/// `setCookie` is fire-and-forget, so the import's count is what was handed to
/// WebKit, not what WebKit kept. This reads the other end. HttpOnly cookies are
/// invisible to script by design, so the answer is a floor, not a total — but a
/// zero here means the injection never landed at all.
#[tauri::command]
pub fn probe_cookies(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(
r#"(function () {
var names = document.cookie
? document.cookie.split(';').map(function (c) { return c.split('=')[0].trim(); })
: [];
if (window.__workAppSend) {
window.__workAppSend('diag', {
host: location.hostname,
visible: String(names.length),
names: names.slice(0, 12).join(',')
});
}
})();"#,
)
.map_err(|e| e.to_string())
}
/// What the last cookie probe saw.
#[tauri::command]
pub fn cookie_probe(app: AppHandle) -> String {
let diag = app.state::<AppState>().diag.lock().unwrap().clone();
if diag.is_empty() { "no answer from the page".into() } else { diag }
}
// ------------------------------------------------------- browser pairing
#[tauri::command]
+2
View File
@@ -85,6 +85,8 @@ pub fn run() {
commands::pick_hidden,
commands::test_notification,
commands::notification_status,
commands::probe_cookies,
commands::cookie_probe,
commands::list_browsers,
commands::pair_browser,
])
+2
View File
@@ -46,3 +46,5 @@ export const pairBrowser = (browser: string) =>
export const testNotification = (appId: string) =>
invoke<void>("test_notification", { appId });
export const notificationStatus = () => invoke<string>("notification_status");
export const probeCookies = (appId: string) => invoke<void>("probe_cookies", { appId });
export const cookieProbe = () => invoke<string>("cookie_probe");
+19
View File
@@ -46,6 +46,7 @@ export default function Settings({
const [browser, setBrowser] = useState("");
const [pairing, setPairing] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [cookieStatus, setCookieStatus] = useState<string | null>(null);
const [paired, setPaired] = useState<PairResult | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
@@ -263,11 +264,29 @@ export default function Settings({
))}
</select>
</label>
<button
onClick={async () => {
if (!activeId) return;
setCookieStatus("checking…");
await api.probeCookies(activeId);
await new Promise((r) => setTimeout(r, 400));
setCookieStatus(await api.cookieProbe());
}}
disabled={!activeId}
title="What the current app can actually see"
className={BTN}
>
Check cookies
</button>
<button onClick={pair} disabled={pairing || !browser} className={BTN_PRIMARY}>
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"}
</button>
</div>
{cookieStatus && (
<p className={`${HELP} font-mono`}>{cookieStatus}</p>
)}
{config.settings.lastPairedAt && !paired && (
<p className={HELP}>Last paired {config.settings.lastPairedAt}.</p>
)}