Skip to content
src.wesktop.desktop
Edit
On this page

Native desktop window via pywebview, backed by a detached Granian ASGI server, with cross-process window refcounting and automatic server lifecycle.

#src.wesktop.desktop

#src.wesktop.desktop

Native desktop window via pywebview, backed by a detached Granian ASGI server, with cross-process window refcounting and automatic server lifecycle.

Process-group / coordination story ---------------------------------- wesktop group-manages exactly ONE child: the detached server subprocess spawned by serve_background (which becomes its own process-group leader so stop can signal the whole group and reap granian workers). wesktop does NOT own the renderer child processes -- pywebview spawns and owns those (WebKitGTK/WebView2/ Cocoa) inside webview.start().

Because a single wesktop process can neither see nor count another wesktop process's windows, window lifecycle is coordinated through the filesystem, not in-process state:

  • Window markers (kind="window" in the fastware instance registry): one

marker file per open native window, carrying {pid, window_id}. Written before webview.start() and removed after it returns. The live-marker count (dead PIDs pruned by kill -0) is the true number of open windows across ALL wesktop processes sharing this app's server. The server is stopped only when zero live window markers remain after this process's window closes -- so process A closing its last window never kills the server under process B's still-open window.

  • Focus-request markers (kind="focus-request"): a platform-neutral,

file-based focus signal. With second_open="focus-existing", a second launch drops a focus-request marker and exits; the window-owning process runs a ~1s daemon poll that consumes the request and raises its window. No DBus, no AppleEvents.

  • Registry entries (list_instances): the detached server registers its

own {pid, port, name} descriptor. Together the registry entry and the live window markers are the cross-process enumeration surface (see :func:list_app_instances).

#_wire_runtime_bridge

python
def _wire_runtime_bridge(window: object, url: str) -> None

Best-effort host-side update wiring for a native window.

Captures the window and, where pywebview exposes a focus event, polls /__fastware/version on focus to reload on a changed build id. This is a no-op on pywebview builds without a focus event -- native windows load the same page + client.js, which is the primary (poll-free) update path.

#_app_url

python
def _app_url(host: str, port: int) -> str

Compose the same-origin packaged app URL from host and port.

The single source of truth for the URL an app window loads. In the join path the port comes from the port file; in the new-server path serve_background returns the same http://host:port form.

#_version_url

python
def _version_url(url: str) -> str

The fastware version endpoint for a given app URL.

#_port_from_url

python
def _port_from_url(url: str) -> int

Extract the TCP port from an http://host:port URL.

#_startup_handshake

python
def _startup_handshake(url: str, *, timeout: float=5.0) -> str | None

Fetch /__fastware/version after window creation; loud stderr on failure.

Returns the observed build id, or None if the server is unreachable or the payload is malformed within timeout. The window is NEVER torn down on failure -- the user's window stays -- but the failure is logged loudly to stderr so it is unmissable.

#_inject_runtime_config

python
def _inject_runtime_config(window: object, build_id: str | None, port: int, app_name: str) -> bool

Best-effort: set window.__wesktop = {buildId, port, appName} via JS.

Runtime-config injection is best-effort by nature -- pywebview's evaluate_js timing depends on the page being loaded. Retries ONCE on failure. Returns True if a call succeeded, False otherwise.

#_wire_runtime_config_injection

python
def _wire_runtime_config_injection(window: object, build_id: str | None, port: int, app_name: str) -> None

Wire runtime-config injection to the window's loaded event if present.

Injecting after page load is the reliable moment; when pywebview exposes no loaded event the injection is attempted immediately (best-effort).

#_raise_window

python
def _raise_window(window: object) -> None

Best-effort raise-to-front of window.

Calls restore() (un-minimize) then show(). Raising a window ABOVE other applications' windows is window-manager dependent and not guaranteed on every platform -- this is the documented limitation of the platform- neutral, file-based focus signal.

#_request_focus_existing

python
def _request_focus_existing(pid_path: Path, existing_pid: int) -> None

Drop a focus-request marker for the window-owning process and return.

Platform-neutral: the joining process writes a marker into the instance- registry dir and exits; the owning process's focus poll consumes it and raises its window.

#_install_focus_request_poll

python
def _install_focus_request_poll(window: object, pid_path: Path, stop_event: threading.Event, *, interval: float=1.0) -> threading.Thread

Poll for focus-request markers while the window is open; raise on request.

Runs a lightweight daemon thread that, every interval seconds until stop_event is set, consumes any focus-request markers (deleting them, even those owned by other/dead PIDs) and raises this window.

#AppInstances

A snapshot of an app's live server + windows from the registry.

#list_app_instances

python
def list_app_instances(pid_path: Path) -> AppInstances

Enumerate the app's live server instance(s) and open window markers.

Reads the fastware instance registry for pid_path: servers are the registered server descriptors (RegistryEntry); windows are the live per-window marker payloads (dicts with pid and window_id). Stale entries are pruned by the underlying registry reads.

#_require_webview_gui

python
def _require_webview_gui() -> object

Import pywebview and verify a GUI backend, returning the webview module.

Raises RuntimeError with an actionable message if pywebview is not installed or no GUI backend is available. Only called on paths that actually open a native window (the focus-existing early exit needs neither).

#WindowChrome

How the native window is drawn: its frame, shape, stacking and geometry.

Every field is forwarded verbatim to webview.create_window and every default is pywebview's own, so a bare WindowChrome() opens exactly the window wesktop opened before this existed.

The two fields an app reaches for when it wants its own shape rather than a rectangle in an OS frame:

  • frameless removes the title bar and border. The page then draws its

own chrome, and easy_drag (on by default) lets a press anywhere that is not an interactive element move the window.

  • transparent makes the window's own background see-through, so the

page's rounded corners, shadows and cut-outs are the window's silhouette instead of sitting on an opaque rectangle. The page must ask for it too: html, body { background: transparent }, since an opaque page paints over a transparent window.

Platform truth for transparent: honoured by the GTK/WebKit backend on Linux (a compositor supplying an RGBA visual) and by Cocoa on macOS; the Windows Edge WebView2 backend ignores it and paints background_color.

zoomable is enforced by wesktop rather than merely forwarded. pywebview stores the flag and its GTK backend never reads it, so a touchpad pinch or a ctrl+scroll rescales the page of every GTK window whatever the flag says. With zoomable=False (the default) wesktop refuses those events and pins the page's zoom level at 1.0.

#as_window_kwargs

python
def as_window_kwargs(self) -> dict[str, object]

The chrome as webview.create_window keyword arguments.

#_create_window

python
def _create_window(webview: object, *, title: str, url: str, width: int, height: int, js_api: object | None, chrome: WindowChrome) -> object

Create the native window. The ONE call site, so join and new-server cannot drift.

#HeadlessApp

A running wesktop app with no window: the server, and where to reach it.

#headless

python
def headless(target: str | Callable, *, host: str='127.0.0.1', port: int=0, pid_path: Path | None=None, name: str='WESKTOP')

Run the app with no window at all, and stop it on the way out.

This is the shape a test wants. run() needs a GUI backend, opens a real window, and blocks until a human closes it -- none of which a test can do. headless() starts the same server the same way, hands back the URL it is listening on, and stops it when the block ends, including when the block raises. pywebview is never imported, so this works on a machine with no display, in CI, and over ssh.

::

with wesktop.headless("myapp:app") as app: assert httpx.get(f"{app.url}/api/health").json()["status"] == "ok"

The port defaults to 0, meaning a free one is chosen -- so two tests running at once do not collide. With no pid_path the run gets a private temporary one, which is removed with the server; passing a real one makes the run visible to status() and stop() like any other instance.

#active_window

python
def active_window() -> object | None

The most recently created native window, or None before there is one.

run() blocks in webview.start() and returns nothing, so a background thread that has to reach the live window -- to close it on a decision taken elsewhere, to reshape it -- needs a way to ask for it. This is that way.

#_gtk_toplevel

python
def _gtk_toplevel(window: object) -> object | None

The GTK toplevel behind a pywebview window handle, or None on another backend.

#_require_gtk_toplevel

python
def _require_gtk_toplevel(window: object, operation: str) -> object

The GTK toplevel for window, or a RuntimeError naming what is unsupported.

#set_input_region

python
def set_input_region(window: object, rects: list[tuple[int, int, int, int]] | None) -> None

Restrict the pointer input the window accepts to rects.

A transparent window is still a solid rectangle to the pointer: the parts the page draws nothing on go on swallowing clicks, so whatever is behind them cannot be reached. This hands the compositor an input region instead, so a click outside rects lands on whatever is underneath.

rects are (x, y, width, height) in window coordinates -- the same coordinates getBoundingClientRect() reports to the page. A shape with a diagonal or a hole is approximated by listing several rectangles. None restores the default: the whole window accepts input.

GTK backend only (X11 and Wayland alike). On any other backend this raises rather than quietly leaving the window solid.

#begin_window_drag

python
def begin_window_drag(window: object, button: int=1) -> None

Ask the window manager to start an interactive move of the window.

This is what a frameless window needs to be draggable, and it is not what pywebview's easy_drag does: easy_drag repositions the window itself with gtk_window_move, which a Wayland compositor ignores outright -- a Wayland client cannot place its own surfaces. Handing the move to the compositor works on Wayland and X11 alike.

Call it from a pointer-press the page reports (a js_api method reached as pywebview.api.<name>()), while the implicit pointer grab from that press is still the seat's most recent one.

GTK backend only. On any other backend this raises.

#_gtk_web_view

python
def _gtk_web_view(window: object) -> object | None

The WebKit view inside a pywebview window, or None on another backend.

#capture_window

python
def capture_window(window: object, path: str | Path, *, timeout: float=10.0) -> Path

Save a PNG of what THIS window is showing, and nothing else.

The image comes from the web view's own snapshot, so it contains this window's rendered page and no pixel of anyone else's: it is not a screen grab, it cannot see another application, and it needs no screen-capture permission. The window need not even be on top.

The snapshot keeps its alpha, so a transparent window's empty parts are transparent in the PNG rather than filled with whatever was behind them.

Blocks until the snapshot arrives or timeout elapses, and returns the path written. Must NOT be called from the GTK main thread -- the snapshot completes on that thread, so waiting there would deadlock; call it from a background thread, a js_api method or a window event handler.

GTK backend only. On any other backend this raises.

#_appearance_portal

python
def _appearance_portal() -> object | None

A proxy for the desktop's appearance settings, or None where there is none.

None also covers "this is not a GTK backend", since PyGObject is what the GTK backend brings; the other backends have no use for this at all.

#desktop_color_scheme

python
def desktop_color_scheme() -> str

The desktop's light/dark preference: "dark", "light" or "no-preference".

Read from the XDG desktop portal's org.freedesktop.appearance color-scheme setting, which every current desktop publishes and which says nothing about any particular toolkit. A session with no portal, or one that declines to answer, is "no-preference" -- the same answer as a desktop that genuinely has no preference, because from here they are the same fact: nothing said which to use.

#_apply_gtk_color_scheme

python
def _apply_gtk_color_scheme(scheme: str) -> None

Tell GTK which scheme to paint, which is what WebKit reports to the page.

#_install_theme_follow

python
def _install_theme_follow(window: object) -> None

Make the window follow the desktop's light/dark preference, and keep following.

A GTK3 WebKit window reports prefers-color-scheme: light to its page forever, whatever the desktop is set to: WebKitGTK derives the media feature from GTK's own gtk-application-prefer-dark-theme, and nothing sets that from the desktop's preference for a plain GTK3 application. So a page that honours prefers-color-scheme -- which is every page that follows the system -- renders light on a dark desktop.

The preference is read from the portal and applied to GTK, and the portal's change signal is followed, so a desktop switched from light to dark while the window is open takes the window with it. CSS media queries are live, so the page re-renders without reloading.

Only the GTK backend needs any of this. Cocoa and Edge WebView2 report the system preference to the page on their own, so where PyGObject is absent -- which is exactly where the backend is not GTK -- there is nothing to install and this does nothing.

#_install_zoom_lock

python
def _install_zoom_lock(window: object) -> bool

Hold the page at 1:1 on the GTK backend. Returns whether the lock went on.

pywebview accepts zoomable=False and its GTK backend never reads it: the flag is stored on the window and nothing consults it, so a touchpad pinch or a ctrl+scroll rescales the page of every GTK window regardless. An app whose window IS its layout -- a frameless dialog sized to its own content -- has no use for a reader-controlled zoom, and asked for it to be off.

Two mechanisms, because they answer different questions. The events that ask for a zoom are refused, so nothing visibly moves; and the zoom level itself is pinned, so anything that reaches it another way is undone.

#_wire_zoom_lock

python
def _wire_zoom_lock(window: object, zoomable: bool) -> None

Install the zoom lock once the page is loaded, unless the app wants zoom.

#_wire_capture

python
def _wire_capture(window: object, capture_to: Path | None, delay: float) -> None

Arrange for the window to save a PNG of itself once its page has loaded.

The loaded event fires when the document is loaded, which is a beat before the first paint, so the capture waits delay seconds after it. The capture runs on a background thread because it blocks on a result the GTK main loop has to deliver.

#_run_window

python
def _run_window(webview: object, window: object, url: str, pid_path: Path, port: int, app_name: str, icon: str | None) -> None

Manage a single native window's full lifecycle around webview.start().

Writes a per-window marker (cross-process refcount), runs the startup handshake, wires the runtime bridge + runtime-config injection + focus- request poll, blocks in webview.start(), then removes the marker and stops the server only when zero live window markers remain.

#ensure_gui_backend

python
def ensure_gui_backend() -> bool

Report whether a native pywebview GUI backend is available, truthfully per platform.

On Linux, this additionally makes the system PyGObject importable in isolated venvs: if gi is not importable, common system site-packages locations are searched and the first one found is added to sys.path.

#_has_gui_backend

python
def _has_gui_backend() -> bool

Probe whether pywebview can load a GUI backend.

Non-Linux platforms delegate to ensure_gui_backend(), which reports availability truthfully per platform. On Linux, honours the PYWEBVIEW_GUI env var and probes GTK first (via ensure_gui_backend, which also makes system PyGObject importable in isolated venvs), then Qt.

#_default_pid_path

python
def _default_pid_path(name: str) -> Path

Stable per-app PID file path under the platform runtime/state dir.

A CWD-relative default would defeat single-instance detection when the app is launched from different directories.

#_launch_command_parts

python
def _launch_command_parts() -> list[str]

Reconstruct a runnable command line (as argv parts) for this process.

Handles the python -m pkg case, where sys.argv[0] is the package's __main__.py (a module file, not an executable): rebuilds sys.executable -m pkg instead.

#_auto_register_entry

python
def _auto_register_entry(title: str, icon: str | None) -> None

Create a desktop entry for this app if one doesn't exist.

On Linux/macOS a launcher script is created in ~/.local/bin and the entry points at it; this also self-heals: if an existing entry points to a missing launcher (e.g. the package was reinstalled to a different venv), the broken entry is removed and recreated with the current launcher path. On Windows the Start Menu shortcut points directly at the target -- a POSIX shell script cannot execute there.

#run

python
def run(target: str | Callable, *, title: str='wesktop', width: int=1280, height: int=800, icon: str | None=None, host: str | None=None, port: int | None=None, pid_path: Path | None=None, name: str='WESKTOP', pre_serve: Callable[[], None] | None=None, reload: bool=False, js_api: object | None=None, chrome: WindowChrome | None=None, capture_to: str | Path | None=None, capture_delay: float=0.6, follow_system_theme: bool=True, single_instance: bool=True, second_open: str='new-window') -> None

Start server + open native desktop window. Blocks until window closes.

The server runs as a detached subprocess (see serve_background), so pre_serve and reload cannot work here and are hard errors: pre_serve would run in this process while the server re-imports the target in another, and a file watcher cannot restart the detached server. Use :func:wesktop.serve for both.

chrome is the window's frame, shape, stacking and geometry (see :class:WindowChrome). Omitted, it is WindowChrome() -- an ordinary decorated, opaque OS window. A frameless, transparent window whose page draws its own silhouette is chrome=WindowChrome(frameless=True, transparent=True).

follow_system_theme makes the window report the desktop's light/dark preference to its page as prefers-color-scheme, and keep reporting it when the desktop changes. Without it a GTK3 window says "light" forever -- see :func:_install_theme_follow. Pass False for an app that pins its own appearance.

capture_to saves a PNG of the window once its page has loaded (see :func:capture_window) -- the image is this window's own rendering, never a screen grab. It is what an app wires its own --capture <path> flag to. capture_delay is the settle time between the load event and the snapshot.

second_open selects what happens on a second launch while an instance is already running (single-instance join). It must be chosen explicitly from:

  • "new-window" (default): open an additional native window joined to the

existing server. Windows are refcounted across processes via marker files; the server stops only when the last window (in any process) closes.

  • "focus-existing": do NOT open a new window. Drop a platform-neutral

focus-request marker and exit; the process that owns the window raises it via a ~1s file-based poll. Raising above other apps is WM-dependent.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
Search