Building and Shipping a Safari Web Extension on macOS (Manifest V3, 2025 Edition): A Complete Step-by-Step Guide

Revision as of 13:36, 11 October 2025 by Ryan (talk | contribs) (1) Quick overview)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

1) Quick overview

Quick version notes (today)

  • Manifest V3 is supported in Safari (initially from Safari 15.4+) and remains supported in current Safari. (WebKit)
  • Latest Xcode release (stable): Xcode 26 (17A324) (Apple’s official releases page).
  • Use the latest macOS and Safari available in System Settings → General → Software Update; verify your Safari version in Safari → About Safari. Apple’s docs describe Safari Web Extensions, how to run them, and how to distribute them via App Store Connect. (Apple Developer)

What is a Safari Web Extension?

A Safari Web Extension lets you add features to Safari using the same WebExtensions APIs used by Chrome, Firefox, and Edge. You write HTML/CSS/JS, plus a JSON file called a manifest that tells the browser what your extension can do. Safari ships an Xcode template that wraps your web extension inside a small Mac app so users can install it from the Mac App Store. (Apple Developer)

How it differs from legacy Safari App Extensions

Legacy “Safari App Extensions” were Mac-only and had different APIs. Safari Web Extensions use the cross-browser WebExtensions model and are the modern choice. (Apple still documents app extensions, but new projects should use Web Extensions.) (Apple Developer)

Typical use cases

  • Toolbars and popups (quick actions)
  • Content scripts that change pages (e.g., highlighting, note-taking)
  • Context menus (right-click actions)
  • DevTools panels (for debugging tools)
  • Optional bridge to your Mac app (“native messaging”) (MDN Web Docs)

How the pieces fit

+---------------- macOS host app (Xcode target) ----------------+
|  - Ships on App Store, installs the extension into Safari     |
|  - Can exchange messages with the extension ("native messaging")|
|                                                                |
|  +---------- Safari Web Extension (Xcode target) -----------+  |
|  | manifest.json (MV3)                                       | |
|  |                                                           | |
|  |  Service worker (background.js)  <--- listens for events  | |
|  |           ^                        (alarms, messages, etc)| |
|  |           |                                                | |
|  |  Popup (popup.html/js)   --- sendMessage --->             | |
|  |           |                                                | |
|  |  Options page (options.html/js) -- chrome.storage.* -->   | |
|  |           |                                                | |
|  |  Content scripts (content.js) <--- tabs.sendMessage ---   | |
|  |           |                                                | |
|  |  Context menus (via chrome.contextMenus)                  | |
|  +-----------------------------------------------------------+ |
+----------------------------------------------------------------+

Mini-glossary

  • Manifest V3: The current extension format. Uses a service worker (event-driven background script) instead of always-running pages. (Chrome for Developers)
  • Content script: JS that runs inside web pages you match.
  • Host app: The tiny Mac app Xcode creates to install/ship your extension.

Checklist

  • ☐ Decide your feature (we’ll build a “reading highlight” toggle).
  • ☐ You’re OK writing HTML/CSS/JS.
  • ☐ You have a Mac that can run the latest macOS + Xcode.



2) Prerequisites & version checks

Minimums and “latest”

  • Safari + MV3: Safari 15.4+ supports MV3 + service workers. Use the newest Safari you have. (WebKit)
  • Xcode: Install the latest Xcode (today: 26 (17A324)).
  • Apple Developer Program (required to distribute on the Mac App Store, or to get Developer ID for notarized outside-store distribution). (Apple Developer)

Install Xcode + Command Line Tools

  1. Install Xcode from the Mac App Store (or developer.apple.com).

  2. Install Command Line Tools:

    xcode-select --install
    

    (Apple’s docs also mention this approach.) (Apple Developer)

Enable the Develop menu in Safari

  • Safari → SettingsAdvancedShow Develop menu in menu bar.
  • For development with local builds, turn on Develop → Allow Unsigned Extensions. (Apple’s sample docs point to this exact menu.) (Apple Developer)

Confirm versions

# Xcode:
xcodebuild -version

# macOS:
sw_vers

# Command Line Tools path (optional):
xcode-select -print-path

Checklist

  • ☐ Xcode installed and opens
  • ☐ Command Line Tools installed
  • ☐ Safari’s Develop menu shown (+ Allow Unsigned Extensions during development)



3) Project setup in Xcode (from scratch)

  1. Create the host app
  • Open Xcode → File → New → Project… → App (macOS).
  • Product Name: ReadingHighlight
  • Team: your developer team
  • Bundle ID: reverse-DNS like com.example.readinghighlight
  • Interface: SwiftUI or AppKit (either works; we don’t need UI).
  • Language: Swift
  1. Add the extension target
  • File → New → Target… → Safari Web Extension.
  • Name: ReadingHighlight Extension
  • Xcode will create a separate extension target and a shared folder with web files (manifest, scripts). Apple documents this flow. (Apple Developer)
  1. Signing & capabilities
  • In Signing & Capabilities for both the app and the extension:
    • Select your Team.
    • Ensure App Sandbox is enabled for the host app if you plan to ship on the Mac App Store. (Apple Developer)
  1. Project structure (what you’ll see)
ReadingHighlight (Mac app)
└─ ReadingHighlight Extension (Safari Web Extension)
   ├─ manifest.json
   ├─ background.js       (service worker)
   ├─ content.js          (runs on pages)
   ├─ popup.html / popup.js
   ├─ options.html / options.js
   └─ icons/              (extension icon assets)

Checklist

  • ☐ Mac app builds
  • ☐ Extension target exists
  • ☐ Team/signing selected; Sandbox on if shipping to App Store



4) Starter extension (Manifest V3)

We’ll build the example feature you asked for: a toolbar popup that toggles a “reading highlight” mode on any page (paragraphs get a gentle background). There’s also a keyboard shortcut and a context menu (“Highlight selection”). We’ll save user settings in chrome.storage.sync. (Storage APIs and messaging are standardized across browsers.) (MDN Web Docs)

Put these files into the extension folder in your Xcode project (the folder that already has a manifest.json).

manifest.json (MV3)

{
  "manifest_version": 3,
  "name": "Reading Highlight",
  "description": "Toggle a gentle reading highlight on any page. Context-menu to highlight selection. Options to choose style.",
  "version": "1.0.0",
  "icons": {
    "16": "icons/icon-16.png",
    "32": "icons/icon-32.png",
    "48": "icons/icon-48.png",
    "128": "icons/icon-128.png"
  },

  "action": {
    "default_title": "Reading Highlight",
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon-16.png",
      "32": "icons/icon-32.png"
    }
  },

  "permissions": [
    "storage",
    "tabs",
    "activeTab",
    "contextMenus"
  ],

  "host_permissions": [
    "<all_urls>",
    "https://api.quotable.io/*"
  ],

  "background": {
    "service_worker": "background.js",
    "type": "module"
  },

  "options_ui": {
    "page": "options.html",
    "open_in_tab": true
  },

  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],

  "commands": {
    "toggle-highlight": {
      "suggested_key": {
        "default": "Alt+Shift+H",
        "mac": "Command+Shift+H"
      },
      "description": "Toggle reading highlight on the current page"
    }
  }
}

Notes

  • host_permissions allow network calls (our sample fetch) and let scripts run on matching pages. (See MDN match patterns + host_permissions docs.) (MDN Web Docs)
  • commands defines our keyboard shortcut. (MDN Web Docs)

background.js (service worker)

/* background.js (MV3 service worker)
   - Creates a context menu
   - Routes messages between popup and content scripts
   - Handles keyboard command
   - Demonstrates a small network fetch
*/

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: "highlight-selection",
    title: "Reading Highlight: Highlight selection",
    contexts: ["selection"]
  });
});

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId === "highlight-selection" && tab?.id) {
    chrome.tabs.sendMessage(tab.id, { type: "APPLY_HIGHLIGHT_TO_SELECTION" });
  }
});

chrome.commands.onCommand.addListener(async (cmd) => {
  if (cmd === "toggle-highlight") {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (tab?.id) chrome.tabs.sendMessage(tab.id, { type: "TOGGLE_HIGHLIGHT" });
  }
});

// Example network request (requires host_permissions for the domain)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg?.type === "GET_QUOTE") {
    fetch("https://api.quotable.io/random")
      .then(r => r.json())
      .then(data => sendResponse({ ok: true, quote: data.content }))
      .catch(err => sendResponse({ ok: false, error: String(err) }));
    return true; // keep the message channel open for async sendResponse
  }
});

(Context menus and messaging APIs are standard WebExtensions.) (Chrome for Developers)

content.js

/* content.js
   - Applies/removes a subtle highlight class on paragraphs
   - Optionally highlights current selection
   - Reads user style from chrome.storage.sync
*/

const STYLE_ID = "__reading_highlight_style__";
const HIGHLIGHT_CLASS = "reading-highlighted";

async function getUserStyle() {
  const { rh_style } = await chrome.storage.sync.get({
    rh_style: { color: "rgba(255, 250, 205, 0.6)", underline: false }
  });
  return rh_style;
}

function ensureStyleTag(style) {
  let tag = document.getElementById(STYLE_ID);
  const underline = style.underline ? "underline" : "none";
  if (!tag) {
    tag = document.createElement("style");
    tag.id = STYLE_ID;
    document.documentElement.appendChild(tag);
  }
  tag.textContent = `
    .${HIGHLIGHT_CLASS} p {
      background: ${style.color};
      transition: background 150ms ease-in-out;
    }
    .${HIGHLIGHT_CLASS} p:hover { filter: brightness(0.98); }
    .${HIGHLIGHT_CLASS} p span.rh-selection {
      background: ${style.color};
      text-decoration: ${underline};
    }
  `;
}

function toggleHighlight() {
  document.documentElement.classList.toggle(HIGHLIGHT_CLASS);
}

function highlightSelectionInline() {
  const sel = window.getSelection();
  if (!sel || sel.rangeCount === 0) return;

  const range = sel.getRangeAt(0);
  if (range.collapsed) return;

  const span = document.createElement("span");
  span.className = "rh-selection";
  range.surroundContents(span);
  sel.removeAllRanges();
}

chrome.runtime.onMessage.addListener(async (msg) => {
  if (msg?.type === "TOGGLE_HIGHLIGHT") {
    const style = await getUserStyle();
    ensureStyleTag(style);
    toggleHighlight();
  }
  if (msg?.type === "APPLY_HIGHLIGHT_TO_SELECTION") {
    const style = await getUserStyle();
    ensureStyleTag(style);
    highlightSelectionInline();
  }
});

popup.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Reading Highlight</title>
    <style>
      body { font: 14px -apple-system, system-ui, sans-serif; min-width: 240px; padding: 12px; }
      button { padding: 8px 10px; border-radius: 8px; border: 1px solid #ccc; cursor: pointer; }
      .row { display: flex; gap: 8px; align-items: center; }
      small { color: #555; display: block; margin-top: 8px; }
    </style>
  </head>
  <body>
    <div class="row">
      <button id="toggle">Toggle highlight</button>
      <button id="quote">Get quote</button>
    </div>
    <small>Tip: Keyboard shortcut Command+Shift+H (Mac) / Alt+Shift+H (others)</small>
    <script src="popup.js"></script>
  </body>
</html>

popup.js

document.getElementById("toggle").addEventListener("click", async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  if (tab?.id) chrome.tabs.sendMessage(tab.id, { type: "TOGGLE_HIGHLIGHT" });
});

document.getElementById("quote").addEventListener("click", async () => {
  const resp = await chrome.runtime.sendMessage({ type: "GET_QUOTE" });
  if (resp?.ok) alert("Quote: " + resp.quote);
  else alert("Failed to fetch quote.");
});

options.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Reading Highlight – Options</title>
    <style>
      body { font: 14px -apple-system, system-ui, sans-serif; padding: 16px; max-width: 560px; }
      label { display: block; margin: 12px 0 6px; }
      input[type="color"] { width: 64px; height: 32px; }
    </style>
  </head>
  <body>
    <h1>Reading Highlight – Options</h1>
    <label>Highlight color
      <input id="color" type="color" value="#fffacd" />
    </label>
    <label>
      <input id="underline" type="checkbox" />
      Underline highlighted selection
    </label>
    <button id="save">Save</button>
    <p id="status" role="status"></p>
    <script src="options.js"></script>
  </body>
</html>

options.js

async function load() {
  const { rh_style } = await chrome.storage.sync.get({
    rh_style: { color: "rgba(255, 250, 205, 0.6)", underline: false }
  });

  // Convert rgba to hex if needed (simple fallback)
  const defaultHex = "#fffacd";
  const colorInput = document.getElementById("color");
  if (rh_style.color.startsWith("#")) colorInput.value = rh_style.color;
  else colorInput.value = defaultHex;

  document.getElementById("underline").checked = !!rh_style.underline;
}

async function save() {
  const color = document.getElementById("color").value;
  const underline = document.getElementById("underline").checked;

  try {
    await chrome.storage.sync.set({ rh_style: { color, underline } });
    const status = document.getElementById("status");
    status.textContent = "Saved!";
    setTimeout(() => (status.textContent = ""), 1200);
  } catch (e) {
    alert("Failed to save: " + e);
  }
}

document.getElementById("save").addEventListener("click", save);
load();

About icons Provide PNGs at 16, 32, 48, 128 (and optionally 256). Put them in /icons and reference them in manifest.json. Keep them square and readable at 16px.

Checklist

  • ☐ All files created under the extension folder
  • ☐ Manifest is MV3 (has manifest_version: 3 + background.service_worker) (WebKit)
  • ☐ Icons referenced exist



5) Build & run

  1. Build the app in Xcode (⌘B).
  2. Run the app (⌘R). The app launches; it primarily exists to install/host the extension.
  3. Open Safari → Settings → Extensions and enable “Reading Highlight Extension”.
  4. (If building unsigned for local dev) Develop → Allow Unsigned Extensions in Safari. (Apple Developer)

Reloading & testing changes

  • After code changes, build again in Xcode. Safari will pick up the new build; disable/enable once if needed. Apple documents the update loop. (Apple Developer)

Debugging (Web Inspector)

  • Open Develop menu → Extensions → inspect your background service worker and any content scripts. (Safari Web Inspector is the dev tool to debug JS/DOM/Network.) (Apple Developer)

Common pitfalls

  • The extension won’t show? You probably forgot to enable it in Safari Settings.
  • Console logs from background appear only when the service worker is running. It spins up on events (MV3 design). (Chrome for Developers)
  • Permissions errors? Check permissions and host_permissions. (Chrome for Developers)
  • Caching: If a page doesn’t reflect your content script, reload the tab.

Checklist

  • ☐ App runs
  • ☐ Extension enabled in Safari
  • ☐ You can toggle highlight from the popup and via shortcut



6) Expanding the extension (mini-tutorials)

A) Context menus (already added)

We created one in background.js with chrome.contextMenus.create and wired its click handler. (Requires "contextMenus" permission.) (Chrome for Developers)

B) Messaging patterns

  • Popup → background: chrome.runtime.sendMessage({type: ...})
  • Background → content: chrome.tabs.sendMessage(tabId, { ... })
  • Content → background: chrome.runtime.sendMessage({ ... }) Docs: MDN/Chrome messaging. (MDN Web Docs)

C) Storage & sync

  • We used chrome.storage.sync. It’s async and works in background/popup/content. (Handle errors.) (MDN Web Docs)

D) Network requests

E) DevTools panel (optional)

Add this to manifest.json:

"devtools_page": "devtools.html"

Create devtools.html + devtools.js and use chrome.devtools.panels.create(...) to add a panel. (MDN Web Docs)

F) TypeScript + bundling (optional)

Keep your Xcode folder as the output of your bundler.

Suggested layout

/ext-src      (TS/JS source, Vite or webpack)
/ext-build    (bundled output; points into the Xcode extension folder)

Vite quick config (vite.config.ts)

import { defineConfig } from "vite";

export default defineConfig({
  build: {
    outDir: "ext-build",
    rollupOptions: {
      input: {
        background: "ext-src/background.ts",
        content: "ext-src/content.ts",
        popup: "ext-src/popup.html",
        options: "ext-src/options.html"
      }
    }
  }
});

Then copy (or output directly) into the Xcode extension folder and update manifest.json to reference the built filenames.

G) Native app bridge (advanced)

Safari supports a native messaging bridge between the extension and its containing Mac app. Add "nativeMessaging" to permissions. On the native side, Xcode generates a SafariWebExtensionHandler.swift for your extension; implement beginRequest to receive messages. (Apple Developer)

manifest.json (add permission)

"permissions": ["storage", "tabs", "activeTab", "contextMenus", "nativeMessaging"]

Swift (in the extension target) — SafariWebExtensionHandler.swift

import SafariServices
import os

let log = Logger(subsystem: "com.example.readinghighlight", category: "Extension")

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
  func beginRequest(with context: NSExtensionContext) {
    guard let item = context.inputItems.first as? NSExtensionItem,
          let message = item.userInfo?[SFExtensionMessageKey] as? [String: Any] else {
      context.completeRequest(returningItems: nil, completionHandler: nil)
      return
    }

    log.debug("Received native message: \(String(describing: message))")

    // Simple echo
    let reply = NSExtensionItem()
    reply.userInfo = [ SFExtensionMessageKey: ["ok": true, "echo": message] ]
    context.completeRequest(returningItems: [reply], completionHandler: nil)
  }
}

JS (service worker)

// Send a message to the native app:
async function pingNative(payload) {
  return await chrome.runtime.sendNativeMessage("com.example.readinghighlight", payload);
}

Notes

  • You must keep the bundle IDs aligned (Xcode templates do this).
  • Native messaging requires that permission; Apple documents the flow. (Apple Developer)

Checklist

  • ☐ Context menu works
  • ☐ Messages flow between popup, background, content
  • ☐ Options persist via storage
  • ☐ (Optional) DevTools panel appears
  • ☐ (Advanced) Native messaging echo works



7) Testing

Manual test checklist

  • ☐ First run: extension enables successfully
  • ☐ Popup toggle highlights paragraphs
  • ☐ Keyboard shortcut toggles highlight
  • ☐ Context menu highlights selection
  • ☐ Options change color/underline, and persist after restart
  • ☐ Network fetch shows a quote (and permissions are correct)

Automated ideas

  • Lint: ESLint for JS/TS.
  • Unit tests: functions in content/background can be factored and tested with Vitest/Jest.
  • Integration: Use WebDriver (safaridriver) to open pages and simulate clicks/keys (Enable via safaridriver --enable and Develop → Allow Remote Automation). (Apple Developer)

MV3 worker nuance in Safari Service workers are event-driven; open the Extension Background page in Web Inspector to see logs when it’s awake. (MV3 design detail.) (Chrome for Developers)



8) Packaging, signing, notarization & distribution

Ship on the Mac App Store

  1. In Xcode, set Release signing to Apple Distribution (your team).
  2. Archive your Mac app (Product → Archive), then Distribute to App Store Connect.
  3. Prepare your product page (icon, screenshots, description, permissions rationale, privacy policy). (Apple Developer)

Package an existing web extension (CLI/web)

  • Apple provides a Safari web extension packager (CLI & web) to package and distribute via App Store Connect.

    • CLI:

      xcrun safari-web-extension-packager /path/to/extension
      
    • Web UI option is documented too. (Apple Developer)

Outside the Mac App Store (direct distribution)

  • Sign with Developer ID and notarize your app (required by Gatekeeper for outside-store apps). You can notarize via Xcode or notarytool. (Apple Support)

App Sandbox

  • App Store distribution requires App Sandbox; add only the entitlements you truly need. (Apple Developer)

Checklist

  • ☐ Archive succeeds and is signed
  • ☐ App Store assets ready (icons, screenshots, text)
  • ☐ Privacy policy URL ready
  • ☐ If outside store: notarization is successful



9) Chrome/Firefox portability

Keep code portable

  • Stick to standard WebExtensions APIs (runtime, storage, tabs, contextMenus, commands). Use MDN/Chrome docs as references. (MDN Web Docs)

Convert an existing Chrome extension

  • Use Apple’s converter to bootstrap an Xcode project:

    xcrun safari-web-extension-converter /path/to/your/chrome-extension
    

    The converter checks unsupported manifest keys and generates a project. (Apple Developer)

Safari quirks to note

  • MV3 service workers are not persistent; design for short-lived wakeups (use events, alarms). (Chrome for Developers)
  • Some APIs (e.g., blocking webRequest in MV3) behave differently or are restricted; plan for declarative or page-level strategies where needed. (Developers commonly hit “non-persistent background page cannot listen to webRequest events”.) (GitHub)

Checklist

  • ☐ Avoid Chrome-only APIs
  • ☐ Watch for MV3 differences across browsers
  • ☐ Use converter for existing projects



10) Troubleshooting & FAQ

“Refused to load … due to Content Security Policy”

  • Don’t inline JS in HTML; keep scripts in separate files and list them in the manifest. (MV3 CSP is strict.) See MDN manifest docs for keys. (MDN Web Docs)

Signing failures during archive

  • Verify Team, provisioning profiles, and that both app + extension targets are signed consistently.

Service worker “not waking”

  • Trigger via events (commands, context menu, messages). Open the background inspector from Develop → Extensions to see logs. (MV3 is event-driven.) (Chrome for Developers)

Missing permissions

  • Add to permissions (for API access) and host_permissions (for site access). Changing them prompts users on update. (Chrome for Developers)

Storage quota

  • Keep sync data small; fall back to chrome.storage.local for larger data. (See MDN storage for concepts.) (MDN Web Docs)

Debugging tips

  • Use console.log generously. Inspect content scripts on the page via Web Inspector. (Apple Developer)

Checklist

  • ☐ Check Safari’s Extensions pane for enablement
  • ☐ Use Develop menu inspectors
  • ☐ Re-build after manifest changes



11) Security & privacy

  • Least privilege: Request only the permissions and host_permissions you truly need. Users see permission prompts. (Apple Developer)
  • Data handling: Don’t store sensitive data (passwords, tokens) in plain text. Be clear in your App Store metadata about what you collect and why. (Follow App Store guidelines.) (Apple Developer)
  • Network: Pin domains in host_permissions; avoid <all_urls> unless necessary. (Chrome for Developers)

Checklist

  • ☐ Permissions minimized
  • ☐ Privacy policy published
  • ☐ No secrets stored in extension code



12) Next steps

  • Polish Options UI (previews, reset defaults).
  • Add an iCloud-style sync experience by sticking with chrome.storage.sync and testing across your devices running Safari. (Support varies by browser; verify behavior.) (MDN Web Docs)
  • Add more commands / keyboard shortcuts (and let users configure them). (MDN Web Docs)
  • Consider iOS/iPadOS support later—the same extension can ship in an iOS app bundle, but this guide focuses on macOS. (Apple documents multi-platform distribution.) (Apple Developer)



Official docs & references (for further reading)

  • Safari Web Extensions Overview / Create / Run / Distribute (Apple) (Apple Developer)
  • Enable unsigned extensions for development (Apple sample note) (Apple Developer)
  • MV3 support introduced (Safari 15.4) (WebKit blog + Apple release notes) (WebKit)
  • Manifest & APIs: MDN manifest.json, storage, commands, menus; Chrome’s messaging + contextMenus reference. (MDN Web Docs)
  • Match patterns & host_permissions (MDN/Chrome) (MDN Web Docs)
  • Native messaging bridge (Apple) (Apple Developer)
  • Package for App Store Connectsafari-web-extension-packager (Apple) (Apple Developer)
  • Xcode latest release (Apple)
  • Notarization & distribution outside the store (Apple) (Apple Developer)
  • WebDriver (Safari) for automated testing (Apple + Selenium) (Apple Developer)



Section-by-section checklists (recap)

  • Overview: understand pieces ✔️
  • Prereqs: Xcode + CLT installed; Develop menu on ✔️
  • Setup: macOS app + Safari Web Extension target; signing ✔️
  • Starter MV3: manifest + background + content + popup + options + icons ✔️
  • Build & run: enable in Safari; use Web Inspector ✔️
  • Features: context menu, messaging, storage, fetch, DevTools, TS, native bridge ✔️
  • Testing: manual list + WebDriver notes ✔️
  • Ship: sign, archive, package, notarize; App Store assets ✔️
  • Portability: use standard APIs; converter; watch MV3 quirks ✔️
  • Troubleshooting: CSP, signing, worker lifecycles, permissions ✔️
  • Security & privacy: least privilege; clear disclosures ✔️
  • Next steps: polish, shortcuts, multi-platform ✔️