From e40920aaddc094778fc86b50386180db5931a50b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:51:15 +0000 Subject: [PATCH 1/2] CI-Tests (Linux/Windows, Node 18/22), Release-Workflow und Changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows/ci.yml: Unit-Tests und Syntaxprüfung der Shims bei jedem Push/PR - .github/workflows/release.yml: erstellt bei einem Tag v* ein GitHub-Release mit den Notizen aus CHANGELOG.md (ohne gebaute .xpi, da diese Anthropic-Code enthielte) - CHANGELOG.md: Notizen für 0.1.0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BpfwyChUnZawLQokRG5xQT --- .github/workflows/ci.yml | 24 ++++++++++++++ .github/workflows/release.yml | 37 +++++++++++++++++++++ CHANGELOG.md | 61 +++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..42efbf8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + unit: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node: [18, 22] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm test + - name: Shim-Dateien auf Syntaxfehler prüfen + shell: bash + run: for f in src/ff/*.js; do node --check "$f"; done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dfdbabd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +# Erstellt ein GitHub-Release, sobald ein Tag wie v0.1.0 gepusht wird. +# Das Release enthält nur dieses Repository (Build-Skript + Firefox-Shims), +# keine gebaute .xpi – die würde Code von Anthropic enthalten. +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test + - name: Release-Notizen aus CHANGELOG.md lesen + run: | + version="${GITHUB_REF_NAME#v}" + awk -v ver="$version" ' + $0 ~ "^## \\[" ver "\\]" { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "Kein Abschnitt für $version in CHANGELOG.md gefunden." >&2 + exit 1 + fi + - name: Release erstellen + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --title "Claude-Firefox $GITHUB_REF_NAME" --notes-file release-notes.md --verify-tag diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22f21e5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Änderungen + +Alle nennenswerten Änderungen an diesem Projekt. Versionsnummern folgen [SemVer](https://semver.org/lang/de/). + +## [0.1.0] – 2026-10-11 + +Erste Version: Die offizielle Claude-Browser-Extension („Claude in Chrome“) wird lokal auf deinem Rechner zu einem +Firefox-Add-on umgebaut. Dieses Release enthält **keinen Code von Anthropic** und keine fertige `.xpi`-Datei. Die +Extension lädt das Build-Skript beim Bauen selbst aus dem Chrome Web Store. + +### So benutzt du es + +1. Unten unter „Assets“ den Quellcode („Source code (zip)“) herunterladen und entpacken. +2. [Node.js](https://nodejs.org) 18 oder neuer installieren. +3. `build.cmd` doppelklicken (Windows) bzw. `node scripts/build.mjs` ausführen. Das erzeugt + `dist/claude-firefox-.xpi`. +4. In Firefox `about:debugging#/runtime/this-firefox` öffnen → „Temporäres Add-on laden…“ → die `.xpi` wählen. + +Details, dauerhafte Installation und Fehlerbehebung: siehe [README](README.md). + +### Enthalten + +- **Build-Skript** ohne Abhängigkeiten: + - CRX laden und entpacken + - Manifest für Firefox umbauen: Service Worker → Hintergrundskript, Seitenpanel → Sidebar, Berechtigungen, Add-on-ID + - Shims einbinden und Icons quadratisch machen + - XPI packen +- **Ersatz für `chrome.debugger`**: + - Screenshots + - JavaScript-Ausführung + - Klicks, Doppelklicks, Tippen (inkl. Umlaute/Emojis), Tastenkürzel, Scrollen und Drag & Drop + - Klicks in iframes + - Mitschnitt von Browser-Konsole und Netzwerk-Anfragen +- **Seitenpanel → Firefox-Sidebar** (eine Claude-Sitzung pro Fenster), echte Firefox-Tab-Gruppen +- **Anmeldung über claude.ai**: Ersatz für `externally_connectable`. Weiterleitungen auf `chrome-extension://` werden abgefangen. +- Offscreen-Dokument, Chrome-only-Optionen, `chrome://`-Links und fehlende Konstanten werden für Firefox ersetzt bzw. ergänzt. +- Windows-Hilfen: `build.cmd`, optionales Skript zur Anbindung von Claude Code / Claude Desktop (experimentell). +- Tests: Unit-Tests (`npm test`) und ein End-to-End-Test in echtem Firefox (`test/e2e/run.mjs`). + +### Teststand + +- Getestet mit Firefox 157 gegen die **ältere Extension-Version 1.0.70**. Alle Unit-Tests und alle End-to-End-Prüfungen bestehen. +- **Nicht getestet:** + - die aktuelle Extension-Version + - die Anmeldung mit einem echten Claude-Konto + - die Windows-Skripte unter echtem Windows (das PowerShell-Skript ist nur auf Syntax geprüft) + - die Anbindung an Claude Code / Claude Desktop + - das Selbst-Signieren über Mozilla + +### Bekannte Einschränkungen + +- Firefox erlaubt Erweiterungen keine echten Maus- und Tastatureingaben. Der Port erzeugt die Events selbst. Nicht möglich sind: + - CSS-Hover-Menüs + - native Auswahllisten aufklappen + - Seiten, die nur „echte“ Klicks akzeptieren +- Keine JavaScript-Ausführung auf Seiten mit strenger Content-Security-Policy. +- Keine Datei-Uploads von lokalen Pfaden. +- Im normalen Firefox nur als temporäres Add-on. Nach einem Neustart neu laden, die Anmeldung bleibt erhalten. + +> Inoffizielles Projekt, nicht von Anthropic. Die Extension ist proprietäre Software von Anthropic. Nutzung nur privat +> und auf eigenes Risiko. Gebaute Dateien nicht weitergeben. From 897d62cdda2a2791979220aeb6ea7b55b88a5ea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:32:30 +0000 Subject: [PATCH 2/2] Korrekturen aus der Review vor dem Release v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eingaben (cdp-agent.js): - Enter löst wie in Chrome keypress (13) aus; ein abgebrochenes keypress verhindert Absenden/Zeilenumbruch. Enter auf Submit-/Button-Inputs und aktiviert das fokussierte Element statt des ersten Submit-Buttons. - macOS-Tastenbefehle: fehlende Befehle ergänzt, unbekannte fallen auf die normale Tastenaktion zurück. - Keine Klick-Events auf deaktivierte Formular-Elemente; kein Klick ohne vorheriges Drücken im selben Dokument. - Dreifachklick und Strg+Pos1/Ende in Editoren (Firefox kennt die Granularitäten nicht). - Tab überspringt versteckte/deaktivierte Elemente und berücksichtigt Shadow DOM. - Pop-ups: stopPropagation, Formulare mit target=_blank, noopener, benannte Frames. - Escape schließt modale Dialoge/Popover; Pfeiltasten in Zahlenfeldern und Radio-Gruppen; Tastatur-Scrollen in inneren Scrollbereichen. - Console: kein Ausführen von Getters, begrenzte Serialisierung, Meldungen vor einer Navigation gehen nicht verloren. Hintergrund (engine.js): - Event-Page bleibt wach, solange ein Tab angehängt ist (sonst ginge der Zustand verloren). - Console/Netzwerk aus fremden iframes überschreiben nicht mehr die Listen der Seite. - Screenshots bei Seiten-Zoom haben die richtige Größe. - JavaScript mit await funktioniert auch auf Seiten mit zone.js. - Console-Registrierung blockiert die Befehlswarteschlange nicht mehr. Sicherheit (background.js): - Abgefangene OAuth-Weiterleitungen werden nur mit passendem oauthState verarbeitet; Extension-Seiten werden dafür nicht mehr in Web-Tabs geöffnet. Build/Windows: Erkennung als Hauptskript über Symlinks/Junctions, ZIP-Ordnereinträge mit Backslash, Sandbox-Seiten bleiben erhalten, PowerShell liest UTF-8 korrekt. Doku: Update-Anleitung, Grenzen (Zwischenablage, „Seite verlassen?“-Dialoge), Tastenkürzel bei temporärem Add-on, Abschnitt zum Selbst-Signieren entfernt (Weitergabe von Anthropics Code), LICENSE wieder wortgleich MIT, *.zip ignoriert. Tests: neue Unit- und End-to-End-Prüfungen (19 Unit-Tests, 52 E2E-Prüfungen grün). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BpfwyChUnZawLQokRG5xQT --- .gitignore | 1 + CHANGELOG.md | 9 +- LICENSE | 5 - README.md | 38 ++-- scripts/build.mjs | 18 +- scripts/lib/manifest.mjs | 9 +- scripts/lib/zip.mjs | 3 +- scripts/windows/install-native-host.ps1 | 5 +- src/ff/background.js | 23 +-- src/ff/cdp-agent.js | 228 ++++++++++++++++++++---- src/ff/engine.js | 85 +++++++-- test/e2e/pages.mjs | 22 +++ test/e2e/runner/runner.js | 46 ++++- test/unit/build.test.mjs | 15 +- 14 files changed, 407 insertions(+), 100 deletions(-) diff --git a/.gitignore b/.gitignore index 9f7bcae..3b4af39 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ cache/ *.crx +*.zip *.xpi web-ext-artifacts/ node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f21e5..d974c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,12 +40,15 @@ Details, dauerhafte Installation und Fehlerbehebung: siehe [README](README.md). ### Teststand - Getestet mit Firefox 157 gegen die **ältere Extension-Version 1.0.70**. Alle Unit-Tests und alle End-to-End-Prüfungen bestehen. +- Vor dem Release hat eine mehrstufige Code-Review 37 Fehler gefunden, alle behoben. Die meisten betrafen Randfälle der + nachgebauten Eingaben, darunter Enter/keypress, macOS-Tastenbefehle, deaktivierte Buttons, Tab-Reihenfolge, + Pop-ups, Zoom bei Screenshots und Ruhezustand des Hintergrunds. Dazu kam eine Härtung der Login-Weiterleitung. - **Nicht getestet:** - die aktuelle Extension-Version - die Anmeldung mit einem echten Claude-Konto - - die Windows-Skripte unter echtem Windows (das PowerShell-Skript ist nur auf Syntax geprüft) + - die Windows-Skripte unter echtem Windows (das PowerShell-Skript ist nur auf Syntax geprüft; + die Unit-Tests laufen in der CI auch unter Windows) - die Anbindung an Claude Code / Claude Desktop - - das Selbst-Signieren über Mozilla ### Bekannte Einschränkungen @@ -53,6 +56,8 @@ Details, dauerhafte Installation und Fehlerbehebung: siehe [README](README.md). - CSS-Hover-Menüs - native Auswahllisten aufklappen - Seiten, die nur „echte“ Klicks akzeptieren +- Kein Kopieren/Einfügen über die Zwischenablage (`Strg+C`/`Strg+V`). +- „Seite verlassen?“-Dialoge beim Navigieren werden nicht erkannt. - Keine JavaScript-Ausführung auf Seiten mit strenger Content-Security-Policy. - Keine Datei-Uploads von lokalen Pfaden. - Im normalen Firefox nur als temporäres Add-on. Nach einem Neustart neu laden, die Anmeldung bleibt erhalten. diff --git a/LICENSE b/LICENSE index b551892..44225f9 100644 --- a/LICENSE +++ b/LICENSE @@ -2,11 +2,6 @@ MIT License Copyright (c) 2026 Kyloschmeilo -Diese Lizenz gilt nur für den Code in diesem Repository (Build-Skripte, -Firefox-Kompatibilitätsschicht, Tests). Die Claude-Browser-Extension selbst ist -Eigentum von Anthropic, nicht Teil dieses Repositorys und steht nicht unter -dieser Lizenz. - Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/README.md b/README.md index a415c8f..51962ca 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ Chrome-Funktionen ergänzen die Skripte unter `src/ff/` zur Laufzeit. Firefox installiert dauerhaft nur von Mozilla signierte Add-ons. Ein temporär geladenes Add-on verschwindet beim Beenden von Firefox. Danach lädst du es über `about:debugging#/runtime/this-firefox` neu (Tipp: Lesezeichen anlegen). Deine Anmeldung -und deine Einstellungen bleiben dabei erhalten (getestet mit Firefox 157). +und die Einstellungen der Extension bleiben dabei erhalten (getestet mit Firefox 157). +Geänderte Tastenkombinationen setzt Firefox dagegen bei jedem Neustart zurück. > Falls die Anmeldung doch einmal verloren geht: In `about:config` die Einstellungen > `extensions.webextensions.keepStorageOnUninstall` und @@ -58,31 +59,23 @@ Diese Firefox-Versionen dürfen unsignierte Add-ons dauerhaft installieren: 1. `about:config` öffnen und `xpinstall.signatures.required` auf `false` setzen. 2. `about:addons` öffnen → Zahnrad → „Add-on aus Datei installieren…“ → `.xpi` wählen. -### C) Dauerhaft im normalen Firefox: selbst signieren (fortgeschritten, ungetestet) - -Mozilla kann das Add-on als „nicht gelistet“ automatisch signieren: - -1. Ein Konto auf [addons.mozilla.org](https://addons.mozilla.org) anlegen und unter - „Developer Hub“ → „API-Schlüssel“ einen Schlüssel erzeugen. -2. Mit eigener Add-on-ID und eigener Versionsnummer bauen: - `node scripts\build.mjs --gecko-id claude-firefox@deinname.local --version-suffix 1` -3. Signieren: - `npx web-ext sign --channel=unlisted --source-dir dist\extension --api-key=… --api-secret=…` -4. Die signierte Datei aus `web-ext-artifacts\` per Drag & Drop in Firefox ziehen. - -Achtung: Dabei lädst du das Paket mit Anthropics Code zur automatischen Prüfung zu Mozilla -hoch. Jede neue Version braucht einen neuen `--version-suffix`. +> **Warum keine dauerhafte Installation im normalen Firefox?** Dafür müsste Mozilla das +> Add-on signieren. Dazu lädt man das komplette Paket – also Anthropics Code – zu +> addons.mozilla.org hoch. Das wäre eine Weitergabe an Dritte (siehe Hinweis oben) und wird +> hier deshalb nicht beschrieben. ## Bedienung - **Claude-Symbol in der Symbolleiste:** öffnet Claude für den aktuellen Tab in der Sidebar. - **Tastenkürzel:** dasselbe wie in Chrome (meist `Strg+E`). Kollidiert es mit Firefox, kannst du es unter `about:addons` → Zahnrad → „Tastenkombinationen für Erweiterungen verwalten“ - ändern. + ändern (bei Variante A nach jedem Firefox-Neustart erneut). - **Pro Fenster eine Claude-Sitzung:** Die Sidebar zeigt die Sitzung des Tabs, für den du Claude zuletzt geöffnet hast. Wechselst du den Tab, läuft eine Aufgabe weiter. -- **Updates:** `build.cmd` erneut ausführen und die neue `.xpi`-Datei laden (Variante A: - in `about:debugging` „Neu laden“ bzw. neu laden). +- **Updates:** `build.cmd` erneut ausführen. Dann in `about:debugging` über „Temporäres + Add-on laden…“ die **neue** Datei `dist\claude-firefox-.xpi` wählen + (Variante B: wie bei der Installation). Achtung: „Neu laden“ lädt nur die zuerst gewählte, + alte Datei erneut. ## Was funktioniert – und was nicht @@ -91,7 +84,7 @@ hoch. Jede neue Version braucht einen neuen `--version-suffix`. | Chat in der Sidebar | ✅ | | Anmeldung über claude.ai | ✅ Die Anmelde-Nachricht von claude.ai bzw. die Weiterleitung auf `chrome-extension://…` wird abgefangen und an die Extension übergeben. Mit einem echten Konto noch nicht getestet. | | Seiten lesen, Elemente finden, Formulare ausfüllen | ✅ | -| Klicken, Tippen, Tastenkürzel, Scrollen, Drag & Drop | ✅ über nachgebaute Browser-Events (Einschränkungen siehe unten) | +| Klicken, Tippen, Tastenkürzel, Scrollen, Drag & Drop | ✅ über nachgebaute Browser-Events (Einschränkungen siehe unten; Kopieren/Einfügen per Zwischenablage geht nicht) | | Screenshots | ✅ | | JavaScript auf der Seite ausführen | ✅ außer auf Seiten mit strenger Content-Security-Policy (Firefox lässt das nicht zu; Claude bekommt dann einen Hinweis) | | Browser-Konsole und Netzwerk-Anfragen mitlesen | ✅ | @@ -101,6 +94,7 @@ hoch. Jede neue Version braucht einen neuen `--version-suffix`. | Claude Code / Claude Desktop | ⚠️ experimentell, siehe unten | | Datei-Upload von lokalen Pfaden (aus Claude Code) | ❌ Firefox-Erweiterungen dürfen keine lokalen Dateien lesen | | Sidebar über den Knopf auf claude.ai öffnen | ⚠️ Firefox erlaubt das Öffnen nur nach einem Klick auf das Symbol. Es erscheint ein Hinweis. | +| „Seite verlassen?“-Dialoge beim Navigieren | ⚠️ werden nicht erkannt: Claude meldet die Navigation als erfolgreich, Firefox zeigt aber seinen Dialog und bleibt auf der Seite | **Grenzen der nachgebauten Eingaben:** Chrome lässt Claude über seine Debugger-Schnittstelle echte Maus- und Tastatureingaben auslösen. Firefox bietet Erweiterungen das nicht an. Der @@ -110,6 +104,8 @@ Nicht möglich sind: - Menüs, die nur per CSS-`:hover` aufklappen - native Auswahllisten aufklappen (Claude wählt Werte stattdessen direkt aus) - Seiten, die ausdrücklich nur „echte“ Klicks akzeptieren (`event.isTrusted`) +- Kopieren, Ausschneiden und Einfügen über die Zwischenablage (`Strg+C`, `Strg+X`, `Strg+V`); + Claude tippt Text stattdessen direkt ein ## Claude Code / Claude Desktop anbinden (optional, experimentell) @@ -161,7 +157,7 @@ node scripts/build.mjs --help --dir entpackte Extension, z. B. direkt aus dem Chrome-Profil: %LOCALAPPDATA%\Google\Chrome\User Data\Default\Extensions\fcoeoabgfenejglbffodgkkbkcdhcgfn --gecko-id eigene Firefox-Add-on-ID - --version-suffix Versionsnummer ergänzen (für selbst signierte Builds) + --version-suffix Versionsnummer um ".n" ergänzen --keep-name Namen nicht um „(Firefox-Port)“ ergänzen --no-xpi nur den entpackten Ordner dist/extension erzeugen ``` @@ -176,7 +172,7 @@ FIREFOX_BIN=… GECKODRIVER_BIN=… node test/e2e/run.mjs Der End-to-End-Test installiert das gebaute Add-on in einem echten Firefox. Er ruft die Browser-Steuerung der Original-Extension direkt auf, also denselben Code, den Claude -benutzt. Geprüft werden 44 Punkte, darunter: +benutzt. Geprüft werden unter anderem: - Klicks, Doppelklicks und Tippen, auch mit Umlauten und Emojis - Tastenkürzel und Formulare - Scrollen und Klicks in fremden iframes diff --git a/scripts/build.mjs b/scripts/build.mjs index 017b720..36b67ac 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -37,7 +37,7 @@ Quelle (eine davon, sonst wird die aktuelle Version heruntergeladen): Weitere Optionen: --out Ausgabeordner (Standard: dist) --gecko-id Firefox-Add-on-ID (Standard: claude-firefox-port@localhost) - --version-suffix Hängt ".n" an die Versionsnummer an (für selbst signierte Builds) + --version-suffix Hängt ".n" an die Versionsnummer an --keep-name Namen der Extension nicht um "(Firefox-Port)" ergänzen --no-xpi Nur den entpackten Ordner erzeugen -h, --help Diese Hilfe @@ -191,7 +191,6 @@ export async function build(opts = {}) { console.log(`• ${chromeManifest.name} ${chromeManifest.version} (Chrome-ID ${chromeExtensionId})`); files.set('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2) + '\n')); - config.pages = [...files.keys()].filter((n) => n.endsWith('.html')).sort(); // Mozilla verlangt quadratische Icons (das Original-Icon ist es nicht immer). const iconPaths = new Set(); @@ -211,9 +210,11 @@ export async function build(opts = {}) { } } + // Sandbox-Seiten haben keine Extension-APIs – dort keine Shims einfügen. + const sandboxPages = new Set(config.sandboxPages.map((p) => p.replace(/^\//, ''))); let pages = 0; for (const [name, data] of files) { - if (name.endsWith('.html')) { + if (name.endsWith('.html') && !sandboxPages.has(name)) { files.set(name, Buffer.from(injectShims(data.toString('utf8'), PAGE_SHIMS))); pages++; } @@ -277,7 +278,16 @@ async function main() { console.log('\nFertig. Installation in Firefox: siehe README.md, Abschnitt "Installation".'); } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +// Auch über Symlinks/Junctions (Windows) als Hauptskript erkennen. +function isMainModule() { + try { + return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { main().catch((err) => { console.error(`\nFehler: ${err.message}`); process.exitCode = 1; diff --git a/scripts/lib/manifest.mjs b/scripts/lib/manifest.mjs index ddfe218..2343227 100644 --- a/scripts/lib/manifest.mjs +++ b/scripts/lib/manifest.mjs @@ -86,7 +86,6 @@ const DROPPED_KEYS = [ 'differential_fingerprint', 'side_panel', 'externally_connectable', - 'sandbox', 'oauth2', 'export', 'import', @@ -280,7 +279,12 @@ export function transformManifest(chromeManifest, options = {}) { // --- Content-Security-Policy --------------------------------------------- if (isPlainObject(m.content_security_policy)) { - delete m.content_security_policy.sandbox; + // Firefox akzeptiert eine Sandbox-CSP nur mit "sandbox"-Direktive und ohne allow-same-origin. + const sb = m.content_security_policy.sandbox; + if (sb !== undefined && (typeof sb !== 'string' || !/(^|;)\s*sandbox(\s|;|$)/.test(sb) || /allow-same-origin/.test(sb))) { + delete m.content_security_policy.sandbox; + report.removedKeys.push('content_security_policy.sandbox'); + } if (!Object.keys(m.content_security_policy).length) delete m.content_security_policy; } else if (typeof m.content_security_policy === 'string') { m.content_security_policy = { extension_pages: m.content_security_policy }; @@ -297,6 +301,7 @@ export function transformManifest(chromeManifest, options = {}) { externallyConnectable, sidePanelPath, sidePanelCommands, + sandboxPages: Array.isArray(m.sandbox?.pages) ? m.sandbox.pages.filter((p) => typeof p === 'string') : [], }; return { manifest: m, config, report }; diff --git a/scripts/lib/zip.mjs b/scripts/lib/zip.mjs index 7ed4440..8f4dfef 100644 --- a/scripts/lib/zip.mjs +++ b/scripts/lib/zip.mjs @@ -60,7 +60,8 @@ export function readZip(buf) { const name = buf.toString(flags & 0x0800 ? 'utf8' : 'latin1', p + 46, p + 46 + nameLen); p += 46 + nameLen + extraLen + commentLen; - if (name.endsWith('/')) continue; + // Ordner-Einträge überspringen (auch "ordner\" aus Windows-ZIPs). + if (name.replace(/\\/g, '/').endsWith('/')) continue; if (flags & 0x1) throw new Error(`Verschlüsselte ZIP-Einträge werden nicht unterstützt: ${name}`); if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) throw new Error(`Beschädigtes ZIP (Local Header für ${name}).`); const localNameLen = buf.readUInt16LE(localOffset + 26); diff --git a/scripts/windows/install-native-host.ps1 b/scripts/windows/install-native-host.ps1 index e2bc094..7b3b86f 100644 --- a/scripts/windows/install-native-host.ps1 +++ b/scripts/windows/install-native-host.ps1 @@ -46,12 +46,13 @@ foreach ($name in $hostNames) { if ($chromeManifest) { break } } } - if (-not $chromeManifest -or -not (Test-Path $chromeManifest)) { + if (-not $chromeManifest -or -not (Test-Path -LiteralPath $chromeManifest)) { Write-Host " - $name : keine Chrome-Registrierung gefunden (Programm nicht installiert oder nicht für Chrome eingerichtet)." continue } - $json = Get-Content -Raw -Path $chromeManifest | ConvertFrom-Json + # Claude Code/Desktop schreiben UTF-8 ohne BOM; Windows PowerShell 5.1 läse sonst ANSI. + $json = Get-Content -Raw -Encoding UTF8 -LiteralPath $chromeManifest | ConvertFrom-Json $hostPath = [string]$json.path if (-not [System.IO.Path]::IsPathRooted($hostPath)) { $hostPath = Join-Path (Split-Path -Parent $chromeManifest) $hostPath diff --git a/src/ff/background.js b/src/ff/background.js index 0183649..78c8fd3 100644 --- a/src/ff/background.js +++ b/src/ff/background.js @@ -295,17 +295,20 @@ if (handledRedirects.has(url)) return; handledRedirects.set(url, now); - const path = (m[2] || '/').replace(/^\//, ''); - const rest = url.slice(m[0].length); - const pageUrl = api.runtime.getURL(path) + rest; - // Gibt es die Zielseite in der Extension, laden wir sie (wie Chrome es täte). - const exists = !!path && (config.pages || []).includes(path); - if (exists && tab && tab.id != null) { - ff.lastRedirect = { url, at: now, via: 'page' }; - await api.tabs.update(tab.id, { url: pageUrl }).catch((err) => ff.warn(err)); + // Nur Weiterleitungen zu einer Anmeldung, die die Extension selbst gestartet + // hat: Der state-Parameter muss zum gespeicherten oauthState passen. So kann + // keine fremde Seite einen eigenen Login-Code unterschieben. + let state = null; + try { + state = new URL(url).searchParams.get('state'); + } catch {} + const { oauthState } = await api.storage.local.get('oauthState'); + if (!state || state !== oauthState) { + ff.lastRedirect = { url, at: now, via: 'ignored', reason: 'state' }; + ff.warn('OAuth-Weiterleitung ignoriert: state passt nicht zur gestarteten Anmeldung.'); return; } - // Sonst so tun, als hätte claude.ai die Weiterleitung per Nachricht geschickt. + // So behandeln, als hätte claude.ai die Weiterleitung per Nachricht geschickt. const sender = { url: tab && tab.url, origin, tab, frameId: 0, documentLifecycle: 'active' }; const result = await dispatchExternal({ type: 'oauth_redirect', redirect_uri: url }, sender); ff.lastRedirect = { url, at: now, via: 'message', result }; @@ -326,7 +329,7 @@ .then((tab) => handleRedirect(d.redirectUrl, tab, origin)) .catch(() => handleRedirect(d.redirectUrl, { id: d.tabId }, origin)); }, - { urls: config.externallyConnectable, types: ['main_frame', 'sub_frame'] }, + { urls: config.externallyConnectable, types: ['main_frame'] }, ); } catch (err) { ff.warn('webRequest.onBeforeRedirect nicht verfügbar:', err); diff --git a/src/ff/cdp-agent.js b/src/ff/cdp-agent.js index 5daf20e..31c2fb0 100644 --- a/src/ff/cdp-agent.js +++ b/src/ff/cdp-agent.js @@ -223,9 +223,9 @@ const sel = window.getSelection(); try { if (clicks >= 3) { - sel.collapse(pos.offsetNode, pos.offset); - sel.modify('move', 'backward', 'paragraphboundary'); - sel.modify('extend', 'forward', 'paragraphboundary'); + let block = pos.offsetNode.nodeType === 1 ? pos.offsetNode : pos.offsetNode.parentElement; + while (block && block.parentElement && /^inline/.test(getComputedStyle(block).display) && !(block.isContentEditable && !block.parentElement.isContentEditable)) block = block.parentElement; + if (block) sel.selectAllChildren(block); } else if (clicks === 2) { sel.collapse(pos.offsetNode, pos.offset); sel.modify('move', 'backward', 'word'); @@ -249,19 +249,46 @@ // --- Pop-ups (Firefox blockiert Fenster aus synthetischen Klicks) --------- + function frameNamed(name) { + try { + const sel = `iframe[name="${CSS.escape(name)}"],frame[name="${CSS.escape(name)}"]`; + for (let w = window; ; w = w.parent) { + if (w.document.querySelector(sel)) return true; + if (w === w.parent) return false; + } + } catch { + return false; + } + } + function withPopupCapture(fn) { const openTabs = []; - const onClick = (e) => { + let pending = null; + let pendingSubmit = null; + const linkTab = (e, prevent, path = e.composedPath ? e.composedPath() : []) => { if (e.defaultPrevented || e.isTrusted) return; - const path = e.composedPath ? e.composedPath() : []; const link = path.find((n) => n && (n.localName === 'a' || n.localName === 'area') && n.href); if (!link || !/^(https?|ftp|file):/i.test(link.href)) return; - const target = (link.getAttribute('target') || '').toLowerCase(); - const newTab = (target && !['_self', '_top', '_parent'].includes(target)) || e.ctrlKey || e.metaKey || e.button === 1; + const rawTarget = link.getAttribute('target') || ''; + const target = rawTarget.toLowerCase(); + const newTab = target === '_blank' || (target && !['_self', '_top', '_parent'].includes(target) && !frameNamed(rawTarget)) || e.ctrlKey || e.metaKey || e.button === 1; if (!newTab) return; - e.preventDefault(); + if (prevent) e.preventDefault(); openTabs.push({ url: link.href, active: !(e.ctrlKey || e.metaKey || e.button === 1) }); }; + const onCapture = (e) => { + if (!e.isTrusted) pending = { e, path: e.composedPath() }; + }; + const onClick = (e) => { + if (pending && e === pending.e) pending = null; + linkTab(e, true); + }; + const onSubmit = (e) => { + pendingSubmit = e; + }; + window.addEventListener('click', onCapture, true); + window.addEventListener('auxclick', onCapture, true); + window.addEventListener('submit', onSubmit, true); window.addEventListener('click', onClick, false); window.addEventListener('auxclick', onClick, false); @@ -273,6 +300,12 @@ if (typeof original === 'function') { exportFunction( function (url, ...rest) { + if (url && /(^|[\s,])(noopener|noreferrer)\b/i.test(String(rest[1] ?? ''))) { + try { + openTabs.push({ url: new URL(String(url), document.baseURI).href, active: true }); + } catch {} + return null; + } const win = Reflect.apply(original, pageWin, [url, ...rest]); if (!win && url) { try { @@ -295,8 +328,28 @@ } finally { window.removeEventListener('click', onClick, false); window.removeEventListener('auxclick', onClick, false); + window.removeEventListener('click', onCapture, true); + window.removeEventListener('auxclick', onCapture, true); + window.removeEventListener('submit', onSubmit, true); if (restore) restore(); } + // Page called stopPropagation(): the bubble listener never ran, Firefox blocked the popup. + const before = openTabs.length; + if (pending) linkTab(pending.e, false, pending.path); + if (pendingSubmit && !pendingSubmit.defaultPrevented) { + try { + const form = pendingSubmit.target; + const sub = pendingSubmit.submitter; + const tgt = ((sub && sub.getAttribute('formtarget')) ?? form.getAttribute('target') ?? '').toLowerCase(); + const method = ((sub && sub.getAttribute('formmethod')) || form.getAttribute('method') || 'get').toLowerCase(); + if (tgt === '_blank' && method === 'get') { + const u = new URL(sub && sub.hasAttribute('formaction') ? sub.formAction : form.action); + u.search = new URLSearchParams(new FormData(form, sub || undefined)).toString(); + openTabs.push({ url: u.href, active: true }); + } + } catch {} + } + for (let i = before; i < openTabs.length; i++) openTabs[i].fallback = true; return openTabs.length ? { openTabs } : {}; } @@ -384,6 +437,8 @@ } case 'mousePressed': { updateHover(target, p); + state.lastPress = target; + if (target.closest && target.closest('button:disabled,input:disabled,select:disabled,textarea:disabled')) { state.down = null; return {}; } const pointerOk = fire(target, 'pointerdown', pointerInit(p, { buttons: p.buttons || 1 }), PointerEvent); const mouseOk = pointerOk ? fire(target, 'mousedown', mouseInit(p)) : false; state.down = { target, button: p.button, suppressMouse: !pointerOk }; @@ -393,6 +448,7 @@ } case 'mouseReleased': { updateHover(target, p); + if (target.closest && target.closest('button:disabled,input:disabled,select:disabled,textarea:disabled')) { state.down = null; return {}; } const down = state.down; state.down = null; fire(target, 'pointerup', pointerInit(p, { buttons: 0 }), PointerEvent); @@ -401,6 +457,7 @@ dragEnd(target, p); return {}; } + if (!down) return {}; const clickTarget = commonAncestor(down ? down.target : target, target); const button = p.button || 'left'; return withPopupCapture(() => { @@ -634,6 +691,15 @@ End: ['forward', word ? 'documentboundary' : 'lineboundary'], }; const m = map[key]; + if (m && m[1] === 'documentboundary') { + let host = sel.focusNode && (sel.focusNode.nodeType === 1 ? sel.focusNode : sel.focusNode.parentElement); + while (host && host.parentElement && host.parentElement.isContentEditable) host = host.parentElement; + if (!host) return; + const off = m[0] === 'forward' ? host.childNodes.length : 0; + if (shift) sel.extend(host, off); + else sel.collapse(host, off); + return; + } if (m) { try { sel.modify(alter, m[0], m[1]); @@ -679,7 +745,14 @@ copy: () => document.execCommand('copy'), cut: () => document.execCommand('cut'), paste: () => document.execCommand('paste'), - insertNewline: () => document.execCommand('insertParagraph'), + insertNewline: (el) => (el && el.localName === 'input' ? false : document.execCommand('insertParagraph')), + deleteToBeginningOfLine: (el) => { + if (isTextField(el)) { + const e = el.selectionEnd; + if (e === el.selectionStart) el.setSelectionRange(lineBounds(el.value, e)[0], e); + } else if (window.getSelection().isCollapsed) window.getSelection().modify('extend', 'backward', 'lineboundary'); + document.execCommand('delete'); + }, insertLineBreak: () => document.execCommand('insertLineBreak'), }; const CARET_COMMANDS = { @@ -699,18 +772,26 @@ moveWordRightAndModifySelection: ['ArrowRight', true, true], moveToBeginningOfLineAndModifySelection: ['Home', true, false], moveToEndOfLineAndModifySelection: ['End', true, false], + moveToLeftEndOfLine: ['Home', false, false], + moveToRightEndOfLine: ['End', false, false], + moveToLeftEndOfLineAndModifySelection: ['Home', true, false], + moveToRightEndOfLineAndModifySelection: ['End', true, false], + moveUpAndModifySelection: ['ArrowUp', true, false], + moveDownAndModifySelection: ['ArrowDown', true, false], + moveToBeginningOfDocumentAndModifySelection: ['Home', true, true], + moveToEndOfDocumentAndModifySelection: ['End', true, true], }; function runEditCommand(name, el) { const cmd = String(name).replace(/:$/, ''); if (EDIT_COMMANDS[cmd]) { try { - EDIT_COMMANDS[cmd](el); + return EDIT_COMMANDS[cmd](el) !== false; } catch {} return true; } const caret = CARET_COMMANDS[cmd]; - if (caret) { + if (caret && (isTextField(el) || isEditable(el))) { if (isTextField(el)) moveCaretInField(el, ...caret); else moveCaretInEditable(...caret); return true; @@ -733,7 +814,8 @@ End: [0, se.scrollHeight], }; const d = deltas[key]; - if (d) scrollFrom(target === document.body ? null : target, d[0], d[1]); + const start = target === document.body || target === document.documentElement ? (state.lastPress && state.lastPress.isConnected ? state.lastPress : null) : target; + if (d) scrollFrom(start, d[0], d[1]); } function changeSelect(select, key) { @@ -758,10 +840,10 @@ switch (key) { case 'Enter': - if (target.localName === 'input') return implicitSubmit(target); + if (target.localName === 'input') return ['submit', 'image', 'reset', 'button'].includes(target.type) ? clickElement(target) : implicitSubmit(target); if (target.localName === 'textarea') insertText('\n'); else if (isEditable(target)) document.execCommand(shift ? 'insertLineBreak' : 'insertParagraph'); - else if (target.localName === 'a' || target.localName === 'button' || target.getAttribute('role') === 'button' || target.getAttribute('role') === 'link') { + else if ((target.localName === 'a' && target.hasAttribute('href')) || target.localName === 'button' || target.localName === 'summary') { return clickElement(target); } return {}; @@ -774,13 +856,28 @@ case 'Tab': moveFocus(shift ? -1 : 1); return {}; + case 'Escape': { + const dlg = [...document.querySelectorAll('dialog:modal')].pop(); + if (dlg) { if (dlg.dispatchEvent(new Event('cancel', { cancelable: true }))) dlg.close(); } + else { const pop = [...document.querySelectorAll('[popover]:popover-open')].filter((e) => e.getAttribute('popover') !== 'manual').pop(); if (pop) pop.hidePopover(); } + return {}; + } case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': case 'Home': case 'End': - if (field) moveCaretInField(target, key, shift, wordMod); + if (target.localName === 'input' && target.type === 'number' && (key === 'ArrowUp' || key === 'ArrowDown')) { + try { key === 'ArrowUp' ? target.stepUp() : target.stepDown(); } catch {} + target.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + } else if (target.localName === 'input' && target.type === 'radio') { + const group = [...(target.form ? target.form.elements : document.querySelectorAll('input[type=radio]'))].filter((r) => r.type === 'radio' && r.name === target.name && !r.matches(':disabled')); + const step = key === 'ArrowDown' || key === 'ArrowRight' ? 1 : -1; + const next = group[(group.indexOf(target) + step + group.length) % group.length]; + if (next && next !== target) { next.focus(); next.checked = true; next.dispatchEvent(new Event('input', { bubbles: true, composed: true })); next.dispatchEvent(new Event('change', { bubbles: true })); } + } else if (field) moveCaretInField(target, key, shift, wordMod); else if (isEditable(target)) moveCaretInEditable(key, shift, wordMod); else if (target.localName === 'select' && !key.startsWith('Home') && !key.startsWith('End')) changeSelect(target, key); else keyScroll(target, key, shift); @@ -811,16 +908,25 @@ const name = el.localName; if (name === 'button' || name === 'summary') return true; if (name === 'input' && ['button', 'submit', 'reset', 'checkbox', 'radio', 'image'].includes(el.type)) return true; - const role = el.getAttribute('role'); - return role === 'button' || role === 'checkbox' || role === 'switch' || role === 'menuitem' || role === 'tab'; + return false; } function focusableElements() { const selector = 'a[href], area[href], button, input, select, textarea, iframe, summary, [tabindex], [contenteditable=""], [contenteditable="true"]'; - const all = [...document.querySelectorAll(selector)].filter((el) => { - if (el.disabled || el.getAttribute('tabindex') === '-1') return false; + const found = []; + const collect = (root) => { + for (const el of root.querySelectorAll('*')) { + if (el.matches(selector)) found.push(el); + const sr = shadowRootOf(el); + if (sr) collect(sr); + } + }; + collect(document); + const all = found.filter((el) => { + if (el.matches(':disabled') || el.getAttribute('tabindex') === '-1' || el.closest('[inert]')) return false; if (el.localName === 'input' && el.type === 'hidden') return false; + if (el.checkVisibility && !el.checkVisibility({ visibilityProperty: true })) return false; const r = el.getBoundingClientRect(); return r.width > 0 || r.height > 0; }); @@ -831,15 +937,18 @@ function moveFocus(dir) { const list = focusableElements(); if (!list.length) return; - const current = document.activeElement; - let i = list.indexOf(current); - i = i < 0 ? (dir > 0 ? 0 : list.length - 1) : (i + dir + list.length) % list.length; - const next = list[i]; - next.focus(); - if (isTextField(next)) { - try { - next.select(); - } catch {} + let i = list.indexOf(deepActiveElement()); + for (let n = 0; n < list.length; n++) { + i = i < 0 ? (dir > 0 ? 0 : list.length - 1) : (i + dir + list.length) % list.length; + const next = list[i]; + next.focus(); + if (deepActiveElement() !== next) continue; + if (isTextField(next)) { + try { + next.select(); + } catch {} + } + return; } } @@ -862,10 +971,15 @@ const code = text.codePointAt(0); // Kein Textfeld fokussiert (z. B. Leertaste auf der Seite): Standardaktion statt Text. if (fireKey(target, 'keypress', p, 0, code) && !insertText(text)) result = defaultKeyAction(target, p) || {}; + } else if (p.type === 'keyDown' && (text === '\r' || text === '\n') && !commandModifier) { + if (!fireKey(target, 'keypress', p, 0, 13)) return {}; + if (!(p.commands && p.commands.length)) result = defaultKeyAction(target, p) || {}; } else if (!(p.commands && p.commands.length)) { result = defaultKeyAction(target, p) || {}; } - for (const c of p.commands || []) runEditCommand(c, target); + let handled = false; + for (const c of p.commands || []) handled = runEditCommand(c, target) || handled; + if (p.commands && p.commands.length && !handled) result = defaultKeyAction(target, p) || {}; return result; } if (p.type === 'keyUp') { @@ -933,18 +1047,45 @@ return raw.stack ? `${raw.name}: ${raw.message}\n${raw.stack}` : `${raw.name}: ${raw.message}`; } if (raw && typeof raw.nodeType === 'number') return describeNode(raw); + const MAX = 5000; const seen = new WeakSet(); - const json = JSON.stringify(raw, (k, v) => { - if (typeof v === 'object' && v !== null) { - if (seen.has(v)) return '[Circular]'; + let out = ''; + const walk = (v, depth) => { + if (out.length > MAX) return; + const t = typeof v; + if (v === null) out += 'null'; + else if (t === 'string') out += JSON.stringify(v.length > 500 ? `${v.slice(0, 500)}…` : v); + else if (t === 'number' || t === 'boolean' || t === 'undefined' || t === 'symbol') out += String(v); + else if (t === 'bigint') out += `${v}n`; + else if (t === 'function') out += '"[Function]"'; + else if (typeof v.nodeType === 'number') out += JSON.stringify(describeNode(v)); + else if (Object.prototype.toString.call(v) === '[object Window]') out += '"[Window]"'; + else if (seen.has(v)) out += '"[Circular]"'; + else { + const isArr = Array.isArray(v); + if (depth >= 3) { + out += isArr ? `"[Array(${v.length})]"` : '"{…}"'; + return; + } seen.add(v); + const keys = isArr ? Array.from({ length: Math.min(v.length, 50) }, (_, i) => String(i)) : Object.keys(v).slice(0, 100); + out += isArr ? '[' : '{'; + keys.forEach((k, i) => { + if (out.length > MAX) return; + if (i) out += ','; + if (!isArr) out += `${JSON.stringify(k)}:`; + const d = Object.getOwnPropertyDescriptor(v, k); + if (d && (d.get || d.set)) out += '"[Getter]"'; + else walk(d ? d.value : undefined, depth + 1); + }); + if (isArr && v.length > 50) out += ',"…"'; + out += isArr ? ']' : '}'; + // Nur Vorfahren zählen als Zyklus; mehrfach genutzte Objekte normal ausgeben. + seen.delete(v); } - if (typeof v === 'function') return '[Function]'; - if (typeof v === 'bigint') return `${v}n`; - return v; - }); - if (json === undefined) return String(raw); - return json.length > 5000 ? `${json.slice(0, 5000)}…` : json; + }; + walk(raw, 0); + return out.length > MAX ? `${out.slice(0, MAX)}…` : out; } catch { try { return String(value); @@ -1062,6 +1203,17 @@ exportFunction(hook, pageConsole, { defineAs: level }); } catch {} } + // Meldungen kurz vor einer Navigation nicht verlieren. + window.addEventListener( + 'pagehide', + () => { + if (con.timer) { + clearTimeout(con.timer); + flushConsole(); + } + }, + true, + ); window.addEventListener('error', onPageError, true); window.addEventListener('unhandledrejection', onRejection, true); } diff --git a/src/ff/engine.js b/src/ff/engine.js index 253c3b1..b07a196 100644 --- a/src/ff/engine.js +++ b/src/ff/engine.js @@ -46,6 +46,19 @@ /** @type {Map} */ const attached = new Map(); + // Firefox beendet die Event-Page nach 30 s ohne API-Aufrufe – samt diesem + // Zustand. Chrome hält Debugger-Sitzungen unabhängig davon. Solange Claude an + // einen Tab angehängt ist, halten wir den Hintergrund deshalb wach. + let keepAliveTimer = null; + function updateKeepAlive() { + if (attached.size && !keepAliveTimer) { + keepAliveTimer = setInterval(() => api.runtime.getPlatformInfo().catch(() => {}), 20000); + } else if (!attached.size && keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } + } + function emit(tabId, method, params) { const target = { tabId }; ff.debuggerEvents?.onEvent.dispatch(target, method, params); @@ -117,6 +130,19 @@ } async function agentCall(tabId, op, params) { + const nativeTabs = []; + const onCreated = (t) => { + if (t.openerTabId === tabId) nativeTabs.push(t); + }; + api.tabs.onCreated.addListener(onCreated); + try { + return await agentCallInner(tabId, op, params, nativeTabs); + } finally { + api.tabs.onCreated.removeListener(onCreated); + } + } + + async function agentCallInner(tabId, op, params, nativeTabs) { const chain = []; let frameId = 0; let current = params; @@ -142,6 +168,10 @@ // Firefox blockiert Pop-ups aus synthetischen Klicks; wir öffnen sie selbst. const tab = await api.tabs.get(tabId).catch(() => null); for (const t of result.openTabs) { + if (t.fallback) { + await new Promise((r) => setTimeout(r, 150)); + if (nativeTabs.length) continue; + } await api.tabs .create({ url: t.url, @@ -179,7 +209,9 @@ const { x = 0, y = 0, width, height, scale = 1 } = params.clip; options.rect = { x, y, width, height }; // CDP: Ausgabegröße = Breite * scale * devicePixelRatio. Firefox: Breite * scale. - options.scale = scale * (await devicePixelRatio(tabId)); + // Firefox rechnet den Seiten-Zoom selbst noch einmal ein (devicePixelRatio enthält ihn schon). + const zoom = await api.tabs.getZoom(tabId).catch(() => 1); + options.scale = (scale * (await devicePixelRatio(tabId))) / (zoom || 1); } const dataUrl = await api.tabs.captureTab(tabId, options); return { data: dataUrl.slice(dataUrl.indexOf(',') + 1) }; @@ -222,12 +254,14 @@ } let value; try { - const seen = new WeakSet(); + // Nur echte Zyklen (Vorfahren) markieren, mehrfach genutzte Objekte bleiben erhalten. + const ancestors = []; value = JSON.parse( - JSON.stringify(v, (k, val) => { + JSON.stringify(v, function (k, val) { if (typeof val === 'object' && val !== null) { - if (seen.has(val)) return '[Circular]'; - seen.add(val); + while (ancestors.length && ancestors[ancestors.length - 1] !== this) ancestors.pop(); + if (ancestors.includes(val)) return '[Circular]'; + ancestors.push(val); if (typeof Node !== 'undefined' && val instanceof Node) return `[${describeNode(val)}]`; } if (typeof val === 'bigint') return `${val}n`; @@ -249,7 +283,14 @@ try { const result = (0, eval)(expression); if (awaitPromise && result && typeof result.then === 'function') { - return Promise.resolve(result).then(pack, packError); + // async-Funktion statt window.Promise: zone.js & Co. ersetzen window.Promise. + return (async () => { + try { + return pack(await result); + } catch (e) { + return packError(e); + } + })(); } return pack(result); } catch (e) { @@ -303,6 +344,13 @@ // --- Console-Mitschnitt --------------------------------------------------- + // Registrierungen nacheinander und ohne die Befehlswarteschlange des Tabs zu blockieren. + let registrationQueue = Promise.resolve(); + function queueConsoleRegistration() { + registrationQueue = registrationQueue.then(updateConsoleRegistration, updateConsoleRegistration); + return registrationQueue; + } + async function updateConsoleRegistration() { const wanted = [...attached.values()].some((st) => st.runtime); let registered = []; @@ -329,14 +377,16 @@ } async function setConsoleTracking(tabId, on) { - await updateConsoleRegistration(); + queueConsoleRegistration().catch(() => {}); // Bereits geladene Dokumente sofort versorgen. let frames = [{ frameId: 0 }]; try { frames = (await api.webNavigation.getAllFrames({ tabId })) || frames; } catch {} await Promise.all( - frames.map((f) => runInFrame(tabId, f.frameId, 'console', { enabled: on }).catch(() => {})), + frames.map((f) => + withTimeout(runInFrame(tabId, f.frameId, 'console', { enabled: on }), 1000, 'frame busy').catch(() => {}), + ), ); } @@ -368,7 +418,12 @@ emit(d.tabId, 'Network.requestWillBeSent', { requestId: String(d.requestId), loaderId: '', - documentURL: d.documentUrl || d.originUrl || d.url, + documentURL: + d.type === 'main_frame' + ? d.url + : d.frameAncestors && d.frameAncestors.length + ? d.frameAncestors[d.frameAncestors.length - 1].url + : d.documentUrl || d.url, request: { url: d.url, method: d.method, headers: {} }, timestamp: d.timeStamp / 1000, wallTime: d.timeStamp / 1000, @@ -424,9 +479,10 @@ api.tabs.onRemoved.addListener((tabId) => { if (!attached.has(tabId)) return; attached.delete(tabId); + updateKeepAlive(); emitDetach(tabId, 'target_closed'); updateNetworkListeners(); - updateConsoleRegistration(); + queueConsoleRegistration().catch(() => {}); }); api.webNavigation.onCommitted.addListener((d) => { const st = attached.get(d.tabId); @@ -450,6 +506,10 @@ if (!msg || typeof msg.__ffCdp !== 'string' || !sender.tab) return undefined; const tabId = sender.tab.id; const st = attached.get(tabId); + // Chromes Tab-Sitzung sieht keine Meldungen aus fremden (Cross-Site-)iframes. + if (sender.frameId !== 0 && safeOrigin(sender.url) !== safeOrigin(sender.tab.url)) { + return msg.__ffCdp === 'consoleQuery' ? Promise.resolve({ enabled: false }) : undefined; + } if (msg.__ffCdp === 'consoleQuery') return Promise.resolve({ enabled: !!(st && st.runtime) }); if (msg.__ffCdp === 'console' && st && st.runtime && Array.isArray(msg.entries)) { for (const e of msg.entries) { @@ -491,6 +551,7 @@ checkAttachable(tab.url); ensureTabListeners(); if (!attached.has(tabId)) attached.set(tabId, { page: false, runtime: false, network: false, queue: Promise.resolve() }); + updateKeepAlive(); } async function detach(target) { @@ -498,6 +559,7 @@ const st = attached.get(tabId); if (!st) throw new Error(`Debugger is not attached to the tab with id: ${tabId}.`); attached.delete(tabId); + updateKeepAlive(); updateNetworkListeners(); if (st.runtime) setConsoleTracking(tabId, false).catch(() => {}); } @@ -596,6 +658,9 @@ return result; } + // Eine von einer früheren Instanz übrig gebliebene Console-Registrierung entfernen. + queueConsoleRegistration().catch(() => {}); + ff.engine = { attach, detach, sendCommand, getTargets, attached }; ff.server.handle('debugger.attach', (_ctx, target) => attach(target)); ff.server.handle('debugger.detach', (_ctx, target) => detach(target)); diff --git a/test/e2e/pages.mjs b/test/e2e/pages.mjs index 2ec5af1..9c1723d 100644 --- a/test/e2e/pages.mjs +++ b/test/e2e/pages.mjs @@ -55,6 +55,28 @@ export function testPages(port) { headers: { Location: 'chrome-extension://fcoeoabgfenejglbffodgkkbkcdhcgfn/oauth_callback.html?code=http-code&state=s2' }, body: '', }, + '/oauth-bad': { + headers: { 'Content-Type': 'text/html' }, + body: `OAuth fremd`, + }, + '/keys': { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + body: `Tasten +
+ +

Erster Satz hier. Zweiter Satz da.

Anderer Absatz.

+`, + }, '/api/data': { headers: { 'Content-Type': 'application/json' }, body: '{"ok":true}' }, '/external': { headers: { 'Content-Type': 'text/html' }, diff --git a/test/e2e/runner/runner.js b/test/e2e/runner/runner.js index 39f5f5b..d0d55cb 100644 --- a/test/e2e/runner/runner.js +++ b/test/e2e/runner/runner.js @@ -275,6 +275,33 @@ async function run() { check('DOM-Knoten', r.result && r.result.subtype === 'node' && r.result.description === 'body', r); }); + await step('Regressionen Tastatur/Klick', async () => { + const t = await chrome.tabs.create({ url: `${BASE}/keys`, active: true }); + await waitTab(t.id); + await cdp.attachDebugger(t.id); + let p = await center(t.id, '#kpi'); + await cdp.click(t.id, p.x, p.y); + await cdp.pressKeyChord(t.id, 'Enter'); + let v = await inPage(t.id, () => ({ keypress: window.k.keypress.slice(), submits: window.k.submits })); + check('Enter löst keypress (13) aus und sendet ab', v.keypress.includes(13) && v.submits === 1, v); + await inPage(t.id, () => (window.k.blockEnter = true)); + await cdp.pressKeyChord(t.id, 'Enter'); + v = await inPage(t.id, () => window.k.submits); + check('Abgebrochenes keypress verhindert das Absenden', v === 1, v); + p = await center(t.id, '#dis'); + await cdp.click(t.id, p.x, p.y); + v = await inPage(t.id, () => window.k.disClicks); + check('Klick auf deaktivierten Button löst nichts aus', v === 0, v); + p = await inPage(t.id, () => { + const r = document.getElementById('para').getBoundingClientRect(); + return { x: r.left + 20, y: r.top + r.height / 2 }; + }); + await cdp.click(t.id, p.x, p.y, 'left', 3); + v = await inPage(t.id, () => window.getSelection().toString().trim()); + check('Dreifachklick markiert den Absatz', v === 'Erster Satz hier. Zweiter Satz da.', v); + await chrome.tabs.update(tabId, { active: true }); + }); + await step('CSP-Seite', async () => { const t2 = await chrome.tabs.create({ url: `${BASE}/csp`, active: true }); await waitTab(t2.id); @@ -385,11 +412,13 @@ async function run() { check('Nachricht von Webseite an Extension', /Untrusted origin|exists/.test(title), title); }); - for (const [path, code] of [ - ['/oauth-js', 'js-code'], - ['/oauth-302', 'http-code'], + for (const [path, code, state] of [ + ['/oauth-js', 'js-code', 's1'], + ['/oauth-302', 'http-code', 's2'], ]) { await step(`OAuth-Weiterleitung ${path}`, async () => { + // So, als hätte die Extension die Anmeldung mit diesem state gestartet. + await chrome.storage.local.set({ oauthState: state }); await chrome.tabs.create({ url: `${BASE}${path}`, active: false }); let last = null; for (let i = 0; i < 40 && !(last && last.url.includes(code)); i++) { @@ -402,6 +431,17 @@ async function run() { }); } + await step('OAuth-Weiterleitung mit fremdem state', async () => { + await chrome.storage.local.set({ oauthState: 'eigener-state' }); + await chrome.tabs.create({ url: `${BASE}/oauth-bad`, active: false }); + let last = null; + for (let i = 0; i < 30 && !(last && last.url.includes('evil-code')); i++) { + await sleep(100); + last = await globalThis.__ff.bg.call('shim.lastRedirect'); + } + check('Fremde OAuth-Weiterleitung wird ignoriert', last && last.url.includes('evil-code') && last.via === 'ignored', last); + }); + await step('Seitenpanel lädt', async () => { const frame = document.createElement('iframe'); frame.src = `/sidepanel.html?tabId=${tabId}`; diff --git a/test/unit/build.test.mjs b/test/unit/build.test.mjs index fa9dfa3..21f8f5e 100644 --- a/test/unit/build.test.mjs +++ b/test/unit/build.test.mjs @@ -26,6 +26,12 @@ test('ZIP: CRC32 stimmt mit zlib überein', () => { assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926); }); +test('ZIP: Ordner-Einträge mit Backslash (Windows) werden übersprungen', () => { + const zip = writeZip(new Map([['assets\\', Buffer.alloc(0)], ['assets\\x.js', Buffer.from('x')]])); + const back = readZip(zip); + assert.deepEqual([...back.keys()], ['assets/x.js']); +}); + test('ZIP: unsichere Pfade werden abgelehnt', () => { const zip = writeZip(new Map([['../boese.txt', Buffer.from('x')]])); assert.throws(() => readZip(zip), /Unsicherer Pfad/); @@ -161,7 +167,8 @@ const CHROME_MANIFEST = { { resources: ['assets/*'], matches: ['https://claude.ai/*'], use_dynamic_url: true }, { resources: ['x'], extension_ids: ['abc'] }, ], - content_security_policy: { extension_pages: "script-src 'self'", sandbox: "sandbox allow-scripts" }, + content_security_policy: { extension_pages: "script-src 'self'", sandbox: 'sandbox allow-scripts' }, + sandbox: { pages: ['sandbox.html'] }, incognito: 'split', }; @@ -191,7 +198,11 @@ test('Manifest: Grundumbau Chrome -> Firefox', () => { assert.equal(m.web_accessible_resources.length, 1); assert.equal(m.web_accessible_resources[0].use_dynamic_url, undefined); assert.equal(m.commands['toggle-side-panel'].global, undefined); - assert.deepEqual(m.content_security_policy, { extension_pages: "script-src 'self'" }); + assert.deepEqual(m.content_security_policy, { extension_pages: "script-src 'self'", sandbox: 'sandbox allow-scripts' }); + assert.deepEqual(m.sandbox, { pages: ['sandbox.html'] }); + assert.deepEqual(config.sandboxPages, ['sandbox.html']); + const bad = transformManifest({ ...CHROME_MANIFEST, content_security_policy: { sandbox: 'sandbox allow-scripts allow-same-origin' } }); + assert.equal(bad.manifest.content_security_policy, undefined); assert.equal(m.incognito, 'spanning'); assert.deepEqual(config.chromeExtensionIds, ['x'.repeat(32)]); assert.deepEqual(config.externallyConnectable, ['https://claude.ai/*']);