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. list. Nothing else is read out of the browser.
4. Inject them into `WKHTTPCookieStore`. 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 Pairing is a button, not a background job. Cookies rotate; a silent task that periodically
reaches into the Keychain is worse than one the user presses when something logs them out. reaches into the Keychain is worse than one the user presses when something logs them out.
+36 -3
View File
@@ -453,11 +453,9 @@ pub fn notification_status(app: AppHandle) -> String {
Err(e) => format!("failed: {e}"), Err(e) => format!("failed: {e}"),
}; };
let app_state = app.state::<AppState>(); let app_state = app.state::<AppState>();
let diag = app_state.diag.lock().unwrap().clone();
let last = app_state.last_notification.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 }; 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. /// 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()) .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 // ------------------------------------------------------- browser pairing
#[tauri::command] #[tauri::command]
+2
View File
@@ -85,6 +85,8 @@ pub fn run() {
commands::pick_hidden, commands::pick_hidden,
commands::test_notification, commands::test_notification,
commands::notification_status, commands::notification_status,
commands::probe_cookies,
commands::cookie_probe,
commands::list_browsers, commands::list_browsers,
commands::pair_browser, commands::pair_browser,
]) ])
+2
View File
@@ -46,3 +46,5 @@ export const pairBrowser = (browser: string) =>
export const testNotification = (appId: string) => export const testNotification = (appId: string) =>
invoke<void>("test_notification", { appId }); invoke<void>("test_notification", { appId });
export const notificationStatus = () => invoke<string>("notification_status"); export const notificationStatus = () => invoke<string>("notification_status");
export const probeCookies = (appId: string) => invoke<void>("probe_cookies", { appId });
export const cookieProbe = () => invoke<string>("cookie_probe");
+19
View File
@@ -46,6 +46,7 @@ export default function Settings({
const [browser, setBrowser] = useState(""); const [browser, setBrowser] = useState("");
const [pairing, setPairing] = useState(false); const [pairing, setPairing] = useState(false);
const [notifyStatus, setNotifyStatus] = useState<string | null>(null); const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
const [cookieStatus, setCookieStatus] = useState<string | null>(null);
const [paired, setPaired] = useState<PairResult | null>(null); const [paired, setPaired] = useState<PairResult | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order); const groups = [...config.groups].sort((a, b) => a.order - b.order);
@@ -263,11 +264,29 @@ export default function Settings({
))} ))}
</select> </select>
</label> </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}> <button onClick={pair} disabled={pairing || !browser} className={BTN_PRIMARY}>
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"} {pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"}
</button> </button>
</div> </div>
{cookieStatus && (
<p className={`${HELP} font-mono`}>{cookieStatus}</p>
)}
{config.settings.lastPairedAt && !paired && ( {config.settings.lastPairedAt && !paired && (
<p className={HELP}>Last paired {config.settings.lastPairedAt}.</p> <p className={HELP}>Last paired {config.settings.lastPairedAt}.</p>
)} )}