<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: M.Bilal Khan</title>
    <description>The latest articles on DEV Community by M.Bilal Khan (@mbilalkhan192003).</description>
    <link>https://dev.to/mbilalkhan192003</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1716950%2Fd394b897-07c3-4b11-a635-fc908095c26c.jpeg</url>
      <title>DEV Community: M.Bilal Khan</title>
      <link>https://dev.to/mbilalkhan192003</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mbilalkhan192003"/>
    <language>en</language>
    <item>
      <title>How MV3 Service Workers Made Me Use an Offscreen Document Just to Play a Goat Sound</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:59:27 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/how-mv3-service-workers-made-me-use-an-offscreen-document-just-to-play-a-goat-sound-dd3</link>
      <guid>https://dev.to/mbilalkhan192003/how-mv3-service-workers-made-me-use-an-offscreen-document-just-to-play-a-goat-sound-dd3</guid>
      <description>&lt;p&gt;So I built a Chrome extension that does one very stupid thing: it waits a random amount of time, then screams at you like a goat if you don't dismiss the notification fast enough.&lt;/p&gt;

&lt;p&gt;Simple idea. Should've been a simple build. It was not, because Manifest V3 said no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plan was easy, in my head&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what I thought I needed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;chrome.alarms&lt;/code&gt; to fire at a random interval&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chrome.notifications&lt;/code&gt; to show a "you good?" popup&lt;/li&gt;
&lt;li&gt;If the user ignores it for 60 seconds, play a goat sound&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is where things fell apart. Because in Manifest V3, your background script isn't a persistent background page anymore — it's a service worker. And service workers have the audio capabilities of a rock. No &lt;code&gt;Audio()&lt;/code&gt; object. No Web Audio API. Nothing. You can't just do &lt;code&gt;new Audio('goat.mp3').play()&lt;/code&gt; and call it a day, because there's no DOM for it to live in.&lt;/p&gt;

&lt;p&gt;I found this out the way everyone finds out things in MV3: by writing the obvious code, watching it silently fail, and then spending 20 minutes wondering if I'd misspelled "goat."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enter: the offscreen document&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Chrome's answer to "service workers can't do X" for a bunch of X's (audio playback included) is the &lt;strong&gt;offscreen document API&lt;/strong&gt;. It's exactly what it sounds like — a hidden HTML page that exists purely so your extension has something with a DOM, so it can do DOM things. In my case, playing an mp3 of a goat losing its mind.&lt;/p&gt;

&lt;p&gt;The flow ends up looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service worker (background.js)
  → creates an offscreen document if one doesn't exist
  → sends it a message: "play the sound"
offscreen.html/offscreen.js
  → has an &amp;lt;audio&amp;gt; tag
  → actually plays the sound
  → closes itself when done
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a little bit like hiring a stunt double because the main actor (your service worker) isn't allowed near water. The offscreen document does the wet work, then goes home.&lt;/p&gt;

&lt;p&gt;Rough version of what that looks like in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// background.js&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;playGoatSound&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasDocument&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDocument&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offscreen.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AUDIO_PLAYBACK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;justification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Playing a goat scream sound effect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playSound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// offscreen.js&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playSound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sounds/goat.mp3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic. But it took me embarrassingly long to figure out that "reasons: ['AUDIO_PLAYBACK']" is a required field and not optional flavor text, and that you genuinely cannot skip the offscreen document if you want sound out of a service worker. There's no secret shortcut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other MV3 gotcha: your background script forgets everything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Service workers also don't persist. Chrome can (and will) kill your background script whenever it feels like, which is a problem when your whole app is "wait N minutes, then do a thing." If your script gets shut down mid-wait, a regular &lt;code&gt;setTimeout&lt;/code&gt; just... doesn't fire. It's gone. Chrome ate it.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;chrome.alarms&lt;/code&gt; is actually for — it's not just a nice API, it's the only way to reliably schedule something in the future when your background context can disappear at any moment. So Goat Alert ends up using two alarms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One for "time to show the notification" (the random 5/10/15/20 min interval)&lt;/li&gt;
&lt;li&gt;One for "15 seconds have passed, did they dismiss it? No? Ok, scream."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both survive the service worker dying and waking back up, because &lt;code&gt;chrome.alarms&lt;/code&gt; is backed by the browser itself, not by whatever fragile JS context happened to be running when you set the timer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd tell past me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Building this taught me that a huge chunk of "why is my simple Chrome extension so complicated" comes down to one thing: Manifest V3 assumes your background script is disposable, and designs everything around surviving that. Once you accept that and stop trying to keep long-lived state or objects (like an Audio instance) alive in the service worker, the actual APIs make sense. Offscreen documents for anything DOM-shaped, alarms for anything time-shaped.&lt;/p&gt;

&lt;p&gt;Anyway. 100+ installs, people are enjoying it, so all efforts are worth it.&lt;/p&gt;

&lt;p&gt;Give it a try, will put a smile on your face &lt;a href="https://chromewebstore.google.com/detail/ihclgidibhioanhjchbhcblpfhaipiok?utm_source=dev.to"&gt;Goat Alert&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I spent a week trying to intercept Slack push notifications from a Chrome extension. Here's why it's impossible.</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Wed, 15 Jul 2026 18:55:52 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/i-spent-a-week-trying-to-intercept-slack-push-notifications-from-a-chrome-extension-heres-why-9mc</link>
      <guid>https://dev.to/mbilalkhan192003/i-spent-a-week-trying-to-intercept-slack-push-notifications-from-a-chrome-extension-heres-why-9mc</guid>
      <description>&lt;p&gt;After I published my last article about building a Chrome extension that speaks browser notifications aloud, a commenter asked a question I didn't have a good answer to.&lt;/p&gt;

&lt;p&gt;He pointed out that a lot of web apps — Slack, Gmail, most modern tools — fire their notifications from a service worker via &lt;code&gt;registration.showNotification()&lt;/code&gt;, not from the page's JavaScript context. My MAIN world override of &lt;code&gt;window.Notification&lt;/code&gt; would never reach those.&lt;/p&gt;

&lt;p&gt;He was right. And I told him I'd look into it.&lt;/p&gt;

&lt;p&gt;I spent a week researching whether there was any way to close that gap. There isn't. But the reason why is more interesting than a simple "no."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Two ways a website can show you a notification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a website sends you a browser notification, it can do it in one of two ways.&lt;/p&gt;

&lt;p&gt;The first is the constructor path. The page's own JavaScript calls &lt;code&gt;new Notification("You have a message")&lt;/code&gt; directly. This is common for in-tab alerts, real-time updates when you're actively on the site, or any notification triggered by something you just did.&lt;/p&gt;

&lt;p&gt;The second is the push path. The browser receives a push event from the website's server, wakes up the website's service worker in the background, and the service worker calls &lt;code&gt;self.registration.showNotification()&lt;/code&gt; from inside its own scope. This is what happens when Slack notifies you of a new message while the tab is closed or backgrounded. The page never runs. No page JavaScript ever fires.&lt;/p&gt;

&lt;p&gt;My extension catches the first path. The MAIN world content script overrides &lt;code&gt;window.Notification&lt;/code&gt; before any page code runs. But the service worker never touches the page's window. It has no &lt;code&gt;window&lt;/code&gt;. It runs in a completely isolated thread, completely separate from the page, and calls &lt;code&gt;showNotification&lt;/code&gt; on itself.&lt;/p&gt;

&lt;p&gt;The override is never reached.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why can't the extension reach the service worker?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part that took me a week to fully accept.&lt;/p&gt;

&lt;p&gt;Chrome extensions can inject content scripts into web pages. They can run code in the MAIN world or the ISOLATED world of a page. They can observe network requests, intercept navigations, and modify headers.&lt;/p&gt;

&lt;p&gt;What they cannot do is inject code into a third-party website's service worker.&lt;/p&gt;

&lt;p&gt;Service workers operate in a separate thread with no DOM, no window, and no connection to the page's JavaScript context. The Chrome Extensions API simply does not offer a mechanism to run extension code inside an arbitrary third-party service worker scope. It's a hard security boundary — and for good reasons. Allowing extensions to arbitrarily patch service workers for any website on the internet would be a significant attack surface.&lt;/p&gt;

&lt;p&gt;So: the extension lives on the page side. The push notification lives on the service worker side. There is a wall between them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Three approaches I investigated&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't accept "impossible" without looking for workarounds. Here's what I found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 1: Override ServiceWorkerRegistration.prototype.showNotification in the MAIN world&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A page's JavaScript can call &lt;code&gt;navigator.serviceWorker.ready.then(reg =&amp;gt; reg.showNotification(...))&lt;/code&gt;. If you override &lt;code&gt;ServiceWorkerRegistration.prototype.showNotification&lt;/code&gt; in the MAIN world, you'd intercept that pattern.&lt;/p&gt;

&lt;p&gt;The problem: Slack's push notifications don't come from page JavaScript. They come from inside the service worker script itself, where it calls &lt;code&gt;self.registration.showNotification()&lt;/code&gt;. The service worker runs in its own scope. The MAIN world prototype override doesn't reach there.&lt;/p&gt;

&lt;p&gt;This approach catches one narrow pattern and misses the actual push notification path entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 2: chrome.debugger API with CDP&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the most discussed workaround in the Chromium Extensions community. The &lt;code&gt;chrome.debugger&lt;/code&gt; API gives an extension access to the Chrome DevTools Protocol. With CDP's &lt;code&gt;Fetch.fulfillRequest&lt;/code&gt; command, you can intercept a network response — including a service worker JavaScript file — and modify it before the browser processes it. Inject your code into the SW script, and it runs inside the service worker scope.&lt;/p&gt;

&lt;p&gt;This technically works. It is completely unusable for a consumer extension.&lt;/p&gt;

&lt;p&gt;When you attach the debugger to any tab, Chrome displays a persistent banner at the top of the browser: "Chrome is being controlled by automated test software." This banner appears on every tab. It cannot be dismissed. It cannot be hidden by the extension. The only way to suppress it is to launch Chrome with &lt;code&gt;--silent-debugger-extension-api&lt;/code&gt;, which is not something a real user would do.&lt;/p&gt;

&lt;p&gt;For a Chrome Web Store extension that people install on their own machine, this approach would immediately look like malware. Ruled out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approach 3: declarativeNetRequest&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3's &lt;code&gt;declarativeNetRequest&lt;/code&gt; API lets extensions block or redirect network requests. Could you redirect the Slack service worker script to a modified version?&lt;/p&gt;

&lt;p&gt;No. &lt;code&gt;declarativeNetRequest&lt;/code&gt; can block or redirect a request, but it cannot modify the response body. And even if it could redirect the service worker script URL, the replacement would need to be served from the same origin as the site — extensions can't serve content from &lt;code&gt;slack.com&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What Firefox can do that Chrome can't&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's where it gets interesting.&lt;/p&gt;

&lt;p&gt;Firefox has an API called &lt;code&gt;webRequest.filterResponseData()&lt;/code&gt;. It lets an extension intercept a network response and modify its body before the browser processes it. Applied to a service worker JavaScript file, you could inject code that runs inside the SW scope. That code could override &lt;code&gt;self.registration.showNotification&lt;/code&gt; and intercept push notifications.&lt;/p&gt;

&lt;p&gt;This capability exists in Firefox. It was advocated for by Giorgio Maone, the developer of NoScript, who needed exactly this kind of deep injection for a privacy tool.&lt;/p&gt;

&lt;p&gt;Chrome removed blocking &lt;code&gt;webRequest&lt;/code&gt; in MV3 and never added a &lt;code&gt;filterResponseData&lt;/code&gt; equivalent. This is one of the more significant functional divergences between the two extension models post-MV3. Extension developers have raised it on the Chromium mailing lists. For now, it's a genuine gap.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What this means for &lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=article2-sw" rel="noopener noreferrer"&gt;Serious Notification&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The extension works well for the constructor path — notifications fired from page JavaScript when a tab is active. For many apps and many use cases, that's most of their notification volume.&lt;/p&gt;

&lt;p&gt;For pure push-via-service-worker notifications from backgrounded tabs — like Slack when you've minimised the window — the extension will miss them. I've added a Known Limitations section to the original article to reflect this honestly.&lt;/p&gt;

&lt;p&gt;What would need to change for this to be solvable on Chrome? Some form of extension access to service worker scope — whether through a filtered response body API like Firefox's, or through a purpose-built notification interception hook in the Chrome Extensions API. Neither exists today.&lt;/p&gt;

&lt;p&gt;If you want to see this change, the Chromium issue tracker is the place to make that case. I'm watching it.&lt;/p&gt;

&lt;p&gt;In the meantime, if you want to try what's possible now:&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=article2-sw" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=article2-sw&lt;/a&gt;&lt;/p&gt;

</description>
      <category>extensions</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>How I built a Chrome extension that speaks your notifications aloud — and the two Manifest V3 problems nobody warned me about</title>
      <dc:creator>M.Bilal Khan</dc:creator>
      <pubDate>Tue, 07 Jul 2026 19:40:25 +0000</pubDate>
      <link>https://dev.to/mbilalkhan192003/how-i-built-a-chrome-extension-that-speaks-your-notifications-aloud-and-the-two-manifest-v3-3239</link>
      <guid>https://dev.to/mbilalkhan192003/how-i-built-a-chrome-extension-that-speaks-your-notifications-aloud-and-the-two-manifest-v3-3239</guid>
      <description>&lt;p&gt;I counted how many times I glanced at a notification during a coding session last week.&lt;/p&gt;

&lt;p&gt;Thirty-four times. Only four needed my attention.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=technical-article" rel="noopener noreferrer"&gt;Serious Notification&lt;/a&gt; — a Chrome extension that speaks browser notifications aloud, but only when they match keywords you set. Type "failed" and only hear build errors. Type "urgent" and only hear what actually matters. Everything else stays silent.&lt;/p&gt;

&lt;p&gt;It took a weekend to build the core. The two Manifest V3 architectural problems I hit along the way took longer to figure out. Neither of them is well documented. Here's both.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Problem 1: You can't intercept the Notification API in a regular content script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The plan seemed simple: listen for notifications, check them against keywords, speak the matching ones. A content script listening for notification events should work, right?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;Chrome's &lt;code&gt;Notification&lt;/code&gt; constructor lives on &lt;code&gt;window&lt;/code&gt;. When a website calls &lt;code&gt;new Notification("You have a message")&lt;/code&gt;, it's using &lt;code&gt;window.Notification&lt;/code&gt; in the page's own JavaScript context.&lt;/p&gt;

&lt;p&gt;A standard Manifest V3 content script runs in what Chrome calls an &lt;strong&gt;ISOLATED world&lt;/strong&gt; — a sandboxed environment that shares the DOM with the page but has its own separate JavaScript context. Your content script can read the DOM, but it cannot touch &lt;code&gt;window.Notification&lt;/code&gt; on the actual page. By the time your script runs, the page's Notification constructor is completely separate from anything you can reach.&lt;/p&gt;

&lt;p&gt;You can listen for a notification after it fires. You cannot intercept it before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: MAIN world content scripts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3 added support for content scripts that run in the MAIN world. Set &lt;code&gt;"world": "MAIN"&lt;/code&gt; in your manifest and your script runs directly in the page's JavaScript context — before any page code executes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content_scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"js"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"interceptor.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"world"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MAIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"document_start"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can override &lt;code&gt;window.Notification&lt;/code&gt; before the page has a chance to use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OriginalNotification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Notification&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__sn_notification__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OriginalNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The catch: MAIN world scripts have no Chrome extension APIs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the problem with MAIN world scripts. They run in the page context, which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;chrome.storage&lt;/code&gt; — you cannot read the user's saved keywords&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;speechSynthesis&lt;/code&gt; — you cannot speak the notification aloud&lt;/li&gt;
&lt;li&gt;No extension APIs at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are in the page's world. Chrome's extension APIs are not available there.&lt;/p&gt;

&lt;p&gt;So you need two scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bridge&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Script 1 (&lt;code&gt;interceptor.js&lt;/code&gt;) runs in the MAIN world. It overrides &lt;code&gt;window.Notification&lt;/code&gt; and fires a custom DOM event when a notification arrives.&lt;/p&gt;

&lt;p&gt;Script 2 (&lt;code&gt;content.js&lt;/code&gt;) runs in the ISOLATED world. It has full access to &lt;code&gt;chrome.storage&lt;/code&gt; and &lt;code&gt;speechSynthesis&lt;/code&gt;. It listens for the custom event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__sn_notification__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keywords&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;speed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utterance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SpeechSynthesisUtterance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;utterance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;speechSynthesis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;speak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utterance&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two scripts. One custom DOM event as the bridge. The MAIN world intercepts, the ISOLATED world decides.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Problem 2: Chrome showed a "read and change all your data on all websites" warning at install&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The extension needs to inject on every page — that's legitimate, since you don't know which tab will fire a notification. So &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; host access is genuinely required.&lt;/p&gt;

&lt;p&gt;But I was declaring it as a required permission in &lt;code&gt;manifest.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"content_scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"matches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This caused Chrome to show the scariest possible install warning before users had any reason to trust the extension. I was watching my analytics and seeing a 33% uninstall rate. Some of that was almost certainly people bouncing at the permission screen, before they ever heard the extension speak a single word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix: optional host permissions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Manifest V3 supports &lt;code&gt;optional_host_permissions&lt;/code&gt;. Move &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; there instead of requiring it at install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scripting"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"optional_host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;all_urls&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then register content scripts dynamically at runtime using &lt;code&gt;chrome.scripting.registerContentScripts()&lt;/code&gt;, only after the user explicitly grants the permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// background.js&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAdded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origins&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerContentScripts&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;interceptor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;interceptor.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;world&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MAIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document_start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;runAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;document_start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the popup, show a "Turn on to hear alerts" button that triggers the permission request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;origins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: the Chrome Web Store install dialog now shows &lt;strong&gt;Storage only&lt;/strong&gt;. No broad-access warning. The scary prompt only appears when the user explicitly clicks "Turn on" inside the popup — after they have already installed the extension and understand what it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One edge case: updating from a version that required the permission&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you update an extension and a previously required permission becomes optional, Chrome removes it from the user's granted set. Existing users lose host access silently on update — the extension just stops working with no explanation.&lt;/p&gt;

&lt;p&gt;The fix: fire a native notification immediately after update using the &lt;code&gt;notifications&lt;/code&gt; permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onInstalled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update-notice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;basic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;iconUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;icon.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Serious Notification — One quick step needed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;We improved how permissions work. Open the extension and click "Turn on" to re-enable.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;requireInteraction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Silent failure is the worst outcome for retention. If something breaks on update, tell the user immediately and tell them exactly what to do.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What I built&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Serious Notification speaks Chrome notifications aloud when they match keywords you set. Free, no account, works offline. Just shipped v0.2.0 with the optional permissions model above.&lt;/p&gt;

&lt;p&gt;If you're building Chrome extensions and hit either of these problems — happy to answer questions in the comments. The MAIN world injection in particular has some edge cases around static property copying (&lt;code&gt;permission&lt;/code&gt;, &lt;code&gt;requestPermission&lt;/code&gt;) that took me a while to get right.&lt;/p&gt;

&lt;p&gt;🔗&lt;a href="https://chromewebstore.google.com/detail/hnaggblalhlbihfaegbknioadncpcged?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=technical-article" rel="noopener noreferrer"&gt;Serious Notification - Live Chrome WebStore&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Known Limitations
&lt;/h2&gt;

&lt;p&gt;This approach only intercepts notifications fired via the Notification constructor in the page's JavaScript context.&lt;/p&gt;

&lt;p&gt;Notifications fired from a service worker via registration.showNotification() — which is the path used by background push notifications in apps like Slack — bypass the MAIN world override entirely. The service worker runs in its own scope and never touches the page's window.&lt;/p&gt;

&lt;p&gt;Chrome's extension model does not currently offer a hook into arbitrary third-party service workers, so this gap cannot be closed with the current architecture.&lt;/p&gt;

&lt;p&gt;The extension works well for in-tab notifications and apps that call the constructor directly from page scripts. For pure push-via-service-worker apps when the tab is backgrounded, it will miss notifications.&lt;/p&gt;

</description>
      <category>extensions</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
