From 3124058425fe72f216bc48c834357b1592a9bdf0 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 8 Aug 2026 16:01:42 -0400 Subject: [PATCH] fix: reinject the content script into open tabs on install/update Manifest content scripts are only injected at document load, so an extension update orphans the copy in every open tab: 'read-hints' has no receiver, and the popup calls the page publication-free and caches that answer. Sweep matching tabs from onInstalled (new 'scripting' permission) so open tabs heal without a reload. The sweep makes every tab report hints at once, so detection runs now go through the coalescing queue: three at a time, newest request per tab wins, popup and active-tab requests first. The content script gains a world-global guard against double injection, and its observer disconnects once its extension generation is gone. Co-Authored-By: Claude Fable 5 --- public/manifest.json | 2 +- src/background.ts | 58 +++++++++++++++++++++++++++++++-- src/content.ts | 76 ++++++++++++++++++++++++++++---------------- 3 files changed, 106 insertions(+), 30 deletions(-) diff --git a/public/manifest.json b/public/manifest.json index 32c7c9b..b3e652e 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -28,6 +28,6 @@ "32": "icons/icon32.png" } }, - "permissions": ["storage", "identity", "activeTab", "tabs"], + "permissions": ["storage", "identity", "activeTab", "tabs", "scripting"], "host_permissions": ["http://*/*", "https://*/*"] } diff --git a/src/background.ts b/src/background.ts index 0eb48d7..b91f0f5 100644 --- a/src/background.ts +++ b/src/background.ts @@ -7,6 +7,7 @@ import { listRecords, parseAtUri, resolveDid } from './lib/atproto' import { detectPage } from './lib/detection' import { ICON_SIZES, type IconState, badgeFor, iconStateFor, renderBaseIcon } from './lib/icon' +import { CoalescingQueue } from './lib/scheduler' import type { Msg, PageState, SessionInfo } from './lib/types' const SUB_COLLECTION = 'site.standard.graph.subscription' @@ -105,6 +106,27 @@ async function getSubscriptions(refresh: boolean): Promise { // --- detection orchestration ------------------------------------------------- +/** Detection is network-bound and the reinjection sweep after an extension + * update makes every open tab report hints at once; run a few at a time, + * newest request per tab wins, user-facing requests jump the queue. */ +const detections = new CoalescingQueue(3) + +function scheduleDetection( + tabId: number, + url: string, + hints: { pubHint?: string; docHint?: string }, + opts: { refreshSubs?: boolean; urgent?: boolean } = {}, +): Promise { + if (detections.depth > 0) { + console.debug('[substandard] detection queued', tabId, 'depth', detections.depth) + } + // Immediate feedback even while the tab waits for a slot. + void setBadge(tabId, 'checking') + return detections.run(tabId, () => computeState(tabId, url, hints, !!opts.refreshSubs), { + urgent: opts.urgent, + }) +} + async function computeState( tabId: number, url: string, @@ -157,7 +179,7 @@ async function handle(msg: Msg, sender: chrome.runtime.MessageSender): Promise undefined) - return computeState(msg.tabId, tab.url, hints ?? {}, !!msg.refresh) + return scheduleDetection(msg.tabId, tab.url, hints ?? {}, { + refreshSubs: !!msg.refresh, + urgent: true, + }) + } + } +} + +// Manifest content scripts are only injected at document load, so an extension +// install or update leaves every already-open tab with no live copy (updates +// orphan the old one): 'read-hints' has no receiver and the tab would read as +// publication-free until reloaded. Put the script back into matching tabs; its +// report() then repopulates badges through the detection queue. +chrome.runtime.onInstalled.addListener(() => { + void reinjectContentScripts() +}) + +async function reinjectContentScripts(): Promise { + for (const cs of chrome.runtime.getManifest().content_scripts ?? []) { + if (!cs.js?.length) continue + const tabs = await chrome.tabs.query({ url: cs.matches }) + let injected = 0 + for (const tab of tabs) { + // Discarded tabs get the manifest injection when they reload on focus. + if (tab.id === undefined || !isHttpUrl(tab.url) || tab.discarded) continue + try { + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: cs.js }) + injected++ + } catch (err) { + // e.g. policy-blocked page; it self-heals on next navigation + console.debug('[substandard] could not reinject into tab', tab.id, err) + } } + console.debug(`[substandard] reinjected content script into ${injected}/${tabs.length} tabs`) } } diff --git a/src/content.ts b/src/content.ts index 09d805b..a523dbd 100644 --- a/src/content.ts +++ b/src/content.ts @@ -8,34 +8,56 @@ function hint(rel: string): string | undefined { } function report() { - chrome.runtime - .sendMessage({ - type: 'page-hints', - pubHint: hint('site.standard.publication'), - docHint: hint('site.standard.document'), - }) - .catch(() => { - // background worker not ready; it will probe on demand instead - }) + try { + chrome.runtime + .sendMessage({ + type: 'page-hints', + pubHint: hint('site.standard.publication'), + docHint: hint('site.standard.document'), + }) + .catch(() => { + // background worker not ready; it will probe on demand instead + }) + } catch { + // extension reloaded out from under this copy; the fresh one reports + } } -report() +function main() { + report() -// The background worker asks for hints again on manual refresh. -chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { - if (msg?.type === 'read-hints') { - sendResponse({ - pubHint: hint('site.standard.publication'), - docHint: hint('site.standard.document'), - }) - } -}) + // The background worker asks for hints again on manual refresh. + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type === 'read-hints') { + sendResponse({ + pubHint: hint('site.standard.publication'), + docHint: hint('site.standard.document'), + }) + } + }) -// SPA navigations swap head tags without a page load. -let lastUrl = location.href -new MutationObserver(() => { - if (location.href !== lastUrl) { - lastUrl = location.href - report() - } -}).observe(document, { subtree: true, childList: true }) + // SPA navigations swap head tags without a page load. + let lastUrl = location.href + new MutationObserver((_mutations, observer) => { + if (!chrome.runtime?.id) { + // The extension was reloaded; this copy is orphaned. Stop observing so + // the page is not paying for a script that can no longer report. + observer.disconnect() + return + } + if (location.href !== lastUrl) { + lastUrl = location.href + report() + } + }).observe(document, { subtree: true, childList: true }) +} + +// The worker re-injects this script into open tabs on install/update, and the +// sweep can race the manifest injection on a tab that is loading right then. +// One extension generation shares one isolated world per tab (a reload gets a +// fresh world), so a world-global flag de-duplicates exactly the live copies. +const w = window as typeof window & { __substandardLoaded?: true } +if (!w.__substandardLoaded) { + w.__substandardLoaded = true + main() +} -- 2.51.2