Skip to main content
Nook exposes browser extension APIs through WebKit’s native WKWebExtension framework. Extensions can use both browser.* (Firefox-style) and chrome.* (Chrome-style) namespaces.

Supported APIs

Nook provides comprehensive API support through WKWebExtension. Available APIs are probed during background worker initialization.

Runtime detection

Check API availability in your background script:
background.js
const apis = {
  runtime: typeof browser.runtime !== 'undefined',
  tabs: typeof browser.tabs !== 'undefined',
  windows: typeof browser.windows !== 'undefined',
  storage: typeof browser.storage !== 'undefined',
  scripting: typeof browser.scripting !== 'undefined'
};

console.log('Available APIs:', apis);
Nook automatically runs API capability probes at +3s and +8s after background load. Check Xcode console for [EXT-HEALTH] logs.
// ExtensionManager.swift:3476
bgWV.evaluateJavaScript("""
    (function() {
        var b = typeof browser !== 'undefined' ? browser : chrome;
        return JSON.stringify({
            runtime: !!b.runtime,
            tabs: !!b.tabs,
            windows: !!b.windows,
            storage: !!b.storage,
            scripting: !!b.scripting,
            webNavigation: !!b.webNavigation,
            permissions: !!b.permissions,
            action: !!b.action,
            // ... full capability probe
        });
    })()
""")

browser.tabs

Manage browser tabs through the ExtensionTabAdapter bridge.

Query tabs

// Get all tabs
const tabs = await browser.tabs.query({});

// Get active tab in current window
const [activeTab] = await browser.tabs.query({ 
  active: true, 
  currentWindow: true 
});

// Get tabs by URL pattern
const githubTabs = await browser.tabs.query({ 
  url: 'https://github.com/*' 
});

Tab properties

The ExtensionTabAdapter exposes these properties from Nook’s Tab model:
id
number
Unique tab identifier (stable across adapter lookups)
url
string
Current tab URL (tab.url from Nook)
title
string
Page title (tab.name from Nook)
active
boolean
Whether tab is currently selected
pinned
boolean
Whether tab is pinned (tabManager.pinnedTabs.contains)
muted
boolean
Audio mute state (tab.isAudioMuted)
audible
boolean
Whether tab is playing audio (tab.hasPlayingAudio)
// Nook/Managers/ExtensionManager/ExtensionBridge.swift:153
final class ExtensionTabAdapter: NSObject, WKWebExtensionTab {
    internal let tab: Tab
    
    func url(for extensionContext: WKWebExtensionContext) -> URL? {
        return tab.url
    }
    
    func title(for extensionContext: WKWebExtensionContext) -> String? {
        return tab.name
    }
    
    func isSelected(for extensionContext: WKWebExtensionContext) -> Bool {
        return browserManager.currentTabForActiveWindow()?.id == tab.id
    }
    
    func isPinned(for extensionContext: WKWebExtensionContext) -> Bool {
        return browserManager.tabManager.pinnedTabs.contains(where: { $0.id == tab.id })
    }
    
    func isMuted(for extensionContext: WKWebExtensionContext) -> Bool {
        return tab.isAudioMuted
    }
}

Tab actions

// Activate tab
await browser.tabs.update(tabId, { active: true });

// Close tab
await browser.tabs.remove(tabId);

// Reload tab
await browser.tabs.reload(tabId);

// Reload from origin (bypass cache)
await browser.tabs.reload(tabId, { bypassCache: true });

// Navigate to URL
await browser.tabs.update(tabId, { url: 'https://example.com' });

// Mute/unmute audio
await browser.tabs.update(tabId, { muted: true });

// Zoom control
await browser.tabs.setZoom(tabId, 1.5);
const zoom = await browser.tabs.getZoom(tabId);

Create tabs

// Create new tab
const newTab = await browser.tabs.create({
  url: 'https://example.com',
  active: true
});

browser.windows

Access window state through the ExtensionWindowAdapter bridge.

Window properties

// Get current window
const window = await browser.windows.getCurrent();

// Window properties
console.log({
  id: window.id,
  focused: window.focused,
  type: window.type,        // 'normal', 'popup', etc.
  state: window.state,      // 'normal', 'minimized', 'maximized', 'fullscreen'
  incognito: window.incognito
});
// Nook/Managers/ExtensionManager/ExtensionBridge.swift:14
final class ExtensionWindowAdapter: NSObject, WKWebExtensionWindow {
    func windowType(for extensionContext: WKWebExtensionContext) -> WKWebExtension.WindowType {
        return .normal
    }
    
    func windowState(for extensionContext: WKWebExtensionContext) -> WKWebExtension.WindowState {
        guard let window = NSApp.mainWindow else { return .normal }
        if window.isMiniaturized { return .minimized }
        if window.styleMask.contains(.fullScreen) { return .fullscreen }
        return .normal
    }
    
    func isPrivate(for extensionContext: WKWebExtensionContext) -> Bool {
        return browserManager.currentTabForActiveWindow()?.isEphemeral ?? false
    }
}

Window actions

// Focus window
await browser.windows.update(windowId, { focused: true });

// Minimize/maximize
await browser.windows.update(windowId, { state: 'minimized' });
await browser.windows.update(windowId, { state: 'maximized' });
await browser.windows.update(windowId, { state: 'fullscreen' });

// Resize/reposition
await browser.windows.update(windowId, {
  left: 100,
  top: 100,
  width: 800,
  height: 600
});

// Close window
await browser.windows.remove(windowId);

browser.runtime

Core runtime APIs for extension lifecycle and messaging.

Extension info

// Extension ID (unique per installation)
const id = browser.runtime.id;

// Get extension resource URL
const iconUrl = browser.runtime.getURL('icons/icon48.png');

// Extension manifest
const manifest = browser.runtime.getManifest();
console.log(manifest.name, manifest.version);

Messaging

content.js
// Send one-time message
const response = await browser.runtime.sendMessage({
  action: 'getData',
  id: 123
});

// Long-lived connection
const port = browser.runtime.connect({ name: 'content-port' });
port.onMessage.addListener((msg) => {
  console.log('Received:', msg);
});
port.postMessage({ type: 'init' });
background.js
// Receive messages
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'getData') {
    sendResponse({ data: 'result' });
  }
});

// Handle connections
browser.runtime.onConnect.addListener((port) => {
  port.onMessage.addListener((msg) => {
    port.postMessage({ response: 'ack' });
  });
});
Web pages can message extensions if listed in externally_connectable:
manifest.json
{
  "externally_connectable": {
    "matches": ["https://example.com/*"]
  }
}
webpage.js (on example.com)
// Page sends message to extension
const response = await browser.runtime.sendMessage(
  EXTENSION_ID,
  { action: 'getData' }
);
Nook automatically injects the externally connectable bridge to handle ID mismatches between Safari and WKWebExtension.

Lifecycle events

background.js
// Extension installed or updated
browser.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('First install');
  } else if (details.reason === 'update') {
    console.log('Updated from', details.previousVersion);
  }
});

// Extension starting up
browser.runtime.onStartup.addListener(() => {
  console.log('Browser started, extension loaded');
});

browser.storage

Persistent storage APIs with profile isolation.
Storage is isolated per profile in Nook. Each profile has its own WKWebsiteDataStore. Switching profiles changes the extension’s storage context.
// ExtensionManager.swift:49
// Profile-aware extension storage
private var profileExtensionStores: [UUID: WKWebsiteDataStore] = [:]
var currentProfileId: UUID?

private func getExtensionDataStore(for profileId: UUID) -> WKWebsiteDataStore {
    if let store = profileExtensionStores[profileId] { return store }
    let store = WKWebsiteDataStore(forIdentifier: profileId)
    profileExtensionStores[profileId] = store
    return store
}

Storage areas

// Store data locally (persists across sessions)
await browser.storage.local.set({ 
  key: 'value',
  settings: { theme: 'dark' }
});

const data = await browser.storage.local.get('key');
const all = await browser.storage.local.get(null); // Get all

await browser.storage.local.remove('key');
await browser.storage.local.clear();

Storage events

// Listen for storage changes
browser.storage.onChanged.addListener((changes, areaName) => {
  for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
    console.log(`${key} in ${areaName}: ${oldValue}${newValue}`);
  }
});

browser.scripting

Dynamic content script injection (requires "scripting" permission).
MV2 extensions automatically get "scripting" permission injected by Nook to support modern script injection.
// Execute script in tab
const results = await browser.scripting.executeScript({
  target: { tabId: tab.id },
  func: () => document.title,
  world: 'ISOLATED' // or 'MAIN'
});

console.log('Page title:', results[0].result);

// Inject CSS
await browser.scripting.insertCSS({
  target: { tabId: tab.id },
  css: 'body { background: red; }'
});

// Remove CSS
await browser.scripting.removeCSS({
  target: { tabId: tab.id },
  css: 'body { background: red; }'
});

Other APIs

Nook supports additional WebExtension APIs through WKWebExtension:

browser.alarms

Schedule recurring tasks

browser.permissions

Runtime permission requests

browser.webNavigation

Monitor navigation events

browser.webRequest

Intercept network requests

browser.declarativeNetRequest

Block/modify requests declaratively

browser.contextMenus

Add context menu items

browser.commands

Keyboard shortcuts

browser.i18n

Localization utilities

browser.notifications

System notifications

Native messaging

Extensions can communicate with native macOS applications using the native messaging protocol.

Host manifest locations

Nook searches for native messaging host manifests in this order:
  1. ~/Library/Application Support/Nook/NativeMessagingHosts/
  2. Chrome: ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/
  3. Chromium: ~/Library/Application Support/Chromium/NativeMessagingHosts/
  4. Edge: ~/Library/Application Support/Microsoft Edge/NativeMessagingHosts/
  5. Brave: ~/Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts/
  6. Firefox: ~/Library/Application Support/Mozilla/NativeMessagingHosts/

Host manifest format

com.example.myapp.json
{
  "name": "com.example.myapp",
  "description": "My native app",
  "path": "/Applications/MyApp.app/Contents/MacOS/native-host",
  "type": "stdio",
  "allowed_extensions": ["extension-id-here"]
}
Place this file in ~/Library/Application Support/Nook/NativeMessagingHosts/com.example.myapp.json.

Protocol

Native messaging uses a 4-byte native-endian length prefix + JSON format:
extension background.js
// One-time message (5s timeout)
const response = await browser.runtime.sendNativeMessage(
  'com.example.myapp',
  { command: 'getData' }
);

// Long-lived connection
const port = browser.runtime.connectNative('com.example.myapp');
port.onMessage.addListener((msg) => {
  console.log('From native:', msg);
});
port.postMessage({ command: 'start' });
Native host (Swift example)
import Foundation

func readMessage() -> [String: Any]? {
    var lengthBytes = [UInt8](repeating: 0, count: 4)
    let lengthRead = fread(&lengthBytes, 1, 4, stdin)
    guard lengthRead == 4 else { return nil }
    
    let length = lengthBytes.withUnsafeBytes { $0.load(as: UInt32.self) }
    var messageBytes = [UInt8](repeating: 0, count: Int(length))
    let messageRead = fread(&messageBytes, 1, Int(length), stdin)
    guard messageRead == length else { return nil }
    
    let data = Data(messageBytes)
    return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}

func sendMessage(_ message: [String: Any]) {
    let data = try! JSONSerialization.data(withJSONObject: message)
    var length = UInt32(data.count)
    fwrite(&length, 4, 1, stdout)
    data.withUnsafeBytes { fwrite($0.baseAddress, 1, data.count, stdout) }
    fflush(stdout)
}

// Main loop
while let message = readMessage() {
    sendMessage(["response": "ok", "data": 123])
}

API compatibility notes

WebView requirement: Extensions can only interact with tabs that have loaded webviews. ExtensionTabAdapter uses tab.assignedWebView (doesn’t trigger lazy initialization).
// ExtensionBridge.swift:213
func webView(for extensionContext: WKWebExtensionContext) -> WKWebView? {
    // Use assignedWebView to avoid triggering lazy initialization
    // Extensions can only interact with tabs that are currently displayed
    return tab.assignedWebView
}
Reader mode is not supported: isReaderModeActive() always returns false.

Next steps

Manifest reference

Declare permissions for these APIs

Debugging

Debug API calls and messaging issues

Build docs developers (and LLMs) love