Provides patterns for WXT browser extensions: project structure, entrypoints, CLI commands, security hardening (49 rules), Firefox/Safari configs, and cross-browser builds.
npx claudepluginhub arustydev/aiThis skill uses the workspace's default tool permissions.
Comprehensive guide for building cross-browser extensions with WXT, including security hardening, Firefox/Safari specifics, and production patterns.
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
Comprehensive guide for building cross-browser extensions with WXT, including security hardening, Firefox/Safari specifics, and production patterns.
WXT is the leading framework for browser extension development, offering:
This skill covers:
This skill does NOT cover:
store-submission skill)| Command | Purpose |
|---|---|
wxt | Start dev mode with HMR |
wxt build | Production build |
wxt build -b firefox | Firefox-specific build |
wxt zip | Package for distribution |
wxt prepare | Generate TypeScript types |
wxt clean | Clean output directories |
wxt submit | Publish to stores |
| Type | File | Manifest Key |
|---|---|---|
| Background | entrypoints/background.ts | background.service_worker |
| Content Script | entrypoints/content.ts | content_scripts |
| Popup | entrypoints/popup/ | action.default_popup |
| Options | entrypoints/options/ | options_page |
| Side Panel | entrypoints/sidepanel/ | side_panel |
| Unlisted | entrypoints/*.ts | Not in manifest |
my-extension/
├── entrypoints/
│ ├── background.ts # Service worker
│ ├── content.ts # Content script
│ ├── content/ # Multi-file content script
│ │ ├── index.ts
│ │ └── styles.css
│ ├── popup/
│ │ ├── index.html
│ │ ├── main.ts
│ │ └── App.vue
│ ├── options/
│ │ └── index.html
│ └── sidepanel/
│ └── index.html
├── public/
│ └── icon/
│ ├── 16.png
│ ├── 32.png
│ ├── 48.png
│ └── 128.png
├── utils/ # Shared utilities
├── wxt.config.ts # WXT configuration
├── tsconfig.json
└── package.json
// entrypoints/background.ts
export default defineBackground(() => {
console.log('Extension loaded', { id: browser.runtime.id });
// Handle messages from content scripts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'getData') {
handleGetData(message.payload).then(sendResponse);
return true; // Keep channel open for async response
}
});
// Use alarms for recurring tasks (MV3 service worker friendly)
browser.alarms.create('sync', { periodInMinutes: 5 });
browser.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') {
performSync();
}
});
});
// entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.example.com/*'],
runAt: 'document_idle',
main(ctx) {
console.log('Content script loaded');
// Use context for lifecycle management
ctx.onInvalidated(() => {
console.log('Extension updated/disabled');
cleanup();
});
// Create isolated UI
const ui = createShadowRootUi(ctx, {
name: 'my-extension-ui',
position: 'inline',
anchor: '#target-element',
onMount(container) {
// Mount your UI framework here
return mount(App, { target: container });
},
onRemove(app) {
app.$destroy();
},
});
ui.mount();
},
});
// entrypoints/content.ts
export default defineContentScript({
matches: ['*://*.example.com/*'],
world: 'MAIN', // Access page's JavaScript context
main() {
// Can access page's window object
window.myExtensionApi = {
getData: () => { /* ... */ }
};
},
});
<!-- entrypoints/popup/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
// entrypoints/popup/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
entrypointsDir: 'src/entrypoints',
outDir: 'dist',
manifest: {
name: 'My Extension',
description: 'Extension description',
version: '1.0.0',
permissions: ['storage', 'activeTab'],
host_permissions: ['*://*.example.com/*'],
},
});
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: ({ browser }) => ({
name: 'My Extension',
description: 'Cross-browser extension',
// Browser-specific settings
...(browser === 'firefox' && {
browser_specific_settings: {
gecko: {
id: 'my-extension@example.com',
strict_min_version: '109.0',
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
},
}),
// Chrome-specific
...(browser === 'chrome' && {
minimum_chrome_version: '116',
}),
}),
});
// entrypoints/background.ts
export default defineBackground({
// Different behavior per browser
persistent: {
firefox: true, // Use persistent background in Firefox
chrome: false, // Service worker in Chrome
},
main() {
// ...
},
});
| # | Rule | Rationale |
|---|---|---|
| 1 | Minimize permissions | Request only what's needed |
| 2 | Use optional_permissions | Request sensitive permissions at runtime |
| 3 | Scope host_permissions | Narrow to specific domains, never <all_urls> |
| 4 | Set minimum_chrome_version | Ensure security features are available |
| 5 | Avoid externally_connectable wildcards | Limit which sites can message extension |
| 6 | Set strict CSP | No unsafe-eval, no external scripts |
| 7 | Use web_accessible_resources sparingly | Fingerprinting risk |
| 8 | Never expose source maps | Hide implementation details |
| 9 | Remove debug permissions in production | e.g., management, debugger |
| 10 | Validate manifest with wxt build --analyze | Catch permission bloat |
| # | Rule | Rationale |
|---|---|---|
| 11 | Use Shadow DOM for injected UI | Style isolation, DOM encapsulation |
| 12 | Never use innerHTML with untrusted data | XSS prevention |
| 13 | Validate all messages from page | Don't trust window.postMessage |
| 14 | Use ContentScriptContext for cleanup | Prevent memory leaks |
| 15 | Avoid storing sensitive data in DOM | Page scripts can read it |
| 16 | Use document_idle over document_start | Less intrusive, more stable |
| 17 | Scope CSS selectors narrowly | Avoid page conflicts |
| 18 | Never inject into banking/payment pages | High-risk surfaces |
| 19 | Use MutationObserver over polling | Performance |
| 20 | Validate URL before injecting | Prevent injection on wrong pages |
| # | Rule | Rationale |
|---|---|---|
| 21 | Persist state to chrome.storage | Service worker terminates |
| 22 | Use chrome.alarms over setInterval | Survives worker restart |
| 23 | Validate all incoming messages | Don't trust content scripts |
| 24 | Never store secrets in code | Use secure storage |
| 25 | Use HTTPS for all fetch requests | Data in transit security |
| 26 | Implement rate limiting | Prevent abuse |
| 27 | Log security events | Audit trail |
| 28 | Handle extension update gracefully | Reconnect content scripts |
| 29 | Use webRequest carefully | Performance impact |
| 30 | Avoid long-running operations | Service worker termination |
| # | Rule | Rationale |
|---|---|---|
| 31 | Use storage.local for sensitive data | Not synced to cloud |
| 32 | Encrypt sensitive values | Defense in depth |
| 33 | Implement storage quotas | Prevent unbounded growth |
| 34 | Validate data before storing | Type safety |
| 35 | Use versioned schema migrations | Data integrity |
| 36 | Clear storage on uninstall | User privacy |
| 37 | Don't store PII without consent | GDPR/CCPA compliance |
| 38 | Use storage.session for temporary data | Auto-cleared |
| 39 | Implement backup/restore | Data recovery |
| 40 | Audit storage access | Security logging |
| # | Rule | Rationale |
|---|---|---|
| 41 | Use runtime.sendMessage over postMessage | Type-safe, scoped |
| 42 | Validate sender in message handlers | Prevent spoofing |
| 43 | Never pass functions in messages | Serialization issues |
| 44 | Chunk large data transfers | Memory efficiency |
| 45 | Use typed message protocols | Maintainability |
| 46 | Implement request timeouts | Prevent hanging |
| 47 | Handle disconnection gracefully | Tab closed, extension disabled |
| 48 | Don't expose internal APIs externally | Use separate handlers |
| 49 | Log and monitor message patterns | Detect anomalies |
// wxt.config.ts
manifest: {
browser_specific_settings: {
gecko: {
// Required for AMO submission
id: 'my-extension@example.com',
// Version constraints
strict_min_version: '109.0',
// Data collection (required since Nov 2025)
data_collection_permissions: {
required: [],
optional: ['technicalAndInteraction'],
},
},
// Firefox for Android
gecko_android: {
strict_min_version: '120.0',
},
},
}
| Feature | Chrome MV3 | Firefox MV3 |
|---|---|---|
| Background | Service worker only | Event page supported |
| Persistent | No | Optional with persistent: true |
browser API | Promisified polyfill needed | Native promises |
| DNR | Full support | Partial support |
| Side Panel | Supported | Not supported |
# Build for Firefox only
wxt build -b firefox
# Build MV2 for Firefox (if needed)
wxt build -b firefox --mv2
// utils/browser-detect.ts
export const isFirefox = navigator.userAgent.includes('Firefox');
// entrypoints/background.ts
export default defineBackground({
persistent: isFirefox, // Keep background alive in Firefox
main() {
if (isFirefox) {
// Firefox-specific initialization
}
},
});
Safari extensions require an Xcode host app:
# Convert existing extension to Safari
xcrun safari-web-extension-converter /path/to/extension \
--project-location /path/to/output \
--app-name "My Extension" \
--bundle-identifier com.example.myextension
Every Safari extension host app needs PrivacyInfo.xcprivacy:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
| Feature | Status | Workaround |
|---|---|---|
| Side Panel | Not supported | Use popup |
declarativeNetRequest | Limited | Use webRequest |
offscreen API | Not supported | Use content script |
| Persistent background | Not supported | State persistence |
chrome.scripting.executeScript | Limited | Declare in manifest |
# 1. Build extension
wxt build -b safari
# 2. Convert to Xcode project
xcrun safari-web-extension-converter dist/safari-mv3 \
--project-location safari-app
# 3. Open in Xcode
open safari-app/MyExtension.xcodeproj
# 4. Add PrivacyInfo.xcprivacy to host app target
# 5. Archive and submit to App Store
As of 2025, Safari extensions can be submitted as ZIP files to App Store Connect for TestFlight testing without needing Xcode locally.
// utils/storage.ts
import { storage } from 'wxt/storage';
// Define typed storage items
export const userSettings = storage.defineItem<{
theme: 'light' | 'dark';
notifications: boolean;
}>('local:settings', {
defaultValue: {
theme: 'light',
notifications: true,
},
});
export const sessionData = storage.defineItem<string[]>(
'session:recentTabs',
{ defaultValue: [] }
);
// Usage
const settings = await userSettings.getValue();
await userSettings.setValue({ ...settings, theme: 'dark' });
// Watch for changes
userSettings.watch((newValue, oldValue) => {
console.log('Settings changed:', newValue);
});
// utils/storage.ts
import { storage } from 'wxt/storage';
export const userPrefs = storage.defineItem('local:prefs', {
defaultValue: { version: 2, theme: 'system' },
migrations: [
// v1 -> v2: renamed 'darkMode' to 'theme'
{
version: 2,
migrate(oldValue: { darkMode?: boolean }) {
return {
version: 2,
theme: oldValue.darkMode ? 'dark' : 'light',
};
},
},
],
});
// tests/background.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fakeBrowser } from 'wxt/testing';
describe('background script', () => {
beforeEach(() => {
fakeBrowser.reset();
});
it('handles getData message', async () => {
// Setup fake response
fakeBrowser.storage.local.get.mockResolvedValue({ data: 'test' });
// Import and run background script
await import('../entrypoints/background');
// Simulate message
const [listener] = fakeBrowser.runtime.onMessage.addListener.mock.calls[0];
const response = await new Promise((resolve) => {
listener({ type: 'getData' }, {}, resolve);
});
expect(response).toEqual({ data: 'test' });
});
});
// tests/e2e/extension.test.ts
import { test, expect, chromium } from '@playwright/test';
import path from 'path';
test('popup shows correct UI', async () => {
const extensionPath = path.join(__dirname, '../../dist/chrome-mv3');
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
// Get extension ID
const [background] = context.serviceWorkers();
const extensionId = background.url().split('/')[2];
// Open popup
const popup = await context.newPage();
await popup.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(popup.locator('h1')).toHaveText('My Extension');
});
npm audit)wxt build --analyze)# Build all browsers
wxt build -b chrome
wxt build -b firefox
wxt build -b safari
wxt build -b edge
# Package for submission
wxt zip -b chrome
wxt zip -b firefox