| 1 | // Running the picker across Chrome's split view. |
| 2 | // |
| 3 | // A split view is two tabs side by side in one window. Both are rendered and |
| 4 | // both are clickable, but only one is `active` — Chrome marks a single tab |
| 5 | // active even while its partner is fully visible. So the obvious |
| 6 | // `tabs.query({active: true})` always resolves to whichever half last had |
| 7 | // focus, and a pick started from the other half highlights nothing. |
| 8 | // |
| 9 | // The fix is to inject into every visible half at once and take the first |
| 10 | // answer. `splitViewId` (Chrome 140+) is what makes the set knowable; it is |
| 11 | // read-only detection data, so nothing here creates or dissolves a split. |
| 12 | // Firefox has neither the property nor the feature, and falls through to the |
| 13 | // single-tab path. |
| 14 | // |
| 15 | // Loaded by both the sidebar (a document) and the background worker (no DOM), |
| 16 | // so nothing here may touch `document`. |
| 17 | |
| 18 | /** |
| 19 | * Every tab a pick should run in: the active one, plus the other half of its |
| 20 | * split view when there is one. Active first — its failure is the one worth |
| 21 | * reporting, because it is the half the user was looking at. |
| 22 | * |
| 23 | * @param {TbExtensionApi} api |
| 24 | * @param {TbTab} tab the active tab |
| 25 | * @returns {Promise<TbTab[]>} |
| 26 | */ |
| 27 | async function pickTargets(api, tab) { |
| 28 | if (!tab || tab.id == null) return []; |
| 29 | if (tab.splitViewId == null) return [tab]; |
| 30 | try { |
| 31 | // Chrome throws on an unrecognised query key rather than ignoring it, so a |
| 32 | // browser that reports splitViewId but won't query on it lands in the catch. |
| 33 | const tabs = await api.tabs.query({ splitViewId: tab.splitViewId, currentWindow: true }); |
| 34 | const out = [tab]; |
| 35 | for (const t of tabs) if (t.id != null && t.id !== tab.id) out.push(t); |
| 36 | return out; |
| 37 | } catch { |
| 38 | return [tab]; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Tear down a picker overlay from outside the page. |
| 44 | * |
| 45 | * picker.js parks its cleanup on `window.__tbPickerActive`; calling it resolves |
| 46 | * that pick's promise with null, exactly as an Escape would. |
| 47 | */ |
| 48 | function tbCancelPicker() { |
| 49 | if (window.__tbPickerActive) window.__tbPickerActive(); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Cancel picks running in `tabIds`. Failures are ignored: a tab that closed or |
| 54 | * navigated took its picker with it. |
| 55 | * |
| 56 | * @param {TbExtensionApi} api |
| 57 | * @param {number[]} tabIds |
| 58 | */ |
| 59 | async function cancelPicks(api, tabIds) { |
| 60 | await Promise.all( |
| 61 | tabIds.map((tabId) => |
| 62 | api.scripting.executeScript({ target: { tabId }, func: tbCancelPicker }).catch(() => {}), |
| 63 | ), |
| 64 | ); |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Run the picker in every candidate tab at once and take the first real answer. |
| 69 | * |
| 70 | * The first half to settle wins, and the losers are torn down — including on a |
| 71 | * cancel, so an Escape in one pane doesn't leave an overlay live in the other. |
| 72 | * An injection that *fails* is not a settle: on the keyboard-shortcut path the |
| 73 | * `activeTab` grant covers only the active half, so the partner half usually |
| 74 | * refuses unless its origin was granted, and that must not end a pick the |
| 75 | * active half is still serving. |
| 76 | * |
| 77 | * @template T |
| 78 | * @param {TbExtensionApi} api |
| 79 | * @param {TbTab[]} tabs |
| 80 | * @param {() => Promise<T | null>} func the picker, serialised into each page |
| 81 | * @returns {Promise<{ tabId: number | undefined; value: T | null; error: string }>} |
| 82 | */ |
| 83 | function racePick(api, tabs, func) { |
| 84 | /** @type {number[]} */ |
| 85 | const ids = []; |
| 86 | for (const t of tabs) if (t?.id != null) ids.push(t.id); |
| 87 | if (!ids.length) return Promise.resolve({ tabId: undefined, value: null, error: "no page to pick in" }); |
| 88 | |
| 89 | return new Promise((resolve) => { |
| 90 | let finished = false; |
| 91 | let pending = ids.length; |
| 92 | let firstError = ""; |
| 93 | |
| 94 | for (const tabId of ids) { |
| 95 | api.scripting |
| 96 | .executeScript({ target: { tabId }, func }) |
| 97 | // `undefined` means the injection itself failed; `null` means the pick |
| 98 | // was cancelled. Only the second one ends the race. |
| 99 | .then((results) => results?.[0]?.result ?? null) |
| 100 | .catch((e) => { |
| 101 | if (!firstError) firstError = e instanceof Error ? e.message : String(e); |
| 102 | return undefined; |
| 103 | }) |
| 104 | .then((value) => { |
| 105 | if (finished) return; |
| 106 | if (value === undefined) { |
| 107 | if (--pending === 0) { |
| 108 | resolve({ tabId: ids[0], value: null, error: firstError || "injection failed" }); |
| 109 | } |
| 110 | return; |
| 111 | } |
| 112 | finished = true; |
| 113 | cancelPicks( |
| 114 | api, |
| 115 | ids.filter((other) => other !== tabId), |
| 116 | ); |
| 117 | resolve({ tabId, value: /** @type {T | null} */ (value), error: "" }); |
| 118 | }); |
| 119 | } |
| 120 | }); |
| 121 | } |
| 122 | |
| 123 | const Split = { pickTargets, racePick, cancelPicks }; |
| 124 | |
| 125 | if (typeof module !== "undefined" && module.exports) { |
| 126 | module.exports = Split; |
| 127 | } |