diff --git a/pywry/docs/docs/components/index.md b/pywry/docs/docs/components/index.md index b4346ac..6c7b84b 100644 --- a/pywry/docs/docs/components/index.md +++ b/pywry/docs/docs/components/index.md @@ -101,7 +101,7 @@ All interactive components emit events via the `event` parameter: ```python Button(label="Save", event="file:save") -Select(label="Theme", event="settings:theme", ...) +Select(label="Theme", event="settings:theme", options=[...]) ``` Use namespaced events like `category:action` for organization: diff --git a/pywry/docs/docs/getting-started/why-pywry.md b/pywry/docs/docs/getting-started/why-pywry.md index 04d40c3..408e1c7 100644 --- a/pywry/docs/docs/getting-started/why-pywry.md +++ b/pywry/docs/docs/getting-started/why-pywry.md @@ -70,7 +70,7 @@ Native windows open in under a second. There's no server to spin up, no browser PyWry isn't just for prototyping and single-user applications: - **Deploy Mode** with Redis backend for horizontal scaling and RBAC -- **0Auth2"** authentication system for both native and deploy modes +- **OAuth2** authentication system for both native and deploy modes - **Token authentication** and CSRF protection out of the box - **CSP headers** and security presets for production environments - **TOML-based configuration** with layered precedence (defaults → project → user → env vars) diff --git a/pywry/docs/docs/guides/window-management.md b/pywry/docs/docs/guides/window-management.md index 802f7c8..0939988 100644 --- a/pywry/docs/docs/guides/window-management.md +++ b/pywry/docs/docs/guides/window-management.md @@ -149,7 +149,7 @@ handle = app.show( ## The Window Handle -`app.show()` returns a handle object. In native mode this is a `WindowProxy` — a full-featured wrapper around the OS window. In browser/notebook mode it's an `InlineWidget` with a subset of the same interface. +`app.show()` returns a `NativeWindowHandle` in native mode — a wrapper that provides the same `emit()` / `on()` interface as notebook widgets, plus access to the full OS window API via its `.proxy` attribute. In browser/notebook mode it's an `InlineWidget` with the same event interface. ### Common Methods (all modes) @@ -158,10 +158,14 @@ handle = app.show( | `handle.emit(event, data)` | Send an event from Python to the window's JavaScript | | `handle.on(event, callback)` | Register a Python callback for events from the window | | `handle.label` | The window/widget label | +| `handle.alert(message, ...)` | Show a toast notification | +| `handle.set_toolbar_value(id, value, ...)` | Update a toolbar component's value | +| `handle.set_toolbar_values({id: value, ...})` | Update multiple toolbar components | +| `handle.request_toolbar_state()` | Request current toolbar state | ### Native-only Methods (WindowProxy) -The `WindowProxy` exposes the full set of OS window operations: +The `WindowProxy` (accessible via `handle.proxy`) exposes the full set of OS window operations: **Window state:** @@ -187,28 +191,27 @@ The `WindowProxy` exposes the full set of OS window operations: | `set_resizable(bool)` | Toggle resizing | | `set_theme(ThemeMode)` | Change theme | -**Read-only properties:** +**Read-only properties (via `handle.proxy`):** ```python -handle.title # Current title -handle.inner_size # Content area size (PhysicalSize) -handle.outer_size # Window frame size -handle.inner_position # Content position on screen -handle.is_fullscreen # bool -handle.is_maximized # bool -handle.is_focused # bool -handle.is_visible # bool -handle.current_monitor # Monitor info (name, size, position, scale) +handle.proxy.inner_size # Content area size (PhysicalSize) +handle.proxy.outer_size # Window frame size +handle.proxy.inner_position # Content position on screen +handle.proxy.is_fullscreen # bool +handle.proxy.is_maximized # bool +handle.proxy.is_focused # bool +handle.proxy.is_visible # bool +handle.proxy.current_monitor # Monitor info (name, size, position, scale) ``` **JavaScript execution:** ```python # Fire-and-forget -handle.eval("document.title = 'Hello'") +handle.eval_js("document.title = 'Hello'") -# With return value (blocks up to timeout) -result = handle.eval_with_result("document.querySelectorAll('li').length", timeout=5.0) +# With return value (blocks up to timeout, via proxy) +result = handle.proxy.eval_with_result("document.querySelectorAll('li').length", timeout=5.0) ``` ## Updating Content diff --git a/pywry/docs/docs/reference/events.md b/pywry/docs/docs/reference/events.md index 1f9e3c8..b0fc52a 100644 --- a/pywry/docs/docs/reference/events.md +++ b/pywry/docs/docs/reference/events.md @@ -11,7 +11,7 @@ All events follow the `namespace:event-name` pattern: | namespace | Starts with letter, alphanumeric | `app`, `plotly`, `grid`, `myapp` | | event-name | Starts with letter, alphanumeric + hyphens | `click`, `row-select`, `update-data` | -**Reserved namespaces:** `pywry:*`, `plotly:*`, `grid:*`, `toolbar:*` +**Reserved namespaces:** `pywry:*`, `plotly:*`, `grid:*`, `toolbar:*`, `auth:*` --- @@ -187,6 +187,53 @@ All events follow the `namespace:event-name` pattern: --- +## Auth Events (auth:*) + +The `auth:*` namespace is used by the built-in OAuth2 authentication system. +Events flow in both directions: the frontend can request login/logout, and the +backend notifies the frontend when auth state changes (e.g. after a token +refresh or successful logout). + +!!! note "Availability" + Auth events are only active when `PYWRY_DEPLOY__AUTH_ENABLED=true` and a + valid `PYWRY_OAUTH2__*` configuration is present. In native mode the full + flow is handled by `app.login()` / `app.logout()` — these events apply to + the frontend integration via `window.pywry.auth`. + +### Auth Requests (JS → Python) + +| Event | Payload | Description | +|-------|---------|-------------| +| `auth:login-request` | `{}` | Frontend requests a login flow (calls `window.pywry.auth.login()`). In native mode the backend opens the provider's authorization URL; in deploy mode it redirects to `/auth/login`. | +| `auth:logout-request` | `{}` | Frontend requests logout (calls `window.pywry.auth.logout()`). The backend revokes tokens, destroys the session, and emits `auth:logout` back. | + +### Auth Notifications (Python → JS) + +| Event | Payload | Description | +|-------|---------|-------------| +| `auth:state-changed` | `{authenticated, user_id?, roles?, token_type?}` | Auth state changed (login succeeded or session expired). When `authenticated` is `false`, `window.__PYWRY_AUTH__` is cleared. | +| `auth:token-refresh` | `{token_type, expires_in?}` | Access token was refreshed in the background. Updates the current session without requiring re-login. | +| `auth:logout` | `{}` | Server-side logout completed. Clears `window.__PYWRY_AUTH__` and notifies registered `onAuthStateChange` handlers. | + +**`auth:state-changed` payload detail:** + +```python +{ + "authenticated": True, + "user_id": "user@example.com", # sub / id / email from userinfo + "roles": ["viewer", "editor"], # from session roles list + "token_type": "Bearer" # OAuth2 token type +} +``` + +When `authenticated` is `false` only the key itself is present: + +```python +{"authenticated": False} +``` + +--- + ## Component Event Payloads Every toolbar component emits its custom event with these payloads: @@ -271,6 +318,40 @@ window.__PYWRY_TOOLBAR__.getValue("component-id") // Get value window.__PYWRY_TOOLBAR__.setValue("component-id", value) // Set value ``` +### Auth Globals (window.pywry.auth) + +When `auth_enabled=True` the `auth-helpers.js` script is injected and the +`window.pywry.auth` namespace becomes available. + +```javascript +// Check authentication state +window.pywry.auth.isAuthenticated() // boolean + +// Get the full auth state +window.pywry.auth.getState() +// Returns: { authenticated, user_id, roles, token_type } + +// Trigger OAuth2 login flow (emits auth:login-request to Python) +window.pywry.auth.login() + +// Trigger logout (emits auth:logout-request to Python) +window.pywry.auth.logout() + +// React to auth state changes (from auth:state-changed / auth:logout events) +window.pywry.auth.onAuthStateChange(function(state) { + if (state.authenticated) { + console.log("Logged in as", state.user_id, "with roles", state.roles); + } else { + console.log("Logged out"); + } +}); +``` + +**`window.__PYWRY_AUTH__`** is injected server-side for authenticated requests and +contains `{ user_id, roles, token_type }`. Use `window.pywry.auth.getState()` +rather than reading it directly — the helper normalizes the value and handles +the unauthenticated case. + ### Tauri Access (Native Mode Only) In native desktop mode, Tauri APIs are available: diff --git a/pywry/tests/test_window_proxy.py b/pywry/tests/test_window_proxy.py index 473c180..14dde26 100644 --- a/pywry/tests/test_window_proxy.py +++ b/pywry/tests/test_window_proxy.py @@ -12,7 +12,9 @@ import sys import time -from typing import Any +from collections.abc import Callable +from functools import wraps +from typing import Any, TypeVar import pytest @@ -28,6 +30,39 @@ from tests.conftest import ReadyWaiter +F = TypeVar("F", bound=Callable[..., Any]) + + +def retry_on_subprocess_failure(max_attempts: int = 3, delay: float = 1.0) -> Callable[[F], F]: + """Retry decorator for tests that may fail due to transient subprocess issues. + + On Windows, WebView2 sometimes fails to start due to resource contention + ("Failed to unregister class Chrome_WidgetWin_0"). On Linux with xvfb, + WebKit initialization may have timing issues. This decorator retries + the test after a delay to allow resources to be released. + """ + + def decorator(func: F) -> F: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + last_error: Exception | None = None + for attempt in range(max_attempts): + try: + return func(*args, **kwargs) + except (TimeoutError, AssertionError) as e: + last_error = e + if attempt < max_attempts - 1: + # Clean up and wait before retry + runtime.stop() + # Progressive backoff for CI stability + time.sleep(delay * (attempt + 1)) + raise last_error # type: ignore[misc] + + return wrapper # type: ignore[return-value] + + return decorator + + # Note: cleanup_runtime fixture is now in conftest.py and auto-used @@ -273,6 +308,7 @@ def test_hide_show(self) -> None: assert proxy.is_visible is True app.close() + @retry_on_subprocess_failure(max_attempts=3, delay=1.0) @pytest.mark.skipif( os.environ.get("CI") == "true" and sys.platform == "linux", reason="Always-on-top requires a real window manager (not available on Linux CI)",