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.