Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/zotero/zotero-connectors/llms.txt

Use this file to discover all available pages before exploring further.

Zotero Connectors target four browsers — Chrome, Firefox, Edge, and Safari — from a single shared codebase. While the WebExtension standard covers most functionality, each browser has meaningful divergences in its extension model, API surface, and runtime constraints. This page documents every place where the connector code branches per browser and explains the engineering decisions behind each workaround.

Manifest V2 vs Manifest V3

The most significant cross-browser split is between Manifest V2 (Firefox) and Manifest V3 (Chrome and Edge). The build system produces two separate extension directories from the same sources: build/firefox/ (MV2) and build/manifestv3/ (MV3).

Background process

MV2 — FirefoxMV3 — Chrome / Edge
Background typeEvent page (persistent as needed)Service worker
Manifest keybackground.scripts arraybackground.service_worker
Entry pointbackground.js (concatenated script list)background-worker.js
DOM availableYesNo
LifetimeStays alive while events are queuedTerminated after ~30 seconds of inactivity

Keeping the MV3 service worker alive

Because Chrome terminates idle service workers, keep-mv3-alive.js is included in the MV3 background. It periodically pings chrome.runtime.getPlatformInfo() to reset the idle timer:
const LET_DIE_AFTER = 10*60e3; // 1 hour

const startedOn = Date.now();
let interval;

function keepAlive() {
  if (startedOn + LET_DIE_AFTER < Date.now()
      && !Zotero.Connector_Browser.shouldKeepServiceWorkerAlive()) {
    clearInterval(interval);
  }
  chrome.runtime.getPlatformInfo();
}
interval = setInterval(keepAlive, 20e3);
After 10 minutes of inactivity (600 seconds — 10*60e3 milliseconds) with no pending operations, the interval clears itself and the service worker is allowed to terminate naturally.

Offscreen documents (MV3 only)

Service workers have no access to the DOM, but the Zotero translation framework needs to parse HTML. MV3 solves this with Chrome’s offscreen document API. Zotero.OffscreenManager (in src/browserExt/background/offscreenManager.js) manages the lifecycle:
// Create offscreen document for DOM-based translation
await browser.offscreen.createDocument({
  url: this.offscreenUrl,        // 'offscreen/offscreen.html'
  reasons: ['DOM_PARSER'],
  justification: 'Scraping the document with Zotero Translators',
});
There are two offscreen documents:
DocumentPurpose
offscreen/offscreen.html + offscreen.jsHosts the translation engine; communicates with the service worker via postMessage
offscreen/offscreenSandbox.html + offscreenSandbox.jsSandboxed environment for running untrusted translator code; listed in manifest-v3.json’s sandbox.pages
The offscreen permission is required in the MV3 manifest:
"permissions": ["tabs", "contextMenus", "cookies", "scripting", "offscreen", ...]

Zotero.isManifestV3

The build system sets this flag at compile time via scripts/replace_browser.js. Use it to branch MV3-specific code:
if (Zotero.isManifestV3) {
  // MV3-specific code path
}

Inject scripts

The MV3 inject bundle includes one extra file not present in MV2:
// MV3 inject includes:
'inject/virtualOffscreenTranslate.js'
// This redirects translation calls to the offscreen document
// instead of running them in the injected context directly
In debug builds the MV3 inject bundle also includes test/testInject.js for the in-extension test runner.

Browser Detection Flags

All browser-detection properties are set by the build system in zotero.js via scripts/replace_browser.js. Their values are true or false constants — there is no runtime UA sniffing for the primary flags.
Zotero.isBrowserExt   // true for Chrome, Firefox, and Edge; false for Safari
Zotero.isFirefox      // true only for the Firefox/MV2 build
Zotero.isManifestV3   // true only for the Chrome/Edge MV3 build
Zotero.isSafari       // true only for the Safari build
Zotero.isChromium     // true when isBrowserExt && !isFirefox (Chrome + Edge)
Zotero.isChrome       // true when isBrowserExt && !isFirefox && UA does not include "Edg/"
Zotero.isEdge         // true when isBrowserExt && !isFirefox && UA includes "Edg/"
isChromium, isChrome, and isEdge are derived at runtime (not set by the build script) because both Chrome and Edge use the same MV3 build and are distinguished only by user-agent string:
if (this.isBrowserExt && !this.isFirefox) {
  this.isChromium = true;
  if (global.navigator.userAgent.includes("Edg/")) {
    this.isEdge = true;
  } else {
    this.isChrome = true;
  }
}
Extensions installed from the Chrome Web Store on Opera or other Chromium-based browsers will also resolve isChrome = true, since the connector treats any non-Firefox, non-Edge BrowserExt context as Chrome-compatible.

Chrome / Chromium-Specific Behaviour

ArrayBuffer message passing

Chrome cannot natively pass ArrayBuffer objects through the extension message-passing channel. The connector works around this in three different ways depending on the build target:
function packArrayBuffer(arrayBuffer) {
  if (Zotero.isFirefox) return arrayBuffer;  // Firefox supports it natively
  if (Zotero.isSafari) {
    // Safari: encode as Base64 string
    return Zotero.Utilities.Connector.arrayBufferToBase64(arrayBuffer);
  }
  if (Zotero.isManifestV3) {
    // MV3: convert to plain Array (8 MB limit)
    let array = Array.from(new Uint8Array(arrayBuffer));
    if (array.length > MAX_CONTENT_SIZE) {
      array = array.slice(0, MAX_CONTENT_SIZE); // 8 MB cap
    }
    return array;
  }
  // MV2 Chrome: create an object URL to a Blob
  return URL.createObjectURL(new Blob([arrayBuffer]));
}
The MAX_CONTENT_SIZE constant is 8 * 1024 * 1024 (8 MB). This cap covers the vast majority of attachment downloads; images larger than 8 MB are truncated.

chromeMessageIframe for cross-origin messaging

MV3 Chrome does not allow injected scripts to call browser.runtime.sendMessage across origins directly in some contexts. src/browserExt/chromeMessageIframe/ provides an iframe-based relay. The iframe is listed as a web-accessible resource in manifest-v3.json:
"web_accessible_resources": [{
  "resources": ["chromeMessageIframe/messageIframe.html", ...],
  "matches": ["http://*/*", "https://*/*"]
}]

declarativeNetRequest and styleInterceptRules.json

The MV3 manifest uses declarativeNetRequest (rather than the deprecated webRequestBlocking) for intercepting file downloads. Static rules are defined in src/browserExt/styleInterceptRules.json and referenced from the manifest:
"declarative_net_request": {
  "rule_resources": [{
    "id": "styleIntercept",
    "enabled": true,
    "path": "styleInterceptRules.json"
  }]
}

Minimum Chrome version

// manifest.json (MV2) — Firefox receives this file
"minimum_chrome_version": "55"

// manifest-v3.json (MV3) — Chrome/Edge receive this file
"minimum_chrome_version": "88"

Firefox-Specific Behaviour

MV2 only

As of this writing, Firefox supports only Manifest V2. The Firefox build uses build/firefox/manifest.json with the applications.gecko block:
"applications": {
  "gecko": {
    "id": "zotero@chnm.gmu.edu",
    "update_url": "https://zotero.org/AUTOFILLED",
    "strict_min_version": "58.0"
  }
}
The build script strips the applications property from the Chrome manifest using jq since Chrome rejects it. When Firefox’s First-Party Isolation (FPI) feature is enabled, calls to browser.cookies.getAll() fail unless firstPartyDomain is provided. The connector passes null as a fallback, which allows saves to proceed even though cookie-dependent attachment downloads may fail:
// When first-party isolation is enabled in Firefox, browser.cookies.getAll()
// will fail if firstPartyDomain isn't provided, causing all saves to fail.
if (Zotero.isFirefox && Zotero.browserMajorVersion >= 59) {
  cookieParams.firstPartyDomain = null;
}

ArrayBuffer message passing

Firefox supports passing ArrayBuffer objects directly through browser.runtime.sendMessage. The packArrayBuffer helper simply returns the buffer as-is for Firefox:
if (Zotero.isFirefox) return arrayBuffer;

Safari-Specific Behaviour

Safari uses the safari-app-extension model rather than the WebExtension model and requires a separate repository and Xcode build. The build/safari/ directory produced by ./build.sh -p s is consumed by that repository.

Global page instead of service worker

Safari’s equivalent of a background page is a global page (src/safari/global.html + src/safari/global.js). It runs persistently (no service worker termination concerns) and handles all background logic.

i18n via message passing

Chrome and Firefox provide synchronous browser.i18n.getMessage(). Safari does not expose this API to extension pages, so the Safari build ships its own i18n layer (src/safari/i18n.js) that sends a message to the global page:
// Safari i18n is a non-trivial class that requests strings via messaging
Zotero.i18n = {
  init: async function() { /* ... */ }
};
The _locales/ directory structure is still used for string storage; the difference is only in how strings are looked up at runtime.

ArrayBuffer encoding

Safari cannot pass ArrayBuffer through its message-passing API. The connector encodes binary data as Base64 before sending:
// Pack (sender side)
return Zotero.Utilities.Connector.arrayBufferToBase64(arrayBuffer);

// Unpack (receiver side)
return Zotero.Utilities.Connector.base64ToArrayBuffer(packedBuffer);
arrayBufferToBase64 is a byte-wise encoder that does not rely on btoa, making it safe for arbitrary binary data including null bytes.

JS context compatibility shim

src/safari/jscontext_shim.js patches differences in the Safari JavaScript context (including the extension’s global page environment) to bring it closer to the standard WebExtension context expected by shared code.

URL polyfill

src/safari/url-polyfill.js provides the URL and URLSearchParams globals for Safari contexts where they are unavailable.

Empty browser-polyfill.js

During the build, an empty browser-polyfill.js (containing only ;) is written to build/safari/. Safari’s messaging layer is handled by native code, so the polyfill used in BrowserExt builds is not needed — but the file must exist because some shared HTML pages unconditionally include it.

browser-polyfill.js

Chrome exposes its extension API as chrome.* rather than browser.*. All shared connector code is written against the browser.* WebExtension API. The BrowserExt builds include Mozilla’s official browser-polyfill.js, which wraps chrome.* calls to provide the Promise-based browser.* interface:
// injectIncludeBrowserExt and backgroundIncludeBrowserExt both start with:
'browser-polyfill.js'
Firefox has native browser.* support, so the polyfill is a no-op there. Safari has its own API bridge in the native extension code.

Per-Browser Loading Instructions

  1. Build: ./build.sh -d -p b
  2. Open chrome://extensions/
  3. Enable Developer mode (top-right toggle)
  4. Click Load unpacked and select build/manifestv3/
  5. After rebuilding, click the ↺ reload icon on the extension card or reload the extensions page
The gulp watch-chrome task automatically calls chrome-cli reload on macOS after each file change, if chrome-cli is installed.

Build docs developers (and LLMs) love