Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src-tauri/src/commands/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,21 @@ pub async fn browser_open(
let app = window.app_handle().clone();
let reporter = key.clone();
builder = builder.on_new_window(move |url, _features| {
// TRACE, off unless asked for: set BRAINS_BROWSER_TRACE=1 before
// launching. Every report crosses here, and this is the only place
// that can say whether one the page believes it sent ever arrived —
// the page cannot tell (a refused popup and a delivered one look
// identical to it) and the webview's devtools are awkward to reach.
let trace = std::env::var("BRAINS_BROWSER_TRACE").is_ok();
let Some(payload) = sentinel_payload(url.as_str()) else {
if trace {
eprintln!(
"[browser] new-window NOT a report: scheme={} host={:?} len={}",
url.scheme(),
url.host_str(),
url.as_str().len()
);
}
if matches!(url.scheme(), "http" | "https") {
// Deprecated in favour of tauri-plugin-opener, taken the
// same way `commands/brains.rs` takes it: the shell plugin
Expand All @@ -170,13 +184,34 @@ pub async fn browser_open(
}
return NewWindowResponse::Deny;
};
let _ = app.emit(
if trace {
// The action, when there is one, is the whole question: a
// passive report arriving while a pointed one goes missing is a
// very different fault from nothing arriving at all.
let action = payload
.split("\"action\":\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or("(passive)");
eprintln!(
"[browser] report key={} action={} bytes={}",
reporter,
action,
payload.len()
);
}
let emitted = app.emit(
REPORT_EVENT,
BrowserReport {
key: reporter.clone(),
payload,
},
);
if trace {
if let Err(error) = &emitted {
eprintln!("[browser] EMIT FAILED: {error}");
}
}
NewWindowResponse::Deny
});
}
Expand Down
280 changes: 123 additions & 157 deletions src/apps/gmail/GmailTab.svelte

Large diffs are not rendered by default.

243 changes: 216 additions & 27 deletions src/apps/gmail/bridge/gmail-observer.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,17 @@
var row = el.closest("tr");
if (!anchor && row) anchor = row.querySelector("[data-legacy-thread-id]");
if (row) out.rowText = clip(row.textContent, 400);
if (!anchor) anchor = document.querySelector("h2[data-legacy-thread-id]");
if (!anchor) return out;
if (!anchor) {
// Rung 4, and it must ask the same question `snapshot` does: a bare
// `querySelector` here returns Gmail's CACHED conversation view, so a
// right-click on the chrome of one mailbox resolved to a thread the
// user last opened in another one.
var open = openThread();
if (!open.id && !open.subject) return out;
out.threadId = open.id;
out.subject = open.subject;
return out;
}
out.threadId = clip(anchor.getAttribute("data-legacy-thread-id"), 32);
out.subject = clip(anchor.textContent, 250);
} catch (_) {
Expand All @@ -119,13 +128,65 @@
return out;
}

/** The open thread's subject, when the reading pane is showing one. */
function openSubject() {
/**
* On screen, as opposed to merely in the document.
*
* `offsetParent` is null for a hidden element — and also for a `position:
* fixed` one, which is why the rect count backs it up rather than replacing
* it.
*/
function onScreen(node) {
try {
var heading = document.querySelector("h2[data-legacy-thread-id]");
return heading ? clip(heading.textContent, 250) : "";
if (node.offsetParent !== null) return true;
return node.getClientRects().length > 0;
} catch (_) {
return "";
return false;
}
}

/**
* THE OPEN CONVERSATION — `{ id, subject }`, empty when a list is showing.
*
* Gmail CACHES conversation views: opening a thread and going back to a list
* leaves that thread's `h2[data-legacy-thread-id]` in the DOM, hidden. A plain
* `querySelector` therefore returns a conversation the user closed — measured
* in the Sent list, where the count was 2 with nothing open — and the app
* showed a thread from another mailbox as the subject of the conversation.
* Worse, the stale node does not change as the user clicks around, so the
* snapshot never changes and the dedupe swallows every report: on any view
* holding a cached conversation, selection appeared to stop working entirely.
*
* So: only a VISIBLE heading counts, and the one whose id the hash names wins
* when the hash names one.
*
* The id comes from the heading rather than the hash, which is the other half
* of the same bug: `threadIdFromHash` accepts hex only, and a modern Gmail
* hash carries `FMfcgz…` permalink ids it rejects — so the id was usually
* empty and the thread prefetch never ran. `data-legacy-thread-id` IS the hex
* id, straight off the element.
*/
function openThread() {
var none = { id: "", subject: "" };
try {
var nodes = document.querySelectorAll("h2[data-legacy-thread-id]");
if (!nodes.length) return none;
var wanted = threadIdFromHash();
var fallback = null;
for (var i = 0; i < nodes.length; i += 1) {
var node = nodes[i];
if (!onScreen(node)) continue;
if (wanted && node.getAttribute("data-legacy-thread-id") === wanted) {
return { id: wanted, subject: clip(node.textContent, 250) };
}
if (!fallback) fallback = node;
}
if (!fallback) return none;
return {
id: clip(fallback.getAttribute("data-legacy-thread-id"), 64),
subject: clip(fallback.textContent, 250),
};
} catch (_) {
return none;
}
}

Expand All @@ -138,10 +199,14 @@
}

function snapshot() {
// One read, so the id and the subject always describe the SAME heading —
// taking them from two places let a stale hash pair a thread id with
// another thread's subject.
var open = openThread();
return {
source: "open-thread",
threadId: threadIdFromHash(),
subject: openSubject(),
threadId: open.id || threadIdFromHash(),
subject: open.subject,
title: clip(document.title, 300),
url: clip(window.location.href, 1200),
selection: selectionText(),
Expand All @@ -168,12 +233,32 @@
// Gmail mutates constantly — unread counts, presence, ads. Reporting only
// on CHANGE is what keeps this from being a firehose into the host.
if (encoded === last) return;
// RECORDED AS SENT, THOUGH NOTHING CAN CONFIRM IT WAS. `send` is
// fire-and-forget by construction: the only transport is a refused popup at
// the sentinel, so a delivered report and one that arrived while the host's
// `browser-report` listener was still being registered look identical from
// in here. Setting `last` anyway is what makes that gap PERMANENT for this
// exact state — the dedupe below then suppresses every retry, and the user
// sees a thread they clicked never reach the chat while a DIFFERENT thread
// works fine. `resync()` is the way out: a real user action clears this.
last = encoded;
try {
send(encoded);
} catch (_) {
/* the host is not listening yet; the next change will try again */
}
send(encoded);
}

/**
* Forget what we believe the host has seen.
*
* The dedupe above exists to tame Gmail's DOM churn, NOT to swallow the
* user's own actions. So an explicit interaction — clicking a thread, taking
* an item off our menu — drops the memo and lets the next report through even
* when the snapshot is byte-identical. Re-sending is bounded by how fast a
* person can click, which is nothing next to the mutation firehose, and it
* makes the unacknowledged-send gap self-healing: whatever the host missed
* the first time arrives the moment the user tries again.
*/
function resync() {
last = "";
schedule();
}

function schedule() {
Expand All @@ -195,13 +280,44 @@

var MENU_ATTR = "data-brains-gmail-menu";

/** What each row does. The verb is validated again on the host side. */
/**
* What each row does. The verb is validated again on the host side.
*
* Label, hint and icon are the Actions rail's, verbatim — `actions/skills.ts`
* `draft` and `thread-summary`, whose paths come from `ws-icons.ts`. The same
* two commands reached two ways should not be two different names: a user who
* learns "Draft a reply" on the rail must not have to recognise "Draft my
* reply" here. Keep this list in step when the rail's copy changes.
*/
var ITEMS = [
["tag", "Ask about this thread", "Put it in the conversation"],
["catch-up", "Catch me up", "What happened, in one read"],
["draft-updates", "Draft my reply", "Grounded in what they said"],
[
"draft-updates",
"Draft a reply",
"In your voice, from what you know",
"M21.5 2.5 11 13M21.5 2.5l-6.7 19-3.8-8.5L2.5 9z",
],
[
"catch-up",
"Summarize",
"The short version, in the chat",
"M13.5 3.5H7a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9zM13.5 3.5V9H19M8.5 13h7M8.5 16.5h4.5",
],
["tag", "Ask about this thread", "Put it in the conversation", ""],
];

/**
* Rows defined but not drawn.
*
* `tag` did one thing: put the clicked thread into the conversation. Opening
* a thread now does that on its own — the selection reaches the chat the
* moment the mailbox reports it — so the row asks the user to request what
* already happened, and a menu item that appears to be a no-op reads as a
* broken one. Kept rather than deleted: the verb is still accepted end to end
* (the host allow-lists it, `ASKS` maps it to a null prompt), so restoring the
* row is deleting a string from this list, and nothing else.
*/
var HIDDEN = { tag: true };

function removeMenu() {
try {
var open = document.querySelector("[" + MENU_ATTR + "]");
Expand All @@ -211,20 +327,68 @@
}
}

function menuRow(verb, label, hint, clicked) {
/**
* The Actions rail's tinted icon tile, rebuilt in the page.
*
* createElementNS, not innerHTML: Gmail's Trusted Types policy makes markup
* assignment throw, which is a documented way for this menu to silently never
* appear. Values are literals from `ws-icons.ts`, resolved here because the
* page has none of the app's CSS variables.
*/
function iconTile(path) {
var tile = document.createElement("span");
tile.style.cssText =
"flex:0 0 auto;width:28px;height:28px;border-radius:8px;background:#eaf3ec;" +
"display:inline-flex;align-items:center;justify-content:center;";
try {
var NS = "http://www.w3.org/2000/svg";
var svg = document.createElementNS(NS, "svg");
svg.setAttribute("width", "15");
svg.setAttribute("height", "15");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "#3d6b4a");
svg.setAttribute("stroke-width", "1.7");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
var d = document.createElementNS(NS, "path");
d.setAttribute("d", path);
svg.appendChild(d);
tile.appendChild(svg);
} catch (_) {
/* a row with no glyph still reads; an exception here would cost the menu */
}
return tile;
}

function menuRow(verb, label, hint, clicked, path) {
var button = document.createElement("button");
button.type = "button";
// Icon beside the text, matching the rail: the tile keeps its size and the
// two lines share what is left.
button.style.cssText =
"display:flex;flex-direction:column;gap:1px;width:100%;border:0;background:transparent;" +
"display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;" +
"border-radius:9px;padding:8px 11px;text-align:left;font:inherit;color:#211f1b;cursor:pointer;";
if (path) button.appendChild(iconTile(path));
var text = document.createElement("span");
text.style.cssText = "display:flex;flex-direction:column;gap:1px;min-width:0;";
var title = document.createElement("span");
title.style.cssText = "font-size:13px;font-weight:700;";
title.style.cssText = "font-size:13px;font-weight:600;";
title.textContent = label;
var sub = document.createElement("span");
sub.style.cssText = "color:#978e7d;font-size:10.5px;line-height:1.4;";
sub.style.cssText =
"color:#978e7d;font-size:10.5px;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;";
sub.textContent = hint;
button.appendChild(title);
button.appendChild(sub);
text.appendChild(title);
text.appendChild(sub);
button.appendChild(text);
// Keep focus where it is. A menu row does not need it, and not taking it
// means nothing behind the menu blurs — belt to the braces of the
// non-capturing blur listener, and the standard way custom menus avoid
// exactly this class of teardown-before-click bug.
button.addEventListener("mousedown", function (event) {
event.preventDefault();
});
button.addEventListener("mouseenter", function () {
button.style.background = "#f3efe7";
});
Expand All @@ -248,7 +412,8 @@
"box-shadow:0 18px 52px rgba(39,34,25,.22),0 2px 8px rgba(39,34,25,.12);" +
"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;";
for (var i = 0; i < ITEMS.length; i += 1) {
menu.appendChild(menuRow(ITEMS[i][0], ITEMS[i][1], ITEMS[i][2], clicked));
if (HIDDEN[ITEMS[i][0]]) continue;
menu.appendChild(menuRow(ITEMS[i][0], ITEMS[i][1], ITEMS[i][2], clicked, ITEMS[i][3]));
}
// Clamped so a right-click near an edge does not put the menu off-screen.
menu.style.left = Math.max(4, Math.min(x, window.innerWidth - 244)) + "px";
Expand Down Expand Up @@ -312,17 +477,41 @@
document.addEventListener("selectionchange", schedule, true);
window.addEventListener("contextmenu", onContextMenu, true);
// Dismissal: anywhere else, Escape, or the page moving under it.
//
// This also carries `resync`, because a pointerdown is the one signal in
// here that is unambiguously the USER rather than Gmail redrawing itself.
// Opening the thread you already had open must still reach the chat, and
// without this it is exactly the case the dedupe eats.
window.addEventListener("pointerdown", function (event) {
var inMenu = false;
try {
if (!event.target || !event.target.closest("[" + MENU_ATTR + "]")) removeMenu();
inMenu = !!(event.target && event.target.closest("[" + MENU_ATTR + "]"));
if (!inMenu) removeMenu();
} catch (_) {
removeMenu();
}
// NOT INSIDE OUR OWN MENU. That pointerdown is one step away from a click
// that sends a POINTED report, and `resync` would schedule a passive one
// 250ms behind it. `send` is `window.open`, Chromium spends transient user
// activation on the first call, and a press held past the throttle lets
// the passive report spend it — leaving the pointed one, the only one that
// carries the user's actual instruction, to be swallowed by the popup
// blocker. The menu click needs no resync anyway: `sendPointed` never
// consults the dedupe.
if (!inMenu) resync();
}, true);
window.addEventListener("keydown", function (event) {
if (event.key === "Escape") removeMenu();
}, true);
window.addEventListener("blur", removeMenu, true);
// NOT CAPTURING. `blur` does not bubble, so a capturing listener on window
// is not "the window lost focus" — it is EVERY element blur in the page,
// caught on the way down. Clicking a menu row moves focus off the Gmail row
// behind it, that row blurs, and the menu was torn out of the DOM between
// mousedown and mouseup — so no `click` ever dispatched and the row did
// nothing at all. The second attempt worked only because focus had already
// left, so there was no blur left to fire. Without capture this is what it
// was always meant to be: the window itself losing focus.
window.addEventListener("blur", removeMenu, false);
window.addEventListener("scroll", removeMenu, true);
// Gmail renders the reading pane after the hash changes, so the hash alone
// is early for the subject. A cheap observer on the body catches the fill.
Expand Down
Loading
Loading