merge: integrate native preview capabilities and lifetime guards

# Conflicts:
#	dev-docs/BROWSER_AUTOMATION.md
#	docs/help/de/browser-sidecars.md
#	docs/help/en/browser-sidecars.md
#	docs/help/es/browser-sidecars.md
#	docs/help/fr/browser-sidecars.md
#	docs/help/he/browser-sidecars.md
#	docs/help/ja/browser-sidecars.md
#	docs/help/ne/browser-sidecars.md
#	docs/help/ru/browser-sidecars.md
#	docs/help/tr/browser-sidecars.md
#	docs/help/zh-Hans/browser-sidecars.md
This commit is contained in:
Pascal André 2026-11-08 02:51:10 +01:00
commit e0d56d428a
No known key found for this signature in database
93 changed files with 4495 additions and 153 deletions

1
.gitattributes vendored
View file

@ -1,2 +1,3 @@
# Vendored-crate patches apply to the published LF crates; keep them byte-exact on every checkout.
packages/tauri-app/vendor/patches/*.patch -text
packages/tauri-app/tests/webkit-capabilities/*.sh text eol=lf

View file

@ -95,11 +95,11 @@ Native/HTTP deadlines bound browser tool execution; the tool context's `signal`
## Native Hosts
Electron uses hardened `<webview>` tabs sharing one persistent browser partition (`persist:codenomad-browser-shared-v1`) per data profile/config pair. Attachment verifies guest ownership, allows only credential-free HTTP(S) URLs and denies permissions and downloads; page operations use the guarded channel above.
Electron uses hardened `<webview>` tabs sharing one persistent browser partition (`persist:codenomad-browser-shared-v1`) per data profile/config pair. Attachment verifies guest ownership and accepts credential-free HTTP(S) entry URLs. Owned pop-ups retain the preview partition and native opener; site documents additionally support `about:blank` and same-origin blob URLs. Camera, microphone, geolocation and notifications are denied. Other classified permissions use origin-bound consent or an explicit device/source chooser; unknown requests are denied with an error. Page operations use the guarded channel above.
Tauri uses a child webview owned by the local application window: WebView2 on Windows, WKWebView on macOS 14+, and WebKitGTK on Linux. Pages load directly in the native view. `embedded_view.rs` owns native creation, geometry, visibility, closure, shared storage and mandatory credential-free HTTP(S) navigation, permission, download and new-window protections; its Windows adapter also installs native accelerators. `browser_controller.rs` owns session registrations, renderer generations, history, events, emulation and automation leases. Application capabilities select primary `local-*` webviews, never their parent-window wildcard: Tauri combines window/webview matching with OR, so a window grant would also authorize an untrusted child, including localhost content. Full primary-renderer navigation disposes its preview children. Final-window decisions count native windows, including multi-webview windows.
Native views follow the preview bounds and hide beneath local menus, lists and dialogs. Reshow applies the latest bounds before revealing the retained document. WebKit Back/Forward uses each view's native back-forward list, with registration-owned entry identities and a 32-entry journal; restored sessions load the selected saved URL on explicit opening. WebKit presets apply fitted native CSS viewports and per-view mobile identity, with density/input differences specified in `BROWSER_DEVICE_EMULATION.md`. Element comments are unavailable in native previews.
Native views follow the preview bounds and hide beneath local menus, lists and dialogs. Reshow applies the latest bounds before revealing the retained document. Windows/GTK HTML fullscreen fills its owning window, preserves normal guest bounds and the previous window mode, and restores both on exit/hide/close; WKWebView uses native fullscreen. WebKit Back/Forward uses each view's native back-forward list, with registration-owned entry identities and a 32-entry journal; restored sessions load the selected saved URL on explicit opening. WebKit presets apply fitted native CSS viewports and per-view mobile identity, with density/input differences specified in `BROWSER_DEVICE_EMULATION.md`. Element comments are unavailable in native previews.
Local Tauri primary renderers receive `__CODENOMAD_BROWSER_CAPABILITIES__` during initialization, with independent `embedding`, `history`, `emulation` and `automation` flags. WebKit publishes embedding/history/emulation true, automation false and `reason: "webkit"`; unsupported targets publish `reason: "unsupported-platform"`. UI host selection consumes this contract; registration/history readiness stays independent of device emulation. UI registrations request hidden construction and recheck `localOverlayOpen()`, clipped bounds and display scale before the first reveal, including asynchronous construction beneath a dialog. WebKit registration/update/profile commands await native scale and configuration off the UI thread.
@ -111,14 +111,30 @@ WebKit publishes `CODENOMAD_NATIVE_BROWSER_AUTOMATION=0` independently of the tr
Private navigation can recover an owned visible guest from an engine-generated error document by loading a validated HTTP(S) destination. Page reads and screenshots require a current credential-free HTTP(S) document. WebKit failure results survive the subsequent load-finished notification; fragment-only navigation completes from the confirmed native URL when document-load callbacks are absent. Capture responses retain the 16 MiB base64 limit, and a hide, close, replacement, navigation or expired backend generation fences the native callback result.
On macOS, a page reading the clipboard after a user gesture can display the system's native **Paste** button. That button performs user-initiated paste. Site permission requests, downloads and new windows are denied by the native preview policy.
Native browser previews support OAuth pop-ups and downloads. Pop-ups are small native windows owned by the local main window; native related-view/configuration/environment APIs preserve the preview store, opener, postMessage, blank-window document writing and script closure. Typed/restored URLs accept credential-free HTTP(S); site navigation also accepts blank documents and blob documents from the observed main-document origin. File and application schemes remain blocked. Closing the opener or reloading/closing the primary renderer disposes its pop-up tree. Application capabilities, application preloads, page Node access, app IPC initialization and app resource protocols stay outside guests and their pop-ups. Remote embedded-view consumers retain their separate policy.
Downloads go directly to the system Downloads directory, with sanitized UUID-prefixed names and no automatic file opening. Windows and Electron atomically reserve names; WebKit refuses existing destinations. WebKitGTK disables overwriting and routes context-wide download signals to the originating view. Missing Downloads directories fail closed. HTTP(S), blob and data downloads use this policy, subject to the engine's multiple-download consent. WebKitGTK turns rejected data document requests into downloads. Tauri supplies native alert/confirm/prompt dialogs; macOS uses asynchronous AppKit sheets. Electron supplies native alert/confirm and a preview-only prompt preload registered on the separate browser session. This preload exposes only standard `window.prompt` on top-level pages, pop-ups and subframes, using the fixed synchronous `codenomad:browser-prompt` channel. Main validates guest ownership, frame membership and bounded string payloads, and returns string/null from a trusted preload-free app form. Electron's subframe preload gate is enabled with explicit sandbox, context isolation and page Node access disabled. Only the prompt preload is registered on the preview session. Device/source selection uses that same scrollable form.
Camera, microphone, geolocation and notifications remain denied on every host. WebView2's two permission layers deny those four kinds and return classified requests to native consent; unknown raw kinds are denied and reported. Electron shares a typed policy between permission checks and requests, keys grants on the requesting frame's origin, permits Chromium's sanitized clipboard writes and uses native consent for reads/local-network and other classified requests. HID/USB/serial and display capture require explicit selection; serial requests require an unambiguous frame. WebKitGTK storage-access requests use native domain-scoped consent. Its page-scoped pointer-lock request identifies the displayed native document and fences answers by load generation; requests lacking sufficient identity are blocked with an error. Engine APIs and hardware determine availability. On macOS, the native **Paste** button performs user-initiated clipboard paste. Preview TLS validation, web security and the WebView2 F6 crash guard remain active.
WKWebView preview delegates implement WebKit's native print and beforeunload callbacks from `WKUIDelegatePrivate.h`. Printing uses the public `printOperationWithPrintInfo:` API and the system print panel; beforeunload uses the initiating-frame-labelled confirmation sheet. Requalify these selectors on supported macOS/WebKit updates.
Native popup-window close requests run WebKitGTK `try_close`, WKWebView `_tryClose`, or WebView2's script-window close action before native disposal. A cancelled beforeunload retains the popup. Owning-window/opener cleanup and primary-renderer replacement dispose their preview tree directly; page work must be saved before these operations.
Cleanup invalidates all selected descendants, including pending construction, before native destruction. Electron binds roots and nested pop-ups to the physical owner and opener and rotates document identities on navigation or renderer failure. Pending prompt/device/display responses and context-menu callbacks recheck their captured identity. Tauri removes a selected subtree under one registry lock, fences native callbacks by creation token, and checks the opener's document generation before publishing a new pop-up. Main-document load start disposes descendants. Owner shutdown and cleanup use forced native disposal; individual user popup closure retains the engine's beforeunload decision.
GTK pointer-lock consent uses a native `GtkInfoBar` on the owning surface. Its buttons retain the WebView's focus on click and offer native mnemonics; load start cancels the bar and the document-generation fence validates the answer. A separate modal surface can cancel WebKit's pending Wayland lock request. Qualification checks the compositor's `zwp_locked_pointer_v1.locked` event as well as DOM state and Escape release.
Affected WebKitGTK builds, including 2.52.6, block `DataTransfer.files` as a security measure for CVE-2025-13947. The system engine's `DataTransfer::allowsFileAccess()` returns false for non-Cocoa ports; the native fixture and a plain WebKit control reproduce an empty FileList with genuine GTK file drags. Keep this protection and use the page's file input. [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277) tracks the secure separation of native file grants from web-writable URI text; never convert arbitrary dropped URI text into host file reads.
WebKitGTK clipboard writes and native Ctrl+C/V use the engine's user-initiated paths. Async Clipboard reads reach `WebKitClipboardPermissionRequest`, whose public API supplies allow/deny without a requesting-frame identity. The preview policy refuses this request and reports the native permission kind; users can paste into the page with Ctrl+V. Keep permission attribution frame-bound rather than substituting the displayed top-level origin.
### Tauri embedded-view contract
- Consumers choose a unique native label for each creation, including replacements, and a persistent storage descriptor separate from the application renderer. Windows/Linux preview storage is `browser/shared-v1` inside the scoped profile. macOS uses a WebKit-managed identified store. The common registry binds each child to a validated local-window owner before native construction. Every bounds, visibility and close operation rechecks ownership; native calls run outside registry locks.
- WebKit adapters use the same registry and construction guards. Linux installs a consuming `permission-request` denial before target navigation. macOS installs per-instance media denial through Tauri's native `on_permission_request` builder callback during construction. Its persistent data-store identifier is the first 16 SHA-256 bytes of length-prefixed UTF-8 domain, profile key and normalized configuration identity, with domain `browser/shared-v1`. Tauri requires macOS 14 for identified stores. Mandatory guard installation failures close the child and return an error.
- The GTK runtime installs one lazy overlay per owning window: primary content remains in a box, native children occupy a fixed overlay below the menu bar, and uncovered pointer input reaches the primary renderer. Wry persists child geometry in GTK layout properties across reallocations and resolves bounds/reparenting against the actual parent. The native regression under `vendor/tauri-runtime-wry-2.12.3/examples/` covers both display backends and independent owning windows.
- Creation uses an off-canvas neutral document, hides the child, installs mandatory protections and applies physical bounds while hidden. Creation failures remove only their exact reservation and close the created child. Consumers install business page-load hooks and may further restrict navigation; the common credential-free HTTP(S), new-window and download guards remain mandatory. `about:blank` is accepted only while preparing the neutral child.
- Creation uses an off-canvas neutral document, hides the child, installs mandatory protections and applies physical bounds while hidden. Creation failures remove only their exact reservation and close the created child. Consumers may further restrict navigation; preview documents keep the mandatory URL, ownership, permission and download policy. Main-document load events establish blob ownership. Browser roots/pop-ups receive no application initialization scripts, IPC handler or application resource protocols. Native drag/drop reaches HTML and file inputs; preview zoom hotkeys, navigation gestures and the Windows status bar use browser settings.
- The host navigation interceptor consults the shared `EmbeddedViews` application state first. Reserved `browser-` and `embedded-remote-` labels with missing records are denied. These labels stay outside primary `local-*` and `remote-*` capability patterns. A child decision returns immediately, before window-origin rules and external-link opening.
- The controller publishes its registration with renderer/generation fencing, navigates, then applies current visibility. Hiding revokes automation leases before native I/O, including when the native hide fails. Owner cleanup also invalidates children still being constructed; an old creation cannot publish after primary-renderer reload.
- WebKit history reads the native back/forward list on the UI thread. Each retained native item gets a monotonic numeric handle, with at most 32 exposed entries; redirects and `replaceState` update that item's URL, while distinct same-URL entries keep distinct handles. Reads and traversal revalidate the registration's owner/generation, reject expired or removed handles and release retained items on replacement, close and primary-renderer reload. GTK observes native URI/list changes. WKWebView samples native lists every 250 ms while visible and every second while hidden because its public list API has no same-URL `pushState` notification. Saved restoration keeps only URLs/cursor and loads the selected URL on explicit reopening.
@ -134,6 +150,7 @@ Native open claims retain their request deadline and backend-generation fence. T
- Native discovery: build with `npm run build:automation --workspace @neuralnomads/codenomad`, then run `node scripts/test-automation-native.mjs <absolute-cli-path>`. The fixture uses an isolated daemon/config/database and exercises ordinary-project discovery, all tools at startup, execution fences, independent leases, shutdown and reopening. CI runs it against the minimum and latest OpenCode runtimes.
- Electron: `browser-controller.test.ts`, `browser-webview-security.test.ts`, preload tests
- Tauri: `embedded_view_tests.rs`, `browser_controller.rs` and `browser_controller_regressions.rs` tests, including ownership, fail-closed child policy, creation rollback, replacement fencing, actual capability resolution, primary-renderer cleanup, window counting, lock ordering and expired dispatch
- Native capabilities: `packages/tauri-app/tests/webkit-capabilities/README.md` describes the backend-free production-layer harness on Windows/macOS/Linux and the Electron harness. It covers OAuth, blank/named windows, opener cleanup, HTTP/data/blob downloads, dialog values/cancellation, permission consent/denial, no app globals, fullscreen/Escape, file inputs/drop and WebAuthn availability. Windows runs the release build with scoped accessibility input; Linux uses both display backends. The HTTPS-to-loopback mode records the requesting origin in Electron's consent UI.
- WebKitGTK history: `python3 packages/tauri-app/scripts/test-webkit-history-linux.py` in a graphical Linux session with GTK 3/WebKitGTK 4.1 development packages. Run with `GDK_BACKEND=wayland` and `GDK_BACKEND=x11`; the standalone native example includes the production adapters, bounded-window selection and handle allocator. It covers redirects, fragments, same-URL `pushState`, `replaceState`, native traversal, branching and the 32-entry bound.
- WebKit private parent channel: `packages/tauri-app/scripts/test-browser-parent-webkit.py` builds the production controller, framing, native adapters and vendored runtime with an isolated owning-window inventory and renderer-claim fixture. The Node fixture uses the production `NativeParent` over real child stdout/stdin pipes. It covers autonomous open from an absent preview, redirects, fragments, failed loads/TLS, deadlines, PNG, hidden/duplicate targets, a fixture-renderer dialog, close/reopen and stale responses. Native PNG-completion tests revoke visibility or close the guest immediately before callback acceptance. The harness owns its fixture child and local servers; the shared daemon keeps its independent lifecycle. Build the Node fixture and run the graphical harness as described in `packages/tauri-app/tests/browser-parent/README.md`.
- UI: `browser-frame-security.test.ts`, `lib/native/browser.test.ts` and `tests/browser/browser-frame-native.test.ts` (real Solid/Electron guests, insecure HTTP, registration disposal and Tauri IPC failure recovery). Linux Electron rendering requires a display; CI runs the browser suite under Xvfb.

View file

@ -18,16 +18,20 @@ Native Vorschauen teilen Cookies, Anmeldungen und Websitespeicher über Projekte
| Client | Vorschauimplementierung | Agenten-Browser-Automatisierung | Mobilvorlagen |
| --- | --- | --- | --- |
| Electron auf unterstützten Desktop-Plattformen | Nativer Chromium-Gast | Für sichtbare zugehörige Sitzungsvorschau unterstützt | Android-/Chromium-Identität, Touch- und Dichteemulation |
| Windows-Tauri | Natives WebView2-Unterfenster | Für sichtbare zugehörige Sitzungsvorschau unterstützt | Android-/Chromium-Identität, Touch- und Dichteemulation |
| macOS-Tauri | Natives WKWebView-Unterfenster | Nicht verfügbar; verwenden Sie Electron oder Windows-Tauri | Mobile WebKit-Identität und CSS-Viewport; native Eingabegrenzen |
| Linux-Tauri | Natives WebKitGTK-Unterfenster | Nicht verfügbar; verwenden Sie Electron oder Windows-Tauri | Mobile WebKit-Identität und CSS-Viewport; native Dichte-/Eingabegrenzen |
| Electron auf unterstützten Desktop-Plattformen | Nativer Chromium-Gast; OAuth-Pop-ups, Downloads und Dialoge | Für sichtbare zugehörige Sitzungsvorschau unterstützt | Android-/Chromium-Identität, Touch- und Dichteemulation |
| Windows-Tauri | Natives WebView2-Unterfenster; OAuth-Pop-ups, Downloads und Dialoge | Für sichtbare zugehörige Sitzungsvorschau unterstützt | Android-/Chromium-Identität, Touch- und Dichteemulation |
| macOS-Tauri (14+) | Natives WKWebView-Unterfenster; OAuth-Pop-ups, Downloads und Dialoge | Für sichtbare zugehörige Sitzungsvorschau unterstützt | Mobile WebKit-Identität und CSS-Viewport; native Eingabegrenzen |
| Linux-Tauri | Natives WebKitGTK-Unterfenster; OAuth-Pop-ups, Downloads und Dialoge | Für sichtbare zugehörige Sitzungsvorschau unterstützt | Mobile WebKit-Identität und CSS-Viewport; native Dichte-/Eingabegrenzen |
| Normaler Browser/Remote-Ansicht | Iframe-Ersatz | Kein lokales natives Vorschauziel | Nur Abmessungen |
Unter Electron und Windows-Tauri wird native Automatisierung beim Desktop-Start vorbereitet und nutzt das sitzungsgebundene Werkzeug `codenomad.browser` zum Öffnen, Navigieren, Schnappschuss, Screenshot und für Seitenaktionen: Klicken, Tippen, Auswählen, Überfahren, Ziehen, Scrollen, Tastendrücke und Warten auf Text. Werkzeugverfügbarkeit autorisiert nicht jedes Fenster: Ausführung prüft zuständiges Backend, native Fähigkeiten, Sitzung und sichtbares Ziel. Eine Aktion, deren Seite inzwischen verborgen, ersetzt oder weiternavigiert wurde, wird abgelehnt statt an eine andere Seite gesendet, und eine Aktion wird nie automatisch wiederholt. Seiteninhalte bleiben nicht vertrauenswürdig. Ein iframe kann scheitern, weil das Ziel Einbettung verbietet; nutzen Sie einen externen Browser, statt Schutzmaßnahmen zu schwächen.
Tauri-Vorschauen unter macOS/Linux werden manuell geöffnet, behalten gemeinsame Anmeldungen über Neustarts hinweg und verwenden den nativen Zurück-/Vorwärts-Verlauf. Native Vorschauen werden unter lokalen Menüs und Dialogen ausgeblendet; Elementkommentare sind im nativen Modus nicht verfügbar. `codenomad.browser` lehnt unter Tauri macOS/Linux alle Aktionen ab, auch bei sichtbarer Vorschau.
Native Pop-ups teilen Vorschau-Anmeldungen, behalten `opener`/`postMessage` und schließen mit ihrer Ursprungsseite. Eingegebene/wiederhergestellte Adressen erlauben HTTP(S) ohne Zugangsdaten; Websites können auch leere und gleichursprüngliche Blob-Dokumente öffnen. Datei- und App-Schemata bleiben gesperrt. Downloads landen mit bereinigten, eindeutigen Namen im Downloads-Ordner, ohne Überschreiben oder automatisches Öffnen. Kamera, Mikrofon, Standort und Benachrichtigungen werden verweigert; klassifizierte Berechtigungen benötigen Zustimmung, unbekannte Anfragen zeigen einen Fehler. Tauri und Electron unterstützen `alert`/`confirm`/`prompt`; Electrons Prompt-Dialog gilt für Hauptseiten der Vorschau, Pop-ups und Unterframes. Geräte- und Bildschirmauswahlen zeigen die verfügbaren Einträge in einer scrollbaren Liste. Weitere APIs hängen von Engine und Hardware ab.
Websites können beim Schließen eines nativen Pop-ups eine Bestätigung verlangen. Unter Linux erscheint die Zustimmung zur Zeigersperre oben im zugehörigen Fenster. Von [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277) betroffene WebKitGTK-Versionen, einschließlich 2.52.6, benötigen für Uploads die Dateiauswahl der Seite, da der Zugriff auf abgelegte Dateien aus Sicherheitsgründen gesperrt ist. Speichern Sie die Arbeit auf der Seite, bevor Sie das zugehörige CodeNomad-Fenster schließen oder neu laden.
## Eine von sechs Ansichtsgrößenvorlagen wählen {#templates}
1. Öffnen Sie das Ansichtsgrößenmenü der Vorschau.

View file

@ -18,16 +18,20 @@ Native previews share cookies, sign-ins and site storage across projects/session
| Client | Preview implementation | Agent browser automation | Mobile templates |
| --- | --- | --- | --- |
| Electron on supported desktop platforms | Native Chromium guest | Supported for the visible owned session preview | Android/Chromium identity, touch and density emulation |
| Windows Tauri | Native WebView2 child | Supported for the visible owned session preview | Android/Chromium identity, touch and density emulation |
| macOS Tauri | Native WKWebView child | Unavailable; use Electron or Windows Tauri | WebKit mobile identity and CSS viewport; native input limits |
| Linux Tauri | Native WebKitGTK child | Unavailable; use Electron or Windows Tauri | WebKit mobile identity and CSS viewport; native density/input limits |
| Electron on supported desktop platforms | Native Chromium guest; OAuth pop-ups, downloads and dialogs | Supported for the visible owned session preview | Android/Chromium identity, touch and density emulation |
| Windows Tauri | Native WebView2 child; OAuth pop-ups, downloads and dialogs | Supported for the visible owned session preview | Android/Chromium identity, touch and density emulation |
| macOS Tauri (14+) | Native WKWebView child; OAuth pop-ups, downloads and dialogs | Supported for the visible owned session preview | WebKit mobile identity and CSS viewport; native input limits |
| Linux Tauri | Native WebKitGTK child; OAuth pop-ups, downloads and dialogs | Supported for the visible owned session preview | WebKit mobile identity and CSS viewport; native density/input limits |
| Plain browser/remote view | Iframe fallback | No local native preview target | Dimensions only |
On Electron and Windows Tauri, native automation is prepared at desktop startup and uses the session-bound `codenomad.browser` tool for open, navigate, snapshot, screenshot and page actions: click, type, select, hover, drag, scroll, key presses and waiting for text. Tool availability is not authorization for every window: execution checks the owning backend, native capabilities, session and visible target. An action whose page was hidden, replaced or navigated meanwhile is refused rather than sent to another page, and an action is never repeated automatically. Page contents remain untrusted. An iframe may fail because the target forbids embedding; use an external browser rather than weakening its protections.
Tauri macOS/Linux previews open manually, retain shared sign-ins across restarts and use native Back/Forward history. Native previews hide beneath local menus and dialogs; element comments are unavailable in native mode. `codenomad.browser` refuses all actions on Tauri macOS/Linux, even with a visible preview.
Native pop-ups share preview sign-ins, preserve `opener`/`postMessage` and close with their opener. Typed/restored addresses accept credential-free HTTP(S); sites can also open blank and same-origin blob documents. File and app schemes are blocked. Downloads save to Downloads with sanitized unique names, never overwrite files and never auto-open. Camera, microphone, location and notifications are denied; classified permissions use consent, unknown requests show an error. Tauri and Electron support `alert`/`confirm`/`prompt`; Electron's prompt dialog covers top-level preview pages, pop-ups and subframes. Device and screen selectors scroll through the available entries. Other APIs depend on the engine and hardware.
Sites can ask for confirmation when closing a native pop-up. Linux pointer-lock consent appears at the top of its owning window. WebKitGTK builds affected by [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277), including 2.52.6, require the page's file input for uploads because dropped-file access is blocked for security. Save page work before closing or reloading the owning CodeNomad window.
## Choose one of six viewport templates {#templates}
1. Open the preview's viewport menu.

View file

@ -18,16 +18,20 @@ Las vistas previas nativas comparten cookies, inicios de sesión y almacenamient
| Cliente | Implementación de vista previa | Automatización de navegador del agente | Plantillas móviles |
| --- | --- | --- | --- |
| Electron en plataformas de escritorio compatibles | Navegador invitado Chromium nativo | Compatible para la vista previa visible de la sesión propia | Emulación de identidad Android/Chromium, entrada táctil y densidad |
| Tauri para Windows | Ventana hija nativa WebView2 | Compatible para la vista previa visible de la sesión propia | Emulación de identidad Android/Chromium, entrada táctil y densidad |
| Tauri para macOS | Ventana hija nativa WKWebView | No disponible; usa Electron o Tauri para Windows | Identidad móvil WebKit y viewport CSS; límites nativos de entrada |
| Tauri para Linux | Ventana hija nativa WebKitGTK | No disponible; usa Electron o Tauri para Windows | Identidad móvil WebKit y viewport CSS; límites nativos de densidad/entrada |
| Electron en plataformas de escritorio compatibles | Navegador invitado Chromium nativo; ventanas OAuth, descargas y diálogos | Compatible para la vista previa visible de la sesión propia | Emulación de identidad Android/Chromium, entrada táctil y densidad |
| Tauri para Windows | Ventana hija nativa WebView2; ventanas OAuth, descargas y diálogos | Compatible para la vista previa visible de la sesión propia | Emulación de identidad Android/Chromium, entrada táctil y densidad |
| Tauri para macOS (14+) | Ventana hija nativa WKWebView; ventanas OAuth, descargas y diálogos | Compatible para la vista previa visible de la sesión propia | Identidad móvil WebKit y viewport CSS; límites nativos de entrada |
| Tauri para Linux | Ventana hija nativa WebKitGTK; ventanas OAuth, descargas y diálogos | Compatible para la vista previa visible de la sesión propia | Identidad móvil WebKit y viewport CSS; límites nativos de densidad/entrada |
| Navegador normal/vista remota | Alternativa iframe | Sin objetivo local de vista previa nativa | Solo dimensiones |
En Electron y Tauri para Windows, la automatización nativa se prepara al iniciar el escritorio y usa la herramienta `codenomad.browser` vinculada a la sesión para abrir, navegar, obtener una instantánea, capturar pantalla y realizar acciones en la página: hacer clic, escribir, seleccionar, pasar el cursor, arrastrar, desplazarse, pulsar teclas y esperar un texto. Que una herramienta esté disponible no autoriza todas las ventanas: la ejecución comprueba el backend propietario, las capacidades nativas, la sesión y el objetivo visible. Una acción cuya página se ocultó, se sustituyó o navegó mientras tanto se rechaza en lugar de enviarse a otra página, y una acción nunca se repite automáticamente. El contenido de las páginas sigue sin ser fiable. Un iframe puede fallar porque el destino prohíbe incrustarlo; usa un navegador externo en lugar de debilitar sus protecciones.
Las vistas previas de Tauri para macOS/Linux se abren manualmente, conservan los inicios de sesión compartidos entre reinicios y usan el historial nativo Atrás/Adelante. Las vistas previas nativas se ocultan bajo los menús y diálogos locales; los comentarios sobre elementos no están disponibles en modo nativo. `codenomad.browser` rechaza todas las acciones en Tauri para macOS/Linux, incluso con una vista previa visible.
Las ventanas emergentes nativas comparten las sesiones de la vista previa, conservan `opener`/`postMessage` y se cierran con su página de origen. Las direcciones introducidas/restauradas permiten HTTP(S) sin credenciales; los sitios también pueden abrir documentos vacíos y blob del mismo origen. Se bloquean los esquemas de archivos y de la aplicación. Las descargas usan nombres saneados y únicos en Descargas, sin sobrescribir ni abrir archivos automáticamente. Se deniegan cámara, micrófono, ubicación y notificaciones; los permisos clasificados requieren consentimiento y las solicitudes desconocidas muestran un error. Tauri y Electron admiten `alert`/`confirm`/`prompt`; el diálogo prompt de Electron cubre las páginas principales de la vista previa, las ventanas emergentes y los submarcos. Los selectores de dispositivos y pantallas permiten desplazarse por las entradas disponibles. Otras API dependen del motor y del hardware.
Los sitios pueden pedir confirmación al cerrar una ventana emergente nativa. En Linux, el consentimiento para bloquear el puntero aparece en la parte superior de su ventana. Las versiones de WebKitGTK afectadas por [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277), incluida 2.52.6, requieren el selector de archivos de la página para subir archivos, ya que el acceso a archivos arrastrados está bloqueado por seguridad. Guarde el trabajo de la página antes de cerrar o recargar la ventana de CodeNomad que la contiene.
## Elegir una de seis plantillas de área visible {#templates}
1. Abre el menú de área visible de la vista previa.

View file

@ -18,16 +18,20 @@ Les aperçus natifs partagent cookies, connexions et stockage de site entre proj
| Client | Implémentation de l’aperçu | Automatisation navigateur par agent | Modèles mobiles |
| --- | --- | --- | --- |
| Electron sur les plateformes de bureau prises en charge | Invité Chromium natif | Prise en charge pour l’aperçu de session détenu visible | Émulation d’identité Android/Chromium, tactile et densité |
| Tauri Windows | Enfant WebView2 natif | Prise en charge pour l’aperçu de session détenu visible | Émulation d’identité Android/Chromium, tactile et densité |
| Tauri macOS | Vue enfant WKWebView native | Indisponible ; utilisez Electron ou Tauri Windows | Identité mobile WebKit et viewport CSS ; limites de saisie natives |
| Tauri Linux | Vue enfant WebKitGTK native | Indisponible ; utilisez Electron ou Tauri Windows | Identité mobile WebKit et viewport CSS ; limites natives de densité/saisie |
| Electron sur les plateformes de bureau prises en charge | Invité Chromium natif ; pop-ups OAuth, téléchargements et dialogues | Prise en charge pour l’aperçu de session détenu visible | Émulation d’identité Android/Chromium, tactile et densité |
| Tauri Windows | Enfant WebView2 natif ; pop-ups OAuth, téléchargements et dialogues | Prise en charge pour l’aperçu de session détenu visible | Émulation d’identité Android/Chromium, tactile et densité |
| Tauri macOS (14+) | Vue enfant WKWebView native ; pop-ups OAuth, téléchargements et dialogues | Prise en charge pour l’aperçu de session détenu visible | Identité mobile WebKit et viewport CSS ; limites de saisie natives |
| Tauri Linux | Vue enfant WebKitGTK native ; pop-ups OAuth, téléchargements et dialogues | Prise en charge pour l’aperçu de session détenu visible | Identité mobile WebKit et viewport CSS ; limites natives de densité/saisie |
| Navigateur ordinaire/vue distante | Iframe de repli | Aucune cible locale d’aperçu natif | Dimensions seules |
Sur Electron et Tauri Windows, l’automatisation native est préparée au démarrage du bureau et utilise l’outil `codenomad.browser` lié à la session pour ouvrir, naviguer, capturer l’état, prendre une capture d’écran et agir sur la page : cliquer, saisir, sélectionner, survoler, glisser, faire défiler, appuyer sur des touches et attendre un texte. La disponibilité d’un outil n’autorise pas toutes les fenêtres : l’exécution vérifie le backend propriétaire, les capacités natives, la session et la cible visible. Une action dont la page a été masquée, remplacée ou a changé d’adresse entre-temps est refusée plutôt qu’envoyée à une autre page, et une action n’est jamais répétée automatiquement. Le contenu des pages reste non fiable. Une iframe peut échouer si la cible interdit l’intégration ; utilisez un navigateur externe plutôt que d’affaiblir ses protections.
Les aperçus Tauri macOS/Linux s’ouvrent manuellement, conservent les connexions partagées entre les redémarrages et utilisent l’historique natif arrière/avant. Les aperçus natifs sont masqués sous les menus et boîtes de dialogue locaux ; les commentaires sur les éléments sont indisponibles en mode natif. `codenomad.browser` refuse toutes les actions sur Tauri macOS/Linux, même avec un aperçu visible.
Les pop-ups natifs partagent les connexions de l’aperçu, conservent `opener`/`postMessage` et se ferment avec leur page d’origine. Les adresses saisies/restaurées acceptent HTTP(S) sans identifiants ; les sites peuvent aussi ouvrir des documents vierges et blob de même origine. Les schémas de fichiers et applicatifs sont bloqués. Les téléchargements vont dans Téléchargements, avec des noms assainis et uniques, sans écrasement ni ouverture automatique. Caméra, microphone, localisation et notifications sont refusés ; les permissions classifiées demandent un consentement, les requêtes inconnues affichent une erreur. Tauri et Electron prennent en charge `alert`/`confirm`/`prompt` ; le dialogue prompt d’Electron couvre les pages principales de l’aperçu, les pop-ups et les sous-frames. Les sélecteurs de périphériques et d’écrans permettent de faire défiler les entrées disponibles. Les autres API dépendent du moteur et du matériel.
Les sites peuvent demander confirmation à la fermeture d’un pop-up natif. Sous Linux, le consentement au verrouillage du pointeur apparaît en haut de sa fenêtre. Les versions de WebKitGTK affectées par [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277), dont 2.52.6, nécessitent le sélecteur de fichiers de la page pour les envois : l’accès aux fichiers déposés est bloqué pour des raisons de sécurité. Enregistrez le travail de la page avant de fermer ou recharger la fenêtre CodeNomad qui l’héberge.
## Choisir parmi six modèles de zone d’affichage {#templates}
1. Ouvrez le menu de zone d’affichage de l’aperçu.

View file

@ -18,16 +18,20 @@
| לקוח | מימוש התצוגה | אוטומציית דפדפן של סוכן | תבניות נייד |
| --- | --- | --- | --- |
| Electron בפלטפורמות שולחן עבודה נתמכות | אורח Chromium מקורי | נתמכת לתצוגת הסשן הגלויה שבבעלות היישום | הדמיית זהות Android/Chromium, מגע וצפיפות |
| Tauri ב־Windows | חלון־בת מקורי של WebView2 | נתמכת לתצוגת הסשן הגלויה שבבעלות היישום | הדמיית זהות Android/Chromium, מגע וצפיפות |
| Tauri ב־macOS | חלון־בת מקורי של WKWebView | אינה זמינה; השתמשו ב־Electron או ב־Tauri ב־Windows | זהות ניידת של WebKit ואזור תצוגת CSS; מגבלות קלט מקוריות |
| Tauri ב־Linux | חלון־בת מקורי של WebKitGTK | אינה זמינה; השתמשו ב־Electron או ב־Tauri ב־Windows | זהות ניידת של WebKit ואזור תצוגת CSS; מגבלות צפיפות וקלט מקוריות |
| Electron בפלטפורמות שולחן עבודה נתמכות | אורח Chromium מקורי; חלונות OAuth, הורדות ותיבות דו־שיח | נתמכת לתצוגת הסשן הגלויה שבבעלות היישום | הדמיית זהות Android/Chromium, מגע וצפיפות |
| Tauri ב־Windows | חלון־בת מקורי של WebView2; חלונות OAuth, הורדות ותיבות דו־שיח | נתמכת לתצוגת הסשן הגלויה שבבעלות היישום | הדמיית זהות Android/Chromium, מגע וצפיפות |
| Tauri ב־macOS (14+) | חלון־בת מקורי של WKWebView; חלונות OAuth, הורדות ותיבות דו־שיח | נתמכת לתצוגת הסשן הגלויה שבבעלות היישום | זהות ניידת של WebKit ואזור תצוגת CSS; מגבלות קלט מקוריות |
| Tauri ב־Linux | חלון־בת מקורי של WebKitGTK; חלונות OAuth, הורדות ותיבות דו־שיח | נתמכת לתצוגת הסשן הגלויה שבבעלות היישום | זהות ניידת של WebKit ואזור תצוגת CSS; מגבלות צפיפות וקלט מקוריות |
| דפדפן רגיל/תצוגה מרוחקת | חלופת iframe | ללא יעד תצוגה מקומית מקורית | ממדים בלבד |
ב־Electron וב־Tauri ב־Windows, האוטומציה המקורית מוכנה בהפעלת שולחן העבודה ומשתמשת בכלי `codenomad.browser` הקשור לסשן לפתיחה, לניווט, לתמונת מצב, לצילום מסך ולפעולות בדף: לחיצה, הקלדה, בחירה, ריחוף, גרירה, גלילה, הקשות מקשים והמתנה לטקסט. זמינות כלי אינה הרשאה לכל חלון: ההרצה בודקת את שרת הקצה הבעלים, היכולות המקוריות, הסשן והיעד הגלוי. פעולה שהדף שלה הוסתר, הוחלף או נווט בינתיים נדחית במקום להישלח לדף אחר, ופעולה לעולם אינה חוזרת אוטומטית. תוכן דפים נשאר לא מהימן. iframe עלול להיכשל כי היעד אוסר הטמעה; השתמשו בדפדפן חיצוני במקום להחליש את הגנותיו.
תצוגות Tauri ב־macOS/Linux נפתחות ידנית, שומרות התחברויות משותפות גם לאחר הפעלה מחדש ומשתמשות בהיסטוריית אחורה/קדימה מקורית. תצוגות מקוריות מוסתרות מתחת לתפריטים ולתיבות דו־שיח מקומיים; הערות על רכיבים אינן זמינות במצב מקורי. `codenomad.browser` דוחה את כל הפעולות ב־Tauri ב־macOS/Linux, גם כשהתצוגה גלויה.
חלונות קופצים מקוריים משתפים את הכניסות לחשבון בתצוגה המקדימה, שומרים על `opener`/`postMessage` ונסגרים עם דף המקור. כתובות שהוזנו או שוחזרו מאפשרות HTTP(S) ללא פרטי גישה; אתרים יכולים לפתוח גם מסמכים ריקים ומסמכי blob מאותו מקור. סכמות קבצים ויישום חסומות. הורדות נשמרות בתיקיית ההורדות בשמות מנוקים וייחודיים, ללא דריסה או פתיחה אוטומטית. מצלמה, מיקרופון, מיקום והתראות נדחים; הרשאות מסווגות דורשות הסכמה ובקשות לא מוכרות מציגות שגיאה. Tauri ו־Electron תומכים ב־`alert`/`confirm`/`prompt`; תיבת prompt של Electron זמינה בדפים הראשיים של התצוגה המקדימה, בחלונות קופצים ובמסגרות משנה. בוררי התקנים ומסכים מאפשרים גלילה בין הרשומות הזמינות. ממשקים אחרים תלויים במנוע ובחומרה.
אתרים יכולים לבקש אישור בסגירת חלון קופץ מקורי. ב־Linux, האישור לנעילת המצביע מופיע בראש החלון שלו. בגרסאות WebKitGTK המושפעות מ־[WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277), ובהן 2.52.6, יש להשתמש בבורר הקבצים של הדף להעלאות, משום שהגישה לקבצים שנגררו חסומה מטעמי אבטחה. שמרו את העבודה בדף לפני סגירה או טעינה מחדש של חלון CodeNomad שמכיל אותו.
## בחירת אחת משש תבניות אזור התצוגה {#templates}
1. פתחו את תפריט אזור התצוגה של התצוגה המקדימה.

View file

@ -18,16 +18,20 @@
| クライアント | プレビューの実装 | エージェントのブラウザー自動操作 | モバイルテンプレート |
| --- | --- | --- | --- |
| 対応デスクトップ環境の Electron | ネイティブ Chromium ゲスト | 所有する表示中のセッションプレビューで対応 | Android/Chromium の識別、タッチ、密度のエミュレーション |
| Windows Tauri | ネイティブ WebView2 子 | 所有する表示中のセッションプレビューで対応 | Android/Chromium の識別、タッチ、密度のエミュレーション |
| macOS Tauri | ネイティブ WKWebView 子 | 利用不可。Electron または Windows Tauri を使用 | WebKit モバイル識別と CSS ビューポート。入力はネイティブ環境の制約あり |
| Linux Tauri | ネイティブ WebKitGTK 子 | 利用不可。Electron または Windows Tauri を使用 | WebKit モバイル識別と CSS ビューポート。密度と入力はネイティブ環境の制約あり |
| 対応デスクトップ環境の Electron | ネイティブ Chromium ゲスト。OAuth ポップアップ、ダウンロード、ダイアログに対応 | 所有する表示中のセッションプレビューで対応 | Android/Chromium の識別、タッチ、密度のエミュレーション |
| Windows Tauri | ネイティブ WebView2 子。OAuth ポップアップ、ダウンロード、ダイアログに対応 | 所有する表示中のセッションプレビューで対応 | Android/Chromium の識別、タッチ、密度のエミュレーション |
| macOS Tauri (14+) | ネイティブ WKWebView 子。OAuth ポップアップ、ダウンロード、ダイアログに対応 | 所有する表示中のセッションプレビューで対応 | WebKit モバイル識別と CSS ビューポート。入力はネイティブ環境の制約あり |
| Linux Tauri | ネイティブ WebKitGTK 子。OAuth ポップアップ、ダウンロード、ダイアログに対応 | 所有する表示中のセッションプレビューで対応 | WebKit モバイル識別と CSS ビューポート。密度と入力はネイティブ環境の制約あり |
| 通常のブラウザー/リモート表示 | 代替 iframe | ローカルのネイティブプレビュー対象なし | 寸法のみ |
Electron と Windows Tauri では、ネイティブ自動操作はデスクトップ起動時に準備され、セッションに紐づく `codenomad.browser` ツールで open、navigate、snapshot、screenshot と、ページ操作(クリック、入力、選択、ホバー、ドラッグ、スクロール、キー入力、テキストの出現待ち)を行います。ツールが使えることは、全ウィンドウの操作許可ではありません。実行時に、所有するバックエンド、ネイティブ機能、セッション、表示中の対象を確認します。その間にページが非表示、置き換え、移動された操作は、別のページへ送られずに拒否され、操作が自動的に繰り返されることもありません。ページ内容は引き続き信頼できない情報です。埋め込みを対象が禁止して iframe が失敗する場合は、保護を弱めず外部ブラウザーを使ってください。
macOS/Linux Tauri のプレビューは手動で開き、共有ログインを再起動後も保持し、ネイティブの戻る/進む履歴を使用します。ローカルのメニューやダイアログが開くとネイティブプレビューは非表示になり、ネイティブモードでは要素へのコメントを利用できません。`codenomad.browser` は、プレビューが表示中でも macOS/Linux Tauri でのすべての操作を拒否します。
ネイティブポップアップはプレビューのログイン状態を共有し、`opener`/`postMessage` を保持し、元のページとともに閉じます。入力・復元するアドレスは認証情報のない HTTP(S) のみで、サイトは空白文書や同一オリジンの blob 文書も開けます。ファイル・アプリのスキームは拒否されます。ダウンロードは安全な一意の名前でダウンロードフォルダーに保存され、上書き・自動実行しません。カメラ、マイク、位置情報、通知は拒否され、分類済み権限には同意が必要です。不明な要求はエラーを表示します。Tauri と Electron は `alert`/`confirm`/`prompt` に対応し、Electron の prompt ダイアログはプレビューのトップレベルページ、ポップアップ、サブフレームで利用できます。デバイスと画面の選択リストはスクロールできます。その他の API はエンジンとハードウェアに依存します。
サイトはネイティブポップアップを閉じる際に確認を求めることがあります。Linux のポインターロック同意は、そのウィンドウの上部に表示されます。2.52.6 など [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277) の影響を受ける WebKitGTK では、セキュリティ上ドロップされたファイルへのアクセスがブロックされるため、アップロードにはページのファイル選択を使ってください。ページを含む CodeNomad ウィンドウを閉じる、または再読み込みする前に、ページの作業を保存してください。
## 6 種類のビューポートテンプレートから選ぶ {#templates}
1. プレビューのビューポートメニューを開きます。

View file

@ -18,16 +18,20 @@
| क्लाइन्ट | पूर्वावलोकन कार्यान्वयन | Agent browser automation | Mobile template |
| --- | --- | --- | --- |
| समर्थित desktop प्लेटफर्ममा Electron | नेटिभ Chromium guest | देखिने स्वामित्वभित्रको session पूर्वावलोकनमा समर्थित | Android/Chromium पहिचान, touch र density emulation |
| Windows Tauri | नेटिभ WebView2 child | देखिने स्वामित्वभित्रको session पूर्वावलोकनमा समर्थित | Android/Chromium पहिचान, touch र density emulation |
| macOS Tauri | नेटिभ WKWebView child | उपलब्ध छैन; Electron वा Windows Tauri प्रयोग गर्नुहोस् | WebKit मोबाइल पहिचान र CSS भ्युपोर्ट; नेटिभ इनपुट सीमा |
| Linux Tauri | नेटिभ WebKitGTK child | उपलब्ध छैन; Electron वा Windows Tauri प्रयोग गर्नुहोस् | WebKit मोबाइल पहिचान र CSS भ्युपोर्ट; नेटिभ घनत्व/इनपुट सीमा |
| समर्थित desktop प्लेटफर्ममा Electron | नेटिभ Chromium guest; OAuth पप-अप, डाउनलोड र संवाद | देखिने स्वामित्वभित्रको session पूर्वावलोकनमा समर्थित | Android/Chromium पहिचान, touch र density emulation |
| Windows Tauri | नेटिभ WebView2 child; OAuth पप-अप, डाउनलोड र संवाद | देखिने स्वामित्वभित्रको session पूर्वावलोकनमा समर्थित | Android/Chromium पहिचान, touch र density emulation |
| macOS Tauri (14+) | नेटिभ WKWebView child; OAuth पप-अप, डाउनलोड र संवाद | देखिने स्वामित्वभित्रको session पूर्वावलोकनमा समर्थित | WebKit मोबाइल पहिचान र CSS भ्युपोर्ट; नेटिभ इनपुट सीमा |
| Linux Tauri | नेटिभ WebKitGTK child; OAuth पप-अप, डाउनलोड र संवाद | देखिने स्वामित्वभित्रको session पूर्वावलोकनमा समर्थित | WebKit मोबाइल पहिचान र CSS भ्युपोर्ट; नेटिभ घनत्व/इनपुट सीमा |
| सामान्य browser/remote दृश्य | iframe fallback | स्थानीय native preview target छैन | आयाम मात्र |
Electron र Windows Tauri मा नेटिभ automation desktop startup मै तयार हुन्छ र open, navigate, snapshot, screenshot र page action (click, type, select, hover, drag, scroll, key press र text पर्खने) का लागि session-bound `codenomad.browser` tool प्रयोग गर्छ। tool उपलब्धता हरेक विन्डोको अनुमति होइन: execution ले स्वामी backend, native capability, session र visible target जाँच्छ। बीचमै पृष्ठ लुकेको, बदलिएको वा अर्कै ठाउँ नेभिगेट भएको action अर्को पृष्ठमा पठाइँदैन, अस्वीकार गरिन्छ; कुनै action स्वतः दोहोर्‍याइँदैन। पृष्ठ सामग्री अविश्वसनीय नै हुन्छ। target ले embedding निषेध गर्दा iframe असफल हुन सक्छ; सुरक्षा कमजोर पार्नुको सट्टा बाह्य browser प्रयोग गर्नुहोस्।
macOS/Linux Tauri पूर्वावलोकन म्यानुअल रूपमा खुल्छन्, पुनःसुरुवातपछि पनि साझा sign-in राख्छन् र नेटिभ Back/Forward इतिहास प्रयोग गर्छन्। स्थानीय menu र dialog खुल्दा नेटिभ पूर्वावलोकन लुक्छन्; नेटिभ मोडमा element comment उपलब्ध छैनन्। `codenomad.browser` ले macOS/Linux Tauri मा पूर्वावलोकन देखिएकै अवस्थामा पनि सबै action अस्वीकार गर्छ।
नेटिभ पप-अपले पूर्वावलोकनका साइन-इन साझा गर्छन्, `opener`/`postMessage` कायम राख्छन् र मूल पृष्ठसँगै बन्द हुन्छन्। लेखिएका वा पुनर्स्थापित ठेगानामा परिचयपत्ररहित HTTP(S) मात्र अनुमति छ; साइटले खाली र उही मूलका blob कागजात पनि खोल्न सक्छन्। फाइल र एप स्किम रोकिएका छन्। डाउनलोडहरू सुरक्षित अद्वितीय नाममा Downloads मा राखिन्छन्, फाइल मेटिँदैन र स्वतः खोलिँदैन। क्यामेरा, माइक्रोफोन, स्थान र सूचना अस्वीकार गरिन्छन्; वर्गीकृत अनुमतिमा सहमति मागिन्छ र अज्ञात अनुरोधमा त्रुटि देखाइन्छ। Tauri र Electron मा `alert`/`confirm`/`prompt` उपलब्ध छन्; Electron को prompt संवाद पूर्वावलोकनका मुख्य पृष्ठ, पप-अप र उप-फ्रेममा उपलब्ध छ। उपकरण र स्क्रिन छनोट सूचीका उपलब्ध प्रविष्टिहरू स्क्रोल गर्न सकिन्छ। अन्य API इन्जिन र हार्डवेयरमा निर्भर छन्।
साइटले नेटिभ पप-अप बन्द गर्दा पुष्टि माग्न सक्छ। Linux मा पोइन्टर लकको सहमति सम्बन्धित विन्डोको माथि देखिन्छ। [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277) बाट प्रभावित WebKitGTK संस्करणहरू, 2.52.6 सहित, मा सुरक्षाका कारण ड्रप गरिएका फाइलको पहुँच रोकिएको हुन्छ; अपलोड गर्न पृष्ठको फाइल छनोट प्रयोग गर्नुहोस्। सम्बन्धित CodeNomad विन्डो बन्द वा पुनः लोड गर्नुअघि पृष्ठको काम सुरक्षित गर्नुहोस्।
## छ viewport template मध्ये एक रोज्नुहोस् {#templates}
1. पूर्वावलोकनको viewport menu खोल्नुहोस्।

View file

@ -18,16 +18,20 @@
| Клиент | Реализация предпросмотра | Автоматизация браузера агентом | Мобильные шаблоны |
| --- | --- | --- | --- |
| Electron на поддерживаемых настольных платформах | Нативный гость Chromium | Поддерживается для видимого принадлежащего сессии предпросмотра | Эмуляция идентичности Android/Chromium, касаний и плотности |
| Windows Tauri | Нативный дочерний WebView2 | Поддерживается для видимого принадлежащего сессии предпросмотра | Эмуляция идентичности Android/Chromium, касаний и плотности |
| macOS Tauri | Нативный дочерний WKWebView | Недоступна; используйте Electron или Windows Tauri | Мобильная идентификация WebKit и CSS-область; нативные ограничения ввода |
| Linux Tauri | Нативный дочерний WebKitGTK | Недоступна; используйте Electron или Windows Tauri | Мобильная идентификация WebKit и CSS-область; нативные ограничения плотности/ввода |
| Electron на поддерживаемых настольных платформах | Нативный гость Chromium; окна OAuth, загрузки и диалоги | Поддерживается для видимого принадлежащего сессии предпросмотра | Эмуляция идентичности Android/Chromium, касаний и плотности |
| Windows Tauri | Нативный дочерний WebView2; окна OAuth, загрузки и диалоги | Поддерживается для видимого принадлежащего сессии предпросмотра | Эмуляция идентичности Android/Chromium, касаний и плотности |
| macOS Tauri (14+) | Нативный дочерний WKWebView; окна OAuth, загрузки и диалоги | Поддерживается для видимого принадлежащего сессии предпросмотра | Мобильная идентификация WebKit и CSS-область; нативные ограничения ввода |
| Linux Tauri | Нативный дочерний WebKitGTK; окна OAuth, загрузки и диалоги | Поддерживается для видимого принадлежащего сессии предпросмотра | Мобильная идентификация WebKit и CSS-область; нативные ограничения плотности/ввода |
| Обычный браузер/удалённый вид | Резервный iframe | Нет локальной нативной цели предпросмотра | Только размеры |
В Electron и Windows Tauri нативная автоматизация подготавливается при запуске настольного приложения и использует связанный с сессией инструмент `codenomad.browser` для открытия, навигации, снимка структуры, снимка экрана и действий на странице: нажатия, ввода, выбора, наведения, перетаскивания, прокрутки, нажатия клавиш и ожидания текста. Доступность инструмента не разрешает доступ к любому окну: выполнение проверяет серверную часть-владельца, нативные возможности, сессию и видимую цель. Действие, страница которого тем временем была скрыта, заменена или перешла на другой адрес, отклоняется, а не отправляется на другую страницу, и действие никогда не повторяется автоматически. Контент страниц остаётся недоверенным. Iframe может не работать из-за запрета встраивания у цели; используйте внешний браузер, не ослабляйте защиту.
Предпросмотры Tauri в macOS/Linux открываются вручную, сохраняют общие входы после перезапуска и используют нативную историю переходов назад/вперёд. Нативные предпросмотры скрываются под локальными меню и диалогами; комментарии к элементам в нативном режиме недоступны. `codenomad.browser` отклоняет все действия в Tauri macOS/Linux, даже при видимом предпросмотре.
Нативные всплывающие окна используют общие входы предпросмотра, сохраняют `opener`/`postMessage` и закрываются вместе с исходной страницей. Введённые и восстановленные адреса допускают HTTP(S) без учётных данных; сайты также могут открывать пустые и одноисточниковые blob-документы. Схемы файлов и приложения заблокированы. Загрузки сохраняются в папку загрузок с очищенными уникальными именами, без перезаписи и автоматического открытия. Камера, микрофон, местоположение и уведомления запрещены; классифицированные разрешения требуют согласия, неизвестные запросы показывают ошибку. Tauri и Electron поддерживают `alert`/`confirm`/`prompt`; диалог prompt в Electron доступен на основных страницах предпросмотра, во всплывающих окнах и подфреймах. Списки устройств и экранов позволяют прокручивать доступные элементы. Остальные API зависят от движка и оборудования.
Сайты могут запрашивать подтверждение при закрытии нативного всплывающего окна. В Linux согласие на блокировку указателя появляется вверху его окна. В версиях WebKitGTK, затронутых [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277), включая 2.52.6, для загрузки файлов требуется выбор файла на странице: доступ к перетаскиваемым файлам заблокирован в целях безопасности. Сохраните работу на странице перед закрытием или перезагрузкой содержащего её окна CodeNomad.
## Выбор одного из шести шаблонов области просмотра {#templates}
1. Откройте меню области просмотра предпросмотра.

View file

@ -18,16 +18,20 @@ Native önizlemeler aynı masaüstü motoru ve profil/yapılandırma kapsamında
| İstemci | Önizleme uygulaması | Ajan tarayıcı otomasyonu | Mobil şablonlar |
| --- | --- | --- | --- |
| Desteklenen masaüstü platformlarında Electron | Yerel Chromium konuğu | Görünür sahip olunan oturum önizlemesi için desteklenir | Android/Chromium kimliği, dokunma ve yoğunluk emülasyonu |
| Windows Tauri | Yerel WebView2 alt öğesi | Görünür sahip olunan oturum önizlemesi için desteklenir | Android/Chromium kimliği, dokunma ve yoğunluk emülasyonu |
| macOS Tauri | Yerel WKWebView alt öğesi | Kullanılamaz; Electron veya Windows Tauri kullanın | WebKit mobil kimliği ve CSS görünümü; yerel giriş sınırları |
| Linux Tauri | Yerel WebKitGTK alt öğesi | Kullanılamaz; Electron veya Windows Tauri kullanın | WebKit mobil kimliği ve CSS görünümü; yerel yoğunluk/giriş sınırları |
| Desteklenen masaüstü platformlarında Electron | Yerel Chromium konuğu; OAuth pencereleri, indirmeler ve iletişim kutuları | Görünür sahip olunan oturum önizlemesi için desteklenir | Android/Chromium kimliği, dokunma ve yoğunluk emülasyonu |
| Windows Tauri | Yerel WebView2 alt öğesi; OAuth pencereleri, indirmeler ve iletişim kutuları | Görünür sahip olunan oturum önizlemesi için desteklenir | Android/Chromium kimliği, dokunma ve yoğunluk emülasyonu |
| macOS Tauri (14+) | Yerel WKWebView alt öğesi; OAuth pencereleri, indirmeler ve iletişim kutuları | Görünür sahip olunan oturum önizlemesi için desteklenir | WebKit mobil kimliği ve CSS görünümü; yerel giriş sınırları |
| Linux Tauri | Yerel WebKitGTK alt öğesi; OAuth pencereleri, indirmeler ve iletişim kutuları | Görünür sahip olunan oturum önizlemesi için desteklenir | WebKit mobil kimliği ve CSS görünümü; yerel yoğunluk/giriş sınırları |
| Normal tarayıcı/uzak görünüm | Alternatif iframe görünümü | Yerel native önizleme hedefi yok | Yalnızca boyutlar |
Electron ve Windows Tauri'de yerel otomasyon, masaüstü başlangıcında hazırlanır ve açma, gezinme, anlık görüntü, ekran görüntüsü ve sayfa eylemleri için oturuma bağlı `codenomad.browser` aracını kullanır: tıklama, yazma, seçme, üzerine gelme, sürükleme, kaydırma, tuşa basma ve metin bekleme. Aracın kullanılabilirliği her pencere için yetkilendirme değildir: yürütme, sahip olunan arka ucu, yerel yetenekleri, oturumu ve görünür hedefi kontrol eder. Sayfası bu arada gizlenmiş, değiştirilmiş veya başka bir adrese gitmiş bir eylem başka bir sayfaya gönderilmek yerine reddedilir ve hiçbir eylem otomatik olarak tekrarlanmaz. Sayfa içerikleri güvenilmez durumda kalıyor. Hedef yerleştirmeyi yasakladığından iframe başarısız olabilir; korumalarını zayıflatmak yerine harici bir tarayıcı kullanın.
Tauri macOS/Linux önizlemeleri elle açılır, paylaşılan oturum açma bilgilerini yeniden başlatmalar arasında korur ve yerel Geri/İleri geçmişini kullanır. Yerel önizlemeler, uygulama içindeki menü ve iletişim kutularının altında gizlenir; yerel modda öğe yorumları kullanılamaz. `codenomad.browser`, görünür bir önizleme olsa bile Tauri macOS/Linux'ta tüm eylemleri reddeder.
Yerel açılır pencereler önizleme oturumlarını paylaşır, `opener`/`postMessage` ilişkisini korur ve açan sayfayla kapanır. Yazılan veya geri yüklenen adresler kimlik bilgisiz HTTP(S) ile sınırlıdır; siteler boş ve aynı kökenli blob belgeleri de açabilir. Dosya ve uygulama şemaları engellenir. İndirmeler temizlenmiş benzersiz adlarla İndirilenler'e kaydedilir; üzerine yazılmaz ve otomatik açılmaz. Kamera, mikrofon, konum ve bildirimler reddedilir; sınıflandırılmış izinler onay ister, bilinmeyen istekler hata gösterir. Tauri ve Electron `alert`/`confirm`/`prompt` destekler; Electron'un prompt iletişim kutusu önizlemenin ana sayfalarında, açılır pencerelerde ve alt çerçevelerde kullanılabilir. Aygıt ve ekran seçicilerinde mevcut girdiler arasında kaydırma yapılabilir. Diğer API'ler motora ve donanıma bağlıdır.
Siteler yerel açılır pencere kapanırken onay isteyebilir. Linux'ta işaretçi kilidi onayı ilgili pencerenin üstünde görünür. 2.52.6 dahil [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277) sorunundan etkilenen WebKitGTK sürümlerinde, bırakılan dosyalara erişim güvenlik nedeniyle engellendiği için yüklemelerde sayfanın dosya seçicisini kullanın. İlgili CodeNomad penceresini kapatmadan veya yeniden yüklemeden önce sayfadaki çalışmanızı kaydedin.
## Altı görünüm alanı şablonundan birini seçin {#templates}
1. Önizlemenin görünüm menüsünü açın.

View file

@ -18,16 +18,20 @@
| 客户端 | 预览实现 | 智能体浏览器自动化 | 移动模板 |
| --- | --- | --- | --- |
| 支持的桌面平台上的 Electron | 原生 Chromium 访客页面 | 支持可见且归属已确认的会话预览 | Android/Chromium 身份、触摸和像素密度仿真 |
| Windows Tauri | 原生 WebView2 子窗口 | 支持可见且归属已确认的会话预览 | Android/Chromium 身份、触摸和像素密度仿真 |
| macOS Tauri | 原生 WKWebView 子窗口 | 不可用;请使用 Electron 或 Windows Tauri | WebKit 移动标识和 CSS 视口;输入受原生限制 |
| Linux Tauri | 原生 WebKitGTK 子窗口 | 不可用;请使用 Electron 或 Windows Tauri | WebKit 移动标识和 CSS 视口;密度和输入受原生限制 |
| 支持的桌面平台上的 Electron | 原生 Chromium 访客页面;支持 OAuth 弹窗、下载和对话框 | 支持可见且归属已确认的会话预览 | Android/Chromium 身份、触摸和像素密度仿真 |
| Windows Tauri | 原生 WebView2 子窗口;支持 OAuth 弹窗、下载和对话框 | 支持可见且归属已确认的会话预览 | Android/Chromium 身份、触摸和像素密度仿真 |
| macOS Tauri (14+) | 原生 WKWebView 子窗口;支持 OAuth 弹窗、下载和对话框 | 支持可见且归属已确认的会话预览 | WebKit 移动标识和 CSS 视口;输入受原生限制 |
| Linux Tauri | 原生 WebKitGTK 子窗口;支持 OAuth 弹窗、下载和对话框 | 支持可见且归属已确认的会话预览 | WebKit 移动标识和 CSS 视口;密度和输入受原生限制 |
| 普通浏览器/远程视图 | iframe 后备实现 | 无本地原生预览目标 | 仅尺寸 |
在 Electron 和 Windows Tauri 上,原生自动化在桌面启动时准备好,使用会话绑定的 `codenomad.browser` 工具进行打开、导航、快照、截图和页面操作:点击、输入、选择、悬停、拖动、滚动、按键以及等待文本出现。工具可用不代表对每个窗口都已获授权:执行会检查所属后端、原生能力、会话和可见目标。如果操作所针对的页面在此期间已被隐藏、替换或导航离开,该操作会被拒绝,而不会发送到其他页面;操作也绝不会被自动重复。页面内容仍不可信。目标禁止嵌入时 iframe 可能失败;请使用外部浏览器,不要削弱保护。
Tauri macOS/Linux 预览需手动打开,共享登录状态在重启后仍会保留,并使用原生后退/前进历史。原生预览会在应用内菜单和对话框下方隐藏;原生模式不支持元素评论。即使预览可见,`codenomad.browser` 也会拒绝 Tauri macOS/Linux 上的所有操作。
原生弹窗共享预览登录状态,保留 `opener`/`postMessage`,并随打开它的页面关闭。输入或恢复的地址仅允许无凭据的 HTTP(S);网站还可打开空白文档和同源 blob 文档。文件及应用协议被阻止。下载使用清理后的唯一名称保存到“下载”文件夹,不覆盖文件,也不自动打开。摄像头、麦克风、位置和通知始终被拒绝;已分类权限需要同意,未知请求显示错误。Tauri 和 Electron 支持 `alert`/`confirm`/`prompt`;Electron 的 prompt 对话框适用于预览顶层页面、弹窗和子框架。设备及屏幕选择列表可滚动查看可用项目。其他 API 取决于引擎和硬件。
网站可在关闭原生弹窗时要求确认。Linux 的指针锁定同意控件显示在所属窗口顶部。受 [WebKit 323277](https://bugs.webkit.org/show_bug.cgi?id=323277) 影响的 WebKitGTK 版本(包括 2.52.6)因安全措施阻止访问拖放的文件,上传时请使用页面的文件选择器。关闭或重新加载所属 CodeNomad 窗口前,请保存页面中的工作。
## 选择六种视口模板之一 {#templates}
1. 打开预览视口菜单。

View file

@ -55,14 +55,14 @@ export default defineConfig({
outDir: "dist/preload",
sourcemap: true,
lib: {
entry: resolve(__dirname, "electron/preload/index.cjs"),
entry: { index: resolve(__dirname, "electron/preload/index.cjs"), "browser-prompt": resolve(__dirname, "electron/preload/browser-prompt.cjs") },
formats: ["cjs"],
fileName: () => "index.js",
fileName: (_format, name) => `${name}.js`,
},
rollupOptions: {
external: ["electron"],
output: {
entryFileNames: "index.js",
entryFileNames: "[name].js",
},
},
},

View file

@ -0,0 +1,4 @@
declare module "*.css?raw" {
const css: string
export default css
}

View file

@ -0,0 +1,63 @@
import { webContents, type Session, type WebContents } from "electron"
import { nativeMenuLabels } from "./view-menu"
import { permissionOrigin } from "./browser-permission-policy"
import { showBrowserDialog } from "./browser-dialog"
import { browserGuestLifetime } from "./browser-guest-lifetime"
export function configureBrowserDeviceChoosers(target: Session): void {
const grants = new Set<string>()
const key = (kind: string, origin: string, id: string) => JSON.stringify([kind, origin, id])
const id = (device: Electron.HIDDevice | Electron.USBDevice | Electron.SerialPort) =>
"deviceId" in device ? device.deviceId : device.portId
target.setDevicePermissionHandler(details => {
const origin = permissionOrigin(details.origin)
return Boolean(origin && grants.has(key(details.deviceType, origin, id(details.device))))
})
async function choose(kind: string, devices: { id: string; label: string }[], contents: WebContents | undefined,
origin: string | undefined, stillOwned: () => boolean, callback: (id: string) => void) {
if (!contents || !origin || !devices.length) { callback(""); return }
const owner = browserGuestLifetime.owner(contents)
const revision = browserGuestLifetime.revision(contents)
const current = () => browserGuestLifetime.owner(contents) === owner && browserGuestLifetime.revision(contents) === revision && stillOwned()
const l = nativeMenuLabels(owner ?? null)
let chosen = ""
try {
const result = owner ? await showBrowserDialog(owner, origin,
l.browserPermission.replace("{permission}", kind).replace("{origin}", origin), { choices: devices.map(device => device.label) }, contents, current) : null
const selected = result !== null && /^(0|[1-9]\d*)$/.test(result) ? devices[Number(result)] : undefined
if (selected && current()) {
grants.add(key(kind, origin, selected.id))
chosen = selected.id
}
} catch {}
callback(chosen)
}
target.on("select-usb-device", (event, details, callback) => {
event.preventDefault()
const frame = details.frame
const origin = permissionOrigin(frame?.origin)
void choose("usb", details.deviceList.map(device => ({ id: device.deviceId, label: device.productName || device.deviceId })),
frame ? webContents.fromFrame(frame) : undefined, origin,
() => Boolean(frame && !frame.detached && permissionOrigin(frame.origin) === origin), callback)
})
target.on("select-hid-device", (event, details, callback) => {
event.preventDefault()
const frame = details.frame
const origin = permissionOrigin(frame?.origin)
void choose("hid", details.deviceList.map(device => ({ id: device.deviceId, label: device.name || device.deviceId })),
frame ? webContents.fromFrame(frame) : undefined, origin,
() => Boolean(frame && !frame.detached && permissionOrigin(frame.origin) === origin), callback)
})
target.on("select-serial-port", (event, ports, contents, callback) => {
event.preventDefault()
// This event exposes a tab. Its origin is usable only when every possible
// requesting frame has the same, non-opaque origin.
const frame = contents.mainFrame
const mainOrigin = permissionOrigin(frame.origin)
const unambiguous = () => Boolean(mainOrigin && frame.framesInSubtree.every(child => permissionOrigin(child.origin) === mainOrigin))
const origin = unambiguous() ? mainOrigin : undefined
void choose("serial", ports.map(port => ({ id: port.portId, label: port.displayName || port.portName })), contents,
origin, () => !frame.detached && unambiguous() && permissionOrigin(frame.origin) === origin, callback)
})
}

View file

@ -0,0 +1,65 @@
import { BrowserWindow, type WebContents } from "electron"
import { randomUUID } from "node:crypto"
import { nativeMenuLabels } from "./view-menu"
const escape = (text: string) => text.replace(/[&<>"']/g, value => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[value]!)
const dialogs = new WeakSet<BrowserWindow>()
export const isBrowserDialog = (window: BrowserWindow) => dialogs.has(window)
/** Trusted, preload-free app form. Only its own cancelled navigation returns data. */
export async function showBrowserDialog(owner: BrowserWindow, origin: string, message: string,
input: { text: string } | { choices: readonly string[] }, source?: WebContents,
current: () => boolean = () => !owner.isDestroyed() && !source?.isDestroyed()): Promise<string | null> {
const [{ default: windowCss }, { default: dialogCss }] = await Promise.all([
import("../../../ui/src/styles/components/window.css?raw"),
import("../../../ui/src/styles/components/browser-native-dialog.css?raw"),
])
if (!current()) return null
const l = nativeMenuLabels(owner)
const target = `https://codenomad.invalid/${randomUUID()}`
const field = "text" in input
? `<input id="value" name="value" value="${escape(input.text)}" autofocus>`
: `<select id="value" name="value" size="10" required autofocus><option value="" disabled selected></option>${input.choices.map((choice, index) => `<option value="${index}">${escape(choice)}</option>`).join("")}</select>`
const html = `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action https://codenomad.invalid"><style>${windowCss}\n${dialogCss}</style><body class="browser-native-dialog"><form action="${target}"><main class="window-body"><label for="value">${escape(message)}</label>${field}</main><footer class="window-footer"><button class="button-primary" name="action" value="accept">${escape(l.browserPromptAccept)}</button><button class="button-secondary" name="action" value="cancel" formnovalidate>${escape(l.browserPromptCancel)}</button></footer></form>`
const dialog = new BrowserWindow({ parent: owner, modal: true, show: false, width: 520, height: "text" in input ? 240 : 440,
title: origin, autoHideMenuBar: true,
webPreferences: { partition: `browser-dialog-${randomUUID()}`, nodeIntegration: false, contextIsolation: true, sandbox: true,
webSecurity: true, webviewTag: false, disableDialogs: true } })
dialogs.add(dialog)
dialog.webContents.session.setPermissionCheckHandler(() => false)
dialog.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
dialog.setMenu(null)
dialog.webContents.setWindowOpenHandler(() => ({ action: "deny" }))
return new Promise(resolve => {
let settled = false
const finish = (value: string | null) => {
if (settled) return
settled = true
owner.removeListener("closed", cancel)
source?.removeListener("destroyed", cancel)
source?.removeListener("did-navigate", cancel)
source?.removeListener("did-frame-navigate", cancel)
source?.removeListener("render-process-gone", cancel)
resolve(current() ? value : null)
if (!dialog.isDestroyed()) dialog.destroy()
}
const cancel = () => finish(null)
owner.once("closed", cancel)
source?.once("destroyed", cancel)
source?.once("did-navigate", cancel)
source?.once("did-frame-navigate", cancel)
source?.once("render-process-gone", cancel)
dialog.once("closed", cancel)
dialog.webContents.on("will-navigate", (event, url) => {
event.preventDefault()
try {
const result = new URL(url)
if (`${result.origin}${result.pathname}` !== target) return
finish(result.searchParams.get("action") === "accept" ? result.searchParams.get("value") ?? "" : null)
} catch { finish(null) }
})
dialog.webContents.on("before-input-event", (_event, key) => { if (key.type === "keyDown" && key.key === "Escape") cancel() })
dialog.once("ready-to-show", () => { if (!dialog.isDestroyed()) dialog.show() })
void dialog.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`).catch(cancel)
})
}

View file

@ -0,0 +1,34 @@
import { desktopCapturer, webContents, type Session, type Streams } from "electron"
import { permissionOrigin } from "./browser-permission-policy"
import { nativeMenuLabels } from "./view-menu"
import { showBrowserDialog } from "./browser-dialog"
import { browserGuestLifetime } from "./browser-guest-lifetime"
export function configureBrowserDisplayChooser(target: Session): void {
target.setDisplayMediaRequestHandler((request, callback) => {
const frame = request.frame
const origin = permissionOrigin(request.securityOrigin)
if (!frame || !origin || !request.userGesture || !request.videoRequested) { callback(null); return }
void (async () => {
let streams: Streams | null = null
try {
const contents = webContents.fromFrame(frame)
if (!contents) throw new Error("Expired browser frame")
const owner = browserGuestLifetime.owner(contents)
const revision = browserGuestLifetime.revision(contents)
const current = () => browserGuestLifetime.owner(contents) === owner && browserGuestLifetime.revision(contents) === revision
&& !frame.detached && permissionOrigin(frame.origin) === origin
const l = nativeMenuLabels(owner ?? null)
const sources = await desktopCapturer.getSources({ types: ["screen", "window"], thumbnailSize: { width: 0, height: 0 } })
if (owner && sources.length && current()) {
const result = await showBrowserDialog(owner, origin,
l.browserPermission.replace("{permission}", "display-capture").replace("{origin}", origin),
{ choices: sources.map(source => source.name) }, contents, current)
const selected = result !== null && /^(0|[1-9]\d*)$/.test(result) ? sources[Number(result)] : undefined
if (selected && current()) streams = { video: selected }
}
} catch (error) { console.warn("[browser-display] request unavailable", error) }
callback(streams)
})()
}, { useSystemPicker: true })
}

View file

@ -0,0 +1,23 @@
import assert from "node:assert/strict"
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join, basename } from "node:path"
import { test } from "node:test"
import { reserveBrowserDownload } from "./browser-downloads"
test("download reservations are confined, sanitized, unique and never replace files", () => {
const directory = mkdtempSync(join(tmpdir(), "codenomad-download-"))
try {
for (const name of ["../../secret.txt", "..\\..\\secret.txt", "..", "a\n:b?.txt", "é".repeat(300)]) {
const first = reserveBrowserDownload(directory, name)
writeFileSync(first, "original")
const second = reserveBrowserDownload(directory, name)
assert.notEqual(first, second)
assert.equal(dirname(first), directory)
assert.equal(/[\x00-\x1f/\\:*?"<>|]/.test(basename(first)), false)
assert.ok(Buffer.byteLength(basename(first)) <= 217)
assert.equal(readFileSync(first, "utf8"), "original")
}
assert.throws(() => reserveBrowserDownload(join(directory, "missing"), "file"))
} finally { rmSync(directory, { recursive: true, force: true }) }
})

View file

@ -0,0 +1,41 @@
import { randomUUID } from "node:crypto"
import { closeSync, openSync, statSync, unlinkSync } from "node:fs"
import { basename, join } from "node:path"
import type { Session } from "electron"
export function reserveBrowserDownload(directory: string, suggested: string): string {
if (!statSync(directory).isDirectory()) throw new Error("Downloads directory unavailable")
const source = basename(suggested.replaceAll("\\", "/")).replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, "")
let name = ""
for (const character of source) {
if (Buffer.byteLength(name + character) > 180) break
name += character
}
name = name.replace(/^[ .]+|[ .]+$/g, "") || "download"
const path = join(directory, `${randomUUID()}-${name}`)
closeSync(openSync(path, "wx"))
return path
}
export function configureBrowserDownloads(target: Session, directory: string): void {
target.on("will-download", (event, item, contents) => {
let path: string | undefined
try {
const url = new URL(item.getURL())
if (!contents || contents.isDestroyed() || !["http:", "https:", "blob:", "data:"].includes(url.protocol)
|| url.username || url.password) throw new Error("Blocked browser download")
path = reserveBrowserDownload(directory, item.getFilename())
item.setSavePath(path)
const reserved = path
item.once("done", (_event, state) => {
if (state !== "completed") {
try { if (statSync(reserved).size === 0) unlinkSync(reserved) } catch {}
}
})
} catch (error) {
event.preventDefault()
if (path) { try { unlinkSync(path) } catch {} }
console.warn("[browser-download]", error)
}
})
}

View file

@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import test from "node:test"
import { BrowserGuestLifetime } from "./browser-guest-lifetime"
function fixture() {
const lifetime = new BrowserGuestLifetime()
const closed: string[] = []
const guest = (name: string) => ({ isDestroyed: () => false, close: (options: unknown) => {
assert.deepEqual(options, { waitForBeforeUnload: false })
assert.equal(lifetime.owner(root as never), undefined)
assert.equal(lifetime.owner(child as never), undefined)
closed.push(name)
} })
const owner = { isDestroyed: () => false, webContents: { isDestroyed: () => false } }
const root = guest("root"), child = guest("child"), grandchild = guest("grandchild")
lifetime.register(owner as never, root as never)
lifetime.register(owner as never, child as never, root as never)
lifetime.register(owner as never, grandchild as never, child as never)
return { lifetime, closed, owner, root, child, grandchild }
}
test("owner cleanup atomically invalidates nested previews before forced destruction and isolates peers", () => {
const f = fixture()
const peerOwner = { isDestroyed: () => false, webContents: { isDestroyed: () => false } }
const peer = { isDestroyed: () => false }
f.lifetime.register(peerOwner as never, peer as never)
f.lifetime.removeOwner(f.owner as never)
assert.deepEqual(new Set(f.closed), new Set(["root", "child", "grandchild"]))
assert.equal(f.lifetime.owner(peer as never), peerOwner)
f.lifetime.removeOwner(f.owner as never)
assert.equal(f.closed.length, 3)
assert.throws(() => f.lifetime.register(f.owner as never, {} as never, f.root as never), /Expired/)
})
test("document replacement rotates the callback fence and destroys every descendant", () => {
const f = fixture()
// Destruction callbacks see the retained root, but all descendants are absent.
const popup = { isDestroyed: () => false, destroy: () => {
assert.equal(f.lifetime.owner(f.child as never), undefined)
assert.equal(f.lifetime.owner(f.grandchild as never), undefined)
} }
f.lifetime.register(f.owner as never, f.child as never, f.root as never, popup as never)
f.lifetime.register(f.owner as never, f.grandchild as never, f.child as never, popup as never)
const before = f.lifetime.revision(f.root as never)
f.lifetime.replaceDocument(f.root as never)
assert.notEqual(f.lifetime.revision(f.root as never), before)
assert.equal(f.lifetime.owner(f.root as never), f.owner)
const other = { isDestroyed: () => false, webContents: { isDestroyed: () => false } }
assert.throws(() => f.lifetime.register(other as never, {} as never, f.root as never), /Expired/)
})

View file

@ -0,0 +1,58 @@
import type { BrowserWindow, WebContents } from "electron"
/** Invalidate the whole tree before native destruction can dispatch callbacks. */
export class BrowserGuestLifetime {
private readonly entries = new Map<WebContents, { owner: BrowserWindow; parent?: WebContents; popup?: BrowserWindow; document: symbol }>()
register(owner: BrowserWindow, guest: WebContents, parent?: WebContents, popup?: BrowserWindow): void {
if (owner.isDestroyed() || owner.webContents.isDestroyed() || (parent && this.owner(parent) !== owner)) throw new Error("Expired preview owner")
this.entries.set(guest, { owner, parent, popup, document: Symbol() })
}
owner(guest: WebContents | null): BrowserWindow | undefined {
return guest && this.live(guest) ? this.entries.get(guest)?.owner : undefined
}
live(guest: WebContents): boolean {
const entry = this.entries.get(guest)
return Boolean(entry && !entry.owner.isDestroyed() && !entry.owner.webContents.isDestroyed() && !guest.isDestroyed())
}
descendants(opener: WebContents): void { this.dispose(new Set([opener]), false) }
revision(guest: WebContents): symbol | undefined { return this.entries.get(guest)?.document }
invalidateCallbacks(guest: WebContents): void {
const entry = this.entries.get(guest)
if (entry) entry.document = Symbol()
}
replaceDocument(guest: WebContents): void {
this.invalidateCallbacks(guest)
this.descendants(guest)
}
remove(guest: WebContents): void { this.dispose(new Set([guest]), true) }
removeOwner(owner: BrowserWindow): void {
this.dispose(new Set([...this.entries].filter(([, entry]) => entry.owner === owner).map(([guest]) => guest)), true)
}
private dispose(roots: Set<WebContents>, includeRoots: boolean): void {
const selected = new Set<WebContents>(includeRoots ? roots : [])
// ponytail: scan per tree depth; index parents if preview trees become large.
let changed = true
while (changed) {
changed = false
for (const [guest, entry] of this.entries) {
if (entry.parent && (roots.has(entry.parent) || selected.has(entry.parent)) && !selected.has(guest)) {
selected.add(guest); changed = true
}
}
}
const entries = [...selected].map(guest => [guest, this.entries.get(guest)] as const)
for (const [guest] of entries) this.entries.delete(guest)
for (const [guest, entry] of entries.reverse()) {
if (!entry) continue
if (entry.popup && !entry.popup.isDestroyed()) entry.popup.destroy()
else if (!guest.isDestroyed()) guest.close({ waitForBeforeUnload: false })
}
}
}
export const browserGuestLifetime = new BrowserGuestLifetime()

View file

@ -0,0 +1,99 @@
import { BrowserWindow, clipboard, dialog, Menu, type WebContents } from "electron"
import { isBrowserDownloadUrlAllowed, isBrowserSiteUrlAllowed, isBrowserUrlAllowed } from "./browser-webview-security"
import { nativeMenuLabels } from "./view-menu"
import { isBrowserDialog } from "./browser-dialog"
import { installBrowserPrompt } from "./browser-prompt"
import { browserGuestLifetime } from "./browser-guest-lifetime"
const windows = new WeakSet<BrowserWindow>()
const observedOwners = new WeakSet<BrowserWindow>()
export const isBrowserAuxiliaryWindow = (window: BrowserWindow) => windows.has(window) || isBrowserDialog(window)
export const browserGuestOwner = (contents: WebContents | null) => browserGuestLifetime.owner(contents)
let promptConfigured = false
export function configureBrowserGuests(): void {
if (promptConfigured) return
promptConfigured = true
installBrowserPrompt(browserGuestOwner)
}
export function focusedBrowserGuest(window: BrowserWindow, focused: WebContents | null): WebContents | undefined {
return focused && browserGuestOwner(focused) === window ? focused : undefined
}
/** Called before a popup's first navigation, and on each attached preview root. */
export function protectBrowserGuest(owner: BrowserWindow, guest: WebContents, inheritedOrigin = "", parent?: WebContents, popup?: BrowserWindow): void {
browserGuestLifetime.register(owner, guest, parent, popup)
if (!observedOwners.has(owner)) {
observedOwners.add(owner)
const cleanup = () => browserGuestLifetime.removeOwner(owner)
owner.on("close", event => { if (!event.defaultPrevented) cleanup() })
owner.once("closed", cleanup)
owner.webContents.once("destroyed", cleanup)
owner.webContents.on("did-navigate", cleanup)
owner.webContents.on("render-process-gone", cleanup)
}
const live = () => browserGuestLifetime.live(guest)
let origin = isBrowserUrlAllowed(guest.getURL()) ? guest.getURL() : inheritedOrigin
guest.once("destroyed", () => browserGuestLifetime.remove(guest))
guest.on("render-process-gone", () => browserGuestLifetime.replaceDocument(guest))
guest.on("did-navigate", (_event, url) => {
browserGuestLifetime.replaceDocument(guest)
if (live() && isBrowserUrlAllowed(url)) origin = url
})
guest.on("did-frame-navigate", (_event, _url, _code, _status, main) => {
if (!main) browserGuestLifetime.invalidateCallbacks(guest)
})
const guard = (event: Electron.Event, url: string) => {
if (!live() || !isBrowserSiteUrlAllowed(url, origin)) event.preventDefault()
}
guest.on("will-navigate", guard)
guest.on("will-redirect", guard)
guest.on("will-prevent-unload", event => {
if (!live()) { event.preventDefault(); return }
const l = nativeMenuLabels(owner)
const response = dialog.showMessageBoxSync(BrowserWindow.fromWebContents(guest) ?? owner, {
type: "question", title: origin,
message: l.browserPermission.replace("{permission}", "navigation").replace("{origin}", origin),
buttons: [l.browserDeny, l.browserAllow], defaultId: 0, cancelId: 0, noLink: true,
})
if (response === 1) event.preventDefault()
})
guest.setWindowOpenHandler(({ url }) => {
if (!live() || !isBrowserSiteUrlAllowed(url, origin)) return { action: "deny" }
const document = browserGuestLifetime.revision(guest)
return { action: "allow", outlivesOpener: false, createWindow: options => {
if (!live() || browserGuestLifetime.revision(guest) !== document) throw new Error("Expired preview opener")
const popup = new BrowserWindow({ ...options, parent: owner, width: 520, height: 680,
frame: true, titleBarStyle: "default", autoHideMenuBar: true,
webPreferences: { ...options.webPreferences, preload: undefined, session: guest.session, nodeIntegration: false, nodeIntegrationInWorker: false,
nodeIntegrationInSubFrames: true, contextIsolation: true, sandbox: true, webviewTag: false,
webSecurity: true, allowRunningInsecureContent: false, disableDialogs: false } })
windows.add(popup)
popup.setMenu(null)
protectBrowserGuest(owner, popup.webContents, origin, guest, popup)
return popup.webContents
} }
})
guest.on("context-menu", (_event, params) => {
if (!live()) return
const document = browserGuestLifetime.revision(guest)
const current = () => live() && browserGuestLifetime.revision(guest) === document
const l = nativeMenuLabels(owner)
const items: Electron.MenuItemConstructorOptions[] = []
if (params.isEditable) items.push({ role: "undo", label: l.undo }, { role: "redo", label: l.redo },
{ role: "cut", label: l.cut }, { role: "copy", label: l.copy }, { role: "paste", label: l.paste },
{ role: "selectAll", label: l.selectAll })
else if (params.selectionText) items.push({ label: l.copy, click: () => guest.copy() })
if (params.linkURL) items.push({ label: l.browserCopyLink, click: () => { if (current()) clipboard.writeText(params.linkURL) } })
if (isBrowserSiteUrlAllowed(params.linkURL, origin)) items.push(
{ label: l.browserOpenLink, click: () => {
const frame = params.frame
if (current() && frame && !frame.detached) void frame.executeJavaScript(`window.open(${JSON.stringify(params.linkURL)},'_blank')`, true)
.catch(error => console.warn("[browser-popup] expired frame", error))
} })
if (params.mediaType === "image" && isBrowserDownloadUrlAllowed(params.srcURL, origin))
items.push({ label: l.browserSaveImage, click: () => { if (current()) guest.downloadURL(params.srcURL) } })
items.push({ label: l.browserInspect, click: () => { if (current()) guest.inspectElement(params.x, params.y) } })
Menu.buildFromTemplate(items).popup({ window: BrowserWindow.fromWebContents(guest) ?? owner })
})
}

View file

@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { browserPermissionDecision, permissionOrigin, permissionResource } from "./browser-permission-policy"
test("four protected permissions stay denied and unknown kinds fail closed", () => {
for (const permission of ["media", "camera", "microphone", "geolocation", "geolocation-approximate", "notifications"])
assert.equal(browserPermissionDecision(permission), "deny")
for (const permission of ["unknown", "future-permission", "openExternal"]) assert.equal(browserPermissionDecision(permission), "unknown")
for (const permission of ["clipboard-read", "local-network-access", "local-network", "loopback-network", "fullscreen", "pointerLock", "midi", "local-fonts", "sensors", "screen-wake-lock", "storage-access", "window-management", "payment-handler"])
assert.equal(browserPermissionDecision(permission), "consent")
for (const permission of ["hid", "usb", "serial"]) assert.equal(browserPermissionDecision(permission), "chooser")
assert.equal(browserPermissionDecision("clipboard-sanitized-write"), "browser")
})
test("file grants remain path/access scoped and storage access remains embedder scoped", () => {
const file = { filePath: "/tmp/one", fileAccessType: "readable", isDirectory: false }
assert.notEqual(permissionResource("fileSystem", file), permissionResource("fileSystem", { ...file, filePath: "/tmp/two" }))
assert.notEqual(permissionResource("fileSystem", file), permissionResource("fileSystem", { ...file, fileAccessType: "writable" }))
assert.equal(permissionResource("fileSystem", {}), undefined)
assert.notEqual(permissionResource("storage-access", {}, "https://one.example"), permissionResource("storage-access", {}, "https://two.example"))
assert.equal(permissionResource("storage-access", {}), undefined)
})
test("permission identity is the requesting HTTP origin, never its embedding page", () => {
assert.equal(permissionOrigin("https://frame.example:8443/path"), "https://frame.example:8443")
for (const raw of ["about:blank", "file:///private", "https://user:pass@example.com", "null", "blob:https://example.com/id"])
assert.equal(permissionOrigin(raw), undefined)
})

View file

@ -0,0 +1,37 @@
export type BrowserPermissionDecision = "deny" | "browser" | "chooser" | "consent" | "unknown"
const denied = new Set(["media", "camera", "microphone", "geolocation", "geolocation-approximate", "notifications"])
const consent = new Set([
"clipboard-read", "deprecated-sync-clipboard-read", "local-network-access", "local-network", "loopback-network",
"fullscreen", "pointerLock", "midi", "midiSysex", "hid", "usb", "serial", "local-fonts", "sensors",
"screen-wake-lock", "system-wake-lock", "persistent-storage", "storage-access", "top-level-storage-access",
"window-management", "payment-handler", "fileSystem", "mediaKeySystem", "speaker-selection", "idle-detection",
"background-fetch", "background-sync", "periodic-background-sync", "display-capture", "keyboardLock",
"automatic-downloads",
])
export function browserPermissionDecision(permission: string): BrowserPermissionDecision {
if (denied.has(permission)) return "deny"
if (permission === "clipboard-sanitized-write") return "browser"
if (["hid", "usb", "serial", "display-capture"].includes(permission)) return "chooser"
return consent.has(permission) ? "consent" : "unknown"
}
export function permissionOrigin(raw: string | undefined): string | undefined {
try {
const url = new URL(raw ?? "")
if ((url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password) return url.origin
} catch {}
}
export function permissionResource(permission: string, details: unknown, embeddingOrigin?: string): string | undefined {
if (permission === "fileSystem") {
if (!details || typeof details !== "object") return undefined
const value = details as { filePath?: unknown; fileAccessType?: unknown; isDirectory?: unknown }
if (typeof value.filePath !== "string" || !value.filePath || (value.fileAccessType !== "readable" && value.fileAccessType !== "writable")
|| typeof value.isDirectory !== "boolean") return undefined
return JSON.stringify([value.filePath, value.fileAccessType, value.isDirectory])
}
if (permission === "storage-access" || permission === "top-level-storage-access") return permissionOrigin(embeddingOrigin)
return ""
}

View file

@ -0,0 +1,70 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { readFileSync } from "node:fs"
import vm from "node:vm"
import { createBrowserPromptHandler, validPromptRequest } from "./browser-prompt-policy"
import type { BrowserWindow, IpcMainEvent, WebContents } from "electron"
test("preview preload exposes only standard prompt and can send only its dedicated channel", () => {
const page: Record<string, unknown> = { prompt: () => { throw Error("unsupported") } }
const calls: unknown[][] = []
const source = readFileSync(new URL("../preload/browser-prompt.cjs", import.meta.url), "utf8")
vm.runInNewContext(source, { require: (name: string) => {
assert.equal(name, "electron")
return { contextBridge: {
executeInMainWorld: ({ func }: { func: Function }) => vm.runInNewContext(`(${func.toString()})()`, { window: page }),
exposeInMainWorld: (key: string, value: unknown) => { assert.equal(key, "prompt"); page[key] = value },
}, ipcRenderer: { sendSync: (...args: unknown[]) => { calls.push(args); return "answer" } } }
} })
assert.deepEqual(Object.keys(page), ["prompt"])
assert.equal((page.prompt as Function)("message", "default"), "answer")
assert.deepEqual(JSON.parse(JSON.stringify(calls)), [["codenomad:browser-prompt", { message: "message", value: "default" }]])
for (const key of ["require", "ipcRenderer", "electronAPI", "contextBridge", "send", "invoke"]) assert.equal(page[key], undefined)
})
test("main rejects non-preview and foreign frames, accepts only bounded prompt values", async () => {
const frame = { origin: "https://frame.example", detached: false }
const sender = { isDestroyed: () => false, mainFrame: { framesInSubtree: [frame] } } as unknown as WebContents
const owner = { isDestroyed: () => false } as BrowserWindow
let shown = 0
const handler = createBrowserPromptHandler(contents => contents === sender ? owner : undefined,
async (_owner, origin, message, value) => { shown++; assert.equal(origin, frame.origin); assert.equal(message, "m"); return value })
const event = (contents: WebContents = sender, source = frame) => ({ sender: contents, senderFrame: source } as unknown as IpcMainEvent)
const foreign = event({} as WebContents); handler(foreign, { message: "m", value: "s" }); assert.equal(foreign.returnValue, null)
const forged = event(sender, { ...frame }); handler(forged, { message: "m", value: "s" }); assert.equal(forged.returnValue, null)
const valid = event(); handler(valid, { message: "m", value: "s" }); await new Promise(resolve => setImmediate(resolve));
assert.equal(valid.returnValue, "s"); assert.equal(shown, 1)
const next = event()
const immediate = event()
Object.defineProperty(immediate, "returnValue", { set: () => handler(next, { message: "m", value: "next" }) })
handler(immediate, { message: "m", value: "first" })
await new Promise(resolve => setImmediate(resolve))
assert.equal(next.returnValue, "next")
assert.equal(shown, 3)
for (const args of [null, [], { message: "m", value: "s", channel: "other" }, { message: "m", value: "x".repeat(65_537) }]) assert.equal(validPromptRequest(args), false)
})
test("late prompt replies are cancelled after document replacement or owner retirement", async () => {
for (const cause of ["document", "owner", "frame"] as const) {
const frame = { origin: "https://frame.example", detached: false }
const sender = { isDestroyed: () => false, mainFrame: { framesInSubtree: [frame] } } as unknown as WebContents
const owner = { isDestroyed: () => false } as BrowserWindow
let live = true, revision = Symbol()
let resolve!: (value: string) => void
let current!: () => boolean
const handler = createBrowserPromptHandler(() => live ? owner : undefined,
(_owner, _origin, _message, _value, source, guard) => {
assert.equal(source, sender); current = guard
return new Promise<string>(answer => { resolve = answer })
}, () => revision)
const event = { sender, senderFrame: frame } as unknown as IpcMainEvent
handler(event, { message: "m", value: "s" })
if (cause === "document") revision = Symbol()
if (cause === "owner") live = false
if (cause === "frame") frame.detached = true
assert.equal(current(), false)
resolve("stale-answer")
await new Promise(resolve => setImmediate(resolve))
assert.equal(event.returnValue, null)
}
})

View file

@ -0,0 +1,31 @@
import type { BrowserWindow, IpcMainEvent, WebContents } from "electron"
export function validPromptRequest(value: unknown): value is { message: string; value: string } {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
const args = value as Record<string, unknown>
return Object.keys(args).length === 2 && typeof args.message === "string" && args.message.length <= 65_536
&& typeof args.value === "string" && args.value.length <= 65_536
}
export function createBrowserPromptHandler(ownerOf: (contents: WebContents) => BrowserWindow | undefined,
show: (owner: BrowserWindow, origin: string, message: string, value: string, source: WebContents, current: () => boolean) => Promise<string | null>,
revisionOf: (contents: WebContents) => unknown = () => null) {
const pending = new Set<WebContents>()
return (event: IpcMainEvent, args: unknown) => {
const owner = ownerOf(event.sender)
try {
if (!owner || owner.isDestroyed() || event.sender.isDestroyed() || pending.has(event.sender)
|| !validPromptRequest(args) || !event.senderFrame || event.senderFrame.detached
|| !event.sender.mainFrame.framesInSubtree.includes(event.senderFrame)) { event.returnValue = null; return }
const origin = event.senderFrame.origin
const revision = revisionOf(event.sender)
const current = () => !owner.isDestroyed() && !event.sender.isDestroyed() && ownerOf(event.sender) === owner
&& revisionOf(event.sender) === revision && !event.senderFrame?.detached
pending.add(event.sender)
const reply = (value: string | null) => { try { if (!event.sender.isDestroyed()) event.returnValue = current() ? value : null } catch {} }
void show(owner, origin, args.message, args.value, event.sender, current)
.then(result => { pending.delete(event.sender); reply(typeof result === "string" ? result : null) },
() => { pending.delete(event.sender); reply(null) })
} catch { pending.delete(event.sender); event.returnValue = null }
}
}

View file

@ -0,0 +1,19 @@
import { ipcMain, type WebContents, type BrowserWindow, type Session } from "electron"
import { showBrowserDialog } from "./browser-dialog"
import { createBrowserPromptHandler } from "./browser-prompt-policy"
import { browserGuestLifetime } from "./browser-guest-lifetime"
export const browserPromptChannel = "codenomad:browser-prompt"
const sessions = new WeakSet<Session>()
export function configureBrowserPromptSession(target: Session, preload: string): void {
if (sessions.has(target)) return
target.registerPreloadScript({ id: browserPromptChannel, type: "frame", filePath: preload,
matches: ["http://*/*", "https://*/*"], matchOriginAsFallback: true })
sessions.add(target)
}
export function installBrowserPrompt(ownerOf: (contents: WebContents) => BrowserWindow | undefined): void {
ipcMain.on(browserPromptChannel, createBrowserPromptHandler(ownerOf,
(owner, origin, message, value, source, current) => showBrowserDialog(owner, origin, message, { text: value }, source, current),
source => browserGuestLifetime.revision(source)))
}

View file

@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import type { WebPreferences } from "electron"
import { browserPartition, isBrowserUrlAllowed, secureBrowserWebview } from "./browser-webview-security"
import { browserPartition, isBrowserUrlAllowed, isBrowserSiteUrlAllowed, isBrowserDownloadUrlAllowed, secureBrowserWebview } from "./browser-webview-security"
describe("browser webview security", () => {
it("accepts only credential-free HTTP(S) URLs in the browser partition", () => {
@ -15,10 +15,23 @@ describe("browser webview security", () => {
assert.equal(secureBrowserWebview(preferences, { partition: browserPartition("session_1"), src: "https://github.com/" }), true)
assert.equal(preferences.preload, undefined)
assert.equal(preferences.nodeIntegration, false)
assert.equal(preferences.nodeIntegrationInWorker, false)
assert.equal(preferences.nodeIntegrationInSubFrames, true)
assert.equal(preferences.contextIsolation, true)
assert.equal(preferences.sandbox, true)
assert.equal(preferences.webSecurity, true)
assert.equal(preferences.allowRunningInsecureContent, false)
assert.equal(secureBrowserWebview({}, { partition: "persist:other", src: "https://github.com/" }), false)
assert.equal(secureBrowserWebview({}, { partition: "persist:codenomad-browser-../other", src: "https://github.com/" }), false)
assert.notEqual(browserPartition("session_1"), browserPartition("session_2"))
})
it("allows blank OAuth windows and only same-origin blob documents without weakening typed URLs", () => {
assert.equal(isBrowserSiteUrlAllowed("about:blank", "https://example.com/"), true)
assert.equal(isBrowserSiteUrlAllowed("blob:https://example.com/id", "https://example.com/"), true)
assert.equal(isBrowserSiteUrlAllowed("blob:https://other.example/id", "https://example.com/"), false)
assert.equal(isBrowserSiteUrlAllowed("file:///private", "https://example.com/"), false)
assert.equal(isBrowserUrlAllowed("about:blank"), false)
assert.equal(isBrowserDownloadUrlAllowed("data:image/png;base64,AA", "https://example.com/"), true)
assert.equal(isBrowserDownloadUrlAllowed("file:///private", "https://example.com/"), false)
})
})

View file

@ -16,15 +16,36 @@ export function isBrowserUrlAllowed(rawUrl: string): boolean {
}
}
export function isBrowserSiteUrlAllowed(rawUrl: string, openerUrl: string): boolean {
if (isBrowserUrlAllowed(rawUrl) || /^about:blank(?:[?#]|$)/.test(rawUrl)) return true
try {
const url = new URL(rawUrl)
return url.protocol === "blob:" && isBrowserUrlAllowed(openerUrl) && url.origin === new URL(openerUrl).origin
} catch { return false }
}
export function isBrowserDownloadUrlAllowed(rawUrl: string, openerUrl: string): boolean {
try {
const url = new URL(rawUrl)
return url.protocol === "data:" || (url.protocol !== "about:" && isBrowserSiteUrlAllowed(rawUrl, openerUrl))
} catch { return false }
}
export function secureBrowserWebview(webPreferences: WebPreferences, params: Record<string, string>): boolean {
if (!params.partition.startsWith(browserPartitionPrefix)
|| !/^[A-Za-z0-9_-]{1,256}$/.test(params.partition.slice(browserPartitionPrefix.length))
|| !isBrowserUrlAllowed(params.src)) return false
delete webPreferences.preload
webPreferences.nodeIntegration = false
webPreferences.nodeIntegrationInWorker = false
// Electron gates frame preloads behind this flag, even with sandbox enabled.
// The separate preview session has only the prompt preload; page Node stays off.
webPreferences.nodeIntegrationInSubFrames = true
webPreferences.webviewTag = false
webPreferences.contextIsolation = true
webPreferences.sandbox = true
webPreferences.webSecurity = true
webPreferences.allowRunningInsecureContent = false
webPreferences.disableDialogs = false
return true
}

View file

@ -10,6 +10,9 @@ import { fileURLToPath, pathToFileURL } from "node:url"
import { appendNodeOption, DeveloperMode } from "./developer-mode"
import { BrowserController, handleNativeBrowserRequest } from "./browser-controller"
import { isBrowserUrlAllowed, secureBrowserWebview } from "./browser-webview-security"
import { protectBrowserGuest, isBrowserAuxiliaryWindow, configureBrowserGuests } from "./browser-guests"
import { configureBrowserDownloads } from "./browser-downloads"
import { configureBrowserPromptSession } from "./browser-prompt"
import { ClientStateManager } from "./client-state"
import { setupClientStateIPC } from "./client-state-ipc"
import { ClientStateNavigationController } from "./client-state-navigation"
@ -215,7 +218,7 @@ function runPrimary(firstIntent: LaunchIntent) {
lifecycle = new MultiwindowLifecycle({
app, clientStateManager: clientState, cliManager: cli,
getLocalWindows: () => registry.all(), getAllWindows: () => BrowserWindow.getAllWindows(),
isSupportWindow: (window) => preferencesWindows.current() === window,
isSupportWindow: (window) => preferencesWindows.current() === window || isBrowserAuxiliaryWindow(window),
isLaunchWindow: (window) => launchWindows.has(window),
flushSupportWindows: async () => {
const window = preferencesWindows.current()
@ -290,6 +293,7 @@ function runPrimary(firstIntent: LaunchIntent) {
(error) => console.error("[cli] bootstrap token exchange failed", error),
)
configureBrowserGuests()
const createWindow = (windowId: string, persisted = true, connectOnly = false): LocalWindowRecord => {
const saved = persisted ? clientState.getWindowState(windowId) : undefined
const minimum = zoomedWindowMinimum(saved?.zoomFactor ?? 1)
@ -333,27 +337,16 @@ function runPrimary(firstIntent: LaunchIntent) {
return
}
const browserSession = session.fromPartition(params.partition)
configureBrowserPromptSession(browserSession, join(mainDirname, "../preload/browser-prompt.js"))
if (!securedBrowserSessions.has(browserSession)) {
securedBrowserSessions.add(browserSession)
configureBrowserPermissionHandlers(browserSession)
browserSession.on("will-download", (downloadEvent) => downloadEvent.preventDefault())
configureBrowserDownloads(browserSession, app.getPath("downloads"))
}
})
window.webContents.on("did-attach-webview", (_event, guest) => {
browserController.observeGuest(window.webContents, guest)
const guardNavigation = (event: Electron.Event, url: string) => {
if (!isBrowserUrlAllowed(url)) event.preventDefault()
}
guest.on("will-navigate", guardNavigation)
guest.on("will-redirect", guardNavigation)
guest.setWindowOpenHandler(({ url }) => {
if (isBrowserUrlAllowed(url)) queueMicrotask(() => {
try {
if (!guest.isDestroyed()) void guest.loadURL(url).catch(() => undefined)
} catch {}
})
return { action: "deny" }
})
protectBrowserGuest(window, guest)
})
window.webContents.on("did-start-navigation", (_event, _url, _isInPlace, isMainFrame) => {
if (isMainFrame) setWorkspaceMenuEnabled(window, false)

View file

@ -1,4 +1,5 @@
import { app, BrowserWindow, Menu, shell, dialog, type MenuItemConstructorOptions } from "electron"
import { app, BrowserWindow, Menu, shell, dialog, webContents, type MenuItemConstructorOptions } from "electron"
import { browserGuestOwner, focusedBrowserGuest } from "./browser-guests"
import { NEW_WINDOW_ACCELERATOR } from "./menu-target"
import { setWindowZoomLevel } from "./window-state"
import { nativeMenuLabels, setViewMenuState, updateViewMenu, viewMenuItems } from "./view-menu"
@ -23,7 +24,8 @@ let builtLabels: string | undefined
export type TitlebarMenu = "file" | "edit" | "view" | "window" | "help"
function target(local: boolean): BrowserWindow | null {
const window = (local ? actions?.getLocalTarget() : actions?.getWindowTarget()) ?? null
const window = (!local ? browserGuestOwner(webContents.getFocusedWebContents()) : undefined)
?? (local ? actions?.getLocalTarget() : actions?.getWindowTarget()) ?? null
return window && !window.isDestroyed() ? window : null
}
@ -91,6 +93,13 @@ function buildApplicationMenu() {
const window = target(false)
if (window) operation(window)
}
const withGuest = (guestAction: (guest: Electron.WebContents) => void, appAction: (window: BrowserWindow) => void) => withTarget(window => {
const guest = focusedBrowserGuest(window, webContents.getFocusedWebContents())
if (guest) guestAction(guest)
else appAction(window)
})
const zoom = (change: number | null) => withGuest(guest => guest.setZoomLevel(change === null ? 0 : guest.getZoomLevel() + change),
window => setWindowZoomLevel(window, change === null ? 0 : window.webContents.getZoomLevel() + change))
const template: MenuItemConstructorOptions[] = [
...(isMac ? [{ label: "CodeNomad", submenu: [
@ -122,13 +131,13 @@ function buildApplicationMenu() {
{ id: "menu-view", label: l.view, submenu: [
...viewMenuItems(sendCommand, target(true)),
{ type: "separator" },
{ label: l.reload, accelerator: "CmdOrCtrl+R", click: withTarget((window) => actions?.reload(window)) },
{ label: l.forceReload, accelerator: "CmdOrCtrl+Shift+R", click: withTarget((window) => actions?.forceReload(window)) },
{ label: l.devTools, accelerator: isMac ? "Alt+Command+I" : "Ctrl+Shift+I", click: withTarget((window) => window.webContents.toggleDevTools()) },
{ label: l.reload, accelerator: "CmdOrCtrl+R", click: withGuest(guest => guest.reload(), window => actions?.reload(window)) },
{ label: l.forceReload, accelerator: "CmdOrCtrl+Shift+R", click: withGuest(guest => guest.reloadIgnoringCache(), window => actions?.forceReload(window)) },
{ label: l.devTools, accelerator: isMac ? "Alt+Command+I" : "Ctrl+Shift+I", click: withGuest(guest => guest.toggleDevTools(), window => window.webContents.toggleDevTools()) },
{ type: "separator" },
{ label: l.actualSize, accelerator: "CmdOrCtrl+0", click: withTarget((window) => setWindowZoomLevel(window, 0)) },
{ label: l.zoomIn, accelerator: "CmdOrCtrl+Plus", click: withTarget((window) => setWindowZoomLevel(window, window.webContents.getZoomLevel() + 0.5)) },
{ label: l.zoomOut, accelerator: "CmdOrCtrl+-", click: withTarget((window) => setWindowZoomLevel(window, window.webContents.getZoomLevel() - 0.5)) },
{ label: l.actualSize, accelerator: "CmdOrCtrl+0", click: zoom(null) },
{ label: l.zoomIn, accelerator: "CmdOrCtrl+Plus", click: zoom(0.5) },
{ label: l.zoomOut, accelerator: "CmdOrCtrl+-", click: zoom(-0.5) },
{ type: "separator" },
{ label: l.fullScreen, accelerator: isMac ? "Ctrl+Command+F" : "F11", click: withTarget((window) => window.setFullScreen(!window.isFullScreen())) },
] },

View file

@ -1,5 +1,10 @@
import { session, systemPreferences, type Session } from "electron"
import { dialog, session, systemPreferences, type Session } from "electron"
import { isAllowedRendererOrigin } from "./renderer-origin"
import { browserPermissionDecision, permissionOrigin, permissionResource } from "./browser-permission-policy"
import { nativeMenuLabels } from "./view-menu"
import { configureBrowserDeviceChoosers } from "./browser-device-chooser"
import { configureBrowserDisplayChooser } from "./browser-display-chooser"
import { browserGuestLifetime } from "./browser-guest-lifetime"
export { isAllowedRendererOrigin } from "./renderer-origin"
@ -35,9 +40,70 @@ export function configureMediaPermissionHandlers(getAllowedOrigins: () => string
}
export function configureBrowserPermissionHandlers(targetSession: Session) {
targetSession.setPermissionCheckHandler(() => false)
targetSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
targetSession.setDevicePermissionHandler(() => false)
const grants = new Set<string>()
const pending = new Map<string, Promise<boolean>>()
const unknown = new Set<string>()
const key = (origin: string, permission: string, resource = "") => JSON.stringify([origin, permission, resource])
targetSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
if (!_webContents || !browserGuestLifetime.live(_webContents)) return false
const origin = permissionOrigin(details.securityOrigin ?? requestingOrigin)
if (!origin) return false
const decision = browserPermissionDecision(permission)
if (decision !== "consent") return decision === "browser" || decision === "chooser"
let embeddingOrigin = details.embeddingOrigin
try { embeddingOrigin ??= _webContents?.mainFrame.origin } catch {}
const resource = permissionResource(permission, details, embeddingOrigin)
return resource !== undefined && grants.has(key(origin, permission, resource))
})
targetSession.setPermissionRequestHandler((contents, permission, callback, details) => {
const revision = browserGuestLifetime.revision(contents)
const owner = browserGuestLifetime.owner(contents)
const live = () => Boolean(owner && browserGuestLifetime.owner(contents) === owner && browserGuestLifetime.revision(contents) === revision)
const decision = browserPermissionDecision(permission)
if (decision === "deny" || !live()) { callback(false); return }
const frameOrigins = new Set(contents.mainFrame.framesInSubtree
.filter(frame => frame.url === details.requestingUrl && (!details.isMainFrame || frame === contents.mainFrame))
.map(frame => permissionOrigin(frame.origin)).filter((origin): origin is string => Boolean(origin)))
const origin = permissionOrigin(details.requestingUrl) ?? (frameOrigins.size === 1 ? [...frameOrigins][0] : undefined)
const resource = permissionResource(permission, details, contents.mainFrame.origin)
const labels = nativeMenuLabels(owner ?? null)
const requestKey = key(origin ?? "null", permission, resource)
if (decision === "unknown" || !origin || resource === undefined) {
callback(false)
if (!unknown.has(requestKey)) {
unknown.add(requestKey)
const options = { type: "warning" as const, title: origin ?? "null", message: labels.browserPermissionUnknown.replace("{permission}", permission) }
void (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options))
.catch(error => console.warn("[browser-permission] closed owner", error))
}
return
}
if (decision === "browser" || decision === "chooser") { callback(true); return }
if (grants.has(requestKey)) { callback(true); return }
let request = pending.get(requestKey)
if (!request) {
const options = { type: "question" as const, title: origin,
message: labels.browserPermission.replace("{permission}", permission).replace("{origin}", origin),
...("filePath" in details && typeof details.filePath === "string" ? { detail: details.filePath } : {}),
buttons: [labels.browserDeny, labels.browserAllow], defaultId: 0, cancelId: 0, noLink: true }
request = (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options))
.then(result => {
if (result.response !== 1 || !live()) return false
grants.add(requestKey)
return true
}, () => false).finally(() => pending.delete(requestKey))
pending.set(requestKey, request)
}
void request.then(allowed => {
let current = false
try { current = allowed && live()
&& contents.mainFrame.framesInSubtree.some(frame => permissionOrigin(frame.origin) === origin) } catch {}
callback(current)
}).catch(error => console.warn("[browser-permission] expired request", error))
})
// Device grants require a chooser selection, not just a general API consent.
configureBrowserDeviceChoosers(targetSession)
configureBrowserDisplayChooser(targetSession)
}
export async function requestMicrophoneAccess(): Promise<boolean> {

View file

@ -0,0 +1,12 @@
const { contextBridge, ipcRenderer } = require("electron")
// Replace only the standard Web API. No bridge object, IPC method or Node value
// crosses into the page, and the channel is fixed inside this isolated closure.
contextBridge.executeInMainWorld({ func: () => { delete window.prompt } })
contextBridge.exposeInMainWorld("prompt", (message, value) => {
const result = ipcRenderer.sendSync("codenomad:browser-prompt", {
message: message === undefined ? "" : String(message),
value: value === undefined ? "" : String(value),
})
return typeof result === "string" ? result : null
})

View file

@ -25,6 +25,7 @@
"prebuild": "npm run prepare:resources",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"pretest:native": "node --import tsx --test electron/main/browser-permission-policy.test.ts electron/main/browser-downloads.test.ts electron/main/browser-prompt-policy.test.ts",
"test:native": "node --import tsx --test electron/main/backend-runtime.test.ts electron/main/browser-controller.test.ts electron/main/browser-webview-security.test.ts electron/main/client-state-cross-host.test.ts electron/main/client-state-process.test.ts electron/main/client-state.test.ts electron/main/client-state-ipc.test.ts electron/main/client-state-navigation.test.ts electron/main/connect-launch.test.ts electron/main/data-profile.test.ts electron/main/data-profile-cleanup.test.ts electron/main/data-profile-cleanup-ipc.test.ts electron/main/developer-mode.test.ts electron/main/dialog-ipc.test.ts electron/main/local-window-registry.test.ts electron/main/menu-target.test.ts electron/main/multiwindow-lifecycle.test.ts electron/main/native-request.test.ts electron/main/native-service-start.test.ts electron/main/navigation-security.test.ts electron/main/notification-badge.test.ts electron/main/opener-startup-state.test.ts electron/main/preferences-ipc.test.ts electron/main/preferences-window.test.ts electron/main/process-exit.test.ts electron/main/profile-transition.test.ts electron/main/process-output.test.ts electron/main/process-stop.test.ts electron/main/windows-process-worker.test.ts electron/main/remote-recovery.test.ts electron/main/remote-window-registry.test.ts electron/main/renderer-client-state-flush.test.ts electron/main/renderer-origin.test.ts electron/main/serialized-lifecycle.test.ts electron/main/startup.test.ts electron/main/user-shell.test.ts electron/main/window-state.test.ts electron/main/workspace-open.test.ts electron/preload/index.test.ts",
"preview": "install-electron && electron-vite preview",
"build:binaries": "node scripts/build.js",

View file

@ -0,0 +1,78 @@
import assert from "node:assert/strict"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { mkdir, writeFile } from "node:fs/promises"
import { resolve } from "node:path"
import { app, BrowserWindow, type WebContents } from "electron"
import { isBrowserDialog } from "../../electron/main/browser-dialog"
const exec = promisify(execFile)
const pause = () => new Promise(resolve => setTimeout(resolve, 30))
/** Real production guest hooks and native dialogs, on the fixture's own X display. */
export async function qualifyBeforeUnload(owner: BrowserWindow, root: WebContents): Promise<void> {
const driver = process.env.CODENOMAD_FIXTURE_DIALOG_DRIVER
assert.ok(driver, "Set CODENOMAD_FIXTURE_DIALOG_DRIVER to linux-answer-beforeunload.sh")
const evidence = resolve(process.env.CODENOMAD_FIXTURE_EVIDENCE ?? `.tmp/beforeunload-${process.pid}`)
await mkdir(evidence, { recursive: true })
const timeout = setTimeout(() => { console.error("beforeunload acceptance timed out"); app.exit(1) }, 90_000)
const capture = async (guest: WebContents, name: string) => writeFile(resolve(evidence, `${name}.png`), (await guest.capturePage()).toPNG())
const fresh = async (guest: WebContents) => {
const end = Date.now() + 5000
while (Date.now() < end) {
if (!guest.isLoading() && await guest.executeJavaScript("document.readyState==='complete' && !document.body.dataset.retained")) return
await pause()
}
assert.fail("Leave did not replace the document")
}
const arm = async (guest: WebContents) => {
guest.focus()
await guest.capturePage()
await pause()
guest.sendInputEvent({ type: "mouseDown", x: 4, y: 4, button: "left", clickCount: 1 })
guest.sendInputEvent({ type: "mouseUp", x: 4, y: 4, button: "left", clickCount: 1 })
await pause()
assert.equal(await guest.executeJavaScript("navigator.userActivation.hasBeenActive"), true)
await guest.executeJavaScript("document.body.dataset.retained='yes'; window.onbeforeunload=e=>{e.preventDefault(); e.returnValue='unsaved'; return 'unsaved'}; void 0")
}
const decide = async (guest: WebContents, name: string, answer: "stay" | "leave", action: () => void) => {
const prevented = new Promise<void>((resolve, reject) => guest.once("will-prevent-unload", event => {
try { assert.equal(event.defaultPrevented, answer === "leave"); resolve() }
catch (error) { reject(error) }
}))
const input = exec("bash", [driver, answer, String(process.pid), resolve(evidence, `${name}-confirm.png`)], { timeout: 13_000, windowsHide: true })
action()
await Promise.all([prevented, input])
if (answer === "stay") {
assert.equal(guest.isDestroyed(), false)
assert.equal(await guest.executeJavaScript("document.body.dataset.retained"), "yes")
await capture(guest, name)
}
}
try {
await arm(root)
await decide(root, "navigation-stay", "stay", () => { void root.loadURL("http://127.0.0.1:18741/next").catch(() => {}) })
await decide(root, "navigation-leave", "leave", () => { void root.loadURL("http://127.0.0.1:18741/next").catch(() => {}) })
await fresh(root)
assert.equal(new URL(root.getURL()).pathname, "/next")
await capture(root, "navigation-leave")
await arm(root)
await decide(root, "reload-stay", "stay", () => root.reload())
await decide(root, "reload-leave", "leave", () => root.reload())
await fresh(root)
await capture(root, "reload-leave")
await root.executeJavaScript("window.open('/close-test'); void 0", true)
const popup = BrowserWindow.getAllWindows().find(window => window !== owner && !isBrowserDialog(window))!
assert.ok(popup)
while (popup.webContents.isLoading()) await pause()
await arm(popup.webContents)
await decide(popup.webContents, "popup-close-stay", "stay", () => popup.close())
const closed = new Promise<void>(resolve => popup.once("closed", () => resolve()))
await decide(popup.webContents, "popup-close-leave", "leave", () => popup.close())
await closed
assert.equal(popup.isDestroyed(), true)
console.log(`PASS native Electron beforeunload navigation/reload/popup-close Stay/Leave; evidence=${evidence}`)
app.exit(0)
} catch (error) { console.error(error); app.exit(1) }
finally { clearTimeout(timeout) }
}

View file

@ -0,0 +1,44 @@
import { app, BrowserWindow, session } from "electron"
import { secureBrowserWebview } from "../../electron/main/browser-webview-security"
import { configureBrowserPermissionHandlers } from "../../electron/main/permissions"
import { configureBrowserDownloads } from "../../electron/main/browser-downloads"
import { protectBrowserGuest, configureBrowserGuests } from "../../electron/main/browser-guests"
import { configureBrowserPromptSession } from "../../electron/main/browser-prompt"
import { qualifyPrompt } from "./prompt-acceptance"
import { qualifyBeforeUnload } from "./beforeunload-acceptance"
import { qualifyLifetime } from "./lifetime-acceptance"
if (!process.env.CODENOMAD_PROFILE) throw new Error("Set an isolated CODENOMAD_PROFILE directory")
app.setPath("userData", process.env.CODENOMAD_PROFILE)
void app.whenReady().then(async () => {
const partition = "persist:codenomad-browser-capabilities"
const store = session.fromPartition(partition)
configureBrowserGuests()
configureBrowserPromptSession(store, process.env.CODENOMAD_FIXTURE_PROMPT_PRELOAD!)
configureBrowserPermissionHandlers(store)
configureBrowserDownloads(store, app.getPath("downloads"))
const owner = new BrowserWindow({ width: 920, height: 820,
webPreferences: { webviewTag: true, nodeIntegration: false, contextIsolation: true, sandbox: true } })
owner.webContents.on("will-attach-webview", (event, preferences, params) => {
if (!secureBrowserWebview(preferences, params)) event.preventDefault()
})
owner.webContents.on("did-attach-webview", (_event, guest) => {
protectBrowserGuest(owner, guest)
if (process.env.CODENOMAD_FIXTURE_PROMPT_TEST) guest.once("did-finish-load", () => { void qualifyPrompt(owner, guest) })
else if (process.env.CODENOMAD_FIXTURE_BEFOREUNLOAD_TEST) guest.once("did-finish-load", () => { void qualifyBeforeUnload(owner, guest) })
else if (process.env.CODENOMAD_FIXTURE_LIFETIME) guest.once("did-finish-load", () => { void qualifyLifetime(owner, guest) })
guest.on("console-message", details => console.log("guest", details.message))
if (process.env.CODENOMAD_FIXTURE_NETWORK) guest.once("did-finish-load", () => {
void guest.loadURL("https://example.com").then(() => guest.executeJavaScript(`
document.body.innerHTML='<button id="network" style="font-size:24px;padding:20px">Test local network</button>';
document.getElementById('network').onclick=()=>fetch('http://127.0.0.1:18741/').then(r=>r.text()).then(v=>console.log('network-result',v.length),e=>console.log('network-error',e.name));
void 0;
`))
})
if (process.env.CODENOMAD_FIXTURE_CLOSE_AFTER) setTimeout(() => guest.close({ waitForBeforeUnload: false }),
Number(process.env.CODENOMAD_FIXTURE_CLOSE_AFTER) * 1000)
})
const path = process.env.CODENOMAD_FIXTURE_CONTROLS ? "controls" : ""
await owner.loadURL(`data:text/html,${encodeURIComponent(`<style>html,body{margin:0;width:100%;height:100%}webview{display:flex;width:100%;height:100%}</style><webview src="http://127.0.0.1:18741/${path}" partition="${partition}" allowpopups></webview>`)}`)
})
app.on("window-all-closed", () => app.quit())

View file

@ -0,0 +1,36 @@
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { app, BrowserWindow, type WebContents } from "electron"
import { browserGuestLifetime } from "../../electron/main/browser-guest-lifetime"
export async function qualifyLifetime(owner: BrowserWindow, guest: WebContents): Promise<void> {
const mode = process.env.CODENOMAD_FIXTURE_LIFETIME!
const pause = () => new Promise(resolve => setTimeout(resolve, 50))
const wait = async (condition: () => boolean) => {
for (let i = 0; i < 200; i++) { if (condition()) return; await pause() }
assert.fail("native lifetime condition timed out")
}
// Keep the process alive so owner destruction can be asserted before app.exit.
const sentinel = new BrowserWindow({ show: false })
try {
await guest.executeJavaScript("window.onbeforeunload=()=>true;window.open('/lifetime-child');void 0", true)
await wait(() => BrowserWindow.getAllWindows().length === 3)
const child = BrowserWindow.getAllWindows().find(window => window !== owner && window !== sentinel)!
await wait(() => !child.webContents.isLoading())
await child.webContents.executeJavaScript("window.onbeforeunload=()=>true;window.open('/hold');void 0", true)
await wait(() => BrowserWindow.getAllWindows().length === 4)
const descendants = BrowserWindow.getAllWindows().filter(window => window !== owner && window !== sentinel)
const contents = descendants.map(window => window.webContents)
if (mode === "opener-close") guest.close({ waitForBeforeUnload: false })
else if (mode === "opener-reload") { await guest.executeJavaScript("window.onbeforeunload=null;void 0"); guest.reload() }
else if (mode === "owner-destroy") owner.destroy()
else if (mode === "owner-close") owner.close()
else if (mode === "renderer-reload") owner.reload()
else assert.fail(`Unknown lifetime mode ${mode}`)
await wait(() => descendants.every(window => window.isDestroyed()))
for (const source of contents) assert.equal(browserGuestLifetime.owner(source), undefined)
if (mode !== "opener-reload") assert.equal(browserGuestLifetime.owner(guest), undefined)
await writeFile(`${process.env.CODENOMAD_PROFILE}/lifetime-${mode}.txt`, `PASS ${mode}: two native descendants destroyed; registries empty; armed child beforeunload bypassed\n`)
app.exit(0)
} catch (error) { console.error(error); app.exit(1) }
}

View file

@ -0,0 +1,60 @@
import assert from "node:assert/strict"
import { app, BrowserWindow, type WebContents } from "electron"
import { isBrowserDialog, showBrowserDialog } from "../../electron/main/browser-dialog"
/** Run inside the real-engine fixture with CODENOMAD_FIXTURE_PROMPT_TEST=1. */
export async function qualifyPrompt(owner: BrowserWindow, guest: WebContents): Promise<void> {
const answer = async (expression: string, value: string | null, target: Pick<WebContents, "executeJavaScript"> = guest) => {
let handled = false
const timer = setInterval(() => {
const dialog = BrowserWindow.getAllWindows().find(isBrowserDialog)
if (handled || !dialog || dialog.webContents.isLoading()) return
handled = true
void dialog.webContents.executeJavaScript(`(() => {
const field = document.querySelector('input');
if (field) field.value = ${JSON.stringify(value ?? "")};
document.querySelector('button[value="${value === null ? "cancel" : "accept"}"]').click();
})()`)
}, 30)
try { return await target.executeJavaScript(expression, true) }
finally { clearInterval(timer) }
}
const timeout = setTimeout(() => { console.error("prompt acceptance timed out"); app.exit(1) }, 20_000)
try {
assert.deepEqual(await guest.executeJavaScript(`[
typeof require, typeof ipcRenderer, typeof electronAPI, typeof contextBridge,
prompt.constructor('return typeof require')(), typeof process
]`), Array(6).fill("undefined"))
assert.equal(await owner.webContents.executeJavaScript("typeof require + '/' + typeof electronAPI"), "undefined/undefined")
assert.equal(await answer("prompt('root', 'default')", "root-answer"), "root-answer")
assert.equal(await answer("prompt('cancel')", null), null)
assert.equal(await answer("prompt('empty')", ""), "")
await guest.executeJavaScript("new Promise(resolve => { const f = document.createElement('iframe'); f.srcdoc = '<title>frame</title>'; f.onload = () => resolve(); document.body.append(f) })")
assert.equal(await answer("document.querySelector('iframe').contentWindow.prompt('subframe')", "frame-answer"), "frame-answer")
assert.deepEqual(await guest.executeJavaScript("['require','process','ipcRenderer','electronAPI','contextBridge'].map(k=>typeof document.querySelector('iframe').contentWindow[k])"), Array(5).fill("undefined"))
assert.equal(guest.getLastWebPreferences().sandbox, true)
assert.equal(guest.getLastWebPreferences().nodeIntegration, false)
await guest.executeJavaScript("new Promise(resolve => { const f=document.createElement('iframe'); f.src='http://localhost:18741/hold'; f.onload=()=>resolve(); document.body.append(f) })")
const crossOrigin = guest.mainFrame.framesInSubtree.find(frame => frame.url === "http://localhost:18741/hold")!
assert.ok(crossOrigin)
assert.equal(await answer("prompt('cross-origin frame')", "cross-frame-answer", crossOrigin), "cross-frame-answer")
assert.deepEqual(await crossOrigin.executeJavaScript("[typeof require,typeof process,typeof ipcRenderer,typeof electronAPI]"), Array(4).fill("undefined"))
await guest.executeJavaScript("window.open('about:blank', 'prompt-test'); void 0", true)
const popup = BrowserWindow.getAllWindows().find(window => window !== owner && !isBrowserDialog(window))!
assert.ok(popup)
assert.equal(await answer("prompt('blank popup')", "popup-answer", popup.webContents), "popup-answer")
popup.destroy()
let selected = false
const choose = setInterval(() => {
const dialog = BrowserWindow.getAllWindows().find(isBrowserDialog)
if (selected || !dialog || dialog.webContents.isLoading()) return
selected = true
void dialog.webContents.executeJavaScript("document.querySelector('select').value='24'; document.querySelector('button[value=accept]').click()")
}, 30)
try { assert.equal(await showBrowserDialog(owner, "https://fixture.invalid", "choose", { choices: Array.from({ length: 25 }, (_, i) => `Device ${i + 1}`) }), "24") }
finally { clearInterval(choose) }
console.log("PASS real-engine prompt: string/cancel/empty/blank-popup/srcdoc/cross-origin subframe; sandbox retained; isolated globals; chooser entry 25")
app.exit(0)
} catch (error) { console.error(error); app.exit(1) }
finally { clearTimeout(timeout) }
}

View file

@ -573,6 +573,24 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "codenomad-webkit-capabilities-fixture"
version = "0.1.0"
dependencies = [
"dirs 5.0.1",
"gtk",
"objc2",
"serde",
"sha2",
"tauri",
"tauri-plugin-dialog",
"tempfile",
"uuid",
"webkit2gtk",
"webview2-com",
"windows-core",
]
[[package]]
name = "codenomad-windows-input-fixture"
version = "0.1.0"

View file

@ -1,5 +1,5 @@
[workspace]
members = ["src-tauri", "tests/windows-input", "tests/windows-runner-refcount", "tests/macos-window", "tests/panel-extension"]
members = ["src-tauri", "tests/windows-input", "tests/windows-runner-refcount", "tests/macos-window", "tests/panel-extension", "tests/webkit-capabilities"]
default-members = ["src-tauri"]
resolver = "2"

View file

@ -50,3 +50,37 @@ test("an update past the vendored version is detected", () => {
assert.deepEqual(lockedPackages(lock, "tao"), [{ name: "tao", version: "9.0.0", source: "registry+https://github.com/rust-lang/crates.io-index" }])
assert.deepEqual(patchedCrates('[patch.crates-io]\ntao = { path = "vendor/tao-1.0.0" }\n'), [{ name: "tao", dir: "vendor/tao-1.0.0" }])
})
test("preview runtime fence separates app IPC, scripts and protocols from browser globals", () => {
const runtime = read("vendor/tauri-runtime-wry-2.12.3/src/lib.rs")
assert.match(runtime, /let browser_guest = label\.starts_with\("browser-"\);/)
assert.match(runtime, /if !browser_guest \{\s*webview_builder = webview_builder\.with_ipc_handler/)
assert.match(runtime, /for \(scheme, protocol\) in uri_scheme_protocols \{[\s\S]*?if browser_guest\s*\{\s*continue;\s*\}/)
assert.doesNotMatch(read("src-tauri/src/embedded_view.rs"), /\.clipboard\(true\)/)
})
test("WK preview print and beforeunload callbacks retain native completion and app policy", () => {
const delegate = read("vendor/wry-0.57.1/src/wkwebview/class/wry_web_view_ui_delegate.rs")
assert.match(delegate, /method\(_webView:printFrame:pdfFirstPageSize:completionHandler:\)/)
assert.match(delegate, /if self\.ivars\(\)\.preview_dialogs \{ preview_print\(webview\); \}\s*completion\.call\(\(\)\)/)
assert.match(delegate, /fn before_unload[\s\S]*?if !self\.ivars\(\)\.preview_dialogs \{ completion\.call\(\(true\.into\(\),\)\); return; \}/)
assert.match(delegate, /webview\.printOperationWithPrintInfo\(&info\)/)
assert.match(delegate, /operation\.runOperation\(\)/)
})
test("GTK pointer consent preserves the requesting surface and cancels on navigation", () => {
const source = read("src-tauri/src/embedded_view/linux.rs")
assert.match(source, /if kind == "pointer-lock"[\s\S]*?pointer_lock_consent\([\s\S]*?return true/)
assert.match(source, /gtk::InfoBar::new\(\)/)
assert.match(source, /button\.set_focus_on_click\(false\)/)
assert.match(source, /LoadEvent::Started[\s\S]*?bar\.response\(gtk::ResponseType::Cancel\)/)
assert.match(source, /allowed\s*&& epoch\.get\(\) == captured/)
assert.match(source, /&& answer_policy\.current\(&answer_label, token\)/)
})
test("native popup closure runs each engine's beforeunload path", () => {
assert.match(read("src-tauri/src/embedded_view/browser.rs"), /CloseRequested \{ api, \.\. \}[\s\S]*?api\.prevent_close\(\)/)
assert.match(read("src-tauri/src/embedded_view/linux.rs"), /native\.inner\(\)\.try_close\(\)/)
assert.match(read("src-tauri/src/embedded_view/macos.rs"), /msg_send!\[view, _tryClose\]/)
assert.match(read("src-tauri/src/embedded_view/windows.rs"), /view\.eval\("window.close\(\)"\)/)
})

View file

@ -47,10 +47,14 @@ fn tauri_authority_does_not_grant_parent_capabilities_to_preview_children() {
url: url.parse().unwrap(),
};
for command in &commands {
for child in ["browser-preview", "embedded-remote-preview"] {
for (window, child) in [
(OWNER, "browser-preview"),
(OWNER, "embedded-remote-preview"),
("browser-popup-preview", "browser-popup-preview"),
] {
assert!(
authority
.resolve_access(command, OWNER, child, &origin)
.resolve_access(command, window, child, &origin)
.is_none(),
"{child} inherited {command} at {url}"
);

View file

@ -1,10 +1,15 @@
//! Owner-fenced native child views. Business/session state belongs to consumers.
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, Runtime};
#[cfg(any(windows, target_os = "linux", target_os = "macos", test))]
#[path = "embedded_view/browser.rs"]
mod browser;
#[path = "embedded_view/fullscreen.rs"]
mod fullscreen;
#[cfg(target_os = "linux")]
#[path = "embedded_view/linux.rs"]
mod linux;
@ -45,7 +50,7 @@ impl StorageIdentity {
}
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
pub(crate) struct Bounds {
pub(crate) x: i32,
pub(crate) y: i32,
@ -67,6 +72,16 @@ pub(crate) fn is_allowed_url(url: &tauri::Url) -> bool {
&& url.password().is_none()
}
fn site_url_allowed(url: &tauri::Url, origin: Option<&str>) -> bool {
is_allowed_url(url)
|| (url.scheme() == "about" && url.path() == "blank")
|| (url.scheme() == "blob"
&& tauri::Url::parse(url.path()).is_ok_and(|inner| {
is_allowed_url(&inner)
&& Some(inner.origin().ascii_serialization().as_str()) == origin
}))
}
pub(crate) fn browser_storage(profile: &Path) -> PathBuf {
profile.join("shared-v1")
}
@ -94,13 +109,19 @@ fn reserved_label(label: &str) -> bool {
struct Inner {
records: HashMap<String, Record>,
sequence: u64,
fullscreen_windows: HashSet<String>,
}
#[derive(Clone, PartialEq, Eq)]
struct Record {
owner: String,
token: u64,
document: u64,
preparing: bool,
opener: Option<String>,
origin: Option<String>,
bounds: Option<Bounds>,
fullscreen: Option<bool>,
}
#[derive(Clone, Default)]
@ -128,7 +149,12 @@ impl EmbeddedViews {
let record = Record {
owner: owner.into(),
token: inner.sequence,
document: 0,
preparing: true,
opener: None,
origin: None,
bounds: None,
fullscreen: None,
};
inner.records.insert(label.into(), record.clone());
Ok(record)
@ -178,13 +204,157 @@ impl EmbeddedViews {
pub(crate) fn navigation_allowed(&self, label: &str, url: &tauri::Url) -> Option<bool> {
let inner = self.0.lock().ok();
if let Some(record) = inner.as_ref().and_then(|inner| inner.records.get(label)) {
return Some(
is_allowed_url(url) || (record.preparing && url.as_str() == "about:blank"),
);
let allowed = if label.starts_with("browser-") {
site_url_allowed(url, record.origin.as_deref())
} else {
is_allowed_url(url) || (record.preparing && url.as_str() == "about:blank")
};
return Some(allowed);
}
reserved_label(label).then_some(false)
}
fn current(&self, label: &str, token: u64) -> bool {
self.0.lock().is_ok_and(|inner| {
inner
.records
.get(label)
.is_some_and(|record| record.token == token)
})
}
fn token(&self, label: &str) -> Option<u64> {
self.0
.lock()
.ok()?
.records
.get(label)
.map(|record| record.token)
}
fn observe_origin(&self, label: &str, token: u64, url: &tauri::Url) {
if is_allowed_url(url) {
if let Ok(mut inner) = self.0.lock() {
if let Some(record) = inner
.records
.get_mut(label)
.filter(|record| record.token == token)
{
record.origin = Some(url.origin().ascii_serialization());
}
}
}
}
/// Invalidate the whole tree before native destruction dispatches callbacks.
fn take_tree(
inner: &mut Inner,
label: &str,
token: u64,
include_root: bool,
) -> Vec<(String, Record)> {
let Some(root) = inner
.records
.get(label)
.filter(|record| record.token == token)
else {
return Vec::new();
};
let owner = root.owner.clone();
let mut labels = HashSet::from([label.to_string()]);
// ponytail: scan per tree depth; index parents if preview trees become large.
loop {
let count = labels.len();
for (child, record) in &inner.records {
if record.owner == owner
&& record
.opener
.as_ref()
.is_some_and(|parent| labels.contains(parent))
{
labels.insert(child.clone());
}
}
if labels.len() == count {
break;
}
}
if !include_root {
labels.remove(label);
}
labels
.into_iter()
.filter_map(|label| inner.records.remove(&label).map(|record| (label, record)))
.collect()
}
fn dispose_records<R: Runtime>(
app: &AppHandle<R>,
records: Vec<(String, Record)>,
) -> Result<(), String> {
let mut failure = None;
for (label, record) in records {
if let Some(before) = record.fullscreen {
if let Some(view) = app.get_webview(&label) {
let _ = view.window().set_fullscreen(before);
}
}
let result = if record.opener.is_some() {
app.get_webview_window(&label)
.map(|window| window.destroy())
} else {
app.get_webview(&label).map(|view| view.close())
};
if let Some(Err(error)) = result {
eprintln!("[browser-cleanup] {label}: {error}");
failure.get_or_insert_with(|| error.to_string());
}
}
failure.map_or(Ok(()), Err)
}
fn dispose_tree<R: Runtime>(
&self,
app: &AppHandle<R>,
label: &str,
token: u64,
) -> Result<(), String> {
let records = self
.0
.lock()
.map(|mut inner| Self::take_tree(&mut inner, label, token, true))
.unwrap_or_default();
Self::dispose_records(app, records)
}
fn page_load<R: Runtime>(
&self,
view: &tauri::Webview<R>,
token: u64,
payload: &tauri::webview::PageLoadPayload<'_>,
) -> bool {
let Ok(mut inner) = self.0.lock() else {
return false;
};
let Some(record) = inner
.records
.get_mut(view.label())
.filter(|record| record.token == token)
else {
return false;
};
let records = if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) {
record.document = record.document.wrapping_add(1);
Self::take_tree(&mut inner, view.label(), token, false)
} else {
Vec::new()
};
drop(inner);
let _ = Self::dispose_records(view.app_handle(), records);
self.observe_origin(view.label(), token, payload.url());
self.current(view.label(), token)
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
pub(crate) fn create(
&self,
@ -236,7 +406,9 @@ impl EmbeddedViews {
on_navigation: impl Fn(&tauri::Url) -> bool + Send + Sync + 'static,
install: impl FnOnce(&tauri::Webview<R>) -> Result<(), String>,
) -> Result<tauri::Webview<R>, String> {
use tauri::webview::{NewWindowResponse, WebviewBuilder};
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
use tauri::webview::NewWindowResponse;
use tauri::webview::WebviewBuilder;
let storage = storage.into();
validate_bounds(bounds)?;
#[cfg(target_os = "macos")]
@ -254,22 +426,68 @@ impl EmbeddedViews {
}
// Register before add_child: the host navigation interceptor runs during creation.
let record = self.reserve(window.label(), label)?;
if label.starts_with("browser-") {
self.install_fullscreen_window(window);
}
let policy = self.clone();
let navigation_label = label.to_string();
let page_policy = self.clone();
let token = record.token;
let builder = WebviewBuilder::new(
label,
tauri::WebviewUrl::External("about:blank".parse().expect("static neutral URL")),
)
.data_directory(storage.directory)
.data_directory(storage.directory.clone())
.focused(false)
.on_page_load(on_page_load)
.disable_drag_drop_handler()
.zoom_hotkeys_enabled(true)
.on_page_load(move |view, payload| {
if page_policy.page_load(&view, token, &payload) {
on_page_load(view, payload);
}
})
.on_navigation(move |url| {
let allowed = policy.navigation_allowed(&navigation_label, url) == Some(true);
let allowed = policy.current(&navigation_label, token)
&& policy.navigation_allowed(&navigation_label, url) == Some(true);
allowed && on_navigation(url)
})
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_permission_request(|_, _| tauri::webview::PermissionResponse::Deny)
.on_download(|_, _| false);
.on_permission_request({
let permissions = self.clone();
let permission_label = label.to_string();
move |view, kind| {
if permissions.current(&permission_label, token) {
browser::permission(view, kind)
} else {
tauri::webview::PermissionResponse::Deny
}
}
});
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
let builder = builder
.on_new_window(|_, _| NewWindowResponse::Deny)
.on_download(|_, _| false);
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
let builder = {
let views = self.clone();
let app = window.app_handle().clone();
let owner = window.label().to_string();
let opener = label.to_string();
let browser = label.starts_with("browser-");
let downloads = self.clone();
let download_label = label.to_string();
builder
.on_download(move |webview, event| {
browser
&& downloads.current(&download_label, token)
&& browser::download(webview, event)
})
.on_new_window(move |url, features| {
if !views.current(&opener, token) {
return tauri::webview::NewWindowResponse::Deny;
}
views.open_popup(&app, &owner, &opener, &storage, url, features)
})
};
#[cfg(target_os = "macos")]
let builder = builder.data_store_identifier(storage_identifier);
// Tauri has no hidden-child builder flag. Keep the neutral child off-canvas
@ -311,6 +529,14 @@ impl EmbeddedViews {
) -> Result<(), String> {
validate_bounds(bounds)?;
let webview = self.webview(app, owner, label)?;
if let Ok(mut inner) = self.0.lock() {
if let Some(record) = inner.records.get_mut(label) {
if record.fullscreen.is_some() {
return Ok(());
}
record.bounds = Some(bounds);
}
}
webview
.set_position(PhysicalPosition::new(bounds.x, bounds.y))
.and_then(|()| webview.set_size(PhysicalSize::new(bounds.width, bounds.height)))
@ -325,6 +551,9 @@ impl EmbeddedViews {
visible: bool,
) -> Result<(), String> {
let webview = self.webview(app, owner, label)?;
if !visible {
let _ = self.set_fullscreen(app, label, false);
}
if visible {
webview.show()
} else {
@ -374,12 +603,7 @@ impl EmbeddedViews {
{
return Err("Embedded view belongs to another window".into());
}
if self.remove_exact(label, &record) {
if let Some(webview) = webview {
webview.close().map_err(|error| error.to_string())?;
}
}
Ok(())
self.dispose_tree(app, label, record.token)
}
pub(crate) fn remove_owner<R: Runtime>(&self, app: &AppHandle<R>, owner: &str) {
@ -388,22 +612,16 @@ impl EmbeddedViews {
.records
.iter()
.filter(|(_, record)| record.owner == owner)
.map(|(label, _)| label.clone())
.map(|(label, record)| (label.clone(), record.clone()))
.collect::<Vec<_>>();
for label in &labels {
for (label, _) in &labels {
inner.records.remove(label);
}
labels
} else {
Vec::new()
};
for label in labels {
if let Some(webview) = app.get_webview(&label) {
if webview.window().label() == owner {
let _ = webview.close();
}
}
}
let _ = Self::dispose_records(app, labels);
}
}

View file

@ -0,0 +1,358 @@
//! Native guest windows retain the engine's real opener, never an app IPC bridge.
use super::*;
pub(super) fn page_permission_subject(raw: &str) -> Option<String> {
let url = tauri::Url::parse(raw).ok()?;
if is_allowed_url(&url) {
return Some(url.origin().ascii_serialization());
}
if url.scheme() == "about" && url.path() == "blank" {
return Some("about:blank".into());
}
if url.scheme() == "blob" {
let inner = tauri::Url::parse(url.path()).ok()?;
if is_allowed_url(&inner) {
return Some(inner.origin().ascii_serialization());
}
}
None
}
fn download_path(directory: &Path, suggested: &Path) -> Option<PathBuf> {
if !directory.is_dir() {
return None;
}
let name: String = suggested
.file_name()?
.to_string_lossy()
.chars()
.filter(|c| !c.is_control() && !"/\\:*?\"<>|".contains(*c))
.scan(0, |bytes, c| {
*bytes += c.len_utf8();
(*bytes <= 180).then_some(c)
})
.collect();
let name = name.trim_matches([' ', '.']);
let name = if name.is_empty() { "download" } else { name };
// WebKit refuses an existing destination; the random prefix also separates
// concurrent downloads before either engine has created its file.
let path = directory.join(format!("{}-{name}", uuid::Uuid::new_v4()));
if path.exists() {
return None;
}
#[cfg(windows)]
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.ok()?;
Some(path)
}
pub(super) fn permission<R: Runtime>(
webview: tauri::Webview<R>,
kind: tauri::webview::PermissionKind,
) -> tauri::webview::PermissionResponse {
use tauri::webview::{PermissionKind as K, PermissionResponse as R};
match kind {
K::Camera | K::Microphone | K::Geolocation | K::Notifications => R::Deny,
// Mandatory desktop adapters classify the raw request, including its
// requester metadata, before allowing consent or reporting a denial.
K::Other if cfg!(any(windows, target_os = "linux")) => R::Default,
K::ClipboardRead
| K::DisplayCapture
| K::Midi
| K::Sensors
| K::MediaKeySystemAccess
| K::LocalFonts
| K::WindowManagement
| K::PointerLock
| K::AutomaticDownloads
| K::FileSystemAccess
| K::Autoplay => R::Default,
_ => {
unknown_permission(&webview, &kind.to_string());
R::Deny
}
}
}
pub(super) fn unknown_permission<R: Runtime>(webview: &tauri::Webview<R>, kind: &str) {
use tauri_plugin_dialog::DialogExt;
let text = webview
.app_handle()
.try_state::<crate::AppState>()
.map(|state| state.menu_labels.text("browserPermissionUnknown"))
.unwrap_or_else(|| crate::menu_labels::label("browserPermissionUnknown").to_string())
.replace("{permission}", kind);
webview
.app_handle()
.dialog()
.message(text)
.parent(&webview.window())
.show(|_| {});
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
pub(super) fn download<R: Runtime>(
_: tauri::Webview<R>,
event: tauri::webview::DownloadEvent<'_>,
) -> bool {
match event {
tauri::webview::DownloadEvent::Requested { url, destination } => {
if !is_allowed_url(&url) && !matches!(url.scheme(), "blob" | "data") {
return false;
}
let Some(path) = dirs::download_dir().and_then(|dir| download_path(&dir, destination))
else {
return false;
};
*destination = path;
true
}
_ => true,
}
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
impl EmbeddedViews {
pub(super) fn open_popup<R: Runtime>(
&self,
app: &AppHandle<R>,
owner: &str,
opener: &str,
storage: &Storage,
url: tauri::Url,
features: tauri::webview::NewWindowFeatures,
) -> tauri::webview::NewWindowResponse<R> {
use tauri::webview::NewWindowResponse;
// Only browser previews opt in; remote-view consumers keep their policy.
let Ok(opener_record) = self.owned(owner, opener) else {
return NewWindowResponse::Deny;
};
if !opener.starts_with("browser-")
|| !site_url_allowed(&url, opener_record.origin.as_deref())
{
return NewWindowResponse::Deny;
}
let Some(parent) = app.get_window(owner) else {
return NewWindowResponse::Deny;
};
let label = format!("browser-popup-{}", uuid::Uuid::new_v4());
let Ok(mut record) = self.reserve(owner, &label) else {
return NewWindowResponse::Deny;
};
record.opener = Some(opener.into());
record.origin = opener_record.origin.clone();
if let Ok(mut inner) = self.0.lock() {
if !inner.records.get(opener).is_some_and(|current| {
current.token == opener_record.token && current.document == opener_record.document
}) {
inner.records.remove(&label);
return NewWindowResponse::Deny;
}
let Some(current) = inner
.records
.get_mut(&label)
.filter(|current| current.token == record.token)
else {
return NewWindowResponse::Deny;
};
current.opener = record.opener.clone();
current.origin = record.origin.clone();
} else {
return NewWindowResponse::Deny;
}
let policy = self.clone();
let navigation_label = label.clone();
let page_policy = self.clone();
let token = record.token;
let downloads = self.clone();
let download_label = label.clone();
let popups = self.clone();
let popup_app = app.clone();
let popup_owner = owner.to_string();
let popup_opener = label.clone();
let popup_storage = Storage {
directory: storage.directory.clone(),
domain: storage.domain.clone(),
};
let builder = tauri::WebviewWindowBuilder::new(
app,
&label,
tauri::WebviewUrl::External("about:blank".parse().unwrap()),
)
.window_features(features)
.title(url.origin().ascii_serialization())
.inner_size(520.0, 680.0)
.disable_drag_drop_handler()
.zoom_hotkeys_enabled(true)
.visible(false);
#[cfg(target_os = "linux")]
let builder = parent
.gtk_window()
.map(|parent| builder.transient_for_raw(&parent));
#[cfg(target_os = "macos")]
let builder = parent.ns_window().map(|parent| builder.parent_raw(parent));
#[cfg(windows)]
let builder = parent.hwnd().map(|parent| builder.owner_raw(parent));
let builder = builder.map(|builder| {
builder
.data_directory(storage.directory.clone())
.on_page_load(move |view, payload| {
page_policy.page_load(view.as_ref(), token, &payload);
})
.on_navigation(move |url| {
policy.current(&navigation_label, token)
&& policy.navigation_allowed(&navigation_label, url) == Some(true)
})
.on_permission_request({
let permissions = self.clone();
let permission_label = label.clone();
move |view, kind| {
if permissions.current(&permission_label, token) {
permission(view, kind)
} else {
tauri::webview::PermissionResponse::Deny
}
}
})
.on_download(move |view, event| {
downloads.current(&download_label, token) && download(view, event)
})
.on_new_window(move |url, features| {
if !popups.current(&popup_opener, token) {
return NewWindowResponse::Deny;
}
popups.open_popup(
&popup_app,
&popup_owner,
&popup_opener,
&popup_storage,
url,
features,
)
})
});
let result = builder.and_then(|builder| builder.build());
let window = match result {
Ok(window) => window,
Err(_) => {
self.remove_exact(&label, &record);
return NewWindowResponse::Deny;
}
};
self.install_fullscreen_window(&window.as_ref().window());
let cleanup = self.clone();
let cleanup_app = app.clone();
let cleanup_label = label.clone();
let cleanup_record = record.clone();
let close_view: tauri::Webview<R> = window.as_ref().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
if !cleanup.current(&cleanup_label, cleanup_record.token) {
return;
}
#[cfg(target_os = "linux")]
let result = super::linux::request_close(&close_view);
#[cfg(target_os = "macos")]
let result = super::macos::request_close(&close_view);
#[cfg(windows)]
let result = super::windows::request_close(&close_view);
if let Err(error) = result {
eprintln!("[browser-close] {error}");
}
return;
}
if matches!(event, tauri::WindowEvent::Destroyed) {
let _ = cleanup.dispose_tree(&cleanup_app, &cleanup_label, cleanup_record.token);
}
});
#[cfg(target_os = "linux")]
let configured = super::linux::install(window.as_ref());
#[cfg(target_os = "macos")]
let configured = super::macos::install(window.as_ref());
#[cfg(windows)]
let configured = super::windows::install(window.as_ref());
if configured
.and_then(|()| self.finish_creation(&label, &record))
.is_err()
|| !self.owned(owner, opener).is_ok_and(|current| {
current.token == opener_record.token && current.document == opener_record.document
})
{
self.remove_exact(&label, &record);
let _ = window.destroy();
return NewWindowResponse::Deny;
}
if window.show().is_err() {
self.remove_exact(&label, &record);
let _ = window.destroy();
return NewWindowResponse::Deny;
}
// WebKit performs the requested navigation after this return, using the
// supplied related-view/configuration so opener/postMessage stay native.
NewWindowResponse::Create { window }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_scoped_permission_subject_is_native_document_identity() {
assert_eq!(
page_permission_subject("https://example.com:8443/path?q=secret"),
Some("https://example.com:8443".into())
);
assert_eq!(
page_permission_subject("about:blank"),
Some("about:blank".into())
);
assert_eq!(
page_permission_subject("blob:https://example.com/id"),
Some("https://example.com".into())
);
for raw in [
"file:///private",
"tauri://localhost",
"https://user:pass@example.com",
"blob:null/id",
] {
assert_eq!(page_permission_subject(raw), None);
}
}
#[test]
fn downloads_are_sanitized_unique_and_confined() {
let dir = tempfile::tempdir().unwrap();
for name in [
"../../report.txt",
"..\\..\\report.txt",
".",
"...",
"a\n:b?.txt",
"résumé.txt",
] {
let first = download_path(dir.path(), Path::new(name));
if name == "." {
assert!(first.is_none());
continue;
}
let first = first.unwrap();
assert_eq!(first.parent(), Some(dir.path()));
assert!(!first
.file_name()
.unwrap()
.to_string_lossy()
.contains(['\\', '/', '\n', ':', '?']));
std::fs::write(&first, "original").unwrap();
assert_ne!(
Some(first.clone()),
download_path(dir.path(), Path::new(name))
);
assert_eq!(std::fs::read_to_string(first).unwrap(), "original");
}
assert!(download_path(&dir.path().join("missing"), Path::new("file")).is_none());
}
}

View file

@ -0,0 +1,130 @@
use super::*;
impl EmbeddedViews {
pub(super) fn install_fullscreen_window<R: Runtime>(&self, window: &tauri::Window<R>) {
let Ok(mut inner) = self.0.lock() else { return };
if !inner.fullscreen_windows.insert(window.label().into()) {
return;
}
drop(inner);
let views = self.clone();
let app = window.app_handle().clone();
let label = window.label().to_string();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Resized(size) => views.fit_fullscreen(&app, &label, *size),
tauri::WindowEvent::Destroyed => {
if let Ok(mut inner) = views.0.lock() {
inner.fullscreen_windows.remove(&label);
}
}
_ => {}
});
}
fn fit_fullscreen<R: Runtime>(
&self,
app: &AppHandle<R>,
window: &str,
size: PhysicalSize<u32>,
) {
let labels = self
.0
.lock()
.map(|inner| {
inner
.records
.iter()
.filter(|(_, record)| record.fullscreen.is_some())
.map(|(label, _)| label.clone())
.collect::<Vec<_>>()
})
.unwrap_or_default();
for label in labels {
if let Some(view) = app
.get_webview(&label)
.filter(|view| view.window().label() == window)
{
let _ = view.set_position(PhysicalPosition::new(0, 0));
let _ = view.set_size(size);
}
}
}
pub(super) fn set_fullscreen<R: Runtime>(
&self,
app: &AppHandle<R>,
label: &str,
active: bool,
) -> Result<(), String> {
let record = self
.0
.lock()
.map_err(|error| error.to_string())?
.records
.get(label)
.cloned()
.ok_or("Browser view unavailable")?;
if !label.starts_with("browser-") || active == record.fullscreen.is_some() {
return Ok(());
}
let view = app.get_webview(label).ok_or("Browser view unavailable")?;
let window = view.window();
let before = if active {
window.is_fullscreen().map_err(|error| error.to_string())?
} else {
record.fullscreen.unwrap()
};
let normal = if active {
view.position()
.ok()
.zip(view.size().ok())
.filter(|(_, size)| size.width > 0 && size.height > 0)
.map(|(position, size)| Bounds {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
})
} else {
None
};
{
let mut inner = self.0.lock().map_err(|error| error.to_string())?;
let current = inner
.records
.get_mut(label)
.filter(|current| current.token == record.token)
.ok_or("Browser view changed")?;
current.fullscreen = active.then_some(before);
if active {
current.bounds = normal.or(record.bounds);
}
}
if let Err(error) = window.set_fullscreen(active || before) {
if let Ok(mut inner) = self.0.lock() {
if let Some(current) = inner
.records
.get_mut(label)
.filter(|current| current.token == record.token)
{
current.fullscreen = record.fullscreen;
}
}
return Err(error.to_string());
}
if active {
if let Ok(size) = window.inner_size() {
self.fit_fullscreen(app, window.label(), size);
}
} else {
// Host hide/close can end fullscreen before the engine's exit event.
let _ =
view.eval("if(document.fullscreenElement)document.exitFullscreen().catch(()=>{})");
if let Some(bounds) = record.bounds {
let _ = view.set_position(PhysicalPosition::new(bounds.x, bounds.y));
let _ = view.set_size(PhysicalSize::new(bounds.width, bounds.height));
}
}
Ok(())
}
}

View file

@ -1,15 +1,177 @@
pub(super) fn install(webview: &tauri::Webview) -> Result<(), String> {
pub(super) fn request_close<R: tauri::Runtime>(view: &tauri::Webview<R>) -> Result<(), String> {
use webkit2gtk::WebViewExt;
view.with_webview(|native| native.inner().try_close())
.map_err(|error| error.to_string())
}
pub(super) fn install<R: tauri::Runtime>(webview: &tauri::Webview<R>) -> Result<(), String> {
use std::sync::mpsc;
use std::time::Duration;
use tauri::Manager;
use webkit2gtk::{PermissionRequestExt, WebViewExt};
let (sender, receiver) = mpsc::sync_channel(1);
let view = webview.clone();
let policy = webview
.app_handle()
.try_state::<super::EmbeddedViews>()
.ok_or("Missing browser registry")?
.inner()
.clone();
let token = policy
.token(webview.label())
.ok_or("Expired browser view")?;
webview
.with_webview(move |platform| {
platform.inner().connect_permission_request(|_, request| {
request.deny();
true
use std::{cell::Cell, rc::Rc};
use tauri::Manager;
let generation = Rc::new(Cell::new(0u64));
let loading = generation.clone();
platform.inner().connect_load_changed(move |_, event| {
if event == webkit2gtk::LoadEvent::Started {
loading.set(loading.get().wrapping_add(1));
}
});
let enter = view.clone();
let enter_policy = policy.clone();
platform.inner().connect_enter_fullscreen(move |_| {
if !enter_policy.current(enter.label(), token) {
return true;
}
if let Some(views) = enter.app_handle().try_state::<super::EmbeddedViews>() {
let _ = views.set_fullscreen(enter.app_handle(), enter.label(), true);
}
false
});
let leave = view.clone();
let leave_policy = policy.clone();
platform.inner().connect_leave_fullscreen(move |_| {
if !leave_policy.current(leave.label(), token) {
return false;
}
if let Some(views) = leave.app_handle().try_state::<super::EmbeddedViews>() {
let _ = views.set_fullscreen(leave.app_handle(), leave.label(), false);
}
false
});
platform
.inner()
.connect_permission_request(move |page, request| {
if !policy.current(view.label(), token) {
request.deny();
return true;
}
use gtk::glib::{Cast, ObjectExt};
use webkit2gtk::{
DeviceInfoPermissionRequest, GeolocationPermissionRequest,
NotificationPermissionRequest, PointerLockPermissionRequest,
UserMediaPermissionRequest, UserMediaPermissionRequestExt,
WebsiteDataAccessPermissionRequest, WebsiteDataAccessPermissionRequestExt,
};
if request.is::<GeolocationPermissionRequest>()
|| request.is::<NotificationPermissionRequest>()
|| request
.downcast_ref::<UserMediaPermissionRequest>()
.is_some_and(|media| {
media.is_for_audio_device() || media.is_for_video_device()
})
|| request.is::<DeviceInfoPermissionRequest>()
{
request.deny();
return true;
}
// WebKit's pointer-lock API grants the WebView's pointer,
// not a frame resource. Its own MiniBrowser attributes this
// page-scoped request to the displayed WebView origin.
// https://github.com/WebKit/WebKit/blob/main/Tools/MiniBrowser/gtk/BrowserTab.c
let consent = if request.is::<PointerLockPermissionRequest>() {
page.uri()
.and_then(|uri| super::browser::page_permission_subject(&uri))
.map(|subject| ("pointer-lock", subject))
} else if let Some(storage) =
request.downcast_ref::<WebsiteDataAccessPermissionRequest>()
{
storage
.requesting_domain()
.zip(storage.current_domain())
.map(|(requester, current)| {
("storage-access", format!("{requester} → {current}"))
})
} else {
None
};
if let Some((kind, subject)) = consent {
use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
let label = |key| {
view.app_handle()
.try_state::<crate::AppState>()
.map(|state| state.menu_labels.text(key))
.unwrap_or_else(|| crate::menu_labels::label(key).to_string())
};
let message = label("browserPermission")
.replace("{permission}", kind)
.replace("{origin}", &subject);
let request = request.clone();
let epoch = generation.clone();
let captured = epoch.get();
let weak_page = page.downgrade();
let answer_policy = policy.clone();
let answer_label = view.label().to_string();
let (reply, answer) =
gtk::glib::MainContext::channel::<bool>(gtk::glib::Priority::DEFAULT);
answer.attach(None, move |allowed| {
if allowed
&& epoch.get() == captured
&& weak_page.upgrade().is_some()
&& answer_policy.current(&answer_label, token)
{
request.allow();
} else {
request.deny();
}
gtk::glib::ControlFlow::Break
});
if kind == "pointer-lock" {
let denied = reply.clone();
if pointer_lock_consent(
page,
&message,
&label("browserAllow"),
&label("browserDeny"),
move |allowed| {
let _ = reply.send(allowed);
},
)
.is_err()
{
let _ = denied.send(false);
super::browser::unknown_permission(
&view,
"pointer-lock: consent unavailable",
);
}
return true;
}
view.app_handle()
.dialog()
.message(message)
.parent(&view.window())
.buttons(MessageDialogButtons::OkCancelCustom(
label("browserAllow"),
label("browserDeny"),
))
.show(move |allowed| {
let _ = reply.send(allowed);
});
return true;
}
// This API exposes no requesting-frame origin for these request
// classes. Never substitute the top-level page's origin.
request.deny();
super::browser::unknown_permission(&view, request.type_().name());
true
});
let _ = sender.send(());
})
.map_err(|error| error.to_string())?;
@ -17,3 +179,93 @@ pub(super) fn install(webview: &tauri::Webview) -> Result<(), String> {
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "Timed out configuring WebKitGTK browser security".to_string())
}
// Keep the requesting Wayland surface focused: a separate modal window causes
// WebKit to cancel pointer lock before its permission response arrives.
fn pointer_lock_consent(
page: &webkit2gtk::WebView,
message: &str,
allow: &str,
deny: &str,
answer: impl FnOnce(bool) + 'static,
) -> Result<(), ()> {
use gtk::{
glib::{Cast, ObjectExt},
prelude::*,
};
use std::{cell::RefCell, rc::Rc};
use webkit2gtk::WebViewExt;
let mut ancestor = page.parent();
let mut overlay = None;
while let Some(widget) = ancestor {
overlay = widget.clone().downcast::<gtk::Overlay>().ok();
if overlay.is_some() {
break;
}
ancestor = widget.parent();
}
let overlay = match overlay {
Some(overlay) => overlay,
None => {
let window = page
.toplevel()
.and_then(|widget| widget.downcast::<gtk::Window>().ok())
.ok_or(())?;
let child = window.child().ok_or(())?;
let overlay = gtk::Overlay::new();
window.remove(&child);
overlay.add(&child);
window.add(&overlay);
overlay.show();
overlay
}
};
let bar = gtk::InfoBar::new();
bar.set_halign(gtk::Align::Fill);
bar.set_valign(gtk::Align::Start);
bar.set_message_type(gtk::MessageType::Question);
let text = gtk::Label::new(Some(message));
text.set_line_wrap(true);
bar.content_area().add(&text);
for (label, response) in [
(deny, gtk::ResponseType::Cancel),
(allow, gtk::ResponseType::Accept),
] {
let button = bar.add_button(&format!("_{label}"), response).ok_or(())?;
button.set_use_underline(true);
button.set_focus_on_click(false);
}
let answer = Rc::new(RefCell::new(Some(answer)));
let response = answer.clone();
bar.connect_response(move |bar, value| {
if let Some(answer) = response.borrow_mut().take() {
answer(value == gtk::ResponseType::Accept);
}
unsafe {
bar.destroy();
}
});
bar.connect_close(|bar| bar.response(gtk::ResponseType::Cancel));
let weak_bar = bar.downgrade();
let loading = page.connect_load_changed(move |_, event| {
if event == webkit2gtk::LoadEvent::Started {
if let Some(bar) = weak_bar.upgrade() {
bar.response(gtk::ResponseType::Cancel);
}
}
});
let weak_page = page.downgrade();
let loading = RefCell::new(Some(loading));
bar.connect_destroy(move |_| {
if let (Some(page), Some(loading)) = (weak_page.upgrade(), loading.borrow_mut().take()) {
page.disconnect(loading);
}
if let Some(answer) = answer.borrow_mut().take() {
answer(false);
}
});
overlay.add_overlay(&bar);
bar.show_all();
Ok(())
}

View file

@ -1,4 +1,26 @@
pub(super) fn install(_: &tauri::Webview) -> Result<(), String> {
pub(super) fn request_close<R: tauri::Runtime>(view: &tauri::Webview<R>) -> Result<(), String> {
let window = view.window();
view.with_webview(move |native| unsafe {
let Some(view) = native.inner().cast::<objc2::runtime::AnyObject>().as_ref() else {
return;
};
// WebKit's native tryClose runs beforeunload, then webViewDidClose.
// An immediate YES means no asynchronous confirmation was needed.
let available: objc2::runtime::Bool =
objc2::msg_send![view, respondsToSelector: objc2::sel!(_tryClose)];
if !available.as_bool() {
eprintln!("[browser-close] WKWebView tryClose unavailable");
return;
}
let close: objc2::runtime::Bool = objc2::msg_send![view, _tryClose];
if close.as_bool() {
let _ = window.destroy();
}
})
.map_err(|error| error.to_string())
}
pub(super) fn install<R: tauri::Runtime>(_: &tauri::Webview<R>) -> Result<(), String> {
// The builder installs per-instance native permission denial at construction.
// Qualify WebKit's other permission defaults before enabling the renderer path.
Ok(())

View file

@ -1,25 +1,93 @@
use std::sync::mpsc;
use std::time::Duration;
pub(super) fn install(webview: &tauri::Webview) -> Result<(), String> {
use webview2_com::{
Microsoft::Web::WebView2::Win32::COREWEBVIEW2_PERMISSION_STATE_DENY,
PermissionRequestedEventHandler,
};
pub(super) fn request_close<R: tauri::Runtime>(view: &tauri::Webview<R>) -> Result<(), String> {
// These windows were opened by script. WebView2's supported close request
// is window.close(), which emits WindowCloseRequested after beforeunload.
view.eval("window.close()")
.map_err(|error| error.to_string())
}
pub(super) fn install<R: tauri::Runtime>(webview: &tauri::Webview<R>) -> Result<(), String> {
use tauri::Manager;
use webview2_com::{Microsoft::Web::WebView2::Win32::*, PermissionRequestedEventHandler};
crate::windows_browser_accelerators::bind(webview);
let (sender, receiver) = mpsc::sync_channel(1);
let view = webview.clone();
let policy = webview
.app_handle()
.try_state::<super::EmbeddedViews>()
.ok_or("Missing browser registry")?
.inner()
.clone();
let token = policy
.token(webview.label())
.ok_or("Expired browser view")?;
webview
.with_webview(move |platform| {
let result = (|| {
let core = unsafe { platform.controller().CoreWebView2() }?;
let permission = PermissionRequestedEventHandler::create(Box::new(|_, args| {
if let Some(args) = args {
unsafe { args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)? };
}
Ok(())
}));
let mut token = 0;
unsafe { core.add_PermissionRequested(&permission, &mut token)? };
let fullscreen_view = view.clone();
let fullscreen_policy = policy.clone();
let permission =
PermissionRequestedEventHandler::create(Box::new(move |_, args| {
if let Some(args) = args {
unsafe {
if !policy.current(view.label(), token) {
args.SetState(COREWEBVIEW2_PERMISSION_STATE_DENY)?;
return Ok(());
}
let mut kind = COREWEBVIEW2_PERMISSION_KIND::default();
args.PermissionKind(&mut kind)?;
let state = permission_state(kind);
if state == COREWEBVIEW2_PERMISSION_STATE_DENY
&& !matches!(kind.0, 1..=4)
{
super::browser::unknown_permission(
&view,
&format!("WebView2:{}", kind.0),
);
}
args.SetState(state)?;
}
}
Ok(())
}));
let mut event_token = 0;
unsafe { core.add_PermissionRequested(&permission, &mut event_token)? };
unsafe {
core.Settings()?.SetIsStatusBarEnabled(true)?;
}
unsafe {
core.add_ContainsFullScreenElementChanged(
&webview2_com::ContainsFullScreenElementChangedEventHandler::create(
Box::new(move |sender, _| {
if !fullscreen_policy.current(fullscreen_view.label(), token) {
return Ok(());
}
if let Some(core) = sender {
use tauri::Manager;
let mut active = Default::default();
core.ContainsFullScreenElement(&mut active)?;
if let Some(views) = fullscreen_view
.app_handle()
.try_state::<super::EmbeddedViews>()
{
if let Err(error) = views.set_fullscreen(
fullscreen_view.app_handle(),
fullscreen_view.label(),
active.as_bool(),
) {
eprintln!("[browser-fullscreen] {error}");
}
}
}
Ok(())
}),
),
&mut 0,
)?;
}
Ok::<(), webview2_com::Error>(())
})();
let _ = sender.send(result.map_err(|error| error.to_string()));
@ -29,3 +97,35 @@ pub(super) fn install(webview: &tauri::Webview) -> Result<(), String> {
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "Timed out configuring WebView2 browser security".to_string())?
}
fn permission_state(
kind: webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_PERMISSION_KIND,
) -> webview2_com::Microsoft::Web::WebView2::Win32::COREWEBVIEW2_PERMISSION_STATE {
use webview2_com::Microsoft::Web::WebView2::Win32::*;
// Current documented values: sensors through PersistentStorage (13).
// https://learn.microsoft.com/microsoft-edge/webview2/reference/winrt/microsoft_web_webview2_core/corewebview2permissionkind
if matches!(kind.0, 5..=13) {
COREWEBVIEW2_PERMISSION_STATE_DEFAULT
} else {
COREWEBVIEW2_PERMISSION_STATE_DENY
}
}
#[cfg(test)]
mod tests {
use super::*;
use webview2_com::Microsoft::Web::WebView2::Win32::*;
#[test]
fn only_classified_non_forbidden_permissions_reach_browser_consent() {
for kind in -1..=30 {
assert_eq!(
permission_state(COREWEBVIEW2_PERMISSION_KIND(kind)),
if (5..=13).contains(&kind) {
COREWEBVIEW2_PERMISSION_STATE_DEFAULT
} else {
COREWEBVIEW2_PERMISSION_STATE_DENY
}
);
}
}
}

View file

@ -37,7 +37,7 @@ fn child_policy_is_registered_before_creation_and_fails_closed() {
views.finish_creation("browser-child", &record).unwrap();
assert_eq!(
views.navigation_allowed("browser-child", &blank),
Some(false)
Some(true)
);
for raw in [
"file:///secret",
@ -136,7 +136,7 @@ fn native_creation_installs_protections_before_return_and_rolls_back_failure() {
));
assert_eq!(
views.navigation_allowed(child.label(), &"about:blank".parse().unwrap()),
Some(false)
Some(true)
);
let record = views.owned(OWNER, child.label()).unwrap();
views.remove_exact(child.label(), &record);
@ -174,6 +174,109 @@ fn native_creation_installs_protections_before_return_and_rolls_back_failure() {
assert!(app.get_webview("browser-reloading").is_none());
}
#[test]
fn document_policy_allows_blank_and_only_owned_blob_origins() {
let views = EmbeddedViews::for_test(OWNER, "browser-origin");
views.observe_origin(
"browser-origin",
views.owned(OWNER, "browser-origin").unwrap().token,
&"https://example.com/page".parse().unwrap(),
);
for url in [
"https://example.com/page",
"about:blank",
"blob:https://example.com/id",
] {
assert_eq!(
views.navigation_allowed("browser-origin", &url.parse().unwrap()),
Some(true)
);
}
for url in [
"blob:https://other.example/id",
"blob:null/id",
"file:///private",
"tauri://localhost",
] {
assert_eq!(
views.navigation_allowed("browser-origin", &url.parse().unwrap()),
Some(false)
);
}
assert!(!is_allowed_url(&"about:blank".parse().unwrap()));
assert!(!is_allowed_url(
&"blob:https://example.com/id".parse().unwrap()
));
}
#[test]
fn tree_invalidation_includes_pending_descendants_and_fences_stale_callbacks() {
let views = EmbeddedViews::default();
let root = views.reserve(OWNER, "browser-tree").unwrap();
let child = views.reserve(OWNER, "browser-popup-child").unwrap();
let grandchild = views.reserve(OWNER, "browser-popup-grandchild").unwrap();
let peer = views.reserve(OWNER, "browser-peer").unwrap();
{
let mut inner = views.0.lock().unwrap();
inner.records.get_mut("browser-popup-child").unwrap().opener = Some("browser-tree".into());
inner
.records
.get_mut("browser-popup-grandchild")
.unwrap()
.opener = Some("browser-popup-child".into());
let removed = EmbeddedViews::take_tree(&mut inner, "browser-tree", root.token, true);
assert_eq!(removed.len(), 3);
assert_eq!(inner.records.len(), 1);
}
assert!(views
.finish_creation("browser-popup-child", &child)
.is_err());
assert!(views
.finish_creation("browser-popup-grandchild", &grandchild)
.is_err());
let replacement = views.reserve(OWNER, "browser-tree").unwrap();
assert!(!views.current("browser-tree", root.token));
views.observe_origin(
"browser-tree",
root.token,
&"https://stale.invalid".parse().unwrap(),
);
assert_eq!(views.owned(OWNER, "browser-tree").unwrap().origin, None);
assert!(EmbeddedViews::take_tree(
&mut views.0.lock().unwrap(),
"browser-tree",
root.token,
true
)
.is_empty());
assert!(views.current("browser-tree", replacement.token));
assert!(views.current("browser-peer", peer.token));
}
#[test]
fn reload_tree_invalidation_retains_root_and_isolates_other_owners() {
let views = EmbeddedViews::default();
let root = views.reserve(OWNER, "browser-reload-tree").unwrap();
let child = views.reserve(OWNER, "browser-popup-reload").unwrap();
let other = "local-22222222-2222-4222-8222-222222222222";
let peer = views.reserve(other, "browser-popup-peer").unwrap();
let mut inner = views.0.lock().unwrap();
for label in ["browser-popup-reload", "browser-popup-peer"] {
inner.records.get_mut(label).unwrap().opener = Some("browser-reload-tree".into());
}
let removed = EmbeddedViews::take_tree(&mut inner, "browser-reload-tree", root.token, false);
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].1.token, child.token);
assert_eq!(
inner.records.get("browser-reload-tree").unwrap().token,
root.token
);
assert_eq!(
inner.records.get("browser-popup-peer").unwrap().token,
peer.token
);
}
#[test]
fn owner_cleanup_removes_ready_and_pending_children_without_touching_other_owners() {
let app = mock_builder().build(mock_context(noop_assets())).unwrap();
@ -216,6 +319,63 @@ fn owner_cleanup_removes_ready_and_pending_children_without_touching_other_owner
views.close(app.handle(), OWNER, "browser-ready").unwrap();
assert!(app.get_webview(OWNER).is_some());
}
#[test]
fn fullscreen_keeps_normal_bounds_and_owner_cleanup_restores_the_window() {
let app = mock_builder().build(mock_context(noop_assets())).unwrap();
WebviewWindowBuilder::new(&app, OWNER, WebviewUrl::default())
.build()
.unwrap();
let window = app.get_window(OWNER).unwrap();
let child = window
.add_child(
tauri::webview::WebviewBuilder::new("browser-fullscreen", WebviewUrl::default()),
PhysicalPosition::new(10, 20),
PhysicalSize::new(300, 200),
)
.unwrap();
let views = EmbeddedViews::for_test(OWNER, child.label());
let normal = Bounds {
x: 10,
y: 20,
width: 300,
height: 200,
};
views
.set_bounds(app.handle(), OWNER, child.label(), normal)
.unwrap();
views
.set_fullscreen(app.handle(), child.label(), true)
.unwrap();
let saved = views.owned(OWNER, child.label()).unwrap().bounds;
views
.set_bounds(
app.handle(),
OWNER,
child.label(),
Bounds {
width: 1600,
height: 900,
..normal
},
)
.unwrap();
assert_eq!(views.owned(OWNER, child.label()).unwrap().bounds, saved);
views
.set_fullscreen(app.handle(), child.label(), false)
.unwrap();
assert!(views
.owned(OWNER, child.label())
.unwrap()
.fullscreen
.is_none());
views
.set_fullscreen(app.handle(), child.label(), true)
.unwrap();
views.remove_owner(app.handle(), OWNER);
assert!(app.get_webview(child.label()).is_none());
assert!(!window.is_fullscreen().unwrap());
}
#[test]
fn storage_identifiers_share_only_the_same_full_identity_and_domain() {
let storage = Storage::from(browser_storage(std::path::Path::new("profile/browser")));

View file

@ -1658,6 +1658,7 @@ fn is_final_application_window<'a>(
!labels.any(|label| {
label != closing_label
&& label != preferences_window::LABEL
&& !label.starts_with("browser-popup-")
&& !launch_windows.iter().any(|launch| launch == label)
})
}
@ -2732,6 +2733,23 @@ mod menu_tests {
));
}
#[test]
fn webkit_popups_follow_the_last_application_window_lifetime() {
assert_eq!(
is_final_application_window(
"local-a",
["local-a", "browser-popup-a"].into_iter(),
&[],
),
true,
);
assert!(!is_final_application_window(
"local-a",
["local-a", "browser-popup-a", "local-b"].into_iter(),
&[],
));
}
#[test]
fn hidden_launch_windows_do_not_keep_the_last_application_window_alive() {
let launch = vec!["local-launch".to_string()];

View file

@ -5,7 +5,7 @@ use tauri::{menu::MenuItemKind, Wry};
/// English labels until a renderer reports its locale. Keys and texts mirror
/// `NATIVE_MENU_LABEL_KEYS` and the English messages in the UI package
/// (`packages/ui/src/lib/native/menu-labels.ts`); a UI test keeps them in sync.
pub(crate) const DEFAULTS: [(&str, &str); 39] = [
pub(crate) const DEFAULTS: [(&str, &str); 49] = [
("file", "File"),
("edit", "Edit"),
("view", "View"),
@ -45,6 +45,19 @@ pub(crate) const DEFAULTS: [(&str, &str); 39] = [
("maximize", "Maximize"),
("zoom", "Zoom"),
("bringAllToFront", "Bring All to Front"),
("browserPermission", "Allow {permission} for {origin}?"),
(
"browserPermissionUnknown",
"Unknown permission blocked: {permission}.",
),
("browserAllow", "Allow"),
("browserDeny", "Deny"),
("browserCopyLink", "Copy Link"),
("browserSaveImage", "Save Image"),
("browserOpenLink", "Open Link in New Window"),
("browserInspect", "Inspect"),
("browserPromptAccept", "OK"),
("browserPromptCancel", "Cancel"),
];
const MAX_LABEL_CHARS: usize = 200;
@ -94,6 +107,13 @@ pub(crate) struct MenuLabels {
}
impl MenuLabels {
pub(crate) fn text(&self, key: &str) -> String {
self.latest
.lock()
.ok()
.and_then(|labels| labels.as_ref().and_then(|labels| labels.get(key)).cloned())
.unwrap_or_else(|| label(key).to_string())
}
pub(crate) fn replace_targets(&self, targets: Vec<(&'static str, MenuItemKind<Wry>)>) {
if let Ok(mut current) = self.targets.lock() {
*current = targets;
@ -106,7 +126,9 @@ impl MenuLabels {
/// Runs on the main thread. Native menu calls happen outside our mutexes.
pub(crate) fn apply(&self, reported: Option<&BTreeMap<String, String>>) {
let labels = {
let Ok(mut latest) = self.latest.lock() else { return };
let Ok(mut latest) = self.latest.lock() else {
return;
};
if let Some(reported) = reported {
*latest = Some(reported.clone());
}
@ -114,7 +136,9 @@ impl MenuLabels {
};
let Some(labels) = labels else { return };
{
let Ok(mut applied) = self.applied.lock() else { return };
let Ok(mut applied) = self.applied.lock() else {
return;
};
if applied.as_ref() == Some(&labels) {
return;
}
@ -143,7 +167,10 @@ mod tests {
use std::collections::BTreeMap;
fn labels(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
entries.iter().map(|(key, text)| (key.to_string(), text.to_string())).collect()
entries
.iter()
.map(|(key, text)| (key.to_string(), text.to_string()))
.collect()
}
#[test]
@ -165,6 +192,9 @@ mod tests {
#[cfg(not(target_os = "macos"))]
#[test]
fn a_literal_ampersand_is_not_a_mnemonic() {
assert_eq!(resolve("copy", Some(&labels(&[("copy", "Copy & paste")]))), "Copy && paste");
assert_eq!(
resolve("copy", Some(&labels(&[("copy", "Copy & paste")]))),
"Copy && paste"
);
}
}

View file

@ -39,7 +39,7 @@ pub(crate) fn install(controller: &ICoreWebView2Controller) -> webview2_com::Res
Ok(())
}
pub(crate) fn bind(webview: &tauri::Webview) {
pub(crate) fn bind<R: tauri::Runtime>(webview: &tauri::Webview<R>) {
if let Err(error) = webview.with_webview(|platform| {
if let Err(error) = install(&platform.controller()) {
eprintln!("[browser-accelerators] failed to protect F6: {error}");

View file

@ -0,0 +1,32 @@
[package]
name = "codenomad-webkit-capabilities-fixture"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "codenomad-webkit-capabilities-fixture"
path = "src/main.rs"
test = false
[dependencies]
tauri = { version = "2.12.5", features = ["devtools", "unstable", "test"] }
serde = { version = "1", features = ["derive"] }
sha2 = "0.10"
uuid = { version = "1", features = ["v4"] }
dirs = "5"
tauri-plugin-dialog = "2.8.4"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
webkit2gtk = "2.0.2"
[target.'cfg(windows)'.dependencies]
webview2-com = "0.39"
windows-core = "0.62"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,117 @@
# Native WebKit capabilities fixture
Run in a graphical Windows, macOS or Linux session, with the Tauri workspace's locked
dependencies installed. This harness includes the production embedded-view layer
and uses neither a CodeNomad backend nor OpenCode.
1. Start `python3 tests/webkit-capabilities/fixture.py` from `packages/tauri-app`.
2. Run `CODENOMAD_PROFILE=<isolated-absolute-directory> cargo run --locked -p
codenomad-webkit-capabilities-fixture`.
3. Open **OAuth popup**, then **Finish OAuth**. Expect shared cookie/localStorage
in the round-trip result, `closed: true` and disposal of the native popup.
Also exercise **Target blank** and **Blocked file popup**.
4. Download twice. Check two distinct `*-capabilities-fixture.txt` files in the
system Downloads directory, each containing `webkit-capabilities-download`.
Exercise data downloads and blob downloads where the engine exposes object URLs.
5. Exercise **Dialogs**, accepting and cancelling confirm/prompt. Check the
returned boolean and string/null, including the prompt's default value.
Start a download immediately before opening a dialog to check responsiveness.
6. Exercise **Permissions**. Expect geolocation error 1 and media/notification
rejection or an unavailable API on engines without that API.
On Linux repeat with `GDK_BACKEND=x11` and `GDK_BACKEND=wayland` in their
corresponding graphical sessions. A nested KWin Wayland compositor supports
native Wayland clients and forwards native pointer/key input from its host window.
For opener cleanup, set `CODENOMAD_FIXTURE_CLOSE_AFTER=30` (1–60 seconds), then
open **Hold popup for opener cleanup**. The harness closes the production opener
and asserts that both the child view and popup tree disappear within five seconds.
`mac-input.py` supplies native macOS pointer/key/text events for VM trials;
Linux X11 and nested-compositor input can use `xdotool`.
Windows uses the shared common-controls manifest and a GUI-subsystem release
fixture. Start the page with `node tests/webkit-capabilities/server.mjs`, then build
`cargo build --release --locked -p codenomad-webkit-capabilities-fixture`.
Launch the executable with an isolated absolute `CODENOMAD_PROFILE` directory.
`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--force-renderer-accessibility` enables the
fixture accessibility driver without a fixed debugging port. Run `windows-ui.ps1`
with Windows PowerShell, the fixture PID, `-Invoke <accessible-name>` and
`-NativePointer -Activate` for gesture-sensitive actions; `-Escape` exits browser
fullscreen/pointer lock. The driver restricts actions to this checkout's fixture
and creation-time-fenced descendants. Node/PowerShell console launchers use hidden
windows. App release smoke launches use `src-tauri` as their working directory.
The **Browser APIs** section additionally exercises blank/named pop-ups, prompt
string/cancel, fullscreen, pointer lock, clipboard consent, beforeunload, print,
file selection/drop and WebAuthn availability. Availability is recorded separately
from authentication success. The page reports absent app IPC/Node globals.
Electron uses `packages/electron-app/tests/browser-capabilities/fixture.ts` bundled
with esbuild (`--platform=node --format=esm --external:electron`) and run by the
installed Electron executable (`--loader:.css=text` when bundling). Its own profile directory is mandatory. Set
`CODENOMAD_FIXTURE_NETWORK=1` to test a trusted HTTPS page (`example.com`) requesting
the loopback fixture: the permission dialog must identify the HTTPS origin. The
fixture requires `CODENOMAD_FIXTURE_PROMPT_PRELOAD` pointing to the preview-only
`electron/preload/browser-prompt.cjs`. Set `CODENOMAD_FIXTURE_PROMPT_TEST=1` for
assertions covering root/blank-popup prompt strings, empty strings, cancellation,
page-global isolation, suppressed subframe prompts and selection of entry 25.
For extended qualification, open **Print** and cancel the system print panel;
select a known local fixture through the native file input and drop it on the HTML
zone; open **PDF display** and verify its text, then **PDF download** and verify a
unique 593-byte PDF in Downloads. Arm beforeunload before **Navigate**, **Reload
page** and native closure, testing cancellation and acceptance separately. Record
native closure independently from navigation. **Hardware APIs** reports interface
availability; **WebAuthn availability** reports platform-authenticator availability.
## Isolated native inputs
`CODENOMAD_FIXTURE_LIFETIME` selects `opener-close`, `opener-reload`,
`owner-destroy` or `renderer-reload`. Activate **Lifetime tree**, then **Create
descendant** in its child using native input. The child arms beforeunload and
opens a grandchild. The harness asserts forced descendant disposal, empty
descendant registries and the expected retained/root state, writing the result
inside the isolated profile. A hidden harness window keeps the event loop alive
for assertions after owner destruction. The renderer case replaces the neutral
primary document with the loopback page and uses the production cleanup contract.
Electron's same flag runs the equivalent production-guest assertions directly.
`CODENOMAD_FIXTURE_CONTROLS=1` opens the focused clipboard, file-picker,
context-menu and viewport probe. The fixture enables Tauri's `devtools` feature,
matching the production application. Native chooser input must target its owned
window; the selected filename is reported by the page's actual FileList.
Linux fixtures and native file sources share the same session bus. Set `DISPLAY`
before `dbus-run-session` and update its activation environment before launching
portal clients; a portal with a different display can open its print dialog
outside the test display. Use a dedicated Xvfb display and compositor, with native
Wayland clients connected to that compositor's own socket. Capture both client and
server Wayland traces when qualifying pointer lock, including the compositor's
`locked` event to establish effective lock.
`gtk-file-source.py <known-fixture-file>` offers a genuine `text/uri-list` drag.
Move inside the destination after its initial drag-enter so WebKit receives a
drag-over before release. `mac-file-source.swift`, built with the system `swiftc`,
offers a native NSURL drag; `mac-input.py drag X Y X Y` sends native mouse events.
On Windows, launch `windows-file-source.ps1 -FixtureProcessId <pid>` in hidden
PowerShell and use `windows-file-drop.ps1` with that source PID and fixture PID.
The source is the checked-in `drop-fixture.txt`; the page reports FileList count
and name. WebKitGTK 2.52.6 reproduces WebKit bug 323277 (CVE-2025-13947 protection)
in both the production layer and a bare native WebKit view. Preserve the engine's
file security policy; uploading through the file input is the available path.
For reproducible WebView2 beforeunload checks, start a fresh isolated release
fixture, then run `windows-beforeunload.ps1 -FixtureProcessId <pid>
-EvidenceDirectory <new-directory>`. It asserts Stay/Leave for navigation, reload
and native popup closure, capturing each stage. The driver admits external-browser
dialog windows only through their live HWND owner chain, restores owned minimized
windows and refuses covered controls before foreground pointer input.
Electron's `CODENOMAD_FIXTURE_BEFOREUNLOAD_TEST=1` runs equivalent assertions against
the production guest hooks. Set `CODENOMAD_FIXTURE_DIALOG_DRIVER` to the absolute
`linux-answer-beforeunload.sh` path and `CODENOMAD_FIXTURE_EVIDENCE` to an isolated
output directory. The native answer helper needs `xdotool`, ImageMagick and the
fixture's display; it matches both the owning PID and origin-labelled dialog.
Run the test process under an external `timeout` too, since a synchronous native
dialog can block the main JavaScript event loop. These checks cover individual
popup closure; owning-window/opener teardown is recorded independently.

View file

@ -0,0 +1,9 @@
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let manifest = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("../../src-tauri/examples/window_constraints_windows.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display());
}
}

View file

@ -0,0 +1 @@
Native browser file drop fixture.

View file

@ -0,0 +1,131 @@
"""Loopback-only native browser acceptance page; logs results to stdout."""
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
import base64
PDF_BASE64 = 'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCAzMDAgMjAwXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA0IDAgUiA+PiA+PiAvQ29udGVudHMgNSAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iago1IDAgb2JqCjw8IC9MZW5ndGggNDkgPj4Kc3RyZWFtCkJUIC9GMSAxNiBUZiAzMCAxMDAgVGQgKE5hdGl2ZSBwcmV2aWV3IFBERikgVGogRVQKZW5kc3RyZWFtCmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCjAwMDAwMDAyNDEgMDAwMDAgbiAKMDAwMDAwMDMxMSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDYgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjQxMAolJUVPRgo='
PAGE = b'''<!doctype html><meta charset="utf-8"><title>WebKit capabilities</title>
<style>body{font:18px sans-serif}button,a{display:inline-block;margin:4px;padding:6px}details{margin-block:12px}pre{white-space:pre-wrap}</style>
<button onclick="window.popup=window.open('/popup','oauth','width=520,height=680')">OAuth popup</button>
<a href="/popup" target="_blank" rel="opener">Target blank</a>
<a href="/download" download>Download</a>
<button onclick="alert('fixture alert');report('alert','dismissed');report('confirm',confirm('fixture confirm'));report('prompt',prompt('fixture prompt','fixture-default'))">Dialogs</button>
<button onclick="permissions()">Permissions</button>
<button onclick="window.open('file:///etc/passwd')">Blocked file popup</button>
<button onclick="popup=window.open('/hold','hold')">Hold popup for opener cleanup</button>
<button onclick="window.open('/lifetime-child')">Lifetime tree</button>
<a href="data:text/plain,generated-download" download="capabilities-data.txt">Data download</a>
<button onclick="blobDownload()">Blob download</button>
<details open><summary>Browser APIs</summary>
<button onclick="blankPopup()">Blank OAuth</button>
<button onclick="const first=window.open('/hold','named-test');const second=window.open('/popup','named-test');report('named',first===second)">Named window</button>
<button onclick="try{report('prompt-only',prompt('fixture prompt','fixture-default'))}catch(e){report('prompt-error',e.message)}">Prompt only</button>
<button onclick="document.documentElement.requestFullscreen().then(()=>report('fullscreen','entered'),e=>report('fullscreen',e.name))">Fullscreen</button>
<button onclick="this.requestPointerLock()">Pointer lock</button>
<button onclick="navigator.clipboard.readText().then(v=>report('clipboard-read','length:'+v.length),e=>report('clipboard-read',e.name))">Read clipboard</button>
<button onclick="navigator.clipboard.writeText('capabilities-fixture').then(()=>report('clipboard-write','written'),e=>report('clipboard-write',e.name))">Write clipboard</button>
<button onclick="window.onbeforeunload=()=>true;report('beforeunload','armed');report('activation',navigator.userActivation?.hasBeenActive)">Arm beforeunload</button><a href="/next">Navigate</a>
<button onclick="location.reload()">Reload page</button>
<button onclick="window.open('/close-test','close-test','width=800,height=700')">Close-test popup</button>
<button onclick="window.print();report('print','requested')">Print</button>
<input type="file" onchange="report('file',this.files[0]?.name)">
<div style="height:80px;border:1px solid" ondragenter="report('drag-enter',Array.from(event.dataTransfer.types).join(','))" ondragover="event.preventDefault();event.dataTransfer.dropEffect='copy'" ondrop="event.preventDefault();report('drop-count',event.dataTransfer.files.length);report('drop',event.dataTransfer.files[0]?.name)">Drop file</div>
<button onclick="if(window.PublicKeyCredential)PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(v=>report('webauthn',v));else report('webauthn','unavailable')">WebAuthn availability</button>
<button onclick="report('hardware',JSON.stringify(Object.fromEntries(['usb','hid','serial','bluetooth'].map(k=>[k,typeof navigator[k]]))))">Hardware APIs</button>
<a href="/pdf" target="_blank">PDF display</a><a href="/pdf-download" download>PDF download</a>
</details>
<a href="/controls">Additional controls</a>
<pre id="results"></pre><script>
function report(k,v){results.textContent+=k+': '+v+'\\n';fetch('/result?'+encodeURIComponent(k+'='+v))}
addEventListener('error',e=>report('error',e.message));
document.addEventListener('pointerlockchange',()=>report('pointer-lock',Boolean(document.pointerLockElement)));
document.addEventListener('pointerlockerror',()=>report('pointer-lock','error'));
let lockedMotions=0;
document.addEventListener('mousemove',event=>{if(document.pointerLockElement&&(event.movementX||event.movementY)&&lockedMotions++<3)report('locked-motion',event.movementX+','+event.movementY)});
document.addEventListener('fullscreenchange',()=>report('fullscreen-state',Boolean(document.fullscreenElement)));
function blankPopup(){
popup=window.open('about:blank','blank-oauth','width=520,height=680');
report('blank-opener',popup?.opener===window);
if(popup){popup.document.write('<button id="finish">Finish blank OAuth</button>');popup.document.getElementById('finish').onclick=()=>{popup.opener.postMessage('blank-roundtrip',location.origin);popup.close()}}
}
function blobDownload(){
const api=typeof URL.createObjectURL==='function'?URL:window.webkitURL;
if(!api?.createObjectURL){report('blob','unavailable');return}
const a=document.createElement('a');a.href=api.createObjectURL(new Blob(['generated-download'],{type:'application/octet-stream'}));a.download='capabilities-blob.txt';document.body.append(a);a.click();a.remove();report('blob','requested');
}
function permissions(){
navigator.geolocation.getCurrentPosition(()=>report('geo','GRANTED'),e=>report('geo',e.code));
if(navigator.mediaDevices?.getUserMedia)navigator.mediaDevices.getUserMedia({audio:true,video:true}).then(()=>report('media','GRANTED'),e=>report('media',e.name));else report('media','unavailable');
if('Notification' in window)Notification.requestPermission().then(v=>report('notifications',v));else report('notifications','unavailable');
}
localStorage.fixture='shared';document.cookie='fixture=shared; SameSite=Lax';
report('isolation',typeof window.__TAURI_INTERNALS__+'/'+typeof window.require+'/'+typeof URL.createObjectURL);
addEventListener('message',e=>{if(e.origin===location.origin){report('oauth',e.data);setTimeout(()=>report('closed',popup?.closed),300)}});
</script>'''
POPUP = b'''<!doctype html><title>OAuth fixture</title><button onclick="if(opener)opener.postMessage('roundtrip:'+localStorage.fixture+':'+document.cookie,location.origin);window.close()">Finish OAuth</button>'''
LIFETIME = b'''<!doctype html><title>Lifetime child</title><button onclick="onbeforeunload=()=>true;window.open('/hold');fetch('/result?lifetime-child=armed')">Create descendant</button>'''
CONTROLS = b'''<!doctype html><meta charset="utf-8"><title>Additional controls</title>
<style>body{font:18px sans-serif}button,input,a{margin:6px;padding:6px}img{width:120px;height:80px}pre{white-space:pre-wrap}</style>
<button onclick="navigator.clipboard.writeText('fixture-clipboard').then(()=>report('write','ok'),e=>report('write',e.name))">Write clipboard</button>
<button onclick="navigator.clipboard.readText().then(v=>report('read',v),e=>report('read',e.name))">Read clipboard</button><br>
<input aria-label="Copy source" value="fixture-native-copy"><input aria-label="Paste target" oninput="report('paste',this.value)"><br>
<input aria-label="Select fixture file" type="file" onchange="report('file',this.files[0]?.name)"><br>
<a href="/hold" target="_blank">Context link</a><br>
<a href="/hold" target="_blank"><img alt="Context image" src="/image.png"></a><br>
<a href="/pdf" target="_blank">PDF display</a>
<button onclick="report('apis',JSON.stringify(Object.fromEntries(['usb','hid','serial','bluetooth'].map(k=>[k,typeof navigator[k]]))))">Hardware APIs</button>
<pre id="results"></pre><script>
function report(k,v){results.textContent+=k+': '+v+'\\n';fetch('/result?'+encodeURIComponent(k+'='+v))}
addEventListener('resize',()=>report('viewport',innerWidth+'x'+innerHeight+':'+devicePixelRatio));
</script>'''
class Handler(BaseHTTPRequestHandler):
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Private-Network', 'true')
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', '*')
self.end_headers()
def do_GET(self):
route = urlsplit(self.path).path
if route == '/image.png':
body = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==')
self.send_response(200)
self.send_header('Content-Type', 'image/png')
self.end_headers()
self.wfile.write(body)
return
if route in ('/pdf', '/pdf-download'):
body = base64.b64decode(PDF_BASE64)
self.send_response(200)
self.send_header('Content-Type', 'application/pdf')
if route == '/pdf-download':
self.send_header('Content-Disposition', 'attachment; filename="capabilities-fixture.pdf"')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if route == '/download':
body = b'webkit-capabilities-download\n'
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', 'attachment; filename="capabilities-fixture.txt"')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = CONTROLS if route == '/controls' else LIFETIME if route == '/lifetime-child' else POPUP if route == '/popup' else b'<title>Held popup</title>held' if route == '/hold' else PAGE
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == '__main__':
ThreadingHTTPServer(('127.0.0.1', 18741), Handler).serve_forever()

View file

@ -0,0 +1,27 @@
"""Native file drag source for the isolated Linux engine fixture."""
import pathlib
import sys
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gdk, Gtk
uri = pathlib.Path(sys.argv[1]).resolve(strict=True).as_uri()
window = Gtk.Window(title='Capabilities file source')
window.set_default_size(160, 80)
window.move(1110, 100)
button = Gtk.Button(label='Drag fixture file')
button.drag_source_set(Gdk.ModifierType.BUTTON1_MASK,
[Gtk.TargetEntry.new('text/uri-list', 0, 0)], Gdk.DragAction.COPY)
button.connect('drag-begin', lambda *_: print('drag-begin', flush=True))
button.connect('drag-end', lambda *_: print('drag-end', flush=True))
button.connect('drag-failed', lambda _button, _context, result: print('drag-failed', result.value_nick, flush=True) or False)
def provide(_button, _context, data, _info, _time):
data.set_uris([uri])
print('drag-data-get', data.get_target().name(), bytes(data.get_data()), flush=True)
button.connect('drag-data-get', provide)
window.add(button)
window.connect('destroy', Gtk.main_quit)
window.show_all()
Gtk.main()

View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Answer only the fixture process's origin-labelled native dialog on its display.
set -eu
answer=$1
pid=$2
output=$3
case "$answer" in stay|leave) ;; *) exit 2;; esac
[[ "$pid" =~ ^[0-9]+$ ]] && [[ "$output" = /* ]] && test -n "${DISPLAY:-}"
window=$(timeout 10s xdotool search --all --sync --onlyvisible --pid "$pid" --name '^http://127\.0\.0\.1:18741/' | head -n 1)
while IFS='=' read -r key value; do
case "$key" in X|Y|WIDTH|HEIGHT)
[[ "$value" =~ ^-?[0-9]+$ ]] || exit 2
printf -v "$key" '%s' "$value";;
esac
done < <(xdotool getwindowgeometry --shell "$window")
import -window "$window" "$output"
x=$((WIDTH / 4))
test "$answer" = stay || x=$((WIDTH * 3 / 4))
xdotool windowfocus "$window" mousemove --sync --window "$window" "$x" "$((HEIGHT - 23))" click 1

View file

@ -0,0 +1,37 @@
// Native NSURL drag source, built with `swiftc mac-file-source.swift -o file-source`.
import AppKit
final class FileSource: NSView, NSDraggingSource {
let file: URL
init(file: URL) {
self.file = file
super.init(frame: NSRect(x: 0, y: 0, width: 200, height: 80))
}
required init?(coder: NSCoder) { fatalError("Use init(file:)") }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
override func draw(_ dirtyRect: NSRect) {
("Drag fixture file" as NSString).draw(at: NSPoint(x: 30, y: 35), withAttributes: nil)
}
override func mouseDown(with event: NSEvent) {
let item = NSDraggingItem(pasteboardWriter: file as NSURL)
item.setDraggingFrame(bounds, contents: NSImage(named: NSImage.multipleDocumentsName))
beginDraggingSession(with: [item], event: event, source: self)
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { .copy }
func draggingSession(_ session: NSDraggingSession, endedAt point: NSPoint, operation: NSDragOperation) {
print("drag-end \(operation.rawValue)")
fflush(stdout)
}
}
let file = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL
precondition(FileManager.default.fileExists(atPath: file.path))
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let window = NSWindow(contentRect: NSRect(x: 800, y: 30, width: 200, height: 80),
styleMask: [.titled, .closable], backing: .buffered, defer: false)
window.title = "Capabilities file source"
window.level = .floating
window.contentView = FileSource(file: file)
window.makeKeyAndOrderFront(nil)
app.run()

View file

@ -0,0 +1,57 @@
"""Native VM input helper: click X Y, key virtual-keycode, or text VALUE."""
import ctypes as c
import sys
import time
class Point(c.Structure):
_fields_ = [('x', c.c_double), ('y', c.c_double)]
cg = c.CDLL('/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics')
cf = c.CDLL('/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation')
cg.CGEventCreateMouseEvent.argtypes = [c.c_void_p, c.c_uint32, Point, c.c_uint32]
cg.CGEventCreateMouseEvent.restype = c.c_void_p
cg.CGEventCreateKeyboardEvent.argtypes = [c.c_void_p, c.c_uint16, c.c_bool]
cg.CGEventCreateKeyboardEvent.restype = c.c_void_p
cg.CGEventKeyboardSetUnicodeString.argtypes = [c.c_void_p, c.c_ulong, c.c_void_p]
cg.CGEventPost.argtypes = [c.c_uint32, c.c_void_p]
cg.CGEventSetFlags.argtypes = [c.c_void_p, c.c_uint64]
cf.CFRelease.argtypes = [c.c_void_p]
def post(event):
cg.CGEventPost(0, event)
cf.CFRelease(event)
time.sleep(0.05)
if sys.argv[1] == 'click':
point = Point(float(sys.argv[2]), float(sys.argv[3]))
for kind in (5, 1, 2):
post(cg.CGEventCreateMouseEvent(None, kind, point, 0))
elif sys.argv[1] == 'key':
for down in (True, False):
event = cg.CGEventCreateKeyboardEvent(None, int(sys.argv[2]), down)
cg.CGEventSetFlags(event, 0)
post(event)
elif sys.argv[1] == 'chord':
for down in (True, False):
event = cg.CGEventCreateKeyboardEvent(None, int(sys.argv[3]), down)
cg.CGEventSetFlags(event, int(sys.argv[2], 0) if down else 0)
post(event)
elif sys.argv[1] == 'drag':
x, y, end_x, end_y = map(float, sys.argv[2:6])
post(cg.CGEventCreateMouseEvent(None, 5, Point(x, y), 0))
post(cg.CGEventCreateMouseEvent(None, 1, Point(x, y), 0))
for step in range(1, 31):
point = Point(x + (end_x - x) * step / 30, y + (end_y - y) * step / 30)
post(cg.CGEventCreateMouseEvent(None, 6, point, 0))
post(cg.CGEventCreateMouseEvent(None, 2, Point(end_x, end_y), 0))
elif sys.argv[1] == 'text':
for character in sys.argv[2]:
text = character.encode('utf-16-le')
buf = c.create_string_buffer(text)
for down in (True, False):
event = cg.CGEventCreateKeyboardEvent(None, 0, down)
cg.CGEventSetFlags(event, 0)
cg.CGEventKeyboardSetUnicodeString(event, len(text) // 2, buf)
post(event)
else:
raise ValueError('click, key, chord, drag or text required')

View file

@ -0,0 +1,31 @@
// Node launcher for the same acceptance page used by fixture.py (Windows hosts).
import { readFileSync } from "node:fs"
import { createServer } from "node:http"
const source = readFileSync(new URL("./fixture.py", import.meta.url), "utf8")
const page = source.match(/PAGE = b'''([\s\S]*?)'''/)[1].replaceAll("\\\\", "\\")
const popup = source.match(/POPUP = b'''([\s\S]*?)'''/)[1]
const lifetime = source.match(/LIFETIME = b'''([\s\S]*?)'''/)[1]
const controls = source.match(/CONTROLS = b'''([\s\S]*?)'''/)[1].replaceAll("\\\\", "\\")
const pdf = Buffer.from(source.match(/PDF_BASE64 = '([^']+)'/)[1], 'base64')
createServer((request, response) => {
console.log(request.method, request.url)
response.setHeader("Access-Control-Allow-Origin", "*")
response.setHeader("Access-Control-Allow-Private-Network", "true")
response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "*")
if (request.method === "OPTIONS") { response.writeHead(204); response.end(); return }
const route = new URL(request.url, "http://127.0.0.1:18741").pathname
if (route === "/image.png") {
response.setHeader("Content-Type", "image/png")
response.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==', 'base64'))
return
}
const download = route === "/download"
const pdfRoute = route === "/pdf" || route === "/pdf-download"
const body = pdfRoute ? pdf : download ? "webkit-capabilities-download\n" : route === "/controls" ? controls : route === "/lifetime-child" ? lifetime : route === "/popup" ? popup : route === "/hold" ? "held" : page
response.setHeader("Content-Type", pdfRoute ? "application/pdf" : download ? "application/octet-stream" : "text/html; charset=utf-8")
if (route === "/pdf-download") response.setHeader("Content-Disposition", 'attachment; filename="capabilities-fixture.pdf"')
if (download) response.setHeader("Content-Disposition", 'attachment; filename="capabilities-fixture.txt"')
response.setHeader("Content-Length", Buffer.byteLength(body))
response.end(body)
}).listen(18741, "127.0.0.1")

View file

@ -0,0 +1,291 @@
//! Isolated real-engine harness: no backend, daemon or application commands.
//! Start fixture.py, then cargo run -p codenomad-webkit-capabilities-fixture.
#![allow(dead_code)]
#![cfg_attr(windows, windows_subsystem = "windows")]
#[path = "../../../src-tauri/src/embedded_view.rs"]
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
mod embedded_view;
#[path = "../../../src-tauri/src/menu_labels.rs"]
mod menu_labels;
struct AppState {
menu_labels: menu_labels::MenuLabels,
}
#[cfg(windows)]
#[path = "../../../src-tauri/src/windows_browser_accelerators.rs"]
mod windows_browser_accelerators;
// The harness has one fixed local owner; production uses identity.rs.
mod identity {
pub fn local_window_id(label: &str) -> Result<String, String> {
(label == super::OWNER)
.then(|| label.into())
.ok_or_else(|| "Invalid fixture owner".into())
}
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
use tauri::Manager;
const OWNER: &str = "local-11111111-1111-4111-8111-111111111111";
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
fn main() {}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
fn main() {
let root =
std::env::var_os("CODENOMAD_PROFILE").expect("set an isolated CODENOMAD_PROFILE directory");
let root = std::path::PathBuf::from(root);
std::fs::create_dir_all(&root).unwrap();
let log = root.join("stages.log");
std::fs::write(&log, "main\n").unwrap();
let panic_log = log.clone();
std::panic::set_hook(Box::new(move |panic| {
let _ = std::fs::write(&panic_log, panic.to_string());
}));
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(embedded_view::StorageIdentity::new(
root.to_string_lossy().into(),
"capabilities-fixture".into(),
))
.manage(embedded_view::EmbeddedViews::default())
.setup(move |app| {
std::fs::write(&log, "setup\n").unwrap();
let window = tauri::WebviewWindowBuilder::new(
app,
OWNER,
tauri::WebviewUrl::External("about:blank".parse().unwrap()),
)
.title("WebKit capabilities fixture")
.data_directory(root.join("renderer"))
.on_page_load(|view, payload| {
if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) {
view.app_handle()
.state::<embedded_view::EmbeddedViews>()
.remove_owner(view.app_handle(), OWNER);
}
})
.inner_size(900.0, 740.0)
.build()?;
std::fs::write(&log, "primary ready\n").unwrap();
let views = app.state::<embedded_view::EmbeddedViews>();
let guest = views.create(
&window.as_ref().window(),
"browser-fixture",
root.join("browser"),
embedded_view::Bounds {
x: 0,
y: 0,
width: 880,
height: 700,
},
|_, _| {},
|_| true,
)?;
std::fs::write(&log, "guest ready\n").unwrap();
#[cfg(windows)]
{
let events = root.join("events.log");
guest.with_webview(move |platform| unsafe {
use webview2_com::Microsoft::Web::WebView2::Win32::*;
use webview2_com::{
NavigationStartingEventHandler, PermissionRequestedEventHandler,
};
let core = platform.controller().CoreWebView2().unwrap();
let permission_log = events.clone();
core.add_PermissionRequested(
&PermissionRequestedEventHandler::create(Box::new(move |_, args| {
if let Some(args) = args {
use std::io::Write;
let mut kind = COREWEBVIEW2_PERMISSION_KIND::default();
let mut state = COREWEBVIEW2_PERMISSION_STATE::default();
args.PermissionKind(&mut kind)?;
args.State(&mut state)?;
let _ = writeln!(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&permission_log)
.unwrap(),
"permission {} state {}",
kind.0,
state.0
);
}
Ok(())
})),
&mut 0,
)
.unwrap();
core.add_NavigationStarting(
&NavigationStartingEventHandler::create(Box::new(move |_, args| {
if let Some(args) = args {
use std::io::Write;
let mut uri = windows_core::PWSTR::null();
args.Uri(&mut uri)?;
let mut cancelled = Default::default();
args.Cancel(&mut cancelled)?;
let uri = webview2_com::take_pwstr(uri);
let _ = writeln!(
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&events)
.unwrap(),
"navigation {} cancelled={:?}",
uri.chars().take(250).collect::<String>(),
cancelled
);
}
Ok(())
})),
&mut 0,
)
.unwrap();
})?;
}
guest.navigate(
if std::env::var_os("CODENOMAD_FIXTURE_CONTROLS").is_some() {
"http://127.0.0.1:18741/controls"
} else {
"http://127.0.0.1:18741/"
}
.parse()
.unwrap(),
)?;
guest.show()?;
if let Ok(mode) = std::env::var("CODENOMAD_FIXTURE_LIFETIME") {
assert!([
"opener-close",
"opener-reload",
"owner-destroy",
"renderer-reload"
]
.contains(&mode.as_str()));
// A hidden harness window keeps the event loop alive after owner destruction.
let _sentinel = tauri::WebviewWindowBuilder::new(
app,
"fixture-sentinel",
tauri::WebviewUrl::External("about:blank".parse().unwrap()),
)
.visible(false)
.build()?;
let policy = views.inner().clone();
let handle = app.handle().clone();
let primary = window.clone();
let target = guest.clone();
let evidence = root.join(format!("lifetime-{mode}.txt"));
std::thread::spawn(move || {
let trial = || -> Result<(), String> {
// The page and its child are activated with native fixture input.
let mut labels = Vec::new();
for _ in 0..1200 {
labels = handle
.windows()
.keys()
.filter(|label| label.starts_with("browser-popup-"))
.cloned()
.collect();
if labels.len() >= 2 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
if labels.len() != 2 {
return Err(format!(
"expected two nested native popups, got {}",
labels.len()
));
}
match mode.as_str() {
"opener-close" => policy.close(&handle, OWNER, "browser-fixture")?,
"opener-reload" => target.reload().map_err(|e| e.to_string())?,
"owner-destroy" => primary.destroy().map_err(|e| e.to_string())?,
"renderer-reload" => primary
.navigate(
"http://127.0.0.1:18741/primary-replacement"
.parse()
.unwrap(),
)
.map_err(|e| e.to_string())?,
_ => unreachable!(),
}
let retained = mode == "opener-reload";
for _ in 0..200 {
if labels.iter().all(|label| {
handle.get_webview_window(label).is_none()
&& policy.navigation_allowed(
label,
&"https://example.com".parse().unwrap(),
) == Some(false)
}) && policy.navigation_allowed(
"browser-fixture",
&"https://example.com".parse().unwrap(),
) == Some(retained)
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
for label in &labels {
if handle.get_webview_window(label).is_some() {
return Err(format!("orphan native window {label}"));
}
if policy
.navigation_allowed(label, &"https://example.com".parse().unwrap())
!= Some(false)
{
return Err(format!("orphan registry {label}"));
}
}
if policy.navigation_allowed(
"browser-fixture",
&"https://example.com".parse().unwrap(),
) != Some(retained)
{
return Err("root registry mismatch".into());
}
Ok(())
};
let result = trial();
std::fs::write(evidence, format!("{mode}: {result:?}\n")).unwrap();
handle.exit(if result.is_ok() { 0 } else { 1 });
});
}
if let Ok(seconds) = std::env::var("CODENOMAD_FIXTURE_CLOSE_AFTER")
.unwrap_or_default()
.parse::<u64>()
{
assert!((1..=60).contains(&seconds));
let policy = views.inner().clone();
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(seconds));
policy.close(&handle, OWNER, "browser-fixture").unwrap();
for _ in 0..100 {
if handle.get_webview("browser-fixture").is_none()
&& handle
.windows()
.keys()
.all(|label| !label.starts_with("browser-popup-"))
{
eprintln!("opener and popup tree disposed");
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!("opener or popup tree survived closure");
});
}
let policy = views.inner().clone();
let handle = app.handle().clone();
window.on_window_event(move |event| {
if matches!(event, tauri::WindowEvent::Destroyed) {
policy.remove_owner(&handle, OWNER);
}
});
Ok(())
})
.run(tauri::test::mock_context(tauri::test::noop_assets()))
.unwrap();
}

View file

@ -0,0 +1,80 @@
# Run against a fresh isolated release fixture and its loopback acceptance page.
param([Parameter(Mandatory=$true)][int]$FixtureProcessId,
[Parameter(Mandatory=$true)][string]$EvidenceDirectory)
$ErrorActionPreference = 'Stop'
$driver = Join-Path $PSScriptRoot 'windows-ui.ps1'
$null = New-Item -ItemType Directory -Path $EvidenceDirectory
function Read-Fixture([switch]$Popup) { (& $driver -FixtureProcessId $FixtureProcessId -Popup:$Popup) -join "`n" }
function Save-State([string]$Name,[switch]$Popup) { Read-Fixture -Popup:$Popup | Set-Content -Encoding UTF8 (Join-Path $EvidenceDirectory "$Name.txt") }
function Wait-State([scriptblock]$Predicate,[switch]$Popup) {
$deadline = [DateTime]::UtcNow.AddSeconds(10)
do {
$text = Read-Fixture -Popup:$Popup
if (& $Predicate $text) { return }
[Threading.Thread]::Sleep(100)
} while ([DateTime]::UtcNow -lt $deadline)
throw 'Expected native fixture state did not appear'
}
function Page-Action([string]$Name) { & $driver -FixtureProcessId $FixtureProcessId -Popup -Invoke $Name -NativePointer -Activate }
function Dialog-Action([string]$Name) { & $driver -FixtureProcessId $FixtureProcessId -Invoke $Name }
function Wait-LeaveDialog { Wait-State { param($t) $t.Contains('WINDOW | Quitter le site') } }
function Wait-ReloadDialog { Wait-State { param($t) $t.Contains('WINDOW | Recharger le site') } }
function Cancel-Dialog {
Dialog-Action 'Annuler'
Wait-State { param($t) !$t.Contains('WINDOW | Quitter le site') -and !$t.Contains('WINDOW | Recharger le site') }
}
function Wait-FreshPage { Wait-State -Popup { param($t) $t.Contains('isolation:') -and !$t.Contains('beforeunload: armed') } }
function Close-Popup {
. $driver -FixtureProcessId $FixtureProcessId -Popup | Out-Null
$handle = [IntPtr]$roots[0].Current.NativeWindowHandle
if ($handle -eq [IntPtr]::Zero) { throw 'Owned fixture popup has no HWND' }
if (!('BeforeUnloadCloseInput' -as [type])) { Add-Type @'
using System; using System.Runtime.InteropServices;
public static class BeforeUnloadCloseInput { [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h,uint m,IntPtr w,IntPtr l); }
'@ }
if (![BeforeUnloadCloseInput]::PostMessage($handle,16,[IntPtr]::Zero,[IntPtr]::Zero)) { throw 'Owned popup close input refused' }
}
if ((Read-Fixture).Contains('beforeunload: armed')) { throw 'Use a fresh fixture' }
& $driver -FixtureProcessId $FixtureProcessId -Invoke 'Close-test popup' -NativePointer -Activate
Wait-FreshPage
Page-Action 'Arm beforeunload'
Page-Action 'Navigate'
Wait-LeaveDialog
Save-State 'navigation-confirm'
Cancel-Dialog
Wait-State -Popup { param($t) $t.Contains('beforeunload: armed') }
Save-State 'navigation-stay' -Popup
Page-Action 'Reload page'
Wait-ReloadDialog
Save-State 'reload-confirm'
Cancel-Dialog
Save-State 'reload-stay' -Popup
if (!(Read-Fixture -Popup).Contains('beforeunload: armed')) { throw 'Stay discarded the page' }
Page-Action 'Reload page'
Wait-ReloadDialog
Dialog-Action 'Recharger'
Wait-FreshPage
Save-State 'reload-leave' -Popup
Page-Action 'Arm beforeunload'
Page-Action 'Navigate'
Wait-LeaveDialog
Dialog-Action 'Quitter'
Wait-FreshPage
Save-State 'navigation-leave' -Popup
Page-Action 'Arm beforeunload'
Close-Popup
Wait-LeaveDialog
Save-State 'close-confirm'
Cancel-Dialog
Wait-State -Popup { param($t) $t.Contains('beforeunload: armed') }
Save-State 'close-stay' -Popup
Close-Popup
Wait-LeaveDialog
Dialog-Action 'Quitter'
Wait-State { param($t) !$t.Contains('ControlType.Window | http://127.0.0.1:18741') -and !$t.Contains('WINDOW | http://127.0.0.1:18741') }
Save-State 'close-leave'
Write-Output "PASS native beforeunload navigation/reload/popup-close: Stay preserves and Leave completes; evidence=$EvidenceDirectory"

View file

@ -0,0 +1,39 @@
# Native mouse input, fenced to the owned source and the isolated fixture.
param([Parameter(Mandatory=$true)][int]$FixtureProcessId,
[Parameter(Mandatory=$true)][int]$SourceProcessId)
$ErrorActionPreference = 'Stop'
. "$PSScriptRoot/windows-ui.ps1" -FixtureProcessId $FixtureProcessId | Out-Null
$source = Get-CimInstance Win32_Process -Filter "ProcessId=$SourceProcessId"
$sourceScript = Join-Path $PSScriptRoot 'windows-file-source.ps1'
if ($source.CommandLine.Replace('/', '\') -notlike "*$sourceScript*") { throw 'Expected the owned native file source' }
$sourceProcess = Get-Process -Id $SourceProcessId
$sourceRoot = [System.Windows.Automation.AutomationElement]::FromHandle($sourceProcess.MainWindowHandle)
$button = $sourceRoot.FindFirst([System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::NameProperty, 'Drag fixture file'))
$target = $roots | ForEach-Object { $_.FindAll([System.Windows.Automation.TreeScope]::Descendants,
[System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::NameProperty, 'Drop file')) } | Select-Object -First 1
if (!$button -or !$target -or $target.Current.IsOffscreen) { throw 'Visible fixture drag endpoints unavailable' }
Add-Type @'
using System; using System.Runtime.InteropServices;
public static class NativeFileDrag {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags,uint x,uint y,uint data,UIntPtr extra);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
}
'@
$dpi = [NativeFileDrag]::SetThreadDpiAwarenessContext([IntPtr](-4))
try {
$start=$button.Current.BoundingRectangle; $end=$target.Current.BoundingRectangle
$sx=[int]($start.X+$start.Width/2); $sy=[int]($start.Y+$start.Height/2)
$ex=[int]($end.X+[Math]::Min(30,$end.Width/2)); $ey=[int]($end.Y+$end.Height/2)
if(![NativeFileDrag]::SetForegroundWindow($sourceProcess.MainWindowHandle)){ throw 'Source cannot receive input' }
$null=[NativeFileDrag]::SetCursorPos($sx,$sy)
[NativeFileDrag]::mouse_event(2,0,0,0,[UIntPtr]::Zero)
try {
for($i=1;$i -le 40;$i++){
$null=[NativeFileDrag]::SetCursorPos([int]($sx+($ex-$sx)*$i/40),[int]($sy+($ey-$sy)*$i/40))
[Threading.Thread]::Sleep(25)
}
} finally { [NativeFileDrag]::mouse_event(4,0,0,0,[UIntPtr]::Zero) }
} finally { $null=[NativeFileDrag]::SetThreadDpiAwarenessContext($dpi) }

View file

@ -0,0 +1,32 @@
# A real OLE FileDrop source for this checkout's isolated release fixture.
param([Parameter(Mandatory=$true)][int]$FixtureProcessId)
$ErrorActionPreference = 'Stop'
$fixture = Get-Process -Id $FixtureProcessId
$expected = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../target/release/codenomad-webkit-capabilities-fixture.exe'))
if ($fixture.Path -ne $expected) { throw 'Expected this checkout''s isolated fixture' }
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$file = (Resolve-Path (Join-Path $PSScriptRoot 'drop-fixture.txt')).Path
$form = New-Object Windows.Forms.Form
$form.Text = 'Capabilities file source'
$form.Size = New-Object Drawing.Size 240,110
$form.StartPosition = 'Manual'
$form.Location = New-Object Drawing.Point ([Windows.Forms.Screen]::PrimaryScreen.WorkingArea.Right - 260),100
$form.TopMost = $true
$button = New-Object Windows.Forms.Button
$button.Dock = 'Fill'
$button.Text = 'Drag fixture file'
$button.Add_MouseDown({
$data = New-Object Windows.Forms.DataObject
$data.SetData([Windows.Forms.DataFormats]::FileDrop, [string[]]@($file))
$effect = $button.DoDragDrop($data, [Windows.Forms.DragDropEffects]::Copy)
Write-Output "drag-end $effect"
})
$form.Controls.Add($button)
Add-Type @'
using System; using System.Runtime.InteropServices;
public static class SourceWindow { [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h,int n); }
'@
$form.Show()
$null = [SourceWindow]::ShowWindow($form.Handle, 5)
[Windows.Forms.Application]::Run($form)

View file

@ -0,0 +1,167 @@
# Native accessibility driver for the isolated Windows release fixture only.
param([Parameter(Mandatory=$true)][int]$FixtureProcessId, [string]$Invoke, [string]$Value, [switch]$NativePointer, [switch]$Activate, [switch]$Escape, [switch]$Popup, [switch]$RightClick)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
$process = Get-Process -Id $FixtureProcessId
if ($process.ProcessName -ne 'codenomad-webkit-capabilities-fixture') { throw 'Expected the isolated fixture process' }
$expected = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../target/release/codenomad-webkit-capabilities-fixture.exe'))
if ($process.Path -ne $expected) { throw 'Fixture belongs to another checkout' }
$root = [System.Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle)
if (!$root) { throw 'Fixture window unavailable' }
$ids = New-Object 'System.Collections.Generic.HashSet[int]'
$null = $ids.Add($FixtureProcessId)
$birth = @{ $FixtureProcessId = $process.StartTime.ToUniversalTime() }
$processes = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate
do {
$changed = $false
foreach ($candidate in $processes) {
if ($ids.Contains([int]$candidate.ParentProcessId) -and $candidate.CreationDate -and ($candidate.CreationDate.ToUniversalTime() -ge $birth[[int]$candidate.ParentProcessId]) -and $ids.Add([int]$candidate.ProcessId)) {
$birth[[int]$candidate.ProcessId] = $candidate.CreationDate.ToUniversalTime()
$changed = $true
}
}
} while ($changed)
$conditions = @($ids | ForEach-Object { New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, [int]$_) })
$owned = if ($conditions.Count -eq 1) { $conditions[0] } else { [System.Windows.Automation.OrCondition]::new([System.Windows.Automation.Condition[]]$conditions) }
$roots = @([System.Windows.Automation.AutomationElement]::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, $owned))
# WebView2 can present a browser-process dialog outside the native process tree.
# Admit it only through its live HWND owner chain, never by executable name.
if (!('FixtureWindowOwnership' -as [type])) { Add-Type @'
using System; using System.Collections.Generic; using System.Runtime.InteropServices;
public static class FixtureWindowOwnership {
delegate bool EnumCallback(IntPtr h,IntPtr p);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumCallback c,IntPtr p);
[DllImport("user32.dll")] static extern IntPtr GetWindow(IntPtr h,uint command);
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h,out uint p);
public static int Process(IntPtr h){uint p;GetWindowThreadProcessId(h,out p);return (int)p;}
public static IntPtr[] Owned(uint pid) {
var result=new List<IntPtr>();
EnumWindows((h,p)=>{
if(!IsWindowVisible(h))return true;
IntPtr owner=h;
for(int depth=0;owner!=IntPtr.Zero && depth<32;depth++,owner=GetWindow(owner,4)){
if(Process(owner)==pid){result.Add(h);break;}
}
return true;
},IntPtr.Zero);
return result.ToArray();
}
}
'@ }
$seen = New-Object 'System.Collections.Generic.HashSet[long]'
foreach ($r in $roots) { $null=$seen.Add([long]$r.Current.NativeWindowHandle) }
foreach ($handle in [FixtureWindowOwnership]::Owned([uint32]$FixtureProcessId)) {
if ($seen.Add($handle.ToInt64())) {
$ownedElement=[System.Windows.Automation.AutomationElement]::FromHandle($handle)
$roots += $ownedElement
$null=$ids.Add([int]$ownedElement.Current.ProcessId)
}
}
if ($Popup) {
$condition=[System.Windows.Automation.AndCondition]::new(
[System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Window),
[System.Windows.Automation.PropertyCondition]::new([System.Windows.Automation.AutomationElement]::NameProperty,'http://127.0.0.1:18741'))
$popups=@($roots | ForEach-Object { $_.FindAll([System.Windows.Automation.TreeScope]::Subtree,$condition) })
$handles=@($popups | ForEach-Object { $_.Current.NativeWindowHandle } | Sort-Object -Unique)
if($handles.Count -ne 1){throw 'Expected one owned fixture popup'}
$roots=@($popups | Select-Object -First 1)
}
if ($Invoke) {
$condition = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $Invoke)
$element = $null
foreach ($window in $roots) {
$element = $window.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $condition)
if ($element) { break }
}
if (!$element) { throw "Fixture control unavailable: $Invoke" }
if (!$NativePointer -or $element.Current.IsKeyboardFocusable) { $element.SetFocus() }
if ($NativePointer) {
if (!('FixturePointer' -as [type])) { Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class FixturePointer {
[StructLayout(LayoutKind.Sequential)] public struct Point { public int X,Y; }
[DllImport("user32.dll")] public static extern bool ScreenToClient(IntPtr hwnd, ref Point point);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr w, IntPtr l);
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hwnd);
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hwnd, uint flags);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y);
[DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(Point point);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags,uint x,uint y,uint data,UIntPtr extra);
[DllImport("user32.dll")] public static extern void keybd_event(byte key,byte scan,uint flags,UIntPtr extra);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hwnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hwnd,int command);
}
'@ }
$native = $element
while ($native -and !$native.Current.NativeWindowHandle) { $native = [System.Windows.Automation.TreeWalker]::RawViewWalker.GetParent($native) }
if (!$native -or !$ids.Contains($native.Current.ProcessId)) { throw 'Native target outside the fixture' }
$bounds = $element.Current.BoundingRectangle
if ($bounds.IsEmpty -or $element.Current.IsOffscreen) { throw 'Fixture control is offscreen' }
$point = New-Object FixturePointer+Point
$point.X = [int]($bounds.X + $bounds.Width/2); $point.Y = [int]($bounds.Y + $bounds.Height/2)
$oldDpi = [FixturePointer]::SetThreadDpiAwarenessContext([IntPtr](-4))
try {
$hwnd = [IntPtr]$native.Current.NativeWindowHandle
if ($Activate) {
$top = [FixturePointer]::GetAncestor($hwnd, 2)
[uint32]$topProcess = 0
$null = [FixturePointer]::GetWindowThreadProcessId($top, [ref]$topProcess)
if (!$ids.Contains([int]$topProcess)) { throw 'Native target outside the fixture' }
if ([FixturePointer]::IsIconic($top)) { $null=[FixturePointer]::ShowWindow($top,9) }
if (![FixturePointer]::SetForegroundWindow($top)) { throw 'Owned fixture window cannot receive foreground input' }
if ($Escape) {
[FixturePointer]::keybd_event(27,0,0,[UIntPtr]::Zero)
[FixturePointer]::keybd_event(27,0,2,[UIntPtr]::Zero)
return
}
$hit = [IntPtr]::Zero
for ($attempt=0; $attempt -lt 20; $attempt++) {
$bounds=$element.Current.BoundingRectangle
$point.X=[int]($bounds.X+$bounds.Width/2); $point.Y=[int]($bounds.Y+$bounds.Height/2)
$hit=[FixturePointer]::GetAncestor([FixturePointer]::WindowFromPoint($point),2)
if ($hit -eq $top) { break }
[Threading.Thread]::Sleep(25)
}
if ($hit -ne $top) { throw 'Fixture control is covered' }
$null = [FixturePointer]::SetCursorPos($point.X,$point.Y)
$down=if($RightClick){8}else{2};$up=if($RightClick){16}else{4}
[FixturePointer]::mouse_event($down,0,0,0,[UIntPtr]::Zero)
[FixturePointer]::mouse_event($up,0,0,0,[UIntPtr]::Zero)
return
}
if ($Escape) {
if (![FixturePointer]::PostMessage($hwnd,0x100,[IntPtr]27,[IntPtr]0x10001)) { throw 'Fixture Escape refused' }
if (![FixturePointer]::PostMessage($hwnd,0x101,[IntPtr]27,[IntPtr](-1073676287))) { throw 'Fixture Escape release refused' }
return
}
if (![FixturePointer]::ScreenToClient($hwnd,[ref]$point)) { throw 'Invalid fixture coordinates' }
$position = [IntPtr](($point.Y -shl 16) -bor ($point.X -band 65535))
foreach ($message in @(0x200,0x201,0x202)) {
$button = if ($message -eq 0x201) { [IntPtr]1 } else { [IntPtr]0 }
if (![FixturePointer]::PostMessage($hwnd,$message,$button,$position)) { throw 'Native fixture input refused' }
}
} finally { $null = [FixturePointer]::SetThreadDpiAwarenessContext($oldDpi) }
} elseif ($PSBoundParameters.ContainsKey('Value')) {
$pattern = $element.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern)
$pattern.SetValue($Value)
} else {
$pattern = $null
if ($element.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)) { $pattern.Invoke() }
elseif ($element.TryGetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern, [ref]$pattern)) { $pattern.Expand() }
else { throw 'Fixture control has no native action' }
}
} else {
foreach ($root in $roots) {
'WINDOW | ' + $root.Current.Name
$elements = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
for ($i=0; $i -lt [Math]::Min($elements.Count, 100); $i++) {
$item = $elements.Item($i)
'{0} | {1}' -f $item.Current.ControlType.ProgrammaticName, $item.Current.Name
}
}
}

View file

@ -10,7 +10,10 @@ MIT/Apache-2.0 crates, including licenses, selected through `[patch.crates-io]`.
- Published SHA-256: runtime `e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f`;
Wry `759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9`.
- Local changes: `patches/tauri-runtime-wry-gtk.patch` and
`patches/wry-gtk.patch`; native trials ship beside the runtime examples.
`patches/wry-gtk.patch`, then `patches/wry-webkit-capabilities.patch`,
`patches/wry-browser-isolation.patch`, `patches/runtime-browser-isolation.patch`
and `patches/wry-browser-print.patch`;
native trials ship beside the runtime examples.
- GTK: the runtime lazily installs a window-owned `GtkOverlay` inside Tao's
content box, below its menu bar. Primary content stays boxed; child webviews use
`GtkFixed`. The overlay passes uncovered input through to the primary view.
@ -28,6 +31,26 @@ MIT/Apache-2.0 crates, including licenses, selected through `[patch.crates-io]`.
renderer keeps its own permission policy. Requalify WebKit's engine-default
handling of location, notifications, display capture and storage access when
changing runtime or OS support.
- Browser previews' WKWebView delegate presents native JavaScript dialogs labeled
with the initiating host; the `browser-popup-` delegate closes its native window
on `window.close()`. WebKitGTK closes the corresponding popup window before
destroying its view, and scopes shared-context download callbacks to the
originating view with overwriting disabled and native file-URI destinations.
Rejected browser blob/data document requests become downloads, preserving the
preview's HTTP(S)-only document policy.
Browser pop-ups use Tauri's related-view/configuration API, retaining the native
opener and shared site state. The backend-free fixture in
`../tests/webkit-capabilities/` exercises these paths on both engines.
- Browser labels select a runtime fence around application initialization scripts,
IPC and app resource protocols. Native browser globals and synchronous dialogs
stay owned by the engine. Preview navigation gestures use Wry's native setting.
WebView2 popup script closure disposes the top-level popup instead of only its
child container; embedded roots remain host-owned.
- WKWebView preview printing and beforeunload use the native delegate selectors
in WebKit's `WKUIDelegatePrivate.h`: both printFrame variants and the
initiating-frame beforeunload completion. Printing uses the public
`printOperationWithPrintInfo:` operation and system panel. Qualify these
selectors on supported macOS/WebKit updates with the native fixture.
Keep these overrides until the resolved upstream runtime supports the same child
layout, persistent geometry policy. Requalify both GTK

View file

@ -0,0 +1,51 @@
diff --git a/src/lib.rs b/src/lib.rs
index 40330f716..0573fa5cf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4751,6 +4751,7 @@ You may have it installed on another user account, but it is not available for t
.with_incognito(webview_attributes.incognito)
.with_clipboard(webview_attributes.clipboard)
.with_hotkeys_zoom(webview_attributes.zoom_hotkeys_enabled)
+ .with_back_forward_navigation_gestures(label.starts_with("browser-"))
.with_general_autofill_enabled(webview_attributes.general_autofill_enabled);
if url != "about:blank" {
@@ -5107,20 +5108,27 @@ You may have it installed on another user account, but it is not available for t
let window_id_for_ipc = window_id.clone();
#[cfg(not(windows))]
let window_id_for_ipc = window_id;
- webview_builder = webview_builder.with_ipc_handler(create_ipc_handler(
- window_id_for_ipc,
- id,
- context.clone(),
- label.clone(),
- ipc_handler,
- ));
-
- for script in webview_attributes.initialization_scripts {
- webview_builder = webview_builder
- .with_initialization_script_for_main_only(script.script, script.for_main_frame_only);
+ let browser_guest = label.starts_with("browser-");
+ if !browser_guest {
+ webview_builder = webview_builder.with_ipc_handler(create_ipc_handler(
+ window_id_for_ipc,
+ id,
+ context.clone(),
+ label.clone(),
+ ipc_handler,
+ ));
+ for script in webview_attributes.initialization_scripts {
+ webview_builder = webview_builder
+ .with_initialization_script_for_main_only(script.script, script.for_main_frame_only);
+ }
}
for (scheme, protocol) in uri_scheme_protocols {
+ // Preview documents retain their browser globals (not app plugin overrides)
+ // and carry neither the application IPC bridge nor app resource protocols.
+ if browser_guest {
+ continue;
+ }
// on Linux the custom protocols are associated with the web context
// and you cannot register a scheme more than once
#[cfg(any(

View file

@ -0,0 +1,71 @@
diff --git a/src/webview2/mod.rs b/src/webview2/mod.rs
index 485bb4ed2..a5b978aeb 100644
--- a/src/webview2/mod.rs
+++ b/src/webview2/mod.rs
@@ -692,10 +692,27 @@ impl InnerWebView {
) -> Result<()> {
unsafe {
// Close container HWND when `window.close` is called in JS
- webview.add_WindowCloseRequested(
- &WindowCloseRequestedEventHandler::create(Box::new(move |_, _| DestroyWindow(hwnd))),
- token,
- )?;
+ if attributes
+ .id
+ .is_none_or(|id| !id.starts_with("browser-") || id.starts_with("browser-popup-"))
+ {
+ // Wry owns a child container even in a top-level Tauri window. A guest
+ // popup's script closure must dispose that top-level window as well.
+ let close_hwnd = if attributes
+ .id
+ .is_some_and(|id| id.starts_with("browser-popup-"))
+ {
+ GetAncestor(hwnd, GA_ROOT)
+ } else {
+ hwnd
+ };
+ webview.add_WindowCloseRequested(
+ &WindowCloseRequestedEventHandler::create(Box::new(move |_, _| {
+ DestroyWindow(close_hwnd)
+ })),
+ token,
+ )?;
+ }
// Document title changed handler
if let Some(document_title_changed_handler) = attributes.document_title_changed_handler.take()
diff --git a/src/wkwebview/class/wry_web_view_ui_delegate.rs b/src/wkwebview/class/wry_web_view_ui_delegate.rs
index 7c3af49e9..9280f1844 100644
--- a/src/wkwebview/class/wry_web_view_ui_delegate.rs
+++ b/src/wkwebview/class/wry_web_view_ui_delegate.rs
@@ -96,6 +96,30 @@ define_class!(
unsafe impl NSObjectProtocol for WryWebViewUIDelegate {}
unsafe impl WKUIDelegate for WryWebViewUIDelegate {
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(webView:requestDeviceOrientationAndMotionPermissionForOrigin:initiatedByFrame:decisionHandler:))]
+ fn request_orientation_permission(
+ &self,
+ _webview: &WryWebView,
+ _origin: &WKSecurityOrigin,
+ _frame: &WKFrameInfo,
+ completion: &Block<dyn Fn(WKPermissionDecision)>,
+ ) {
+ let decision = if self.ivars().preview_dialogs {
+ match self
+ .ivars()
+ .permission_handler
+ .as_ref()
+ .map(|handler| handler(PermissionKind::Sensors))
+ {
+ Some(PermissionResponse::Deny) => WKPermissionDecision::Deny,
+ _ => WKPermissionDecision::Prompt,
+ }
+ } else {
+ WKPermissionDecision::Prompt
+ };
+ completion.call((decision,));
+ }
#[cfg(target_os = "macos")]
#[unsafe(method(webViewDidClose:))]
fn web_view_did_close(&self, webview: &WryWebView) {

View file

@ -0,0 +1,51 @@
diff --git a/src/wkwebview/class/wry_web_view_ui_delegate.rs b/src/wkwebview/class/wry_web_view_ui_delegate.rs
--- a/src/wkwebview/class/wry_web_view_ui_delegate.rs
+++ b/src/wkwebview/class/wry_web_view_ui_delegate.rs
@@ -98,1 +98,28 @@ define_class!(
+ // WebKit's native window.print/beforeunload hooks are declared in
+ // Source/WebKit/UIProcess/API/Cocoa/WKUIDelegatePrivate.h (macOS 12+).
+ // They are confined to preview delegates; the page receives no app bridge.
+ impl WryWebViewUIDelegate {
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(_webView:printFrame:))]
+ fn print_frame(&self, webview: &WryWebView, _frame: &NSObject) {
+ if self.ivars().preview_dialogs { preview_print(webview); }
+ }
+
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(_webView:printFrame:pdfFirstPageSize:completionHandler:))]
+ fn print_frame_with_completion(&self, webview: &WryWebView, _frame: &NSObject,
+ _size: objc2_foundation::NSSize, completion: &Block<dyn Fn()>) {
+ if self.ivars().preview_dialogs { preview_print(webview); }
+ completion.call(());
+ }
+
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(_webView:runBeforeUnloadConfirmPanelWithMessage:initiatedByFrame:completionHandler:))]
+ fn before_unload(&self, webview: &WryWebView, message: &objc2_foundation::NSString,
+ frame: &WKFrameInfo, completion: &Block<dyn Fn(objc2::runtime::Bool)>) {
+ if !self.ivars().preview_dialogs { completion.call((true.into(),)); return; }
+ self.javascript_confirm(objc2::sel!(webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:), webview, message, frame, completion);
+ }
+ }
+
unsafe impl WKUIDelegate for WryWebViewUIDelegate {
@@ -436,1 +463,18 @@ impl WryWebViewUIDelegate {
+#[cfg(target_os = "macos")]
+fn preview_print(webview: &WryWebView) {
+ // Use the same public print operation as Wry's host-initiated printing.
+ unsafe {
+ if webview.window().is_none()
+ || objc2_app_kit::NSPrintOperation::currentOperation(MainThreadMarker::new().unwrap())
+ .is_some()
+ {
+ return;
+ }
+ let info = objc2_app_kit::NSPrintInfo::sharedPrintInfo();
+ let operation = webview.printOperationWithPrintInfo(&info);
+ operation.setCanSpawnSeparateThread(true);
+ operation.runOperation();
+ }
+}
+
#[cfg(target_os = "macos")]

View file

@ -0,0 +1,312 @@
diff --git a/Cargo.toml b/Cargo.toml
index 16bd55ab8..9731746c0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -278,9 +278,12 @@ default-features = false
version = "0.3.2"
features = [
"std",
+ "block2",
"objc2-core-foundation",
"NSApplication",
"NSButton",
+ "NSAlert",
+ "NSTextField",
"NSControl",
"NSEvent",
"NSWindow",
diff --git a/src/webkitgtk/mod.rs b/src/webkitgtk/mod.rs
index d225c9648..bca5da85d 100644
--- a/src/webkitgtk/mod.rs
+++ b/src/webkitgtk/mod.rs
@@ -46,7 +46,7 @@ use webkit2gtk::{
};
use webkit2gtk_sys::{
webkit_get_major_version, webkit_get_micro_version, webkit_get_minor_version,
- webkit_policy_decision_ignore, webkit_policy_decision_use,
+ webkit_policy_decision_download, webkit_policy_decision_ignore, webkit_policy_decision_use,
};
#[cfg(feature = "x11")]
use x11_dl::xlib::*;
@@ -468,7 +468,25 @@ impl InnerWebView {
attributes: &mut WebViewAttributes,
) {
// window.close()
- webview.connect_close(move |webview| unsafe { webview.destroy() });
+ let browser = attributes.id.is_some_and(|id| id.starts_with("browser-"));
+ let popup = attributes
+ .id
+ .is_some_and(|id| id.starts_with("browser-popup-"));
+ webview.connect_close(move |webview| unsafe {
+ if popup {
+ if let Some(window) = webview
+ .toplevel()
+ .and_then(|widget| widget.downcast::<gtk::Window>().ok())
+ {
+ window.destroy();
+ return;
+ }
+ }
+ // Embedded preview roots are closed by their host, like WKWebView roots.
+ if !browser {
+ webview.destroy();
+ }
+ });
// Synthetic mouse events
synthetic_mouse_events::setup(webview);
@@ -556,6 +574,7 @@ impl InnerWebView {
}
// Navigation handler
+ let browser_downloads = attributes.id.is_some_and(|id| id.starts_with("browser-"));
if let Some(navigation_handler) = attributes.navigation_handler.take() {
webview.connect_decide_policy(move |_webview, policy_decision, policy_type| {
let handler = match policy_type {
@@ -572,6 +591,12 @@ impl InnerWebView {
unsafe {
if allow {
webkit_policy_decision_use(pointer)
+ } else if browser_downloads
+ && (uri.starts_with("blob:") || uri.starts_with("data:"))
+ {
+ // Generated content can be saved, but never becomes a document
+ // in a browser preview whose navigation policy rejected it.
+ webkit_policy_decision_download(pointer)
} else {
webkit_policy_decision_ignore(pointer)
}
@@ -679,6 +704,7 @@ impl InnerWebView {
|| attributes.download_completed_handler.is_some()
{
web_context.register_download_handler(
+ &webview,
attributes.download_started_handler.take(),
attributes.download_completed_handler.take(),
)
@@ -714,11 +740,9 @@ impl InnerWebView {
.dynamic_cast_ref::<gtk::Fixed>()
.unwrap()
.put(webview, x, y);
-
} else {
container.add(webview);
}
-
}
fn attach_ipc_handler(webview: WebView, ipc_handler: Box<dyn Fn(Request<String>)>) {
diff --git a/src/webkitgtk/web_context.rs b/src/webkitgtk/web_context.rs
index 861046759..1be7136f4 100644
--- a/src/webkitgtk/web_context.rs
+++ b/src/webkitgtk/web_context.rs
@@ -117,6 +117,7 @@ pub trait WebContextExt {
fn register_download_handler(
&mut self,
+ webview: &WebView,
download_started_callback: Option<Box<dyn FnMut(String, &mut PathBuf) -> bool>>,
download_completed_callback: Option<Rc<dyn Fn(String, Option<PathBuf>, bool) + 'static>>,
);
@@ -306,14 +307,23 @@ impl WebContextExt for super::WebContext {
fn register_download_handler(
&mut self,
+ webview: &WebView,
download_started_handler: Option<Box<dyn FnMut(String, &mut PathBuf) -> bool>>,
download_completed_handler: Option<Rc<dyn Fn(String, Option<PathBuf>, bool) + 'static>>,
) {
let context = &self.os.context;
let download_started_handler = Rc::new(RefCell::new(download_started_handler));
+ let webview = webview.downgrade();
context.connect_download_started(move |_context, download| {
+ let Some(webview) = webview.upgrade() else {
+ return;
+ };
+ if download.web_view().as_ref() != Some(&webview) {
+ return;
+ }
+ download.set_allow_overwrite(false);
// Failure belongs to this download, not every download in the context.
let failed = Rc::new(RefCell::new(false));
let download_started_handler = download_started_handler.clone();
@@ -354,7 +364,9 @@ impl WebContextExt for super::WebContext {
}
if download_started_handler(uri, &mut download_destination) {
- download.set_destination(&download_destination.to_string_lossy());
+ use gtk::gio::prelude::FileExt;
+ let uri = gtk::gio::File::for_path(&download_destination).uri();
+ download.set_destination(&uri);
} else {
download.cancel();
}
diff --git a/src/wkwebview/class/wry_web_view_ui_delegate.rs b/src/wkwebview/class/wry_web_view_ui_delegate.rs
index 5ba4ddbb9..7c3af49e9 100644
--- a/src/wkwebview/class/wry_web_view_ui_delegate.rs
+++ b/src/wkwebview/class/wry_web_view_ui_delegate.rs
@@ -83,6 +83,8 @@ pub struct WryWebViewUIDelegateIvars {
#[cfg(target_os = "macos")]
new_windows: Rc<RefCell<Vec<NewWindow>>>,
permission_handler: Option<Box<dyn Fn(PermissionKind) -> PermissionResponse + Send + Sync>>,
+ preview_dialogs: bool,
+ popup: bool,
}
define_class!(
@@ -94,6 +96,95 @@ define_class!(
unsafe impl NSObjectProtocol for WryWebViewUIDelegate {}
unsafe impl WKUIDelegate for WryWebViewUIDelegate {
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(webViewDidClose:))]
+ fn web_view_did_close(&self, webview: &WryWebView) {
+ if self.ivars().popup {
+ if let Some(window) = webview.window() {
+ window.close();
+ }
+ }
+ }
+
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:))]
+ fn javascript_alert(
+ &self,
+ webview: &WryWebView,
+ message: &objc2_foundation::NSString,
+ frame: &WKFrameInfo,
+ completion: &Block<dyn Fn()>,
+ ) {
+ let Some(window) = webview.window().filter(|_| self.ivars().preview_dialogs) else {
+ completion.call(());
+ return;
+ };
+ let alert = preview_alert(message, frame, false);
+ let completion = completion.copy();
+ let callback = block2::RcBlock::new(move |_| completion.call(()));
+ alert.beginSheetModalForWindow_completionHandler(&window, Some(&callback));
+ }
+
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:))]
+ fn javascript_confirm(
+ &self,
+ webview: &WryWebView,
+ message: &objc2_foundation::NSString,
+ frame: &WKFrameInfo,
+ completion: &Block<dyn Fn(objc2::runtime::Bool)>,
+ ) {
+ let Some(window) = webview.window().filter(|_| self.ivars().preview_dialogs) else {
+ completion.call((false.into(),));
+ return;
+ };
+ let alert = preview_alert(message, frame, true);
+ let completion = completion.copy();
+ let callback = block2::RcBlock::new(move |response| {
+ completion.call(((response == objc2_app_kit::NSAlertFirstButtonReturn).into(),))
+ });
+ alert.beginSheetModalForWindow_completionHandler(&window, Some(&callback));
+ }
+
+ #[cfg(target_os = "macos")]
+ #[unsafe(method(webView:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:))]
+ fn javascript_prompt(
+ &self,
+ webview: &WryWebView,
+ message: &objc2_foundation::NSString,
+ default_text: Option<&objc2_foundation::NSString>,
+ frame: &WKFrameInfo,
+ completion: &Block<dyn Fn(*const objc2_foundation::NSString)>,
+ ) {
+ let Some(window) = webview.window().filter(|_| self.ivars().preview_dialogs) else {
+ completion.call((std::ptr::null(),));
+ return;
+ };
+ let mtm = MainThreadMarker::new().unwrap();
+ let alert = preview_alert(message, frame, true);
+ let input = objc2_app_kit::NSTextField::initWithFrame(
+ mtm.alloc(),
+ objc2_foundation::NSRect::new(
+ objc2_foundation::NSPoint::new(0.0, 0.0),
+ objc2_foundation::NSSize::new(320.0, 24.0),
+ ),
+ );
+ if let Some(text) = default_text {
+ input.setStringValue(text);
+ }
+ alert.setAccessoryView(Some(&input));
+ let completion = completion.copy();
+ let callback = block2::RcBlock::new(move |response| {
+ if response == objc2_app_kit::NSAlertFirstButtonReturn {
+ let value = input.stringValue();
+ completion.call((Retained::as_ptr(&value),));
+ } else {
+ completion.call((std::ptr::null(),));
+ }
+ });
+ alert.beginSheetModalForWindow_completionHandler(&window, Some(&callback));
+ }
+
#[cfg(target_os = "macos")]
#[unsafe(method(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:))]
fn run_file_upload_panel(
@@ -297,6 +388,8 @@ impl WryWebViewUIDelegate {
mtm: MainThreadMarker,
new_window_req_handler: Option<Box<dyn Fn(String, NewWindowFeatures) -> NewWindowResponse>>,
permission_handler: Option<Box<dyn Fn(PermissionKind) -> PermissionResponse + Send + Sync>>,
+ preview_dialogs: bool,
+ popup: bool,
) -> Retained<Self> {
#[cfg(target_os = "ios")]
let _new_window_req_handler = new_window_req_handler;
@@ -309,7 +402,33 @@ impl WryWebViewUIDelegate {
#[cfg(target_os = "macos")]
new_windows: Rc::new(RefCell::new(vec![])),
permission_handler,
+ preview_dialogs,
+ popup,
});
unsafe { msg_send![super(delegate), init] }
}
}
+
+#[cfg(target_os = "macos")]
+fn preview_alert(
+ message: &objc2_foundation::NSString,
+ frame: &WKFrameInfo,
+ cancel: bool,
+) -> Retained<objc2_app_kit::NSAlert> {
+ use objc2::ClassType;
+ use objc2_foundation::{NSBundle, NSString};
+ let alert = objc2_app_kit::NSAlert::new(MainThreadMarker::new().unwrap());
+ // Site-controlled text is always identified by the initiating frame's host.
+ let host = unsafe { frame.securityOrigin().host() };
+ alert.setMessageText(&host);
+ alert.setInformativeText(message);
+ if cancel {
+ let bundle = unsafe { NSBundle::bundleForClass(objc2_app_kit::NSAlert::class()) };
+ for key in ["OK", "Cancel"] {
+ let key = NSString::from_str(key);
+ let title = bundle.localizedStringForKey_value_table(&key, Some(&key), None);
+ alert.addButtonWithTitle(&title);
+ }
+ }
+ alert
+}
diff --git a/src/wkwebview/mod.rs b/src/wkwebview/mod.rs
index 25c7ce8e5..9b1db532b 100644
--- a/src/wkwebview/mod.rs
+++ b/src/wkwebview/mod.rs
@@ -653,6 +653,10 @@ impl InnerWebView {
mtm,
attributes.new_window_req_handler,
attributes.permission_handler,
+ attributes.id.is_some_and(|id| id.starts_with("browser-")),
+ attributes
+ .id
+ .is_some_and(|id| id.starts_with("browser-popup-")),
);
let proto_ui_delegate = ProtocolObject::from_ref(&*ui_delegate);
webview.setUIDelegate(Some(proto_ui_delegate));

View file

@ -4751,6 +4751,7 @@ You may have it installed on another user account, but it is not available for t
.with_incognito(webview_attributes.incognito)
.with_clipboard(webview_attributes.clipboard)
.with_hotkeys_zoom(webview_attributes.zoom_hotkeys_enabled)
.with_back_forward_navigation_gestures(label.starts_with("browser-"))
.with_general_autofill_enabled(webview_attributes.general_autofill_enabled);
if url != "about:blank" {
@ -5107,20 +5108,27 @@ You may have it installed on another user account, but it is not available for t
let window_id_for_ipc = window_id.clone();
#[cfg(not(windows))]
let window_id_for_ipc = window_id;
webview_builder = webview_builder.with_ipc_handler(create_ipc_handler(
window_id_for_ipc,
id,
context.clone(),
label.clone(),
ipc_handler,
));
for script in webview_attributes.initialization_scripts {
webview_builder = webview_builder
.with_initialization_script_for_main_only(script.script, script.for_main_frame_only);
let browser_guest = label.starts_with("browser-");
if !browser_guest {
webview_builder = webview_builder.with_ipc_handler(create_ipc_handler(
window_id_for_ipc,
id,
context.clone(),
label.clone(),
ipc_handler,
));
for script in webview_attributes.initialization_scripts {
webview_builder = webview_builder
.with_initialization_script_for_main_only(script.script, script.for_main_frame_only);
}
}
for (scheme, protocol) in uri_scheme_protocols {
// Preview documents retain their browser globals (not app plugin overrides)
// and carry neither the application IPC bridge nor app resource protocols.
if browser_guest {
continue;
}
// on Linux the custom protocols are associated with the web context
// and you cannot register a scheme more than once
#[cfg(any(

View file

@ -278,9 +278,12 @@ default-features = false
version = "0.3.2"
features = [
"std",
"block2",
"objc2-core-foundation",
"NSApplication",
"NSButton",
"NSAlert",
"NSTextField",
"NSControl",
"NSEvent",
"NSWindow",

View file

@ -46,7 +46,7 @@ use webkit2gtk::{
};
use webkit2gtk_sys::{
webkit_get_major_version, webkit_get_micro_version, webkit_get_minor_version,
webkit_policy_decision_ignore, webkit_policy_decision_use,
webkit_policy_decision_download, webkit_policy_decision_ignore, webkit_policy_decision_use,
};
#[cfg(feature = "x11")]
use x11_dl::xlib::*;
@ -468,7 +468,25 @@ impl InnerWebView {
attributes: &mut WebViewAttributes,
) {
// window.close()
webview.connect_close(move |webview| unsafe { webview.destroy() });
let browser = attributes.id.is_some_and(|id| id.starts_with("browser-"));
let popup = attributes
.id
.is_some_and(|id| id.starts_with("browser-popup-"));
webview.connect_close(move |webview| unsafe {
if popup {
if let Some(window) = webview
.toplevel()
.and_then(|widget| widget.downcast::<gtk::Window>().ok())
{
window.destroy();
return;
}
}
// Embedded preview roots are closed by their host, like WKWebView roots.
if !browser {
webview.destroy();
}
});
// Synthetic mouse events
synthetic_mouse_events::setup(webview);
@ -556,6 +574,7 @@ impl InnerWebView {
}
// Navigation handler
let browser_downloads = attributes.id.is_some_and(|id| id.starts_with("browser-"));
if let Some(navigation_handler) = attributes.navigation_handler.take() {
webview.connect_decide_policy(move |_webview, policy_decision, policy_type| {
let handler = match policy_type {
@ -572,6 +591,12 @@ impl InnerWebView {
unsafe {
if allow {
webkit_policy_decision_use(pointer)
} else if browser_downloads
&& (uri.starts_with("blob:") || uri.starts_with("data:"))
{
// Generated content can be saved, but never becomes a document
// in a browser preview whose navigation policy rejected it.
webkit_policy_decision_download(pointer)
} else {
webkit_policy_decision_ignore(pointer)
}
@ -679,6 +704,7 @@ impl InnerWebView {
|| attributes.download_completed_handler.is_some()
{
web_context.register_download_handler(
&webview,
attributes.download_started_handler.take(),
attributes.download_completed_handler.take(),
)
@ -714,11 +740,9 @@ impl InnerWebView {
.dynamic_cast_ref::<gtk::Fixed>()
.unwrap()
.put(webview, x, y);
} else {
container.add(webview);
}
}
fn attach_ipc_handler(webview: WebView, ipc_handler: Box<dyn Fn(Request<String>)>) {

View file

@ -117,6 +117,7 @@ pub trait WebContextExt {
fn register_download_handler(
&mut self,
webview: &WebView,
download_started_callback: Option<Box<dyn FnMut(String, &mut PathBuf) -> bool>>,
download_completed_callback: Option<Rc<dyn Fn(String, Option<PathBuf>, bool) + 'static>>,
);
@ -306,14 +307,23 @@ impl WebContextExt for super::WebContext {
fn register_download_handler(
&mut self,
webview: &WebView,
download_started_handler: Option<Box<dyn FnMut(String, &mut PathBuf) -> bool>>,
download_completed_handler: Option<Rc<dyn Fn(String, Option<PathBuf>, bool) + 'static>>,
) {
let context = &self.os.context;
let download_started_handler = Rc::new(RefCell::new(download_started_handler));
let webview = webview.downgrade();
context.connect_download_started(move |_context, download| {
let Some(webview) = webview.upgrade() else {
return;
};
if download.web_view().as_ref() != Some(&webview) {
return;
}
download.set_allow_overwrite(false);
// Failure belongs to this download, not every download in the context.
let failed = Rc::new(RefCell::new(false));
let download_started_handler = download_started_handler.clone();
@ -354,7 +364,9 @@ impl WebContextExt for super::WebContext {
}
if download_started_handler(uri, &mut download_destination) {
download.set_destination(&download_destination.to_string_lossy());
use gtk::gio::prelude::FileExt;
let uri = gtk::gio::File::for_path(&download_destination).uri();
download.set_destination(&uri);
} else {
download.cancel();
}

View file

@ -692,10 +692,27 @@ impl InnerWebView {
) -> Result<()> {
unsafe {
// Close container HWND when `window.close` is called in JS
webview.add_WindowCloseRequested(
&WindowCloseRequestedEventHandler::create(Box::new(move |_, _| DestroyWindow(hwnd))),
token,
)?;
if attributes
.id
.is_none_or(|id| !id.starts_with("browser-") || id.starts_with("browser-popup-"))
{
// Wry owns a child container even in a top-level Tauri window. A guest
// popup's script closure must dispose that top-level window as well.
let close_hwnd = if attributes
.id
.is_some_and(|id| id.starts_with("browser-popup-"))
{
GetAncestor(hwnd, GA_ROOT)
} else {
hwnd
};
webview.add_WindowCloseRequested(
&WindowCloseRequestedEventHandler::create(Box::new(move |_, _| {
DestroyWindow(close_hwnd)
})),
token,
)?;
}
// Document title changed handler
if let Some(document_title_changed_handler) = attributes.document_title_changed_handler.take()

View file

@ -83,6 +83,8 @@ pub struct WryWebViewUIDelegateIvars {
#[cfg(target_os = "macos")]
new_windows: Rc<RefCell<Vec<NewWindow>>>,
permission_handler: Option<Box<dyn Fn(PermissionKind) -> PermissionResponse + Send + Sync>>,
preview_dialogs: bool,
popup: bool,
}
define_class!(
@ -93,7 +95,147 @@ define_class!(
unsafe impl NSObjectProtocol for WryWebViewUIDelegate {}
// WebKit's native window.print/beforeunload hooks are declared in
// Source/WebKit/UIProcess/API/Cocoa/WKUIDelegatePrivate.h (macOS 12+).
// They are confined to preview delegates; the page receives no app bridge.
impl WryWebViewUIDelegate {
#[cfg(target_os = "macos")]
#[unsafe(method(_webView:printFrame:))]
fn print_frame(&self, webview: &WryWebView, _frame: &NSObject) {
if self.ivars().preview_dialogs { preview_print(webview); }
}
#[cfg(target_os = "macos")]
#[unsafe(method(_webView:printFrame:pdfFirstPageSize:completionHandler:))]
fn print_frame_with_completion(&self, webview: &WryWebView, _frame: &NSObject,
_size: objc2_foundation::NSSize, completion: &Block<dyn Fn()>) {
if self.ivars().preview_dialogs { preview_print(webview); }
completion.call(());
}
#[cfg(target_os = "macos")]
#[unsafe(method(_webView:runBeforeUnloadConfirmPanelWithMessage:initiatedByFrame:completionHandler:))]
fn before_unload(&self, webview: &WryWebView, message: &objc2_foundation::NSString,
frame: &WKFrameInfo, completion: &Block<dyn Fn(objc2::runtime::Bool)>) {
if !self.ivars().preview_dialogs { completion.call((true.into(),)); return; }
self.javascript_confirm(objc2::sel!(webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:), webview, message, frame, completion);
}
}
unsafe impl WKUIDelegate for WryWebViewUIDelegate {
#[cfg(target_os = "macos")]
#[unsafe(method(webView:requestDeviceOrientationAndMotionPermissionForOrigin:initiatedByFrame:decisionHandler:))]
fn request_orientation_permission(
&self,
_webview: &WryWebView,
_origin: &WKSecurityOrigin,
_frame: &WKFrameInfo,
completion: &Block<dyn Fn(WKPermissionDecision)>,
) {
let decision = if self.ivars().preview_dialogs {
match self
.ivars()
.permission_handler
.as_ref()
.map(|handler| handler(PermissionKind::Sensors))
{
Some(PermissionResponse::Deny) => WKPermissionDecision::Deny,
_ => WKPermissionDecision::Prompt,
}
} else {
WKPermissionDecision::Prompt
};
completion.call((decision,));
}
#[cfg(target_os = "macos")]
#[unsafe(method(webViewDidClose:))]
fn web_view_did_close(&self, webview: &WryWebView) {
if self.ivars().popup {
if let Some(window) = webview.window() {
window.close();
}
}
}
#[cfg(target_os = "macos")]
#[unsafe(method(webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:))]
fn javascript_alert(
&self,
webview: &WryWebView,
message: &objc2_foundation::NSString,
frame: &WKFrameInfo,
completion: &Block<dyn Fn()>,
) {
let Some(window) = webview.window().filter(|_| self.ivars().preview_dialogs) else {
completion.call(());
return;
};
let alert = preview_alert(message, frame, false);
let completion = completion.copy();
let callback = block2::RcBlock::new(move |_| completion.call(()));
alert.beginSheetModalForWindow_completionHandler(&window, Some(&callback));
}
#[cfg(target_os = "macos")]
#[unsafe(method(webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:))]
fn javascript_confirm(
&self,
webview: &WryWebView,
message: &objc2_foundation::NSString,
frame: &WKFrameInfo,
completion: &Block<dyn Fn(objc2::runtime::Bool)>,
) {
let Some(window) = webview.window().filter(|_| self.ivars().preview_dialogs) else {
completion.call((false.into(),));
return;
};
let alert = preview_alert(message, frame, true);
let completion = completion.copy();
let callback = block2::RcBlock::new(move |response| {
completion.call(((response == objc2_app_kit::NSAlertFirstButtonReturn).into(),))
});
alert.beginSheetModalForWindow_completionHandler(&window, Some(&callback));
}
#[cfg(target_os = "macos")]
#[unsafe(method(webView:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:))]
fn javascript_prompt(
&self,
webview: &WryWebView,
message: &objc2_foundation::NSString,
default_text: Option<&objc2_foundation::NSString>,
frame: &WKFrameInfo,
completion: &Block<dyn Fn(*const objc2_foundation::NSString)>,
) {
let Some(window) = webview.window().filter(|_| self.ivars().preview_dialogs) else {
completion.call((std::ptr::null(),));
return;
};
let mtm = MainThreadMarker::new().unwrap();
let alert = preview_alert(message, frame, true);
let input = objc2_app_kit::NSTextField::initWithFrame(
mtm.alloc(),
objc2_foundation::NSRect::new(
objc2_foundation::NSPoint::new(0.0, 0.0),
objc2_foundation::NSSize::new(320.0, 24.0),
),
);
if let Some(text) = default_text {
input.setStringValue(text);
}
alert.setAccessoryView(Some(&input));
let completion = completion.copy();
let callback = block2::RcBlock::new(move |response| {
if response == objc2_app_kit::NSAlertFirstButtonReturn {
let value = input.stringValue();
completion.call((Retained::as_ptr(&value),));
} else {
completion.call((std::ptr::null(),));
}
});
alert.beginSheetModalForWindow_completionHandler(&window, Some(&callback));
}
#[cfg(target_os = "macos")]
#[unsafe(method(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:))]
fn run_file_upload_panel(
@ -297,6 +439,8 @@ impl WryWebViewUIDelegate {
mtm: MainThreadMarker,
new_window_req_handler: Option<Box<dyn Fn(String, NewWindowFeatures) -> NewWindowResponse>>,
permission_handler: Option<Box<dyn Fn(PermissionKind) -> PermissionResponse + Send + Sync>>,
preview_dialogs: bool,
popup: bool,
) -> Retained<Self> {
#[cfg(target_os = "ios")]
let _new_window_req_handler = new_window_req_handler;
@ -309,7 +453,50 @@ impl WryWebViewUIDelegate {
#[cfg(target_os = "macos")]
new_windows: Rc::new(RefCell::new(vec![])),
permission_handler,
preview_dialogs,
popup,
});
unsafe { msg_send![super(delegate), init] }
}
}
#[cfg(target_os = "macos")]
fn preview_print(webview: &WryWebView) {
// Use the same public print operation as Wry's host-initiated printing.
unsafe {
if webview.window().is_none()
|| objc2_app_kit::NSPrintOperation::currentOperation(MainThreadMarker::new().unwrap())
.is_some()
{
return;
}
let info = objc2_app_kit::NSPrintInfo::sharedPrintInfo();
let operation = webview.printOperationWithPrintInfo(&info);
operation.setCanSpawnSeparateThread(true);
operation.runOperation();
}
}
#[cfg(target_os = "macos")]
fn preview_alert(
message: &objc2_foundation::NSString,
frame: &WKFrameInfo,
cancel: bool,
) -> Retained<objc2_app_kit::NSAlert> {
use objc2::ClassType;
use objc2_foundation::{NSBundle, NSString};
let alert = objc2_app_kit::NSAlert::new(MainThreadMarker::new().unwrap());
// Site-controlled text is always identified by the initiating frame's host.
let host = unsafe { frame.securityOrigin().host() };
alert.setMessageText(&host);
alert.setInformativeText(message);
if cancel {
let bundle = unsafe { NSBundle::bundleForClass(objc2_app_kit::NSAlert::class()) };
for key in ["OK", "Cancel"] {
let key = NSString::from_str(key);
let title = bundle.localizedStringForKey_value_table(&key, Some(&key), None);
alert.addButtonWithTitle(&title);
}
}
alert
}

View file

@ -653,6 +653,10 @@ impl InnerWebView {
mtm,
attributes.new_window_req_handler,
attributes.permission_handler,
attributes.id.is_some_and(|id| id.starts_with("browser-")),
attributes
.id
.is_some_and(|id| id.starts_with("browser-popup-")),
);
let proto_ui_delegate = ProtocolObject::from_ref(&*ui_delegate);
webview.setUIDelegate(Some(proto_ui_delegate));

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "OK",
"window.menu.native.browserPromptCancel": "Abbrechen",
"window.menu.native.browserPermission": "{permission} für {origin} erlauben?",
"window.menu.native.browserPermissionUnknown": "Unbekannte Berechtigung blockiert: {permission}.",
"window.menu.native.browserAllow": "Erlauben",
"window.menu.native.browserDeny": "Ablehnen",
"window.menu.native.browserCopyLink": "Link kopieren",
"window.menu.native.browserSaveImage": "Bild speichern",
"window.menu.native.browserOpenLink": "Link in neuem Fenster öffnen",
"window.menu.native.browserInspect": "Untersuchen",
"window.title.main": "CodeNomad",
"window.controls.minimize": "Fenster minimieren",
"window.controls.maximize": "Fenster maximieren oder wiederherstellen",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "OK",
"window.menu.native.browserPromptCancel": "Cancel",
"window.menu.native.browserPermission": "Allow {permission} for {origin}?",
"window.menu.native.browserPermissionUnknown": "Unknown permission blocked: {permission}.",
"window.menu.native.browserAllow": "Allow",
"window.menu.native.browserDeny": "Deny",
"window.menu.native.browserCopyLink": "Copy Link",
"window.menu.native.browserSaveImage": "Save Image",
"window.menu.native.browserOpenLink": "Open Link in New Window",
"window.menu.native.browserInspect": "Inspect",
"window.title.main": "CodeNomad",
"window.controls.minimize": "Minimize window",
"window.controls.maximize": "Maximize or restore window",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "Aceptar",
"window.menu.native.browserPromptCancel": "Cancelar",
"window.menu.native.browserPermission": "¿Permitir {permission} para {origin}?",
"window.menu.native.browserPermissionUnknown": "Permiso desconocido bloqueado: {permission}.",
"window.menu.native.browserAllow": "Permitir",
"window.menu.native.browserDeny": "Denegar",
"window.menu.native.browserCopyLink": "Copiar enlace",
"window.menu.native.browserSaveImage": "Guardar imagen",
"window.menu.native.browserOpenLink": "Abrir enlace en una ventana nueva",
"window.menu.native.browserInspect": "Inspeccionar",
"window.title.main": "CodeNomad",
"window.controls.minimize": "Minimizar ventana",
"window.controls.maximize": "Maximizar o restaurar ventana",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "OK",
"window.menu.native.browserPromptCancel": "Annuler",
"window.menu.native.browserPermission": "Autoriser {permission} pour {origin} ?",
"window.menu.native.browserPermissionUnknown": "Permission inconnue bloquée : {permission}.",
"window.menu.native.browserAllow": "Autoriser",
"window.menu.native.browserDeny": "Refuser",
"window.menu.native.browserCopyLink": "Copier le lien",
"window.menu.native.browserSaveImage": "Enregistrer l’image",
"window.menu.native.browserOpenLink": "Ouvrir le lien dans une nouvelle fenêtre",
"window.menu.native.browserInspect": "Inspecter",
"window.title.main": "CodeNomad",
"window.controls.minimize": "Réduire la fenêtre",
"window.controls.maximize": "Agrandir ou restaurer la fenêtre",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "אישור",
"window.menu.native.browserPromptCancel": "ביטול",
"window.menu.native.browserPermission": "לאפשר {permission} עבור {origin}?",
"window.menu.native.browserPermissionUnknown": "הרשאה לא מוכרת נחסמה: {permission}.",
"window.menu.native.browserAllow": "אפשר",
"window.menu.native.browserDeny": "דחה",
"window.menu.native.browserCopyLink": "העתקת קישור",
"window.menu.native.browserSaveImage": "שמירת תמונה",
"window.menu.native.browserOpenLink": "פתיחת קישור בחלון חדש",
"window.menu.native.browserInspect": "בדיקה",
"window.title.main": "CodeNomad",
"window.controls.minimize": "מזער חלון",
"window.controls.maximize": "הגדל או שחזר חלון",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "OK",
"window.menu.native.browserPromptCancel": "キャンセル",
"window.menu.native.browserPermission": "{origin} に {permission} を許可しますか?",
"window.menu.native.browserPermissionUnknown": "不明な権限をブロックしました: {permission}。",
"window.menu.native.browserAllow": "許可",
"window.menu.native.browserDeny": "拒否",
"window.menu.native.browserCopyLink": "リンクをコピー",
"window.menu.native.browserSaveImage": "画像を保存",
"window.menu.native.browserOpenLink": "新しいウィンドウでリンクを開く",
"window.menu.native.browserInspect": "検証",
"window.title.main": "CodeNomad",
"window.controls.minimize": "ウィンドウを最小化",
"window.controls.maximize": "ウィンドウを最大化または元に戻す",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "ठीक छ",
"window.menu.native.browserPromptCancel": "रद्द गर्नुहोस्",
"window.menu.native.browserPermission": "{origin} का लागि {permission} अनुमति दिने?",
"window.menu.native.browserPermissionUnknown": "अज्ञात अनुमति रोकियो: {permission}।",
"window.menu.native.browserAllow": "अनुमति दिनुहोस्",
"window.menu.native.browserDeny": "अस्वीकार गर्नुहोस्",
"window.menu.native.browserCopyLink": "लिङ्क प्रतिलिपि गर्नुहोस्",
"window.menu.native.browserSaveImage": "तस्बिर सुरक्षित गर्नुहोस्",
"window.menu.native.browserOpenLink": "लिङ्क नयाँ विन्डोमा खोल्नुहोस्",
"window.menu.native.browserInspect": "निरीक्षण गर्नुहोस्",
"window.title.main": "CodeNomad",
"window.controls.minimize": "सञ्झ्याल सानो पार्नुहोस्",
"window.controls.maximize": "सञ्झ्याल ठूलो पार्नुहोस् वा पुनर्स्थापना गर्नुहोस्",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "ОК",
"window.menu.native.browserPromptCancel": "Отмена",
"window.menu.native.browserPermission": "Разрешить {permission} для {origin}?",
"window.menu.native.browserPermissionUnknown": "Неизвестное разрешение заблокировано: {permission}.",
"window.menu.native.browserAllow": "Разрешить",
"window.menu.native.browserDeny": "Запретить",
"window.menu.native.browserCopyLink": "Копировать ссылку",
"window.menu.native.browserSaveImage": "Сохранить изображение",
"window.menu.native.browserOpenLink": "Открыть ссылку в новом окне",
"window.menu.native.browserInspect": "Исследовать",
"window.title.main": "CodeNomad",
"window.controls.minimize": "Свернуть окно",
"window.controls.maximize": "Развернуть или восстановить окно",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "Tamam",
"window.menu.native.browserPromptCancel": "İptal",
"window.menu.native.browserPermission": "{origin} için {permission} izni verilsin mi?",
"window.menu.native.browserPermissionUnknown": "Bilinmeyen izin engellendi: {permission}.",
"window.menu.native.browserAllow": "İzin ver",
"window.menu.native.browserDeny": "Reddet",
"window.menu.native.browserCopyLink": "Bağlantıyı kopyala",
"window.menu.native.browserSaveImage": "Görseli kaydet",
"window.menu.native.browserOpenLink": "Bağlantıyı yeni pencerede aç",
"window.menu.native.browserInspect": "İncele",
"window.title.main": "CodeNomad",
"window.controls.minimize": "Pencereyi küçült",
"window.controls.maximize": "Pencereyi büyüt veya geri yükle",

View file

@ -1,4 +1,14 @@
export const appMessages = {
"window.menu.native.browserPromptAccept": "确定",
"window.menu.native.browserPromptCancel": "取消",
"window.menu.native.browserPermission": "允许 {origin} 使用 {permission}?",
"window.menu.native.browserPermissionUnknown": "已阻止未知权限:{permission}。",
"window.menu.native.browserAllow": "允许",
"window.menu.native.browserDeny": "拒绝",
"window.menu.native.browserCopyLink": "复制链接",
"window.menu.native.browserSaveImage": "保存图片",
"window.menu.native.browserOpenLink": "在新窗口中打开链接",
"window.menu.native.browserInspect": "检查",
"window.title.main": "CodeNomad",
"window.controls.minimize": "最小化窗口",
"window.controls.maximize": "最大化或还原窗口",

View file

@ -32,7 +32,7 @@ describe("native menu labels", () => {
it("keep the Tauri host's English defaults identical to the English messages", () => {
const source = readFileSync(new URL("../../../../tauri-app/src-tauri/src/menu_labels.rs", import.meta.url), "utf8")
const block = source.slice(source.indexOf("pub(crate) const DEFAULTS"), source.indexOf("];"))
const rust = Object.fromEntries([...block.matchAll(/\("(\w+)", "((?:[^"\\]|\\.)*)"\)/g)].map(([, key, text]) => [key, text]))
const rust = Object.fromEntries([...block.matchAll(/\(\s*"(\w+)",\s*"((?:[^"\\]|\\.)*)",?\s*\)/g)].map(([, key, text]) => [key, text]))
assert.deepEqual(rust, DEFAULT_NATIVE_MENU_LABELS)
assert.deepEqual(Object.keys(rust).sort(), Object.keys(NATIVE_MENU_LABEL_KEYS).sort())
})

View file

@ -40,6 +40,16 @@ export const NATIVE_MENU_LABEL_KEYS = {
maximize: "window.menu.native.maximize",
zoom: "window.menu.native.zoom",
bringAllToFront: "window.menu.native.bringAllToFront",
browserPermission: "window.menu.native.browserPermission",
browserPermissionUnknown: "window.menu.native.browserPermissionUnknown",
browserAllow: "window.menu.native.browserAllow",
browserDeny: "window.menu.native.browserDeny",
browserCopyLink: "window.menu.native.browserCopyLink",
browserSaveImage: "window.menu.native.browserSaveImage",
browserOpenLink: "window.menu.native.browserOpenLink",
browserInspect: "window.menu.native.browserInspect",
browserPromptAccept: "window.menu.native.browserPromptAccept",
browserPromptCancel: "window.menu.native.browserPromptCancel",
} as const
export type NativeMenuLabels = Record<keyof typeof NATIVE_MENU_LABEL_KEYS, string>

View file

@ -0,0 +1,41 @@
.browser-native-dialog,
.browser-native-dialog form {
height: 100%;
margin: 0;
}
.browser-native-dialog {
color-scheme: light dark;
font: menu;
}
.browser-native-dialog form {
display: flex;
flex-direction: column;
}
.browser-native-dialog .window-body {
flex: 1;
padding: 12px;
overflow: auto;
}
.browser-native-dialog label {
display: block;
margin-block-end: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.browser-native-dialog input,
.browser-native-dialog select {
width: 100%;
box-sizing: border-box;
}
.browser-native-dialog .window-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px;
}

View file

@ -51,3 +51,4 @@
@import "./components/integration-attachment.css";
@import "./components/integration-catalog.css";
@import "./components/agent-memory.css";
@import "./components/browser-native-dialog.css";