From 24d89d3ab8507072845c3b812f6d565bdd97fa2b Mon Sep 17 00:00:00 2001 From: winter100004 Date: Wed, 10 Jun 2026 17:37:57 +0800 Subject: [PATCH] feat(extension): inject content script into open tabs on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh install the manifest's content script is not present in tabs that are already open — they need a reload before the SDK can reach the extension (isAvailable() is false until then). On runtime.onInstalled with reason "install", query open http(s) tabs and backfill the content script into each via chrome.scripting.executeScript, so picking works with zero refresh for every integration, not just the tab that triggered the install. - Only on first install (not update): the old content script is already running in open tabs after an update, so re-injecting would double it up. - http/https tab filter excludes restricted pages (chrome://, the web store, …); any straggler that still rejects is skipped per-tab. - The content script self-guards against a double injection in one frame (__openpickerContentLoaded), so the declared + programmatic paths can't register listeners twice. - Adds the "scripting" permission (host access was already granted). - Bumps the extension to 0.3.2. demo: auto re-detect the extension on visibilitychange/focus while still undetected, so installing it while the demo tab is open just works on return — no refresh, no Recheck click. Guarded so a post-pick refocus won't re-flicker. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/demo/main.ts | 38 +++++++++++++---- packages/extension/entrypoints/background.ts | 42 +++++++++++++++++++ .../extension/entrypoints/content/index.tsx | 10 +++++ packages/extension/package.json | 2 +- packages/extension/wxt.config.ts | 5 ++- 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/apps/demo/main.ts b/apps/demo/main.ts index fd8e726..d410c3f 100644 --- a/apps/demo/main.ts +++ b/apps/demo/main.ts @@ -56,16 +56,26 @@ function setStatus(text: string, kind: keyof typeof STATUS_COLORS): void { * attaches its listener at document_idle, which can be after this page's first ping — * a single ping would be lost in that race, so poll a few times. */ +let detected = false +let detecting = false + async function detect(): Promise { - setStatus("Checking for the extension…", "checking") - let ok = false - for (let attempt = 0; attempt < 4 && !ok; attempt++) { - ok = await op.isAvailable() - if (!ok) await sleep(200) + if (detecting) return // guard against overlapping runs (load + focus + visibility) + detecting = true + try { + setStatus("Checking for the extension…", "checking") + let ok = false + for (let attempt = 0; attempt < 4 && !ok; attempt++) { + ok = await op.isAvailable() + if (!ok) await sleep(200) + } + detected = ok + installEl.hidden = ok + demoEl.hidden = !ok + setStatus(ok ? "✓ Extension detected" : "✗ Extension not detected", ok ? "ok" : "warn") + } finally { + detecting = false } - installEl.hidden = ok - demoEl.hidden = !ok - setStatus(ok ? "✓ Extension detected" : "✗ Extension not detected", ok ? "ok" : "warn") } async function pick(): Promise { @@ -131,6 +141,18 @@ function showError(message: string): void { pickBtn.addEventListener("click", () => void pick()) recheckBtn.addEventListener("click", () => void detect()) + +// If the extension is installed while this page is already open, its content script is +// backfilled into this tab with no reload — but the page only checks on load and on +// "Recheck". Re-detect automatically when the user returns to this tab (the natural +// moment right after installing), so it just works with no refresh and no click. Only +// while still undetected, so refocusing after a successful pick won't re-flicker. +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible" && !detected) void detect() +}) +window.addEventListener("focus", () => { + if (!detected) void detect() +}) copyBtn.addEventListener("click", async () => { await navigator.clipboard.writeText(selectorEl.textContent ?? "").catch(() => {}) copyBtn.textContent = "Copied" diff --git a/packages/extension/entrypoints/background.ts b/packages/extension/entrypoints/background.ts index 7314af1..d669d31 100644 --- a/packages/extension/entrypoints/background.ts +++ b/packages/extension/entrypoints/background.ts @@ -238,6 +238,41 @@ async function refocusSource(sourceTabId: number): Promise { } } +/** + * Right after a fresh install, the manifest's content script is NOT present in tabs + * that are already open — they would need a reload before the SDK could reach the + * extension (`isAvailable()` would be false). Inject it into every open http(s) tab + * once, so picking works with zero refresh — every integration benefits, not just the + * tab that happened to trigger the install. + * + * Only on first install: on update the old content script is already running in open + * tabs, so re-injecting would double it up. The content script also self-guards + * against a double injection (see content/index.tsx). Restricted pages (chrome://, + * the web store, …) are excluded by the http/https filter; any straggler that still + * rejects is skipped per-tab. + * + * The injected file name is WXT's fixed content-script output path. + */ +async function backfillContentScriptIntoOpenTabs(): Promise { + let tabs: { id?: number }[] + try { + tabs = await browser.tabs.query({ url: ["http://*/*", "https://*/*"] }) + } catch { + return + } + await Promise.all( + tabs.map( + (tab) => + tab.id === undefined + ? Promise.resolve() + : browser.scripting + .executeScript({ target: { tabId: tab.id }, files: ["/content-scripts/content.js"] }) + .then(() => {}) + .catch(() => {}), // restricted/unsupported tab — skip it + ), + ) +} + const consentKey = (origin: string) => `consent:${origin}` async function getConsent(origin: string): Promise { @@ -255,6 +290,13 @@ export default defineBackground(() => { // The toolbar icon opens the popup (entrypoints/popup); its "Pick" button sends // `startPick` to the active tab's content script — so there is no action.onClicked. + // On fresh install, backfill the content script into already-open tabs so the SDK + // can reach the extension without a page reload (see fn doc above). + browser.runtime.onInstalled.addListener((details) => { + if (details.reason !== "install") return + void backfillContentScriptIntoOpenTabs() + }) + // Keep the source↔target map clean: when a mapped tab closes, drop its pair. browser.tabs.onRemoved.addListener(async (tabId) => { // If the closed tab was a target with a pick in flight, tell its source the pick diff --git a/packages/extension/entrypoints/content/index.tsx b/packages/extension/entrypoints/content/index.tsx index 5183f10..20440c5 100644 --- a/packages/extension/entrypoints/content/index.tsx +++ b/packages/extension/entrypoints/content/index.tsx @@ -30,6 +30,16 @@ export default defineContentScript({ matches: [""], cssInjectionMode: "ui", main() { + // Guard against a double injection in one frame: this content script is declared + // in the manifest (injected on navigation) AND, right after a fresh install, + // injected programmatically into already-open tabs (see background.ts). If both + // ever land in the same frame, wire everything up only once — otherwise we would + // register the message listeners twice. Content scripts share one isolated `window` + // per frame, so a flag on it is visible across injections. + const w = window as unknown as { __openpickerContentLoaded?: boolean } + if (w.__openpickerContentLoaded) return + w.__openpickerContentLoaded = true + const manifest = browser.runtime.getManifest() const capabilities = [ "ping", diff --git a/packages/extension/package.json b/packages/extension/package.json index 90ed78d..4eb1a01 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@openpicker/extension", - "version": "0.3.1", + "version": "0.3.2", "private": true, "type": "module", "scripts": { diff --git a/packages/extension/wxt.config.ts b/packages/extension/wxt.config.ts index 3c10485..7978356 100644 --- a/packages/extension/wxt.config.ts +++ b/packages/extension/wxt.config.ts @@ -24,7 +24,10 @@ export default defineConfig({ // Locale to fall back to; per-locale UI strings live in locales/*.yml and are // generated into _locales by @wxt-dev/i18n. The language follows the browser. default_locale: "en", - permissions: ["storage", "activeTab", "tabs"], + // `scripting` is used once, on fresh install, to backfill the content script into + // already-open tabs (see entrypoints/background.ts) so the SDK can reach the + // extension with zero page reload. Host access is already granted below. + permissions: ["storage", "activeTab", "tabs", "scripting"], // captureVisibleTab needs host access (or an activeTab gesture). Cross-tab and // SDK-triggered picks have no per-tab gesture, so grant host access for the // screenshot capability to work on any page.