C.W.K.
Stream
Lesson 05 of 05 · published

ClipDeck Just-in-Time Permissions — The Whole Flow End to End

~14 min · clipdeck, optional_permissions, ux, export, host-request

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Install with the minimum. Earn the rest with intent. Track 6 ends with two opt-in flows wired end to end: Export Clips asks for downloads when the user clicks it, and Enable on This Site asks for the host permission when the user wants ClipDeck on a previously-excluded URL."

The Two Flows

Track 5 left ClipDeck with downloads and optional_host_permissions declared but unrequested. This lesson lights both:

  1. Export Clips — popup or side-panel button. On click, request downloads if not granted, then trigger the actual export through the SW.
  2. Enable on This Site — popup button that appears only when content script ISN'T running on the current tab (because the URL didn't match content_scripts.matches or it was in exclude_matches). On click, request https://<current-host>/* as a host permission, then programmatically inject the content script.

Both follow the same shape: detect → ask → run → fall back gracefully if denied. The shape is so consistent it's worth abstracting into a small helper.

The ensurePermission Helper

One function the popup and side panel can both use:

async function ensurePermission(req) {
  const has = await chrome.permissions.contains(req);
  if (has) return true;
  return chrome.permissions.request(req);
}

It collapses the contains-then-request dance into one call. Callers branch only on the boolean result.

The 'Enable on This Site' Flow

This is the more interesting of the two because it changes the extension's reach at runtime. The flow:

  1. Popup opens. Read current tab's URL.
  2. Check if content script is already injected: send a ping message, catch the failure if no listener exists.
  3. If running, the button reads "ClipDeck is active on this site." If not, the button reads "Enable ClipDeck on this site."
  4. On click, request the host permission for https://<host>/*.
  5. If granted, ask the SW to inject the content script via chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] }). Now ClipDeck works on this tab.
  6. For persistent injection across reloads, also add a dynamic content script via chrome.scripting.registerContentScripts so future loads of the same host auto-inject.

Persistent Dynamic Content Scripts

chrome.scripting.registerContentScripts (Chrome 96+) registers a content script that survives SW evictions and browser restarts, scoped to URLs you grant host permission to. The shape:

await chrome.scripting.registerContentScripts([{
  id: 'clipdeck-dynamic-acme',
  matches: ['https://acme.com/*'],
  js: ['content.js'],
  runAt: 'document_idle',
}]);

List existing ones with getRegisteredContentScripts; remove with unregisterContentScripts({ ids: ['...'] }). The 'Disable on this site' counterpart unregisters AND calls chrome.permissions.remove({ origins: ['https://acme.com/*'] }).

The Privacy Trust Story

By the end of this lesson ClipDeck installs with:

  • One install warning (the narrowed content_scripts.matches), one moderate warning (tabs), one silent set (storage/sidePanel/contextMenus/scripting/activeTab/alarms).
  • Export, notifications, and "enable on additional sites" all gated behind per-feature in-product prompts.
  • A privacy-minded user can use the entire core feature set without ever granting downloads or additional hosts; they pay only for the upgrades they actually want.

This is the bar Chrome Web Store reviewers look for: "is the install warning proportional to what the extension actually does at install?" An extension that asks for everything upfront fails the bar even if technically it would use everything. An extension that defers asks to in-product moments passes.

Wrapping Track 6

Six tracks down. ClipDeck has:

  • An MV3 manifest with clean permission categories.
  • A service worker, a popup, a side panel, content scripts, context menus, keyboard shortcuts, omnibox, badge.
  • Full CRUD-C (Track 3) and CRUD-R (Track 4).
  • Per-tab pause (Track 5).
  • Just-in-time downloads and host permissions (this track).

Track 7 adds the rest of CRUD — Update and Delete — and the larger DOM-handling toolkit ClipDeck needs to make the existing reach work on the messy, framework-heavy sites people actually use.

Install thin. Upgrade in-product. Every optional permission is a feature the user opted into, not a tax they paid at install. Reviewers and users both reward that discipline.
What the user sees in chrome://extensions. Granted optional permissions show up under 'Site access' and 'Permissions' with revoke controls. This is the visible audit trail. Users can confirm at any time what your extension has and can pull access back. Design as if every user might check this page tomorrow — because some do.

Code

popup.js — detect, prompt host permission, then enable·javascript
// popup.js — Enable on this site flow
function hostPatternFor(url) {
  try {
    const u = new URL(url);
    return `${u.protocol}//${u.host}/*`;
  } catch {
    return null;
  }
}

async function isContentScriptLive(tabId) {
  try {
    await chrome.tabs.sendMessage(tabId, { type: "ping" });
    return true;
  } catch {
    return false;
  }
}

async function refreshEnableButton() {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id || !tab.url) return;
  const live = await isContentScriptLive(tab.id);
  const btn = document.getElementById("enableBtn");
  btn.style.display = live ? "none" : "block";
  btn.textContent = `Enable ClipDeck on ${new URL(tab.url).host}`;
}

document.getElementById("enableBtn").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (!tab?.id || !tab.url) return;
  const pattern = hostPatternFor(tab.url);
  if (!pattern) return;
  const granted = await chrome.permissions.request({ origins: [pattern] });
  if (!granted) {
    alert("ClipDeck needs site access to capture clips here.");
    return;
  }
  await chrome.runtime.sendMessage({ type: "enableOnSite", tabId: tab.id, pattern });
  await refreshEnableButton();
});

chrome.permissions.onAdded.addListener(refreshEnableButton);
chrome.permissions.onRemoved.addListener(refreshEnableButton);
refreshEnableButton();
background.js — inject for the current load + register for future loads·javascript
// background.js — SW handles the inject + register flow
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "enableOnSite") {
    (async () => {
      const { tabId, pattern } = message;
      // 1) inject content script into the current tab right now
      try {
        await chrome.scripting.executeScript({
          target: { tabId },
          files: ["content.js"],
        });
      } catch (err) {
        sendResponse({ ok: false, reason: err.message });
        return;
      }
      // 2) register a dynamic content script for future loads of this host
      const id = `clipdeck-dynamic-${btoa(pattern).replace(/=/g, "")}`;
      const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [id] });
      if (existing.length === 0) {
        await chrome.scripting.registerContentScripts([{
          id,
          matches: [pattern],
          js: ["content.js"],
          runAt: "document_idle",
        }]);
      }
      sendResponse({ ok: true });
    })();
    return true;
  }
});

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "ping") {
    // content.js will respond with this; if no content script, the sendMessage throws.
    sendResponse({ ok: true });
    return;
  }
});
content.js — ping responder lets the popup detect injection state·javascript
// content.js — add a ping responder for the isContentScriptLive check
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message?.type === "ping") {
    sendResponse({ ok: true });
    return;
  }
  // ...other message handlers from earlier lessons stay here...
});

External links

Exercise

Add an <button id="enableBtn"> to clipdeck/popup.html (hidden by default; the JS shows it when needed). Add the first code block to clipdeck/popup.js. Add the second code block's enable-on-site message router to clipdeck/background.js, and the third code block's ping responder to clipdeck/content.js. Reload the extension. Narrow your content_scripts.matches to ["https://wikipedia.org/*"] temporarily so ClipDeck is NOT on github.com by default. Visit github.com — popup opens with an Enable ClipDeck on github.com button. Click it — Chrome prompts "Allow on github.com?" Click Allow. The button hides; the content script is now injected on this tab. Refresh the page — content script auto-injects via the dynamic registration. Then chrome://extensions → ClipDeck → Site access — revoke github.com; the dynamic script vanishes and the popup button reappears.
Hint
If the popup button never appears, the isContentScriptLive check is succeeding when it shouldn't — the ping responder might already be in your content.js from another lesson. Verify by removing the ping responder and checking the popup again. If chrome.scripting.registerContentScripts throws Cannot register scripts before user permission, the host permission hasn't been granted yet — make sure you await the chrome.permissions.request call AND check the boolean result before reaching for register. If you revoke a host but the dynamic script remains, your unregister flow isn't wired — add a corresponding 'disable on this site' message that calls unregisterContentScripts AND chrome.permissions.remove.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.