Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pywry/docs/docs/components/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pywry/docs/docs/getting-started/why-pywry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 18 additions & 15 deletions pywry/docs/docs/guides/window-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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:**

Expand All @@ -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
Expand Down
83 changes: 82 additions & 1 deletion pywry/docs/docs/reference/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:*`

---

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 37 additions & 1 deletion pywry/tests/test_window_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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


Expand Down Expand Up @@ -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)",
Expand Down