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 @@ + + + + `; + 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 }); +}