Add WinUI3 backend support (#1837)
* Update architecture.md (#1785)
Corrected spelling mistake on page
* Update architecture.md (#1786)
* Update architecture.md
Cleaned up grammar, syntax and reworded `Serverless` section to be better in line with rest of document.
* Fix typo in architecture.md
Corrected a typo in the architecture documentation regarding serverless HTML loading.
---------
Co-authored-by: Roman <roman@flowrl.com>
* add example for testing JS alert()
* Add new winui3 backend
PythonNet doesn't work on ARM64, so until that happens, we need another
alterative on Windows. This uses WinUI3, which is the latest UI toolkit
from Microsoft.
* [WinUI]: Fix wheel scrolling
* [WinUI3] More fixes
* Introduce webview2core class
* [WinUI3] Add DPI awareness
* Fix WinUI CI
* Fix formatting
* Clean up custom_title_bar example
* [Winforms / WinUI] Fix clearing user data on exit
* [WinUI3]: Fix file dropping
* [Winforms / WinUI3] Smoother window drag using Win32
* Upgrade to Python 3.10
* [Winforms / WinUI] Another drag fix
* [Winforms / WinUI] Prevent dragging with maximized window
* Fomat fixes
* [WinUI3] Support for new Screen object properties
* [WinUI3] Fix wintypes import
* [WinUI3] Add warning about not supporting transparent background
* [Windows] Prevent right button drag
* [Windows] Fix monitor scale reporting in get_screens
* [WinUI3] Implement Win32 based multiple folder dialog
* [WinUI3] Experinmental Appium compatibility
* [Windows] Restore on drag when maximized
* [WinUI]: Improve file dialogs
* [WinUI3]: Bug fixes and improvements
Co-authored-by: Copilot <copilot@github.com>
* Address WinUI3 review feedback
* Address second WinUI3 review
* Address WinUI3 follow-up review
* Update CI workflow to run tests using run.py for better thread management
* Enhance header management in WinForms and WinUI3 by using threading for async operations
* Skip unused WebView2 request dispatch
* Address latest WinUI3 review
* Fix remaining WinUI review issues
* [WinUI3]: Support initial directory in file dialogs, install Windows App Runtime in CI
- create_file_dialog no longer raises NotImplementedError when a directory
is passed. The WinRT pickers have no API to set a starting location, so
when a directory is requested we now fall back to the equivalent Win32
IFileDialog (open/save) or IFileOpenDialog (folder), which supports
IFileDialog::SetFolder. These new win32 helpers reuse and generalize the
existing pick_folders_win32 COM plumbing.
- tests-winui3 CI job now downloads and silently installs the Windows App
Runtime redistributable before running tests, since the winui3 bootstrap
API requires it and it isn't provided by the WebView2 Runtime install.
* [WinUI3]: Fix ruff-format violations from previous commit
Two lines in the new win32 file-dialog helpers didn't match ruff-format's
output, failing the Code Quality CI job. Re-ran ruff format to fix.
* [WinUI3]: Fix child-window failure handling, wheel modifier flags, dialog type hints
- WebView2 environment/controller creation failures for a secondary window
no longer go through _fail_main_window_creation (which records global
creation state and calls Application.current.exit()). Only the master
window failing does that; a child window failing now just closes that
window via a new _fail_child_window_creation.
- Forwarded WM_MOUSEWHEEL/WM_MOUSEHWHEEL messages built their wParam from
MSLLHOOKSTRUCT.mouseData directly, whose low word is always zero. This
dropped MK_CONTROL/MK_SHIFT/button flags, breaking Ctrl+wheel zoom and
similar modified-wheel interactions. Rebuild wParam from the delta plus
live GetAsyncKeyState-derived MK_* flags.
- create_file_dialog and its three Future/callback signatures were typed
str | tuple[str] | None, but every return path is either None or an
N-element tuple (never a bare str, never exactly one element). Retyped
as tuple[str, ...] | None.
- The save-dialog fallback used an empty file-type description ('') when
no file_types were given, leaving a blank entry in the picker; use '*'.
* [WinUI3]: Fix CI hang caused by mismatched Windows App Runtime version
The tests-winui3 job installed Windows App Runtime 2.4.0, but the winui3
extra's pywinrt packages (>=3.1, currently resolving to 3.2.x) are built
against Windows App SDK 1.7.250513003 (per pywinrt's own CHANGELOG). The
version mismatch means setup_app()'s bootstrap initialize() call, using
InitializeOptions.ON_NO_MATCH_SHOW_UI, can't find a matching runtime and
tries to show an install prompt — which just hangs on a headless CI runner,
stalling the whole job on the very first test with no further output.
Pin the CI install to 1.7.250513003 to match what's actually loaded.
* [WinUI3]: Document winui3 pip extra, drop unreachable Python<3.10 dependency marker
- docs/guide/web_engine.md's renderer table didn't mention that the winui3
backend needs `pip install pywebview[winui3]`, only the native runtime
requirement; a plain `pip install pywebview` won't satisfy the row.
- pyproject.toml pinned `importlib_resources` for python_version < '3.10',
but requires-python is now >=3.10 and nothing in the codebase imports
importlib_resources, so the marker is dead and the metadata contradicts
itself. Removed.
* Fix flaky test_dom.py::test_events on EdgeChromium CI
events_test asserted button_value right after triggering a JS click, with
no allowance for the JS bridge's click_handler callback to actually arrive
before the assertion ran. This is unrelated to the WinUI3 work in this PR
(pre-existing test, last touched by an unrelated reformatting commit) but
flaked intermittently in CI — the exact same commit passed on one workflow
run and failed on another. Poll for button_value briefly instead of
asserting immediately.
* [WinUI3]: Fix ishtml clobbered by about:blank source change, clarify install error
- guilib.py's Windows fallback error only mentioned Windows App Runtime, not
the pywebview[winui3] Python extra also required for the winui3 backend;
a user following just that message would still fail to import it.
- on_source_changed unconditionally cleared self.ishtml on every source
change. navigate_to_string() (used by load_html for HTML/default content)
reports the source as about:blank, so if WebView2 ever raises SourceChanged
for that internal navigation, it would silently flip windows out of HTML
mode and get_current_url() would start returning 'about:blank' instead of
None. Guard against that specific case while still clearing ishtml for
real navigations.
* [WinUI3]: Fix dialog deadlocks from before_show, implement window.shadow
- create_confirmation_dialog and create_file_dialog both waited on
_main_window_created and then enqueued-and-blocked on the dispatcher
queue unconditionally. Calling either from a synchronous `before_show`
handler deadlocks: that handler runs on the dispatcher before WebView2
signals readiness, so the wait for _main_window_created never resolves,
and even past that, blocking this same thread after enqueueing to its
own queue prevents the queue from ever running the enqueued callback.
The XAML root (which is all these dialogs need) already exists once the
BrowserForm instance is registered, so drop the unnecessary readiness
wait, and add _run_dispatched, which runs the dialog callback inline
when the caller already has dispatcher access (mirroring
invoke_on_ui_thread) instead of enqueueing to itself and blocking.
- WinUI3 never consulted window.shadow, unlike the WinForms backend (which
restores a native drop shadow on frameless windows via a DWM
non-client-rendering trick). Implemented the same trick for WinUI3's
frameless windows via new shared win32.py helpers.
* [WinUI3]: Fix file-dialog array lifetime bug, cookie expiry format, icon fallback
- _set_dialog_file_types returned the `specs` list, but SetFileTypes was
passed the separately-allocated `array` built from it. Only `specs` was
kept alive by callers, so `array`'s own backing memory (and the raw
pointer IFileDialog retains into it through Show()) could be freed right
after this function returned. Build and populate the array in place
instead of copying in separately-built structs, so the one object callers
keep alive anchors both the struct-array buffer and its string fields.
- get_cookies formatted a cookie's expiry via str(c.expires), but WinRT
projects CoreWebView2Cookie.expires as a raw Unix-epoch double (unlike the
WinForms/.NET binding's DateTime), producing a value that isn't a valid
HTTP-date for the cookie's Expires attribute. Format it properly.
- The window-icon fallback passed sys.executable (a .exe) to
AppWindow.set_icon(), which requires an .ico file path. Leave the icon
unset in that case instead, so Windows falls back to its own default.
* [WinUI3]: Fix pointer truncation and two more UI-thread deadlocks
- clear_user_data's OpenProcess/WaitForSingleObject/CloseHandle calls had no
ctypes argtypes/restype, so OpenProcess's pointer-sized HANDLE return
defaulted to a 32-bit c_int. On 64-bit Windows this can corrupt the handle,
making the wait a no-op and letting private-mode cleanup delete the user
data folder while the WebView2 process is still using it. Declared proper
pointer-sized signatures.
- evaluate_js() and get_cookies() had the same before_show-class deadlock
already fixed for the dialog functions: both start async work on the
dispatcher queue and then block the calling thread on a semaphore that
only that same dispatcher can release. Reachable from any native XAML
event handler (e.g. the custom title bar example's button click), not
just before_show. evaluate_js now runs inline and returns an unavailable
result when already on the dispatcher thread, matching _run_dispatched's
approach for dialogs; get_cookies rejects that call context outright
since there's no way to synchronously return cookies without waiting.
* [WinUI3]: Fix Windows 7 import crash, shutdown hang, and focus=False stealing focus
- win32.py bound PhysicalToLogicalPointForPerMonitorDPI (Windows 8.1+) at
module import time. Since winforms.py imports this module unconditionally
for every renderer including MSHTML (documented to support Windows 7),
this raised AttributeError before MSHTML could even start on Windows 7.
Look the export up with getattr and skip the DPI conversion (a no-op on
pre-8.1, since there's no per-monitor DPI there) when it's unavailable.
- _wait_for_main_window() had no terminal path if the master window closes
normally before WebView2 initialization completes (e.g. closed from a
before_show handler). Since child window creation starts once `shown`
fires — which happens before _main_window_created — a thread blocked
waiting for a child window could hang forever, leaving a non-daemon
thread alive after the WinUI application itself has already exited.
Signal the same creation-failure path from the master's close handler.
- create_window() always called activate() for a non-hidden window
regardless of window.focus, then relied on the activation handler to
un-focus it afterwards — but a previously focused app's foreground focus
can't be reliably restored once activate() has already taken it. Show
without activating up front instead when focus=False.
* [WinUI3]: Force filesystem results in file dialogs, correct before_show claim
- pick_files_win32/pick_save_file_win32 never set FOS_FORCEFILESYSTEM, so a
user selecting a virtual shell item (not backed by a real file path) would
have _shell_item_path()'s SIGDN_FILESYSPATH conversion silently fail,
making the dialog return None despite a successful selection. Set it on
both while preserving existing options (multi-select).
- Corrected an inaccurate comment: removing create_confirmation_dialog's
wait for WebView2 readiness does not, by itself, make the dialog callable
from a `before_show` handler. The public Window.create_confirmation_dialog
is separately gated on events.shown, which isn't set until after
before_show's handler returns, so that specific path still times out
before ever reaching this function regardless. The fix here still avoids
an unnecessary wait for callers that do reach it.
* [WinUI3]: Raise instead of silently misreporting from UI-thread reentrant calls
Fixing the earlier before_show-class deadlocks by returning None when a
dialog/evaluate_js/get_cookies call is made from the dispatcher thread
itself avoided the hang, but silently violated each method's documented
contract: evaluate_js() promises a synchronous result, create_confirmation_dialog
promises a bool, and a file dialog returning None is indistinguishable from
the user cancelling. All four now still start the operation (the dialog is
shown, the script runs) but raise a RuntimeError instead of returning a
placeholder value a caller could mistake for a real answer. _run_dispatched
now owns this decision in one place; its two callers (create_confirmation_dialog,
create_file_dialog) simplify to `return _run_dispatched(...)`.
Also: _route_script_message read WebView2's AdditionalObjects eagerly for
every bridge message, not just the FilesDropped one that needs it. Some
runtimes (old ones, or arbitrary fixed ones via WEBVIEW2_RUNTIME_PATH) don't
support that property, so a normal message could throw before routing.
Changed the parameter to a callable so it's only accessed when needed,
applied to both the WinForms/EdgeChromium and WinUI3 backends that share
this method.
* [WinUI3]: Fix false-positive raise, forced winui3 fallback, hidden+maximized state
- _run_dispatched raised on the dispatcher thread even when the callback had
already completed the future synchronously (the Win32 IFileDialog
fallbacks pump their own modal loop via Show() and resolve the future
before returning). Return the real result in that case; only raise when
the result is still genuinely pending asynchronously.
- guilib.py's Windows selection let gui='winui3' silently fall back to
WinForms if the WinUI 3 import failed, contradicting the explicit request
and letting the winui3 CI job accidentally exercise EdgeChromium/MSHTML
instead (pythonnet is always installed on the runner). No fallback when
winui3 is explicitly requested.
- create_window() only applied initial maximized/minimized state in the
visible branch, so a window created with hidden=True together with
maximized/minimized stayed in the normal state once later shown, unlike
WinForms/GTK. Apply it regardless of initial visibility.
* [WinUI3]: Pin winui3 extras, fix cef/mshtml/edgechromium fallback, bound request threads
- pyproject.toml's winui3 extra floor (>=3.1) could resolve a future pywinrt
release built against a different Windows App SDK version than what CI's
tests-winui3 job installs, silently reintroducing the bootstrap hang that
was fixed by pinning the CI runtime install. Narrowed to >=3.2,<3.3, the
release train the CI-pinned runtime version actually matches.
- guilib.py's Windows selection let explicit gui='cef'/'mshtml'/'edgechromium'
silently fall through to WinUI 3 if import_winforms() failed, contradicting
gui being documented as forcing a specific renderer (same issue already
fixed for gui='winui3' in an earlier commit, just not carried through to
these three). No WinUI 3 fallback for those explicit selections either.
- on_web_resource_request (both WinForms/EdgeChromium and WinUI3) spawned a
new OS thread per intercepted request whenever a request_sent listener was
registered. An asset-heavy page can generate hundreds of concurrent
requests, so this could exhaust threads/memory. Added a small shared,
bounded ThreadPoolExecutor (WebView2Core._dispatch_request_event) that
both backends now dispatch through instead.
* [WinUI3]: Document Edge Runtime requirement, fix cross-monitor DPI positioning
- WinUI 3 hosts the same WebView2 control as edgechromium, so it needs the
Edge/WebView2 Runtime in addition to the Windows App Runtime and the
pywebview[winui3] extra — neither the docs table/prose nor the guilib.py
fallback error mentioned it, despite the CI job installing it separately.
Documented and included in the error message.
- BrowserForm.__init__ resolved the DPI scale for initial_x/initial_y
positioning from the window's current (pre-move) monitor, before the
window was actually moved there. On a multi-monitor setup where the
target monitor has a different scale, both the initial size and the
physical position computed from the logical initial_x/initial_y were
wrong. Move using the initial scale as a first estimate, then re-resolve
the actual monitor the window landed on and correct size/position if its
scale differs, reusing the existing get_monitor_scale() helper.
* Fix case-sensitive header diffing, dispatch request_sent off GTK/Cocoa UI thread
- _compute_request_header_diff (shared by WinForms/EdgeChromium and WinUI3)
compared header names case-sensitively, but HTTP header names are
case-insensitive. A handler that only changed a header's casing (e.g.
'User-Agent' -> 'user-agent') would have the new name added and the old
name removed — but WebView2 treats both spellings as the same header, so
the removal deleted the value that was just set. Diff on lowercased names
now, so a casing-only change re-sets the header instead of losing it.
- window.events.request_sent = Event(self, True) (made synchronous earlier
in this PR for WinUI3/EdgeChromium's header-mutation flow) affects every
backend, but GTK and Cocoa still fired it inline from their own native
UI-thread callbacks (WebKit2's resource-request signal on the GTK main
thread; WKNavigationDelegate's policy decision on the AppKit main thread).
docs/api/README.md documents window event handlers (other than
before_show/before_load) as running in a separate thread — this silently
violated that for GTK/Cocoa specifically, and any request_sent handler
calling a synchronous window API (e.g. evaluate_js()) would deadlock the
main loop. Both backends now dispatch request_sent through a small shared
bounded thread pool (matching the pattern already used for WinUI3/
EdgeChromium), marshaling only the actual WebKit2/WKWebView mutation calls
(which must run on the main thread) back via glib.idle_add /
AppHelper.callAfter.
* Fix navigation reordering, drag DPI mismatch, unattended Chocolatey prompts
- The GTK/Cocoa request_sent thread pool added in the previous commit used
8 workers. Both backends' request handling stops the in-flight navigation
and reissues a new one (webview.load_request() / webview.loadRequest_()),
so out-of-order completion could let an earlier, slower request overwrite
a later one's navigation, leaving the webview on the wrong page. Reduced
both to a single worker so requests complete in submission order; still
bounded (no unbounded thread-per-request), just no longer parallel.
- start_drag() read the initial cursor position via GetCursorPos() without
converting it to logical coordinates, while every subsequent WM_MOUSEMOVE
point in the hook is explicitly converted via
PhysicalToLogicalPointForPerMonitorDPI. On a scaled display this mixed
coordinate spaces for the first delta computed against it, which could
make a drag jump or immediately exceed the tolerance threshold. Apply the
same conversion to the initial point.
- The tests-winui3 and general Windows CI jobs' `choco install` calls had no
explicit non-interactive flag; add -y so a confirmation prompt can't leave
the job hanging until its timeout.
* [GTK]: Revert request_sent to synchronous, fix its deadlock via evaluate_js guard instead
The previous commit's GTK request_sent fix was itself wrong: WebKitGTK's
resource-load-started signal has no deferral mechanism (unlike WebView2's
GetDeferral() or WKNavigationDelegate's asynchronously-invokable
decisionHandler — verified the latter is genuinely safe via Apple's own
documentation before trusting the Cocoa fix). Dispatching request_sent
asynchronously let the original, unmutated request reach the network before
stop_loading()/load_request() could replace it, replaying non-idempotent
requests (e.g. POST) whenever the request_sent handler took any measurable
time to run.
Reverted on_request to fire and apply synchronously and inline, matching
its original pre-PR behavior (removing the now-unused per-file thread
pool). The actual deadlock this was trying to fix — a request_sent handler
calling evaluate_js() reenters the GTK main thread it's already running on
— is now fixed directly in evaluate_js() instead: detect same-thread
reentrancy (current_thread() is main_thread()) and raise rather than block,
mirroring the has_thread_access guard already used for WinUI3. This keeps
request_sent's header-mutation timing intact while still turning the
reentrant-call deadlock into a clear exception instead of a hang.
Cocoa's request_sent fix from the previous commit is unaffected — WebKit's
decisionHandler is documented as safely invokable asynchronously, so it
doesn't have this replay risk.
* [WinUI3] Handle master window creation failures in _on_launched; document request_sent as synchronous
A synchronous exception while constructing/showing the master window (in
_on_launched, before any WebView2 async op even starts) previously escaped
the WinRT callback without setting _main_window_created, leaving any thread
blocked in _wait_for_main_window() for a child window stuck forever. Route
it through _fail_main_window_creation() like the existing async WebView2
setup failure paths.
Also document window.events.request_sent as synchronous/blocking, matching
before_show and before_load — GTK/Cocoa now run its handlers inline on the
platform UI thread and can raise from evaluate_js() if called reentrantly.
* [WinUI3] Handle secondary window creation failures; fix request_sent docs wording
A synchronous exception while creating a secondary BrowserForm (dispatched
via _enqueue) previously escaped silently: nothing removed the failed
window from the global `windows` list or set its lifecycle events, so a
caller waiting on `shown`/`closed` could block indefinitely. Wrap the
dispatched create() call, close the partially-created browser (letting
on_close do its normal cleanup) or unregister the window directly if
construction failed before anything was registered, and release
before_show/shown either way.
Also correct docs/api/README.md: request_sent is synchronous (the request
is held up until the handler returns), but saying it "blocks the main
thread" is only true on GTK — Cocoa and the WebView2 backends dispatch its
handler to a worker thread instead. Reworded to describe the blocking
behavior without implying UI-thread affinity everywhere.
* [WinUI3] Use CoreWebView2Cookie.is_session for session cookies; don't let maximize/minimize undo fullscreen
_format_cookie_expiry() previously used a negative `expires` value as an
undocumented sentinel for "session cookie, no expiry". CoreWebView2Cookie
exposes is_session as the documented authoritative flag for this (verified
against the actual webview2-Microsoft.Web.WebView2.Core 3.2.1 .pyi stub) —
a runtime that ever supplies a non-negative placeholder for a session
cookie would otherwise get a bogus persistent Expires value. Pass is_session
through explicitly instead of inferring it from expires' sign.
Also: create_window()'s post-construction maximized/minimized handling
ran unconditionally, even when window.fullscreen was requested and already
applied via toggle_fullscreen() during BrowserForm.__init__ — calling
maximize()/minimize() on the overlapped presenter switches the window's
active presenter back to it, silently undoing fullscreen. Skip that block
when fullscreen is set, matching WinForms' behavior of always applying
fullscreen last so it wins regardless of the other two flags.
* [GTK/WinUI3] Let run_js() return instead of raising on reentrant calls; fix screen origin overlap
evaluate_js()'s reentrancy guard (added earlier to fix a deadlock when a
synchronous callback like request_sent/before_load calls back into the
webview) raised unconditionally when called from the native UI thread —
but Window.run_js() (parse_json=False) is fire-and-forget by contract, so
it doesn't need to wait for or return a real result. Only Window.evaluate_js()
(parse_json=True) genuinely needs one and can't get it without deadlocking.
Both gtk.py and winui3.py now start the script either way but only raise
when parse_json is True, returning None for run_js() instead.
Also, winui3.py's get_screens(): DisplayArea.outer_bounds reports true
physical-pixel monitor positions, so dividing each monitor's *origin* by
its own DPI scale put differently-scaled monitors in inconsistent
coordinate spaces (e.g. a 200%-scaled secondary starting at physical
x=1920 was reported at logical x=960, overlapping a 1920-wide primary).
Fixed by converting every monitor's origin using the primary monitor's
scale instead (matching WinForms' Screen.Bounds, which is already in that
shared system-DPI-aware space) while keeping each monitor's own scale for
its size. Note: move()/get_position()/initial_x/initial_y positioning
still use other scale conventions and were deliberately left alone this
round — see memory note in cross-monitor-dpi-positioning-pattern for the
follow-up needed to reconcile them.
* [WinUI3] Standardize move/position on primary-scale desktop coordinates; guard more WinRT callback failures
Three fixes for the same underlying "which DPI scale?" bug class:
1. get_screens() (fixed previously) uses the primary monitor's scale for
monitor origins so they share one consistent desktop coordinate space,
but move(), get_position(), the position-changed event, and
BrowserForm.__init__'s initial_x/initial_y positioning still converted
origins using self._scale (the window's own current/target monitor) —
disagreeing with get_screens() and each other on mixed-DPI setups.
Standardized all of them on a new _primary_monitor_scale() helper for
origin/position conversions, while size conversions (window dimensions,
the initial_x/y two-pass resize-on-landing correction) keep using the
target monitor's own scale, since content genuinely renders at that
monitor's real DPI. This also simplifies initial_x/y positioning: since
the primary's scale doesn't depend on which monitor the window lands
on, position no longer needs the two-pass re-resolve/re-move dance that
size still does.
Not fixed: webview/screen.py's Screen.physical_x/physical_y properties
still use Screen.scale (each monitor's own), so they no longer
round-trip to the true physical origin for a monitor whose scale
differs from the primary's. Deliberately deferred — needs a real design
decision about the shared, cross-backend Screen class's contract, not a
quick patch. See memory for the reasoning.
2. WinUI3EdgeChrome's on_env_op_completed success branch (get_results(),
controller-option creation, ensure_core_webview2_with_environment_and_
options_async()) had no exception handling, unlike its two async-status
failure branches — a synchronous exception there escaped the WinRT
callback without ever calling _fail_main_window_creation/
_fail_child_window_creation, hanging any thread blocked in
_wait_for_main_window(). Same bug class already fixed twice elsewhere
in this file (_on_launched, child window create()).
3. BrowserForm.__init__ installed its global WH_MOUSE_LL hook as the very
first construction step. If any later step raised, the hook stayed
active with no BrowserView.instances entry for a failure handler to
find and uninstall it through, risking Windows invoking a freed ctypes
callback once the partially-constructed form was garbage collected.
Moved installation to the last line of __init__, once construction has
fully succeeded.
Also: webview/guilib.py's Windows import-failure message named both
pythonnet and the winui3 extra even when gui was explicitly forced to a
family only one of them could satisfy (e.g. forced 'winui3' can't be
fixed by installing pythonnet). Select the message based on forced_gui.
* [Screen] Add origin_scale so Screen.physical_x/y round-trips on mixed-DPI WinUI3 setups
get_screens() reports monitor origins using the primary monitor's shared
scale (so mixed-DPI secondaries don't overlap the primary — fixed
earlier), but each Screen still stored that monitor's own scale, and
physical_x/physical_y derived from x * scale. For a 200%-scaled secondary
beside a 100% primary, that meant physical_x for a screen at logical
x=1920 came out as 3840 instead of the true 1920.
Added an optional origin_scale parameter to Screen.__init__, defaulting to
scale when omitted, so every other backend's single-scale-per-monitor call
site (gtk.py, qt.py, cocoa.py, winforms.py) is unaffected. physical_x/y now
use origin_scale; physical_width/height still use scale, since a monitor's
own DPI is still what its content actually renders at. winui3.py's
get_screens() now passes origin_scale=primary_scale explicitly.
* Document the mixed-DPI physical_x/y exception; fix session cookies rendering "expires=None"
The origin_scale fix from the previous commit broke a documented, tested
public contract: docs/api/README.md said physical_x/physical_y always
equal x/y * scale, and tests/test_screens.py's test_screen_physical_pixels
asserts exactly that. It still holds for every backend by default (and for
screens[0] in CI's single-monitor environment), but no longer universally
once origin_scale differs from scale on a mixed-DPI WinUI3 setup. Updated
the docs to describe the exception, and added test_screen_origin_scale to
tests/test_screens.py covering both the default (origin_scale == scale)
and mixed-DPI cases directly against the Screen class.
Also: create_cookie() (webview/util.py) passed a session cookie's
expires=None straight through to the Morsel, which SimpleCookie.output()
then renders as the literal string "expires=None" instead of omitting the
attribute (a Morsel only treats '' as "omit this"). Affects any backend
that reports a session cookie with no expiry, including winui3.py's
_format_cookie_expiry(). Fixed by coercing None to '' before assignment,
plus two new tests in tests/test_util.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WebView2/Win32] Don't delete a caller's storage_path; fix stale mouse-hook HWND and 0-expiry cookies
1. clear_user_data() (webview2core.py) recursively deleted
self.user_data_folder whenever private_mode was true - but
winforms.py/winui3.py's init_storage() honors an explicit storage_path
even under private_mode (e.g. to reuse a location while still getting
WebView2's in-private browsing behavior), only falling back to a
throwaway temp directory when no storage_path is configured. Closing
the last private-mode window could therefore delete a caller's own,
possibly unrelated, directory. Guarded with the exact same condition
init_storage() uses to decide which path it took.
2. install_mouse_hook()'s wheel-forwarding (win32.py) cached the WebView2
input HWND indefinitely and never checked whether GetWindowRect/
PostMessageW actually succeeded. If WebView2 recreates that HWND (e.g.
after a navigation), both calls silently fail against the stale handle
while the hook still swallows the original event - permanently losing
scroll input for that window. Now invalidates the cache and lets the
original event through on either failure, so the next event re-resolves
the real HWND instead of failing forever.
3. create_cookie()'s expires fix from the previous commit used `or ''`,
which also normalizes 0 (a legitimate, falsy expiry - the Unix epoch,
used to expire a cookie immediately) into "no expiry". Changed to an
explicit `is None` check so only an actual missing expiry is coerced to
the empty Morsel value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3/GTK] Support .png window icons; guard remaining GTK sync APIs against reentrancy
1. winui3.py: AppWindow.set_icon() only accepts .ico, but the documented
public API (examples/icon.py) passes a .png, which every other backend
accepts directly - selecting WinUI3 with that example raised during
BrowserForm construction. Added _png_to_ico(), which wraps a PNG file's
raw bytes in a minimal ICONDIR/ICONDIRENTRY container (Windows has
accepted PNG-compressed icon images since Vista, so no re-encoding is
needed) and writes it to a temp file cleaned up at exit. Verified the
byte layout against a real generated PNG standalone. Non-.ico/.png
icons now log a warning and fall back to the default icon instead of
crashing.
2. gtk.py: the evaluate_js() reentrancy guard added earlier only covered
one of several APIs using the same glib.idle_add() + semaphore-wait
pattern. get_cookies(), get_current_url(), get_position(), get_size(),
create_confirmation_dialog(), and create_file_dialog() all have the
identical deadlock risk if called from a synchronous GTK callback (a
request_sent or before_load handler) - the idle callback can only run
once that same thread returns to the main loop. Added a shared
_raise_if_reentrant() guard and applied it to all six.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3] Reject oversized PNG icons instead of writing a malformed ICO; fix resized event dimensions
1. _png_to_ico()'s ICONDIRENTRY width/height are single bytes where 0
specifically means "exactly 256", not "256 or larger" - my previous fix
used `>= 256` to decide when to write 0, so a 512x512 (or any >256) PNG
got wrapped with a directory entry claiming 256x256 while the embedded
image was actually bigger. AppWindow.set_icon() can reject that
mismatch as malformed. The classic ICONDIRENTRY format has no valid
encoding for images above 256x256 at all, so these are now rejected
with a warning and fall back to the default icon instead of risking a
bad ICO, consistent with how unsupported icon formats are already
handled.
2. on_resize() emitted args.size (the XAML content area's size) for the
`resized` event, but resize()/get_size() work in terms of the outer
AppWindow.size - they disagree whenever title-bar/menu chrome changes
the difference between the two. Emit get_size()'s own value instead, so
events.resized always agrees with what a handler reading
window.get_size() would see.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3/GTK] Don't start async work before raising on reentrancy; fix screen tiling for any topology
1. Both evaluate_js() reentrancy guards (gtk.py, winui3.py) called the
script-starting callback *before* checking parse_json and raising for
the result-bearing case - so a script with side effects still ran even
though the caller received an exception and might retry it (risking
double execution). Reordered to check parse_json and raise first;
only the fire-and-forget run_js() path (parse_json=False) still starts
the script.
2. winui3.py's _run_dispatched() had the same shape of bug for dialogs:
when called reentrantly from the UI thread, it always invoked the
dispatched callback before checking whether the result was ready,
which for a genuinely async operation (ContentDialog.show_async(), a
WinRT picker's *_async()) meant a dialog could pop up on screen after
the caller had already received a "this failed" exception, with no way
to ever consume its result. Added a `may_complete_synchronously`
parameter callers set only when they know their specific callback
resolves synchronously (the Win32 IFileDialog fallback paths, which
pump their own modal loop) - when False, _run_dispatched now raises
before invoking the callback at all instead of after.
3. get_screens()'s "origin uses primary scale, size uses own scale" model
(from the previous fix) only prevented overlap when a neighboring
monitor's own scale was >= the primary's - a lower-scaled monitor
placed adjacent to a higher-scaled primary still overlapped it, since
origin and size were measured in two different reference scales.
Fixed by converting a screen's entire bounding box (x, y, width, AND
height) through the same single primary scale - a single linear
transform of true physical coordinates preserves every pairwise
adjacency regardless of topology, matching how WinForms' Screen.Bounds
already works for system-DPI-aware processes. Screen.physical_width/
height now use origin_scale too (previously only physical_x/y did),
matching width/height now also using the shared scale; Screen.scale/
dpi are unaffected and still report each monitor's own true DPI.
Added a regression test encoding the exact counter-example. Docs
updated to scope the mixed-DPI physical_x/y/width/height exception to
WinUI3 specifically (WinForms doesn't implement origin_scale).
Also wrote up a plausible but pre-existing, unrelated, and unverifiable-
here Android request_sent deadlock risk in memory rather than attempting
a blind fix to third-party android.runnable threading behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3] Sort get_screens() so the primary display is always first
webview.screens documents the primary display as the first element of the
returned list (docs/api/README.md:97), but DisplayArea.find_all() makes no
such ordering guarantee - on a system where its native enumeration order
starts with a secondary monitor, callers (and tests/test_screens.py, which
inspects index 0) would get the wrong screen. Sort by DisplayArea.is_primary
before building the Screen list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3] Fix sorted()-on-projected-collection crash; bypass close veto on creation failure; robust _scale
1. CRITICAL: last commit's get_screens() fix called sorted() directly on
DisplayArea.find_all()'s return value, but the module's own existing
comment says that exact projected collection "can't directly iterate"
(https://github.com/microsoft/microsoft-ui-xaml/issues/6454) - the
established workaround is index-based access, which sorted() bypasses
since it needs to iterate its input itself. This would break
webview.screens outright on affected projections. Fixed by
materializing into a real Python list via indexed access first, then
sorting that list - simplified the later loop to match.
2. _fail_child_window_creation() and create_window()'s create_guarded()
both close a failed child window via its normal .close() path, which
fires AppWindow's Closing event - the same handler a real user-facing
close goes through, including the pywebview `closing` event (a handler
can veto it) and the confirm_close dialog. A window that failed to
initialize must always be torn down, not left registered because a
closing handler cancelled it or a confirmation dialog is still waiting
on unrelated user input. Both now set the BrowserForm's
`_closing_confirmed` flag first - the same fast path on_closing already
uses once a close has been legitimately user-confirmed - to bypass the
veto/confirmation logic for this forced case.
3. _scale read GetDpiForWindow(self.handle) directly, but its return value
depends on the HWND's own effective DPI_AWARENESS: for a SYSTEM_AWARE
window it's a fixed system DPI regardless of which monitor the window
is actually on. setup_app() sets the process SYSTEM_DPI_AWARE via
SetProcessDPIAware() (an existing comment notes per-monitor-v2
conflicts with pywinrt's own internal DPI context there), so rather
than depend on whether pywinrt's window creation ends up overriding
that awareness for its own HWND, use get_monitor_scale() - already
written to give the correct answer under either awareness mode - on
the window's current physical bounds instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* Fix unclickable title-bar button in custom_title_bar example; add header-diff unit tests
examples/custom_title_bar.py: win.set_title_bar(title_bar) marked the whole
title bar Grid - including FullscreenButton - as the drag/caption region.
WinUI 3's title-bar hit testing treats the entire marked element's bounds
as non-client caption area regardless of what's drawn over it, so the
button's Click handler registered right below could never actually fire.
Fixed by wrapping only the draggable part (the TextBlock) in a new
DragRegion element spanning the columns to the button's left, and passing
that to set_title_bar() instead of the whole title bar - the button's own
column no longer overlaps the caption region at all.
tests/test_webview2core_header_diff.py: added unit tests for
WebView2Core._compute_request_header_diff(), which previously only had
integration-style coverage for adding a header (tests/test_request.py).
Covers removing a header, changing a value, and the case-sensitivity fix
from earlier this session (a casing-only rename like User-Agent ->
user-agent must appear in the "extra" set to re-apply, not "missing", since
WebView2 treats both spellings as the same header). Module-level imports
are limited to stdlib + pytest so the test file can always be collected;
the actual webview2core/win32 imports (Windows-only, ctypes.windll) are
deferred into the test helper and the whole module is skipped outside
Windows.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* Fix "SameSite=None" cookie serialization; close leaked native window on BrowserForm construction failure
1. create_cookie()'s samesite field had the exact same None-vs-'' issue
already fixed for expires: several backends (winui3.py, edgechromium.py)
can't distinguish "SameSite attribute absent" from "SameSite=None" in
the underlying platform cookie API and report both as Python None -
passing that straight to a Morsel renders the literal string
"SameSite=None" in Set-Cookie output, asserting a cross-site policy the
cookie may never have had. Coerce None to '' (the Morsel sentinel for
"omit this attribute") the same way expires already is. Added tests
for both the omitted and explicitly-set cases.
2. BrowserForm.__init__ creates a native WinRT Window as one of its first
steps (self.window = WinUIWindow()), well before most of what can
actually fail (XAML loading, WebView2 setup, icon handling, ...). If
__init__ raised after that point, the caller (create_guarded() in
create_window()) never sees a reference to the partially-constructed
`self` - Python discards it when __init__ raises - so it had no way to
close that leaked native window and its registered callbacks. Split
__init__ into a thin wrapper and the original body (now _construct()):
the wrapper catches any exception from _construct(), closes self.window
if it was already created (bypassing the closing event/confirm_close
veto path, same reasoning as _fail_child_window_creation), then
re-raises so create_guarded()'s own higher-level cleanup still runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* Preserve real SameSite=None cookie policy; install pytest-timeout for WinUI3 CI job
1. My previous commit's samesite fix went too far: create_cookie() coercing
None to '' is correct for genuinely-absent samesite info, but winui3.py/
edgechromium.py were themselves mapping WebView2's
CoreWebView2CookieSameSiteKind.NONE enum value to Python None before it
ever reached create_cookie() - and that enum value is not "unspecified".
Modern Chromium/WebView2 treats a cookie with no SameSite attribute as
implicitly Lax by default, so the NONE member is only ever reported for
a cookie that explicitly has SameSite=None set - a real, meaningful
policy, not an absence of one. Mapping it to Python None made
create_cookie() strip it entirely instead of reporting it. Fixed both
backends to report the string 'none' instead (matching how 'lax'/
'strict' are already derived from the same enum/its .NET equivalent),
reserving Python None in create_cookie()'s contract for callers that
truly have no samesite information at all (e.g. GTK, which doesn't set
the field).
2. .github/workflows/ci.yml's tests-winui3 job installs pytest but not
pytest-timeout, even though pyproject.toml configures a 60s per-test
timeout - pytest silently ignores that as an unknown option without the
plugin (confirmed locally: this exact "Unknown config option: timeout"
warning shows up in this sandbox's own venv, which also lacks the
plugin), so a single hanging test could consume the whole 30-minute job
budget instead of being killed after 60s. Added pytest-timeout,
matching the main test job's existing install step.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [GTK] Use replace() not append() for changed headers; [WinUI3] idempotent instances cleanup; docs gui= list
1. gtk.py's request header mutation used headers.append(k, v) for both
genuinely new headers and headers that already existed with a
different value - append() always adds a second value rather than
overriding the original, so a request_sent handler changing an
existing header (e.g. User-Agent) ended up sending both the original
and the new value instead of just the new one. Switched to replace(),
which adds the header if absent and overwrites it if present, matching
request_.headers' one-value-per-key model.
2. winui3.py's on_close did `del BrowserView.instances[self.uid]`, but a
form can reach on_close without ever having been added there: if
_construct() registers add_closed and then fails before returning, the
__init__ wrapper's failure-cleanup close() runs before create() ever
gets a chance to add the uid to `instances`. The bare del would KeyError
and abort the rest of on_close's cleanup (removing from `windows`,
firing `closed`), not just this one line. Made it idempotent via
.pop(self.uid, None).
3. docs/api/README.md's gui= parameter description only listed cef/qt/gtk,
missing mshtml/edgechromium/winui3 that the actual start() docstring
already documents. Synced the two.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3] Fix undersized initial window on HiDPI displays
BrowserForm._construct() computed the initial resize scale via
self._scale, which reads the freshly-constructed Window's own (not
yet placed) app_window position/size - meaningless before the window
has landed anywhere real, unlike every other geometry call in this
file which already uses _primary_monitor_scale() for this reason.
This under-scaled the initial size on any HiDPI display (e.g. a
Parallels VM on a Retina Mac), producing a visibly too-small window
compared to the WinForms backend.
Also extends the post-placement scale re-verification (previously
only done for the explicit initial_x/initial_y branch) to the
screen-centering branches too, and re-centers after a correction so a
centered window doesn't end up off-center by the size delta.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [Windows] Fix get_monitor_scale() always returning 1.0 on single-monitor systems
get_monitor_scale() assumed GetDpiForMonitor returns a fixed 96
(scale 1.0) for system-DPI-aware callers, and used a physical/logical
GetMonitorInfoW ratio as a fallback for that case. Verified empirically
on a real single-monitor, system-DPI-aware, 200%-scaled machine that
this assumption is wrong: GetDpiForMonitor correctly reports the true
DPI for any DPI-aware caller (system or per-monitor), and only falls
back to 96 for a fully DPI-unaware one - which never happens here,
since every caller of this helper calls SetProcessDPIAware() first.
The ratio fallback, meanwhile, is fundamentally broken for exactly the
monitor that defines system DPI (trivially the only monitor on any
single-monitor setup): a system-DPI-aware process sees that specific
monitor's bounds unvirtualized (physical == logical), so the ratio is
always 1.0 there regardless of the real scale.
This made every WinUI3 window undersized on any single-monitor HiDPI
machine (reported: a window built at 800x600 logical came out at
800x600 *physical* pixels on a 200%-scaled display, because the
resize() call's scale factor silently came out as 1.0 instead of 2.0).
It also affected WinForms' get_screens()/webview.screens() scale
reporting on the same class of machine, though not WinForms' own
window sizing (Form.Size scaling is handled by .NET directly, not by
this helper).
Simplified to just call GetDpiForMonitor directly and drop the now
provably-wrong ratio fallback and awareness-branching entirely.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3] Force process exit if dispatcher queue is gone after private-mode cleanup
clear_user_data() can block for several seconds waiting on the browser
process handle before deleting the temp folder. By the time it returns,
the WinUI runtime can already be tearing its dispatcher queue down
(the last window just closed), so the deferred Application.current.exit()
call silently fails to enqueue and Application.start() never unblocks -
the process hangs instead of exiting. Fall back to os._exit(0) in that
case; cleanup has already completed, so there's nothing left to lose by
skipping a graceful WinRT shutdown that can no longer happen anyway.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
* [WinUI3] Fix private-mode cleanup thread hang without force-killing the process
The previous fix (50ed1fa) used os._exit(0) as a fallback when the
deferred Application.current.exit() couldn't be enqueued - but that
kills the whole interpreter, breaking the contract that control returns
to the caller's script after webview.start() finishes.
Instead, call exit_application() synchronously and immediately from
on_close (on the UI thread, before the race with dispatcher-queue
teardown can happen at all), and move only the slow clear_user_data()
work to a background thread. create_window()'s master-window `finally`
now joins that thread before returning, so cleanup still always
completes before control reaches the caller's script, without needing
to re-enter a dispatcher queue that may already be shutting down.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QHqNyRQfCqs6gmVzFKFnmh
---------
Co-authored-by: Stewart <39267436+ZTStew@users.noreply.github.com>
Co-authored-by: David Lechner <david@pybricks.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> R
Roman committed
f6dc52a1d94f1eed9de4fb53781cf06bfec0fbb6
Parent: afbea2e
Committed by GitHub <noreply@github.com>
on 9/9/2026, 8:30:36 PM