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
38 changes: 30 additions & 8 deletions apps/demo/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
Expand Down Expand Up @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions packages/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,41 @@ async function refocusSource(sourceTabId: number): Promise<void> {
}
}

/**
* 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<void> {
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<ConsentStatus> {
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/extension/entrypoints/content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export default defineContentScript({
matches: ["<all_urls>"],
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",
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openpicker/extension",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
5 changes: 4 additions & 1 deletion packages/extension/wxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading