diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 746aeaf..3c723d0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [main, dev]
+ branches: [main, dev, palemoon]
pull_request:
branches: [main, dev]
@@ -81,7 +81,21 @@ jobs:
test -s docs/SETTINGS-BACKUP-FORMAT.md
test -s docs/AMO-SOURCE-INSTRUCTIONS.md
test -s docs/PUBLICATION-CHECKLIST.md
+ test -s docs/PALEMOON-SUBMISSION.md
test -s SECURITY.md
+ test -s legacy/palemoon/install.rdf
+ test -s legacy/palemoon/chrome.manifest
+ test -s legacy/palemoon/icon64.png.base64
+ test -s legacy/palemoon/palemoon/browser-overlay.xul
+ test -s legacy/palemoon/palemoon/browser-overlay.css
+ test -s legacy/palemoon/palemoon/popup-panel.js
+ test -s legacy/palemoon/palemoon/legacy-api.js
+ grep -q 'BrowserToolbarPalette' legacy/palemoon/palemoon/browser-overlay.xul
+ grep -q 'SprachverstandPaleMoonUI.togglePopup' legacy/palemoon/palemoon/browser-overlay.xul
+ grep -q '__VERSION__' legacy/palemoon/install.rdf
+ grep -q '34.3.2' legacy/palemoon/install.rdf
+ ! grep -q '' legacy/palemoon/install.rdf
+ ! grep -Eq '[^<]*\*' legacy/palemoon/install.rdf
for size in 32 48 128; do
test -s "static/icons/icon${size}.png"
done
@@ -91,6 +105,26 @@ jobs:
- name: Browser-Builds erstellen
run: npm run build
+ - name: Pale-Moon-Build prüfen
+ shell: bash
+ run: |
+ test -s dist/palemoon/install.rdf
+ test -s dist/palemoon/chrome.manifest
+ test -s dist/palemoon/icons/icon64.png
+ test -s dist/palemoon/palemoon/browser-overlay.xul
+ test -s dist/palemoon/palemoon/browser-overlay.css
+ test -s dist/palemoon/palemoon/popup-panel.js
+ test -s dist/palemoon/palemoon/controller.js
+ test -s dist/palemoon/palemoon/content.js
+ test -s dist/palemoon/palemoon/legacy-api.js
+ file dist/palemoon/icons/icon64.png | grep -q '64 x 64'
+ ! grep -q '__VERSION__' dist/palemoon/install.rdf
+ grep -q '34.3.2' dist/palemoon/install.rdf
+ ! grep -q '' dist/palemoon/install.rdf
+ grep -q '../palemoon/legacy-api.js' dist/palemoon/popup/popup.html
+ grep -q '../palemoon/legacy-api.js' dist/palemoon/options/options.html
+ test ! -e dist/palemoon/manifest.json
+
- name: Erzeugte Logo-Dateien vollständig prüfen
run: npm run validate:icons
@@ -114,6 +148,11 @@ jobs:
zip -qr "../../artifacts/sprachverstand-${VERSION}-firefox.xpi" .
)
+ (
+ cd dist/palemoon
+ zip -qr "../../artifacts/sprachverstand-${VERSION}-palemoon.xpi" .
+ )
+
git archive \
--format=zip \
--output="artifacts/sprachverstand-${VERSION}-source.zip" \
@@ -121,6 +160,8 @@ jobs:
cp docs/BETA-TEST.md artifacts/
cp docs/AMO-SOURCE-INSTRUCTIONS.md artifacts/
+ cp docs/PALEMOON-SUBMISSION.md artifacts/
+ cp dist/palemoon/icons/icon64.png artifacts/palemoon-icon64.png
printf '%s\n' "$GITHUB_SHA" > artifacts/SOURCE_COMMIT.txt
(
@@ -128,7 +169,9 @@ jobs:
sha256sum \
"sprachverstand-${VERSION}-chromium.zip" \
"sprachverstand-${VERSION}-firefox.xpi" \
+ "sprachverstand-${VERSION}-palemoon.xpi" \
"sprachverstand-${VERSION}-source.zip" \
+ palemoon-icon64.png \
SOURCE_COMMIT.txt \
> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
@@ -136,6 +179,7 @@ jobs:
unzip -t "artifacts/sprachverstand-${VERSION}-chromium.zip"
unzip -t "artifacts/sprachverstand-${VERSION}-firefox.xpi"
+ unzip -t "artifacts/sprachverstand-${VERSION}-palemoon.xpi"
unzip -t "artifacts/sprachverstand-${VERSION}-source.zip"
if unzip -l "artifacts/sprachverstand-${VERSION}-firefox.xpi" | grep -q 'META-INF/'; then
diff --git a/docs/PALEMOON-SUBMISSION.md b/docs/PALEMOON-SUBMISSION.md
new file mode 100644
index 0000000..e4cff10
--- /dev/null
+++ b/docs/PALEMOON-SUBMISSION.md
@@ -0,0 +1,46 @@
+# Pale Moon submission notes
+
+This document describes the dedicated Pale Moon build and the metadata intended for submission to the official Pale Moon Add-ons Site.
+
+## Distribution
+
+- Distribution model: Hosted add-on on the Pale Moon Add-ons Site
+- Category: Tools & Utilities
+- Add-on name: Sprachverstand
+- Source repository: https://github.com/HyperCriSiS/Sprachverstand
+- Suggested slug: `sprachverstand` (subject to availability on the Pale Moon Add-ons Site)
+- Update URL: none. Hosted add-ons must use the Pale Moon Add-ons Site update service.
+
+## Compatibility
+
+- Pale Moon application ID: `{8de7fcbb-c55c-4fbe-bfc5-fc555c87dbc4}`
+- Minimum version: 34.0.0
+- Maximum version: 34.3.2
+- Runtime-tested version: 34.3.2
+
+The maximum version is deliberately explicit. Do not replace it with a wildcard for a hosted submission. Raise it only after testing a newer Pale Moon release.
+
+## Submission metadata
+
+- Description: `Macht Webseiten leichter lesbar.`
+- 64x64 alpha-transparent PNG: generated as `dist/palemoon/icons/icon64.png` during the Pale Moon build
+- The XPI uses the same generated 64x64 icon through `em:iconURL`.
+
+## Final runtime checklist before submission
+
+- Install the generated XPI in a clean Pale Moon 34.3.2 profile.
+- Restart Pale Moon and confirm that the toolbar button remains available.
+- Confirm that the toolbar icon is rendered at normal toolbar size.
+- Confirm that the popup is anchored to the toolbar button instead of opening a separate window.
+- Confirm that the popup closes when clicking outside it.
+- Confirm that replacements work on a normal static page.
+- Confirm that replacements also work after dynamically inserted page content.
+- Toggle the extension off and on and verify that the page is restored/reprocessed correctly.
+- Test excluded domains.
+- Test the rule-group switches and text-processing switches in the popup.
+- Open the full options page and save settings.
+- Open several tabs and verify that the replacement count follows the active tab.
+- Check the Error Console for new Sprachverstand errors.
+- Restart the browser and verify that settings persist.
+
+Only submit after this checklist passes on the XPI produced by the final commit.
diff --git a/legacy/palemoon/chrome.manifest b/legacy/palemoon/chrome.manifest
new file mode 100644
index 0000000..f25ca7b
--- /dev/null
+++ b/legacy/palemoon/chrome.manifest
@@ -0,0 +1,2 @@
+content sprachverstand ./
+overlay chrome://browser/content/browser.xul chrome://sprachverstand/content/palemoon/browser-overlay.xul
diff --git a/legacy/palemoon/icon16.png.base64 b/legacy/palemoon/icon16.png.base64
new file mode 100644
index 0000000..30793ae
--- /dev/null
+++ b/legacy/palemoon/icon16.png.base64
@@ -0,0 +1 @@
+iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC0ElEQVR4nG2TX2iWZRiHr9/9Pq/79m3ftk8r0oLqQHCdVQRBpVHQiWdi9seDwA7yJBQ6CYtGWie2ooiImlEUWexPYmQI1sJFBQkKapNllGTGbCqs7dv27X2f5+5gWy7qgvvo5jq4uX8/sYFQm7ivx6RNnlwsIDyBDMAB8LS0Q1mWUopDU9eN7FZt3fo9ysJzpLikXsX5fwQow2P5YpC0hVS6REzJLcaEFl2TkPQfNyVPZmSStgScqCzTTGPG8mBqba2QkiOJGCPJ/V9ydMgkWziTaBaMxswsD957J8cOfajjh/fr2OcfaHR4QL3P7tDcXBMzYYLZEu64PnLk0YbqFaeIrlAUJR3tVd586Rkd/f44n33xJXm1Sh4yLlyc8DzPcQcTFKV46vam5ubx8YZRCYmQyqi2zhqr6l18/e0PfHrkG7rqHZRFZGryL9rrnQhnpoBbr408cHNk84GqF8lVDWCV1or/Pj7Ba+/u573X93Dp9DBnjx7g0smv6N/Xu/BInPnC2H5bobErGSPnM9pzp0yJEGOiva1Kz6vv+NChYVbVO9RszlNrb2Owr5ftWzdp7xvv+41r6jzSXbBzuNVjEmYiRmF4Eu7cdMNqJqcbjP3yG+cujHN6dIxPDh5mxxMPY5UaW9c1uNgQB38KtK1IpARChIi8kgeG3t6r7rW3qChLAPI8p+flt7j/nrvY+fhGPVT2se/Uam80od4Ki7EjZLiKGNn29AtUWlaQ3DGJIkZOnBz1jq6VemXXNk71DjJwRlRbILpA4O4EkLs7P/78q6d0NTQhZOQtLfR9NOBPPrZRH0/ezR+Xv6Pe2UaZfDGr7uro3jAGrBUeEba8B+4QU2TNNSu5PN2kOTeNWQZ4QpZ58rPm7v3I5Ci4y/4ZZC5ZFnI7/+cVm2/OWhZyQ2ZSFmRBkvoFhFr3+ucN2+zLKrscM8M9sVQLyTyRBqfOjOz+GzhBUpGw8OQNAAAAAElFTkSuQmCC
\ No newline at end of file
diff --git a/legacy/palemoon/icon64.png.base64 b/legacy/palemoon/icon64.png.base64
new file mode 100644
index 0000000..3c5cff2
--- /dev/null
+++ b/legacy/palemoon/icon64.png.base64
@@ -0,0 +1 @@
+iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAABgFBMVEUNKD0PKkENJzz8/PwBGzEOKkD6lzAAAAAtQ1bHy9BUZXWdpq8vNzrn6OocNEglOkwOKD1teoeQZTbqjC4OLEMAVVVHWmpsVDh0gY2zdDSstLvWhDAOKDy8wsnS1dkMGSwMJjkQLUFNRTqKk5yhbDaorLHAezPe4uYADyUSMEg7T2A/UmCBjpqTnaexucDQsY/b3uH/oC/9vH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAr0djTAAAAgHRSTlP+/qz//6r/AP//////////m////1MD////////U///FBSZ/////////1T///////////8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEOeelgAAAIZSURBVHjaxZeJcoIwEIYB1xi5wqV4gGe19/n+D9du1xQEbS3ZTv8ZYSaSb+bfbDYba/Cp29v12nH6X3IOao/Q6HpdFDTTYgCsVo5jd5DjrFYcgNEIp1sdhIjRyBxQFN2mEyLLzAFRZAKIInNA1xDqMJoD+v0K8FsULwCfAP8BoCDiT6ntdtjQeNye5vvTD/EBtAWlFoteSw8PAM3QJsnrqxC+zwsA8DycUJbel3Y7z5OybSBNhUgSMs4BoCCGYRD0ep4HDbVDmOdCpCka4AVIiYA4vrmph+xUas/nQlxd6eTjA1gWLuJiIWV4kFJh2LaABmgJ+QB6GScTSp2gptmsuYz7vRCUxiQOQFXSKJWOdX0dhtXnmw0a2Gx4AVVJA7i7i+PJh/AZx7MZIjC59OfTqRD7fd0SL4CCWRchlNLbCA3keb0IcwDOHywA9/cIeHykopIkQsznx6nOCbBteSSl4th10UQQKAVAhSTPz56NnQE6iGXZTiMs6/jebgHIwDcnkzHg5aUNKEvbfnrChFYKCwmVUm6ADqKUbkO4lQHIxPNzmupS+leAZhrTFsL/hkNEVKW0sZkYAe12GscAXBcBb2++/zeAepN1Tmhitzt10HEAfmo0qfkZj+vFlRdwWbN9rgG8uFv/BpBllwBOf2PbRWEOwEtX92b/cGszAgwGy2XXi+dy+XlzNQagsiyKnF8oirLs6PbeHfAOb71DTTt2EFMAAAAASUVORK5CYII=
\ No newline at end of file
diff --git a/legacy/palemoon/install.rdf b/legacy/palemoon/install.rdf
new file mode 100644
index 0000000..2fa71eb
--- /dev/null
+++ b/legacy/palemoon/install.rdf
@@ -0,0 +1,23 @@
+
+
+
+ sprachverstand@hypercrisis
+ 2
+ Sprachverstand
+ __VERSION__
+ Macht Webseiten leichter lesbar.
+ HyperCriSiS
+ https://github.com/HyperCriSiS/Sprachverstand
+ chrome://sprachverstand/content/icons/icon64.png
+ chrome://sprachverstand/content/options/options.html
+
+
+
+ {8de7fcbb-c55c-4fbe-bfc5-fc555c87dbc4}
+ 34.0.0
+ 34.3.2
+
+
+
+
diff --git a/legacy/palemoon/palemoon/browser-overlay.css b/legacy/palemoon/palemoon/browser-overlay.css
new file mode 100644
index 0000000..96d00b1
--- /dev/null
+++ b/legacy/palemoon/palemoon/browser-overlay.css
@@ -0,0 +1,12 @@
+#sprachverstand-popup-panel {
+ padding: 0;
+ margin: 0;
+}
+
+#sprachverstand-popup-browser {
+ width: 408px;
+ height: 600px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
diff --git a/legacy/palemoon/palemoon/browser-overlay.xul b/legacy/palemoon/palemoon/browser-overlay.xul
new file mode 100644
index 0000000..a84bcb4
--- /dev/null
+++ b/legacy/palemoon/palemoon/browser-overlay.xul
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/legacy/palemoon/palemoon/legacy-api.js b/legacy/palemoon/palemoon/legacy-api.js
new file mode 100644
index 0000000..e746c56
--- /dev/null
+++ b/legacy/palemoon/palemoon/legacy-api.js
@@ -0,0 +1,297 @@
+(function (global) {
+ "use strict";
+
+ var Services = Components.utils.import(
+ "resource://gre/modules/Services.jsm",
+ {}
+ ).Services;
+
+ var LOCAL_PREFIX = "extensions.sprachverstand.storage.local.";
+ var STORAGE_TOPIC = "sprachverstand-palemoon-storage-changed";
+ var RUNTIME_TOPIC = "sprachverstand-palemoon-runtime-message";
+ var storageListeners = [];
+ var runtimeListeners = [];
+
+ function clone(value) {
+ if (value === undefined) {
+ return undefined;
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function prefName(key) {
+ return LOCAL_PREFIX + key;
+ }
+
+ function sanitizeValue(key, value) {
+ var copy = clone(value);
+ if (
+ key === "settings" &&
+ copy &&
+ typeof copy === "object" &&
+ !Array.isArray(copy)
+ ) {
+ copy.syncCategoryIds = [];
+ }
+ return copy;
+ }
+
+ function readLocalValue(key) {
+ var name = prefName(key);
+ if (!Services.prefs.prefHasUserValue(name)) {
+ return undefined;
+ }
+
+ try {
+ return JSON.parse(Services.prefs.getCharPref(name));
+ } catch (_error) {
+ return undefined;
+ }
+ }
+
+ function selectedKeys(keys) {
+ if (typeof keys === "string") {
+ return [keys];
+ }
+ if (Array.isArray(keys)) {
+ return keys.slice();
+ }
+ if (keys && typeof keys === "object") {
+ return Object.keys(keys);
+ }
+ return [];
+ }
+
+ function localGet(keys) {
+ var result = {};
+ var names = selectedKeys(keys);
+
+ if (keys && typeof keys === "object" && !Array.isArray(keys)) {
+ Object.keys(keys).forEach(function (key) {
+ result[key] = clone(keys[key]);
+ });
+ }
+
+ names.forEach(function (key) {
+ var value = readLocalValue(key);
+ if (value !== undefined) {
+ result[key] = value;
+ }
+ });
+
+ return Promise.resolve(result);
+ }
+
+ function notifyStorageChanges(changes) {
+ if (!Object.keys(changes).length) {
+ return;
+ }
+ Services.obs.notifyObservers(
+ null,
+ STORAGE_TOPIC,
+ JSON.stringify({ areaName: "local", changes: changes })
+ );
+ }
+
+ function localSet(items) {
+ var changes = {};
+
+ Object.keys(items).forEach(function (key) {
+ var oldValue = readLocalValue(key);
+ var newValue = sanitizeValue(key, items[key]);
+ Services.prefs.setCharPref(prefName(key), JSON.stringify(newValue));
+ changes[key] = { oldValue: oldValue, newValue: clone(newValue) };
+ });
+
+ notifyStorageChanges(changes);
+ return Promise.resolve();
+ }
+
+ function localRemove(keys) {
+ var changes = {};
+ var names = typeof keys === "string" ? [keys] : keys;
+
+ names.forEach(function (key) {
+ var oldValue = readLocalValue(key);
+ if (oldValue === undefined) {
+ return;
+ }
+ Services.prefs.clearUserPref(prefName(key));
+ changes[key] = { oldValue: oldValue };
+ });
+
+ notifyStorageChanges(changes);
+ return Promise.resolve();
+ }
+
+ function mostRecentBridge() {
+ var browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ return browserWindow && browserWindow.SprachverstandPaleMoon
+ ? browserWindow.SprachverstandPaleMoon
+ : null;
+ }
+
+ var storageObserver = {
+ observe: function (_subject, topic, data) {
+ if (topic !== STORAGE_TOPIC) {
+ return;
+ }
+
+ try {
+ var message = JSON.parse(data);
+ storageListeners.slice().forEach(function (listener) {
+ listener(message.changes, message.areaName);
+ });
+ } catch (_error) {
+ // Ignore malformed internal notifications.
+ }
+ }
+ };
+
+ var runtimeObserver = {
+ observe: function (_subject, topic, data) {
+ if (topic !== RUNTIME_TOPIC) {
+ return;
+ }
+
+ try {
+ var message = JSON.parse(data);
+ runtimeListeners.slice().forEach(function (listener) {
+ listener(message, {});
+ });
+ } catch (_error) {
+ // Ignore malformed internal notifications.
+ }
+ }
+ };
+
+ Services.obs.addObserver(storageObserver, STORAGE_TOPIC, false);
+ Services.obs.addObserver(runtimeObserver, RUNTIME_TOPIC, false);
+
+ global.addEventListener(
+ "unload",
+ function () {
+ Services.obs.removeObserver(storageObserver, STORAGE_TOPIC);
+ Services.obs.removeObserver(runtimeObserver, RUNTIME_TOPIC);
+ storageListeners.length = 0;
+ runtimeListeners.length = 0;
+ },
+ { once: true }
+ );
+
+ var localArea = {
+ get: localGet,
+ set: localSet,
+ remove: localRemove
+ };
+
+ var syncArea = {
+ get: function () {
+ return Promise.resolve({});
+ },
+ set: function () {
+ return Promise.resolve();
+ },
+ remove: function () {
+ return Promise.resolve();
+ }
+ };
+
+ global.browser = {
+ storage: {
+ local: localArea,
+ sync: syncArea,
+ onChanged: {
+ addListener: function (listener) {
+ if (storageListeners.indexOf(listener) === -1) {
+ storageListeners.push(listener);
+ }
+ },
+ removeListener: function (listener) {
+ var index = storageListeners.indexOf(listener);
+ if (index !== -1) {
+ storageListeners.splice(index, 1);
+ }
+ }
+ }
+ },
+ runtime: {
+ openOptionsPage: function () {
+ var bridge = mostRecentBridge();
+ if (bridge) {
+ bridge.openOptions();
+ }
+ return Promise.resolve();
+ },
+ sendMessage: function (message) {
+ var bridge = mostRecentBridge();
+ if (!bridge) {
+ return Promise.reject(new Error("Kein Pale-Moon-Browserfenster verfügbar."));
+ }
+ try {
+ return Promise.resolve(bridge.handleMessage(clone(message)));
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ },
+ onMessage: {
+ addListener: function (listener) {
+ if (runtimeListeners.indexOf(listener) === -1) {
+ runtimeListeners.push(listener);
+ }
+ },
+ removeListener: function (listener) {
+ var index = runtimeListeners.indexOf(listener);
+ if (index !== -1) {
+ runtimeListeners.splice(index, 1);
+ }
+ }
+ }
+ },
+ action: {
+ setBadgeText: function () {
+ return Promise.resolve();
+ },
+ getBadgeText: function (details) {
+ var bridge = mostRecentBridge();
+ return Promise.resolve(
+ bridge ? bridge.getCountText(details && details.tabId) : ""
+ );
+ },
+ setBadgeBackgroundColor: function () {
+ return Promise.resolve();
+ }
+ },
+ tabs: {
+ query: function () {
+ var bridge = mostRecentBridge();
+ var tabId = bridge ? bridge.getActiveTabId() : undefined;
+ return Promise.resolve(tabId === undefined ? [] : [{ id: tabId }]);
+ },
+ onRemoved: {
+ addListener: function () {},
+ removeListener: function () {}
+ }
+ }
+ };
+
+ if (global.document && /\/options\/options\.html$/.test(global.location.pathname)) {
+ var syncContainer = global.document.getElementById("sync-categories");
+ if (syncContainer) {
+ var syncSection = syncContainer;
+ while (syncSection && syncSection.localName !== "details") {
+ syncSection = syncSection.parentElement;
+ }
+ if (syncSection) {
+ syncSection.hidden = true;
+ }
+ Array.prototype.forEach.call(
+ syncContainer.querySelectorAll("input[data-sync-category]"),
+ function (input) {
+ input.checked = false;
+ input.disabled = true;
+ }
+ );
+ }
+ }
+})(this);
diff --git a/legacy/palemoon/palemoon/popup-panel.js b/legacy/palemoon/palemoon/popup-panel.js
new file mode 100644
index 0000000..5283f0d
--- /dev/null
+++ b/legacy/palemoon/palemoon/popup-panel.js
@@ -0,0 +1,96 @@
+(function (global) {
+ "use strict";
+
+ var POPUP_ID = "sprachverstand-popup-panel";
+ var POPUP_BROWSER_ID = "sprachverstand-popup-browser";
+ var POPUP_URL = "chrome://sprachverstand/content/popup/popup.html";
+ var POPUP_WIDTH = 408;
+ var POPUP_MAX_HEIGHT = 600;
+ var POPUP_MIN_HEIGHT = 360;
+ var POPUP_VERTICAL_MARGIN = 120;
+
+ function panel() {
+ return global.document.getElementById(POPUP_ID);
+ }
+
+ function popupBrowser() {
+ return global.document.getElementById(POPUP_BROWSER_ID);
+ }
+
+ function refreshActiveTabCount() {
+ try {
+ if (global.SprachverstandPaleMoon) {
+ global.SprachverstandPaleMoon.getCountText();
+ }
+ } catch (_error) {
+ // The content document may be navigating while the panel is opened.
+ }
+ }
+
+ function sizePopupBrowser() {
+ var browser = popupBrowser();
+ if (!browser) {
+ return;
+ }
+
+ var availableHeight = POPUP_MAX_HEIGHT;
+ if (typeof global.innerHeight === "number" && global.innerHeight > 0) {
+ availableHeight = global.innerHeight - POPUP_VERTICAL_MARGIN;
+ }
+
+ var height = Math.max(
+ POPUP_MIN_HEIGHT,
+ Math.min(POPUP_MAX_HEIGHT, availableHeight)
+ );
+
+ browser.setAttribute("width", String(POPUP_WIDTH));
+ browser.setAttribute("height", String(height));
+ }
+
+ function loadPopupDocument() {
+ var browser = popupBrowser();
+ if (!browser) {
+ return;
+ }
+
+ browser.setAttribute("src", POPUP_URL);
+ }
+
+ function resetPopup() {
+ var browser = popupBrowser();
+ if (browser) {
+ browser.setAttribute("src", "about:blank");
+ }
+ }
+
+ function togglePopup(anchor) {
+ var popup = panel();
+ if (!popup || !anchor) {
+ return;
+ }
+
+ if (popup.state === "open" || popup.state === "showing") {
+ popup.hidePopup();
+ return;
+ }
+
+ refreshActiveTabCount();
+ sizePopupBrowser();
+ loadPopupDocument();
+ popup.openPopup(anchor, "after_end", 0, 0, false, false);
+ }
+
+ global.SprachverstandPaleMoonUI = {
+ togglePopup: togglePopup,
+ resetPopup: resetPopup
+ };
+
+ global.addEventListener(
+ "unload",
+ function () {
+ resetPopup();
+ delete global.SprachverstandPaleMoonUI;
+ },
+ { once: true }
+ );
+})(this);
diff --git a/package.json b/package.json
index d61e235..2ed0270 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"build": "node scripts/build.mjs",
"build:chromium": "node scripts/build.mjs --target chromium",
"build:firefox": "node scripts/build.mjs --target firefox",
+ "build:palemoon": "node scripts/build.mjs --target palemoon",
"dev": "node scripts/build.mjs --target chromium --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
diff --git a/scripts/build.mjs b/scripts/build.mjs
index c941543..96bcedb 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -11,7 +11,7 @@ const requestedTarget =
const watch = argumentsSet.has("--watch");
const cleanOnly = argumentsSet.has("--clean");
-const supportedTargets = ["chromium", "firefox"];
+const supportedTargets = ["chromium", "firefox", "palemoon"];
const targets = requestedTarget ? [requestedTarget] : supportedTargets;
for (const target of targets) {
@@ -24,15 +24,90 @@ const packageJson = JSON.parse(
await readFile(path.join(projectRoot, "package.json"), "utf8")
);
+async function injectPaleMoonCompatibilityScript(outputDirectory) {
+ for (const relativePath of ["popup/popup.html", "options/options.html"]) {
+ const filePath = path.join(outputDirectory, relativePath);
+ const html = await readFile(filePath, "utf8");
+ const bundleName = path.basename(relativePath, ".html");
+ const marker = ``;
+ const compatibilityScript =
+ `\n ${marker}`;
+
+ if (!html.includes(marker)) {
+ throw new Error(
+ `Pale-Moon-Kompatibilität konnte nicht in ${relativePath} eingefügt werden.`
+ );
+ }
+
+ await writeFile(
+ filePath,
+ html.replace(marker, compatibilityScript),
+ "utf8"
+ );
+ }
+}
+
+async function preparePaleMoonFiles(outputDirectory) {
+ const legacyDirectory = path.join(projectRoot, "legacy", "palemoon");
+ const paleMoonRuntimeDirectory = path.join(outputDirectory, "palemoon");
+ const paleMoonIconDirectory = path.join(outputDirectory, "icons");
+ await mkdir(paleMoonRuntimeDirectory, { recursive: true });
+ await mkdir(paleMoonIconDirectory, { recursive: true });
+
+ const installManifest = await readFile(
+ path.join(legacyDirectory, "install.rdf"),
+ "utf8"
+ );
+ await writeFile(
+ path.join(outputDirectory, "install.rdf"),
+ installManifest.replaceAll("__VERSION__", packageJson.version),
+ "utf8"
+ );
+
+ for (const size of [16, 64]) {
+ const iconBase64 = await readFile(
+ path.join(legacyDirectory, `icon${size}.png.base64`),
+ "utf8"
+ );
+ await writeFile(
+ path.join(paleMoonIconDirectory, `icon${size}.png`),
+ Buffer.from(iconBase64.trim(), "base64")
+ );
+ }
+
+ await Promise.all([
+ cp(
+ path.join(legacyDirectory, "chrome.manifest"),
+ path.join(outputDirectory, "chrome.manifest")
+ ),
+ cp(
+ path.join(legacyDirectory, "palemoon", "browser-overlay.xul"),
+ path.join(paleMoonRuntimeDirectory, "browser-overlay.xul")
+ ),
+ cp(
+ path.join(legacyDirectory, "palemoon", "browser-overlay.css"),
+ path.join(paleMoonRuntimeDirectory, "browser-overlay.css")
+ ),
+ cp(
+ path.join(legacyDirectory, "palemoon", "popup-panel.js"),
+ path.join(paleMoonRuntimeDirectory, "popup-panel.js")
+ ),
+ cp(
+ path.join(legacyDirectory, "palemoon", "legacy-api.js"),
+ path.join(paleMoonRuntimeDirectory, "legacy-api.js")
+ )
+ ]);
+
+ await injectPaleMoonCompatibilityScript(outputDirectory);
+}
+
async function prepareStaticFiles(target) {
const outputDirectory = path.join(projectRoot, "dist", target);
await mkdir(outputDirectory, { recursive: true });
- await cp(
- path.join(projectRoot, "static"),
- outputDirectory,
- { recursive: true }
- );
+ await cp(path.join(projectRoot, "static"), outputDirectory, {
+ recursive: true
+ });
const legalDirectory = path.join(outputDirectory, "legal");
await mkdir(legalDirectory, { recursive: true });
@@ -45,6 +120,11 @@ async function prepareStaticFiles(target) {
cp(path.join(projectRoot, "NOTICE"), path.join(legalDirectory, "NOTICE.txt"))
]);
+ if (target === "palemoon") {
+ await preparePaleMoonFiles(outputDirectory);
+ return;
+ }
+
const manifest = JSON.parse(
await readFile(
path.join(projectRoot, "manifests", `${target}.json`),
@@ -60,6 +140,34 @@ async function prepareStaticFiles(target) {
);
}
+function entryPointsFor(target) {
+ if (target === "palemoon") {
+ return {
+ "palemoon/controller": path.join(
+ projectRoot,
+ "src",
+ "palemoon",
+ "controller.ts"
+ ),
+ "palemoon/content": path.join(
+ projectRoot,
+ "src",
+ "palemoon",
+ "content.ts"
+ ),
+ "popup/popup": path.join(projectRoot, "src", "popup.ts"),
+ "options/options": path.join(projectRoot, "src", "options.ts")
+ };
+ }
+
+ return {
+ background: path.join(projectRoot, "src", "background.ts"),
+ content: path.join(projectRoot, "src", "content.ts"),
+ "popup/popup": path.join(projectRoot, "src", "popup.ts"),
+ "options/options": path.join(projectRoot, "src", "options.ts")
+ };
+}
+
if (cleanOnly) {
await rm(path.join(projectRoot, "dist"), { recursive: true, force: true });
process.exit(0);
@@ -73,16 +181,11 @@ for (const target of targets) {
await prepareStaticFiles(target);
const buildContext = await context({
- entryPoints: {
- background: path.join(projectRoot, "src", "background.ts"),
- content: path.join(projectRoot, "src", "content.ts"),
- "popup/popup": path.join(projectRoot, "src", "popup.ts"),
- "options/options": path.join(projectRoot, "src", "options.ts")
- },
+ entryPoints: entryPointsFor(target),
outdir: path.join(projectRoot, "dist", target),
bundle: true,
format: "iife",
- target: ["es2022"],
+ target: [target === "palemoon" ? "es2017" : "es2022"],
platform: "browser",
sourcemap: watch ? "inline" : false,
minify: false,
diff --git a/src/palemoon/content.ts b/src/palemoon/content.ts
new file mode 100644
index 0000000..eaa9fc7
--- /dev/null
+++ b/src/palemoon/content.ts
@@ -0,0 +1,68 @@
+import { DomProcessor } from "../core/dom-processor";
+import { defaultRules } from "../rules";
+import { disabledRuleIdsForGroups } from "../rules/catalog";
+import type { Settings } from "../settings/defaults";
+
+interface PaleMoonContentRuntime {
+ apply(settings: Settings): void;
+ stop(restore?: boolean): void;
+ getReplacementCount(): number;
+}
+
+type PaleMoonContentGlobal = typeof globalThis & {
+ SprachverstandPaleMoonContent?: PaleMoonContentRuntime;
+ __sprachverstandReplacementCount?: number;
+};
+
+const runtimeGlobal = globalThis as PaleMoonContentGlobal;
+let processor: DomProcessor | undefined;
+
+function reportReplacementCount(count: number): void {
+ runtimeGlobal.__sprachverstandReplacementCount = Math.max(
+ 0,
+ Math.trunc(count)
+ );
+}
+
+function optionsFor(settings: Settings) {
+ return {
+ rules: defaultRules,
+ profile: "aggressive" as const,
+ disabledRuleIds: disabledRuleIdsForGroups(settings.enabledRuleGroupIds),
+ protectedTerms: settings.protectedTerms,
+ customReplacements: settings.customReplacements,
+ processAccessibleAttributes: settings.processAccessibleAttributes,
+ processQuotedText: settings.processQuotedText,
+ processSubtitles: settings.processSubtitles,
+ onReplacementCountChange: reportReplacementCount
+ };
+}
+
+const runtime: PaleMoonContentRuntime = {
+ apply(settings) {
+ const options = optionsFor(settings);
+
+ if (processor) {
+ processor.updateOptions(options);
+ reportReplacementCount(processor.getReplacementCount());
+ return;
+ }
+
+ processor = new DomProcessor(document, options);
+ processor.start();
+ reportReplacementCount(processor.getReplacementCount());
+ },
+
+ stop(restore = true) {
+ processor?.stop({ restore });
+ processor = undefined;
+ reportReplacementCount(0);
+ },
+
+ getReplacementCount() {
+ return processor?.getReplacementCount() ?? 0;
+ }
+};
+
+runtimeGlobal.SprachverstandPaleMoonContent = runtime;
+runtimeGlobal.__sprachverstandReplacementCount = 0;
diff --git a/src/palemoon/controller.ts b/src/palemoon/controller.ts
new file mode 100644
index 0000000..f1903c8
--- /dev/null
+++ b/src/palemoon/controller.ts
@@ -0,0 +1,607 @@
+import { formatBadgeCount } from "../browser/badge";
+import { defaultRules } from "../rules";
+import { isDomainExcluded } from "../settings/domain";
+import { normalizeSettings, type Settings } from "../settings/defaults";
+
+declare const Components: {
+ readonly utils: {
+ import(path: string, scope?: Record): Record;
+ Sandbox(
+ principal: Window,
+ options?: {
+ readonly sandboxPrototype?: Window;
+ readonly wantXrays?: boolean;
+ }
+ ): Record;
+ evalInSandbox(source: string, sandbox: Record): unknown;
+ nukeSandbox?(sandbox: Record): void;
+ };
+};
+
+interface PaleMoonBrowser extends HTMLElement {
+ readonly contentDocument?: Document;
+}
+
+interface PaleMoonTab extends HTMLElement {
+ readonly linkedBrowser?: PaleMoonBrowser;
+}
+
+interface PaleMoonGBrowser extends HTMLElement {
+ readonly browsers: readonly PaleMoonBrowser[];
+ readonly selectedBrowser?: PaleMoonBrowser;
+ selectedTab?: PaleMoonTab;
+ readonly tabContainer?: EventTarget;
+ addTab(url: string): PaleMoonTab;
+}
+
+interface PaleMoonServices {
+ readonly prefs: {
+ prefHasUserValue(name: string): boolean;
+ getCharPref(name: string): string;
+ getBoolPref(name: string): boolean;
+ setBoolPref(name: string, value: boolean): void;
+ addObserver(name: string, observer: PrefObserver, holdWeak: boolean): void;
+ removeObserver(name: string, observer: PrefObserver): void;
+ };
+ readonly obs: {
+ notifyObservers(subject: null, topic: string, data: string): void;
+ };
+ readonly scriptloader: {
+ loadSubScript(
+ url: string,
+ target: Record,
+ charset?: string
+ ): void;
+ };
+}
+
+interface PrefObserver {
+ observe(subject: unknown, topic: string, data: string): void;
+}
+
+interface ContentSandbox extends Record {
+ __sprachverstandCommandPayload?: string;
+}
+
+interface GetCountMessage {
+ readonly type: "sprachverstand.get-count";
+ readonly tabId: number;
+}
+
+interface SetInspectedTabMessage {
+ readonly type: "sprachverstand.set-inspected-tab";
+ readonly tabId: number;
+}
+
+interface GetInspectedCountMessage {
+ readonly type: "sprachverstand.get-inspected-count";
+}
+
+interface PaleMoonBridge {
+ openPopup(): void;
+ openOptions(): void;
+ getActiveTabId(): number | undefined;
+ getCountText(tabId?: number): string;
+ handleMessage(message: unknown): unknown;
+}
+
+interface PaleMoonWindow extends Window {
+ readonly gBrowser?: PaleMoonGBrowser;
+ SprachverstandPaleMoon?: PaleMoonBridge;
+ openDialog(url: string, name: string, features: string): Window | null;
+}
+
+interface PaleMoonChromeDocument extends Document {
+ persist(id: string, attribute: string): void;
+}
+
+const windowObject = window as unknown as PaleMoonWindow;
+const chromeDocument = document as PaleMoonChromeDocument;
+const importedServices = Components.utils.import(
+ "resource://gre/modules/Services.jsm",
+ {}
+) as { readonly Services?: PaleMoonServices };
+const importedServiceValue = importedServices.Services;
+
+if (!importedServiceValue) {
+ throw new Error("Pale-Moon-Dienste konnten nicht geladen werden.");
+}
+
+const Services: PaleMoonServices = importedServiceValue;
+const settingsPreference = "extensions.sprachverstand.storage.local.settings";
+const toolbarInstalledPreference =
+ "extensions.sprachverstand.palemoon.toolbarInstalled";
+const runtimeMessageTopic = "sprachverstand-palemoon-runtime-message";
+const toolbarButtonId = "sprachverstand-toolbar-button";
+const contentRuntimeUrl = "chrome://sprachverstand/content/palemoon/content.js";
+
+const sandboxesByDocument = new Map();
+const countsByTabId = new Map();
+const tabIdsByBrowser = new WeakMap();
+const browsersByTabId = new Map();
+let nextTabId = 1;
+let inspectedTabId: number | undefined;
+let started = false;
+
+function loadSettings(): Settings {
+ if (!Services.prefs.prefHasUserValue(settingsPreference)) {
+ return normalizeSettings(undefined);
+ }
+
+ try {
+ return normalizeSettings(
+ JSON.parse(Services.prefs.getCharPref(settingsPreference))
+ );
+ } catch {
+ return normalizeSettings(undefined);
+ }
+}
+
+function isEligibleDocument(documentToCheck: Document): boolean {
+ const protocol = documentToCheck.location?.protocol;
+ return protocol === "http:" || protocol === "https:" || protocol === "file:";
+}
+
+function shouldRun(documentToCheck: Document, settings: Settings): boolean {
+ return (
+ settings.enabled &&
+ defaultRules.length > 0 &&
+ isEligibleDocument(documentToCheck) &&
+ !isDomainExcluded(
+ documentToCheck.location?.hostname ?? "",
+ settings.excludedDomains
+ )
+ );
+}
+
+function browserForDocument(documentToFind: Document): PaleMoonBrowser | undefined {
+ return windowObject.gBrowser?.browsers.find(
+ (browser) => browser.contentDocument === documentToFind
+ );
+}
+
+function tabIdForBrowser(browser: PaleMoonBrowser): number {
+ const existing = tabIdsByBrowser.get(browser);
+ if (existing !== undefined) {
+ return existing;
+ }
+
+ const created = nextTabId;
+ nextTabId += 1;
+ tabIdsByBrowser.set(browser, created);
+ browsersByTabId.set(created, browser);
+ return created;
+}
+
+function activeTabId(): number | undefined {
+ const browser = windowObject.gBrowser?.selectedBrowser;
+ return browser ? tabIdForBrowser(browser) : undefined;
+}
+
+function notifyRuntimeMessage(message: unknown): void {
+ Services.obs.notifyObservers(
+ null,
+ runtimeMessageTopic,
+ JSON.stringify(message)
+ );
+}
+
+function updateToolbarTooltip(): void {
+ const button = document.getElementById(toolbarButtonId);
+ if (!button) {
+ return;
+ }
+
+ const tabId = activeTabId();
+ const count = tabId === undefined ? 0 : countsByTabId.get(tabId) ?? 0;
+ const text = formatBadgeCount(count);
+ button.setAttribute(
+ "tooltiptext",
+ text ? `Sprachverstand – ${text} Korrekturen` : "Sprachverstand"
+ );
+ button.setAttribute("sprachverstand-count", text);
+}
+
+function reportCount(documentToReport: Document, count: number): void {
+ const browser = browserForDocument(documentToReport);
+ if (!browser) {
+ return;
+ }
+
+ const tabId = tabIdForBrowser(browser);
+ const normalizedCount = Math.max(0, Math.trunc(count));
+ countsByTabId.set(tabId, normalizedCount);
+ updateToolbarTooltip();
+ notifyRuntimeMessage({
+ type: "sprachverstand.count-updated",
+ tabId,
+ text: formatBadgeCount(normalizedCount) || "0"
+ });
+}
+
+function evaluateInContentSandbox(
+ sandbox: ContentSandbox,
+ source: string
+): unknown {
+ return Components.utils.evalInSandbox(source, sandbox);
+}
+
+function sandboxHasRuntime(sandbox: ContentSandbox): boolean {
+ return (
+ evaluateInContentSandbox(
+ sandbox,
+ "Boolean(this.SprachverstandPaleMoonContent)"
+ ) === true
+ );
+}
+
+function replacementCountFromSandbox(sandbox: ContentSandbox): number {
+ try {
+ const value = evaluateInContentSandbox(
+ sandbox,
+ "this.SprachverstandPaleMoonContent ? this.SprachverstandPaleMoonContent.getReplacementCount() : 0"
+ );
+ return typeof value === "number" && Number.isFinite(value)
+ ? Math.max(0, Math.trunc(value))
+ : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function refreshDocumentCount(documentToRefresh: Document): number {
+ const sandbox = sandboxesByDocument.get(documentToRefresh);
+ const count = sandbox ? replacementCountFromSandbox(sandbox) : 0;
+ const browser = browserForDocument(documentToRefresh);
+ if (browser) {
+ countsByTabId.set(tabIdForBrowser(browser), count);
+ }
+ return count;
+}
+
+function destroySandbox(documentToStop: Document, restore: boolean): void {
+ const sandbox = sandboxesByDocument.get(documentToStop);
+ if (!sandbox) {
+ return;
+ }
+
+ try {
+ evaluateInContentSandbox(
+ sandbox,
+ `if (this.SprachverstandPaleMoonContent) this.SprachverstandPaleMoonContent.stop(${restore ? "true" : "false"});`
+ );
+ } catch {
+ // The document can already be tearing down during navigation.
+ }
+
+ sandboxesByDocument.delete(documentToStop);
+ Components.utils.nukeSandbox?.(sandbox);
+}
+
+function createSandbox(documentToProcess: Document): ContentSandbox | undefined {
+ const contentWindow = documentToProcess.defaultView;
+ if (!contentWindow) {
+ return undefined;
+ }
+
+ const sandbox = Components.utils.Sandbox(contentWindow, {
+ sandboxPrototype: contentWindow,
+ wantXrays: true
+ }) as ContentSandbox;
+
+ Services.scriptloader.loadSubScript(contentRuntimeUrl, sandbox, "UTF-8");
+ if (!sandboxHasRuntime(sandbox)) {
+ Components.utils.nukeSandbox?.(sandbox);
+ throw new Error("Pale-Moon-Inhaltsruntime konnte nicht geladen werden.");
+ }
+
+ sandboxesByDocument.set(documentToProcess, sandbox);
+ return sandbox;
+}
+
+function applySettingsToDocument(
+ documentToProcess: Document,
+ settings = loadSettings()
+): void {
+ if (!shouldRun(documentToProcess, settings)) {
+ destroySandbox(documentToProcess, true);
+ if (browserForDocument(documentToProcess)) {
+ reportCount(documentToProcess, 0);
+ }
+ return;
+ }
+
+ const sandbox =
+ sandboxesByDocument.get(documentToProcess) ??
+ createSandbox(documentToProcess);
+ if (!sandbox) {
+ return;
+ }
+
+ sandbox.__sprachverstandCommandPayload = JSON.stringify(settings);
+ try {
+ evaluateInContentSandbox(
+ sandbox,
+ "this.SprachverstandPaleMoonContent.apply(JSON.parse(this.__sprachverstandCommandPayload));"
+ );
+ } finally {
+ delete sandbox.__sprachverstandCommandPayload;
+ }
+
+ reportCount(documentToProcess, replacementCountFromSandbox(sandbox));
+}
+
+function refreshOpenDocuments(): void {
+ const settings = loadSettings();
+
+ for (const browser of windowObject.gBrowser?.browsers ?? []) {
+ const contentDocument = browser.contentDocument;
+ if (contentDocument) {
+ applySettingsToDocument(contentDocument, settings);
+ }
+ }
+
+ updateToolbarTooltip();
+}
+
+function eventDocument(event: Event): Document | undefined {
+ const candidate =
+ (event as Event & { readonly originalTarget?: EventTarget | null })
+ .originalTarget ?? event.target;
+
+ if (!candidate || typeof candidate !== "object") {
+ return undefined;
+ }
+
+ const nodeType = (candidate as { readonly nodeType?: unknown }).nodeType;
+ return nodeType === 9 ? (candidate as unknown as Document) : undefined;
+}
+
+function handleDocumentLoaded(event: Event): void {
+ const loadedDocument = eventDocument(event);
+ if (loadedDocument) {
+ applySettingsToDocument(loadedDocument);
+ }
+}
+
+function handlePageHide(event: Event): void {
+ const hiddenDocument = eventDocument(event);
+ if (hiddenDocument) {
+ destroySandbox(hiddenDocument, false);
+ }
+}
+
+function handleTabSelect(): void {
+ const browser = windowObject.gBrowser?.selectedBrowser;
+ const contentDocument = browser?.contentDocument;
+ if (contentDocument) {
+ refreshDocumentCount(contentDocument);
+ }
+ updateToolbarTooltip();
+}
+
+function handleTabClose(event: Event): void {
+ const tab = event.target as PaleMoonTab | null;
+ const browser = tab?.linkedBrowser;
+ if (!browser) {
+ return;
+ }
+
+ const tabId = tabIdsByBrowser.get(browser);
+ if (tabId !== undefined) {
+ countsByTabId.delete(tabId);
+ browsersByTabId.delete(tabId);
+ if (inspectedTabId === tabId) {
+ inspectedTabId = undefined;
+ }
+ }
+ updateToolbarTooltip();
+}
+
+function ensureToolbarButtonInstalled(): void {
+ if (Services.prefs.prefHasUserValue(toolbarInstalledPreference)) {
+ try {
+ if (Services.prefs.getBoolPref(toolbarInstalledPreference)) {
+ return;
+ }
+ } catch {
+ // Fall through and try installing the button once.
+ }
+ }
+
+ const navigationBar = document.getElementById("nav-bar") as
+ | (HTMLElement & {
+ readonly currentSet?: string;
+ insertItem?(id: string, before?: Element | null): void;
+ })
+ | null;
+
+ if (!navigationBar?.insertItem) {
+ return;
+ }
+
+ const currentSet =
+ navigationBar.currentSet ?? navigationBar.getAttribute("currentset") ?? "";
+ if (!currentSet.split(",").includes(toolbarButtonId)) {
+ navigationBar.insertItem(toolbarButtonId, null);
+ const updatedSet =
+ navigationBar.currentSet ?? navigationBar.getAttribute("currentset") ?? "";
+ navigationBar.setAttribute("currentset", updatedSet);
+ chromeDocument.persist("nav-bar", "currentset");
+ }
+
+ Services.prefs.setBoolPref(toolbarInstalledPreference, true);
+}
+
+function openPopup(): void {
+ const browser = windowObject.gBrowser?.selectedBrowser;
+ const contentDocument = browser?.contentDocument;
+ if (contentDocument) {
+ refreshDocumentCount(contentDocument);
+ updateToolbarTooltip();
+ }
+
+ windowObject.openDialog(
+ "chrome://sprachverstand/content/popup/popup.html",
+ "sprachverstand-popup",
+ "chrome,dialog=no,resizable=yes,centerscreen,width=420,height=720"
+ );
+}
+
+function openOptions(): void {
+ const browser = windowObject.gBrowser;
+ if (!browser) {
+ return;
+ }
+
+ const tab = browser.addTab(
+ "chrome://sprachverstand/content/options/options.html"
+ );
+ browser.selectedTab = tab;
+}
+
+function countText(tabId?: number): string {
+ const resolvedTabId = tabId ?? activeTabId();
+ if (resolvedTabId === undefined) {
+ return "0";
+ }
+
+ const browser = browsersByTabId.get(resolvedTabId);
+ const contentDocument = browser?.contentDocument;
+ if (contentDocument) {
+ refreshDocumentCount(contentDocument);
+ }
+
+ return formatBadgeCount(countsByTabId.get(resolvedTabId) ?? 0) || "0";
+}
+
+function isGetCountMessage(message: unknown): message is GetCountMessage {
+ return (
+ Boolean(message) &&
+ typeof message === "object" &&
+ (message as Partial).type === "sprachverstand.get-count" &&
+ typeof (message as Partial).tabId === "number"
+ );
+}
+
+function isSetInspectedTabMessage(
+ message: unknown
+): message is SetInspectedTabMessage {
+ return (
+ Boolean(message) &&
+ typeof message === "object" &&
+ (message as Partial).type ===
+ "sprachverstand.set-inspected-tab" &&
+ typeof (message as Partial).tabId === "number"
+ );
+}
+
+function isGetInspectedCountMessage(
+ message: unknown
+): message is GetInspectedCountMessage {
+ return (
+ Boolean(message) &&
+ typeof message === "object" &&
+ (message as Partial).type ===
+ "sprachverstand.get-inspected-count"
+ );
+}
+
+const bridge: PaleMoonBridge = {
+ openPopup,
+ openOptions,
+ getActiveTabId: activeTabId,
+ getCountText: countText,
+ handleMessage(message) {
+ if (isGetCountMessage(message)) {
+ return { text: countText(message.tabId) };
+ }
+
+ if (isSetInspectedTabMessage(message)) {
+ inspectedTabId = message.tabId;
+ return undefined;
+ }
+
+ if (isGetInspectedCountMessage(message)) {
+ const tabId = inspectedTabId ?? activeTabId();
+ return {
+ ...(tabId === undefined ? {} : { tabId }),
+ text: countText(tabId)
+ };
+ }
+
+ return undefined;
+ }
+};
+
+const prefObserver: PrefObserver = {
+ observe(_subject, topic, data) {
+ if (topic === "nsPref:changed" && data === settingsPreference) {
+ refreshOpenDocuments();
+ }
+ }
+};
+
+function shutdown(): void {
+ if (!started) {
+ return;
+ }
+ started = false;
+
+ Services.prefs.removeObserver(settingsPreference, prefObserver);
+ windowObject.gBrowser?.removeEventListener(
+ "DOMContentLoaded",
+ handleDocumentLoaded,
+ true
+ );
+ windowObject.gBrowser?.removeEventListener("pagehide", handlePageHide, true);
+ windowObject.gBrowser?.tabContainer?.removeEventListener(
+ "TabSelect",
+ handleTabSelect as EventListener
+ );
+ windowObject.gBrowser?.tabContainer?.removeEventListener(
+ "TabClose",
+ handleTabClose
+ );
+
+ for (const contentDocument of [...sandboxesByDocument.keys()]) {
+ destroySandbox(contentDocument, true);
+ }
+
+ browsersByTabId.clear();
+ delete windowObject.SprachverstandPaleMoon;
+}
+
+function start(): void {
+ if (started || !windowObject.gBrowser) {
+ return;
+ }
+ started = true;
+
+ windowObject.SprachverstandPaleMoon = bridge;
+ Services.prefs.addObserver(settingsPreference, prefObserver, false);
+ windowObject.gBrowser.addEventListener(
+ "DOMContentLoaded",
+ handleDocumentLoaded,
+ true
+ );
+ windowObject.gBrowser.addEventListener("pagehide", handlePageHide, true);
+ windowObject.gBrowser.tabContainer?.addEventListener(
+ "TabSelect",
+ handleTabSelect as EventListener
+ );
+ windowObject.gBrowser.tabContainer?.addEventListener(
+ "TabClose",
+ handleTabClose
+ );
+ window.addEventListener("unload", shutdown, { once: true });
+
+ ensureToolbarButtonInstalled();
+ refreshOpenDocuments();
+}
+
+if (document.readyState === "complete") {
+ start();
+} else {
+ window.addEventListener("load", start, { once: true });
+}