Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dry

Dry is a Python library that opens one native window rendering a web frontend, plus a Bridge between that frontend and Python. It is written in Rust on top of Wry, ships as a compiled extension module, and depends on nothing but the Python standard library.

from dry import Webview

wv = Webview(
    app_id='com.example.hello',
    title='Hello',
    html='<h1>Hello from Dry</h1>',
)
wv.run()

wv.run() opens the window and does not come back. Closing the window exits the process, so nothing written after it runs.

What is in scope

Anything a frontend legitimately needs from its host window: the window itself, its titlebar and edges, what it renders, and a channel to Python. General application concerns — filesystem access, databases, HTTP clients — are not. Those are Python’s, and the developer wires them through the Bridge.

The shape of the library

There is one public object, the Webview, and one channel, the Bridge. The Bridge carries exactly two message shapes, and both travel in both directions:

Frontend to PythonPython to frontend
Call — returns a valuewindow.dry.api.name(...)deliberately absent; use wv.eval_js
Event — returns nothingwindow.dry.emit(name, value)wv.emit(name, value)

The missing quadrant is a decision, not an omission: a Python-side await on the frontend never resolves if the page navigates or hangs. When the answer matters, have the frontend Call Python.

Where to go next

The vocabulary used throughout this site — Webview, Bridge, Call, Event, Api, Portal, Content, Root, Drag region, Resize edge, Bridge contract, Close hook, App id — is defined in CONTEXT.md and used with exactly those meanings.

Installing

pip install dry-webview
uv add dry-webview

Dry has no Python dependencies. The wheel carries the compiled extension module, so there is no Rust toolchain to install and nothing to build.

Python

CPython 3.14 or newer. Dry ships stable-ABI (abi3-py314) wheels, one per platform rather than one per Python version, so the same wheel keeps working on every later CPython. The floor buys two things the library relies on: modern typing with no typing_extensions dependency, and PEP 649 deferred annotations, which are what let Dry read a callback’s declared signature and report save_file expects str for path, received number instead rather than a raw failure from inside the Bridge. See ADR-0003.

Free-threaded builds (python3.14t) cannot install Dry: abi3 does not cover them. That changes when abi3t arrives with Python 3.15.

Platforms

PlatformWheelStatus
Windows (x86-64)win_amd64Built and tested on every commit
macOS (Intel and Apple silicon)macosx_universal2Built and tested on every commit
LinuxnoneNot supported

Both platforms run the full test suite in CI, including tests that open a real window and drive it. Linux has no wheel, no CI job and no backend work: it is not that it is untested, it is that it is not there.

Dry uses the platform’s own web engine — WebView2 on Windows, WKWebView on macOS — so the rendering engine is the one the operating system ships and updates.

From source

Building needs a Rust toolchain and maturin:

git clone https://github.com/barradasotavio/dry
cd dry
uv run maturin develop --uv

That compiles the extension module and installs it into the environment, after which python examples/minimal.py opens a window.

Your first Webview

from dry import Webview

wv = Webview(
    app_id='com.example.hello',
    title='Hello',
    size=(900, 600),
    html='<h1>Hello from Dry</h1>',
)
wv.run()

Three things are worth knowing before you write anything longer than this.

Every option is a keyword argument

Webview() takes keyword arguments only. Your editor lists what there is, and a typo raises instead of quietly creating an attribute that never applies:

wv = Webview(html='<h1>Hi</h1>')
wv.titel = 'My App'
# AttributeError: 'Webview' object has no attribute 'titel' and no __dict__
# for setting new attributes. Did you mean: 'title'?

Every option is also a property, for the values you only work out later:

wv = Webview(app_id='com.example.hello')
wv.title = f'Report — {report.name}'
wv.html = render_page(report)
wv.run()

Most settings are read once, while the window is being built. Assigning one of those after run() raises a RuntimeError naming it rather than doing nothing — see Window options for which is which.

run() never returns

wv.run() hands the main thread to the platform’s event loop, which does not give it back: closing the window exits the process from inside that loop.

wv.run()
print('goodbye')  # never printed

Nothing after run() executes, and a finally: wrapped around it does not run either. Work that has to happen on the way out belongs in a close hook, in an atexit handler — Dry runs those itself before the process goes — or in a finally: inside a callback.

The corollary is that an application cannot make asyncio.run(main()) its entry point. Dry runs an asyncio loop of its own, on a background thread, and your async def code lives inside Api callables and Event listeners. See The Portal and ADR-0001.

Give it an App id

wv = Webview(app_id='com.example.hello', html=HTML)

The App id decides where cookies, local storage and cache are kept. Leave it out and one is derived from your entry-point script, which is enough to develop against but moves when the script moves. Declare your own before you ship: Where your data lives.

Talking to Python

Give the Webview an api, and the frontend can Call it:

from dry import Webview

HTML = """
<button onclick="greet()">Greet</button>
<p id="out"></p>
<script>
  async function greet() {
    out.textContent = await window.dry.api.hello('World');
  }
</script>
"""


def hello(name: str) -> str:
    return f'Hello, {name}!'


wv = Webview(app_id='com.example.hello', html=HTML, api={'hello': hello})
wv.run()

That is the whole of the Bridge’s Call half. Calls covers what happens when the callable is slow, raises, or is declared async def.

The three Content modes

A Webview renders exactly one Content, declared as exactly one of three mutually exclusive modes.

from pathlib import Path

from dry import Webview

# An HTML string
Webview(html='<h1>Hello, World!</h1>')

# A URL
Webview(url='http://localhost:8000')

# A Root: a local directory, served starting at its index.html
Webview(root=Path(__file__).parent / 'dist')

There is no sniffing. Dry does not look at a string and decide whether it is markup, a path or an address; you say which it is, and the mode you named is the mode you get.

Declaring two, or none

Declaring a second mode raises as soon as you do it, naming the conflict and how to resolve it:

wv = Webview(html='<h1>Hi</h1>')
wv.url = 'https://example.com'
# ValueError: Content is already declared as html, so it cannot also be
# declared as url. A Webview renders exactly one of html, url or root.
# Set webview.html = None first.

Setting a mode back to None clears it, so switching is a two-step move on purpose:

wv.html = None
wv.url = 'https://example.com'

Declaring none is caught at run(), because a Webview may legitimately be built empty and filled in later:

Webview().run()
# ValueError: No content declared. A Webview renders exactly one of html, url
# or root: pass html=, url= or root= to Webview(...), or set the matching
# property before run().

html

An HTML string, loaded as the document. Anything relative inside it — <img src="logo.png">, <script src="./app.js"> — has no directory to resolve against, so html is for self-contained pages: markup, inline styles, inline scripts, and absolute URLs. The moment you have files beside your page, you want a Root.

url

Any address the platform’s web engine can load: a remote site, or a local server of your own. See Loading a URL from a local server.

root

A local directory served to the Webview over Dry’s own internal protocol, so relative assets resolve. This is the mode a compiled frontend wants — the output directory of a Vite, esbuild or Parcel build. See Serving a Root.

Content is fixed once the window opens

html, url and root are read while the Webview is being built. Assigning one after run() raises a RuntimeError naming the setting, rather than silently doing nothing. To change what the frontend shows while it is running, change it from the frontend — that is what wv.emit and wv.eval_js are for.

Serving a Root

A Root is a local directory served to the Webview over an internal protocol, so that relative assets resolve. It is the Content mode a compiled frontend wants, and it needs no server, no port and no second process.

from pathlib import Path

from dry import Webview

wv = Webview(
    app_id='com.example.myapp',
    root=Path(__file__).parent / 'dist',
)
wv.run()

The Webview starts at the directory’s index.html, and everything that page names relatively — ./assets/index.js, <img src="logo.png">, @font-face { src: url(fonts/inter.woff2) } — is fetched back out of the same directory.

root accepts a str or any os.PathLike, expands ~, and is stored resolved. It is checked when you assign it, not at run():

Webview(root='./does-not-exist')
# FileNotFoundError: root does not exist: does-not-exist

Webview(root='./index.html')
# NotADirectoryError: root must be a directory, not a file: index.html.
# To render a single file, read it and set webview.html instead.

What the internal protocol answers

RequestAnswer
A file inside the Root200, with the content type its extension implies
A directory inside the Rootits index.html
A path that resolves outside the Root403 Outside the root: <path>
A path inside the Root with no file there404 Not found: <path>

Escaping is refused twice over: a .., a backslash, a colon or a NUL in any path component is rejected before the path is joined, and the canonicalised result must still sit beneath the Root — which also catches a symlink pointing out of the tree. Both refusals are ordinary HTTP statuses your frontend can observe from fetch, not a blank window.

Percent-escapes are decoded, so a file whose name holds a space or a non-ASCII character is found.

Content types

Extensions are mapped to types explicitly; text types carry ; charset=utf-8, without which WebKit guesses the encoding of CSS and JavaScript.

html, htm, js, mjs, css, json, map, txt, csv, xml, wasm, pdf, svg, png, jpg, jpeg, gif, webp, avif, bmp, ico, woff, woff2, ttf, otf, mp3, wav, ogg, oga, mp4, webm.

An extension not on that list is served as application/octet-stream rather than guessed at from the bytes.

Notes for a bundler

  • Build with relative asset paths. A frontend that emits /assets/app.js is asking for the root of the origin, which is the Root’s own top level; that happens to work here, but relative paths are what survive being served from anywhere. In Vite, base: './'.
  • Client-side routing that relies on a server rewriting unknown paths to index.html will get a 404 instead. Hash routing works as it stands.
  • A working example is examples/root.py.

Loading a URL from a local server

Most people reaching for a local server want a Root, which serves the same directory with no server to start and no port to pick. Reach for a server when you already have one: a dev server with hot reload, or an application that genuinely speaks HTTP.

from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from multiprocessing import Process
from pathlib import Path

from dry import Webview

ROOT = Path(__file__).parent / 'dist'
PORT = 8000


def serve() -> None:
    handler = partial(SimpleHTTPRequestHandler, directory=str(ROOT))
    ThreadingHTTPServer(('127.0.0.1', PORT), handler).serve_forever()


if __name__ == '__main__':
    server = Process(target=serve, daemon=True)
    server.start()

    wv = Webview(app_id='com.example.myapp', url=f'http://localhost:{PORT}')
    wv.run()

A daemon process goes down with its parent, so closing the window stops the server. The full file is examples/server.py.

A thread works too, since 0.4.0

run() releases the GIL before the event loop takes the main thread and never takes it back, so ordinary Python threads keep running for the whole life of the window — there is a regression test that opens a real window and counts a thread’s ticks inside it.

Before 0.4.0 that was not true: run() held the GIL, every Python thread in the process stopped, and a server started in a threading.Thread accepted no connection while the window was up. If you carried a multiprocessing.Process around to work past that, a threading.Thread is now enough:

from threading import Thread

Thread(target=serve, daemon=True).start()

A separate process still buys isolation — a crashing server does not take the window with it — which is why the shipped example uses one.

Serve plain HTTP, not a self-signed HTTPS

Plain http:// loads on macOS exactly as it does on Windows. App Transport Security does not stand between a Python process and its own local server: an interpreter run from a terminal has no app bundle and no Info.plist, so there is no policy to apply. This was measured, including against a real DNS hostname rather than only localhost and raw IPs.

HTTPS with a certificate the system does not trust is what fails. WKWebView abandons the navigation with no prompt and no visible error. Dry now notices — after five seconds without a page it diagnoses the address from Python and writes what it found to the dry.webview logger — but the window is still blank. Serve local development over plain HTTP on localhost.

See Errors and logging for how to see that diagnosis.

Calls: the frontend asks Python

A Call is a Bridge message that returns a value. The frontend Calls a name in the Api, and the Promise it gets back resolves with what the Python callable returned.

from dry import Webview


def hello(name: str) -> str:
    return f'Hello, {name}!'


def add(a: int, b: int) -> int:
    return a + b


wv = Webview(app_id='com.example.myapp', html=HTML, api={'hello': hello, 'add': add})
wv.run()
const greeting = await window.dry.api.hello('World');  // "Hello, World!"
const sum = await window.dry.api.add(1, 2);            // 3

The Api is the mapping of names to Python callables the frontend may Call. The name on the JavaScript side is the key in the dictionary, not the Python function’s own name, so {'helloWorld': hello_world} lets each side keep its own conventions.

Every entry must be callable. One that is not is refused before a window is built:

Webview(api={'total': 42}, html=HTML).run()
# dry.exceptions.BridgeError: Api entry 'total' is not callable.

functools.partial, an object with __call__, a bound method and most built-ins all work.

Sync, async, and why nothing freezes

A callable declared async def is scheduled onto the asyncio loop Dry owns. Anything else runs in a thread pool. Either way it is off the thread drawing the window, so a slow Call does not freeze the UI and two Calls do not queue behind one another.

import asyncio


async def fetch_report(id: str) -> dict[str, object]:
    await asyncio.sleep(2)
    return {'id': id, 'rows': []}

The consequence is that your callables run concurrently, and any state they share is yours to make thread-safe. See The Portal and ADR-0001.

Arguments are checked against your annotations

A Call arrives as JSON and lands on a callable you wrote. Before it runs, Dry compares what arrived with what the callable declared:

TypeError: save_file expects str for path, received number instead.
TypeError: save_file takes 2 arguments, received 1. data was not passed.

The name in the message is the Api key the frontend Called. The expected type is your annotation as written; the received type is named in JSON’s own vocabulary, because JSON is what the frontend wrote — with Python’s name added where JSON is too coarse to explain the refusal, as when an int is handed 1.5.

The check is deliberately shallow: arity, and the top level of each argument. list[int] asks whether an array arrived, not what is in it. Turning a dictionary into a dataclass is validation, which is not this library’s job.

It is also deliberately timid. An annotation Dry cannot resolve, or can resolve but cannot express in the JSON data model, leaves its parameter unchecked — including your own classes and dataclasses, and forward references, and parameters with no annotation at all. A Call wrongly refused would be a bug in code you cannot reach; a Call wrongly let through is a bug in code you can see. Every uncertainty resolves towards letting the Call through.

Two judgements worth knowing: float accepts an integer, because JSON has one number type, while int does not accept a float; and bool is not int in either direction, despite issubclass(bool, int).

When a callable raises

The Promise rejects with an Error whose name is the Python exception’s type, so the frontend can tell one failure from another:

try {
    await window.dry.api.loadFile('missing.txt');
} catch (error) {
    if (error.name === 'FileNotFoundError') { /* ... */ }
}

The exception is also logged, with its traceback, on the dry.bridge logger.

A Call always settles. A callable that returns a value outside the Bridge contract rejects with the TypeError explaining the way out rather than leaving the Promise hanging, and a Call that arrives while the window is closing is rejected rather than left unanswered.

Python does not Call the frontend

There is no await wv.call_js(...). A Python-side await on the frontend never resolves if the page navigates away or hangs, and Dry owns the process, so the consequence would be an application that cannot be closed. When the answer matters, have the frontend Call Python.

For the cases where a script simply has to run in the page, wv.eval_js(script) evaluates one and reads nothing back.

Events: both directions

An Event is a Bridge message that returns nothing. It carries a name and a value, and every listener registered for that name receives it. Both directions work, and they are the same bus.

Python to the frontend

wv.emit('progress', {'done': 12, 'total': 40})
window.dry.on('progress', ({ done, total }) => bar.value = done / total);

wv.emit returns as soon as the Event is on its way, and returns nothing — that is exactly what separates it from a Call. An Event nobody is listening for is a no-op, not an error. It is safe from any thread and from inside any callback. Before run() there is no frontend to reach, so it raises a BridgeError.

The value crosses under the Bridge contract, default= hook included, so anything outside it raises at the emit rather than arriving mangled.

The frontend to Python

window.dry.emit('form-dirty', { form: 'invoice' });
def remember(value):
    dirty.add(value['form'])


wv.on('form-dirty', remember)

wv.on(name, listener) returns the listener it was given, so it can be used inline: remember = wv.on('form-dirty', remember).

Registering costs nothing and needs no window, so listeners may be registered before run(). wv.off(name, listener) takes one registration off; taking off a listener that was never registered is not an error. Registering the same listener twice registers it twice, and it is then delivered to twice — two identical closures are not the same subscription.

On the JavaScript side, window.dry.on returns an unsubscribe function, which is what a component wants to hold on to:

const stop = window.dry.on('progress', update);
// later
stop();

How a listener runs

A listener takes the Event’s value — one argument — and returns nothing that anybody reads. An Event has no return path, so whatever it returns is dropped.

Listeners are handed over in the order they registered, and that is the only ordering you may rely on. Each runs off the thread drawing the window, on Dry’s loop if it is an async def and in the thread pool otherwise, so they overlap and finish in any order. Two listeners sharing state must make that state thread-safe, exactly as an Api must.

A listener that raises is logged with its traceback — on dry.bridge in Python, on the console in the frontend — and the other listeners still get theirs.

Reserved names

A name beginning with window: belongs to Dry. Listen for one as much as you like; wv.emit and window.dry.emit refuse to emit one:

wv.emit('window:resized', {'width': 10, 'height': 10})
# dry.exceptions.BridgeError: 'window:resized' is a reserved Event name: a name
# starting with 'window:' belongs to Dry's own window Events. Listen for it as
# much as you like, but emit under a name of your own.

That is what makes a listener for one trustworthy: it is hearing from the window and nothing else. See Window Events.

Running a script in the page

wv.eval_js('document.title = "Saved"')

eval_js evaluates a script in the page and reads nothing back. It is the escape hatch for the one quadrant the Bridge deliberately does not have — see Calls. An Event is almost always the better answer, because the frontend decides what to do with it.

The Bridge contract

The Bridge contract is the closed set of values that may cross the Bridge: the JSON data model, with json.dumps / json.loads semantics. A value outside it raises rather than being converted into something you did not send.

It applies to everything that crosses, in both directions — a Call’s arguments and its return value, an Event’s value.

PythonJavaScript
Nonenull
boolboolean
intnumber
floatnumber
strstring
listarray
tuplearray
dictobject

Coming back the other way, a JSON number arrives in Python as an int if it is whole and a float if it is not, an array as a list, and an object as a dict.

The consequences worth knowing

  • A tuple is written as an array, so a round trip returns a list.
  • A dict keeps its insertion order across the Bridge.
  • Dictionary keys are coerced to strings, exactly as json.dumps coerces them, so a round trip returns string keys. Only str, int, float, bool and None may be keys; anything else raises.
  • An int outside ±2**53 raises, in both directions, because JavaScript would read it with digits missing. Pass it as a str if you need the digits.
  • NaN and Infinity raise. JSON has neither.
  • set, frozenset, bytes and bytearray raise. JSON has none of them and none survives the round trip. Pass a list, or a str — base64 if the bytes are binary.
  • datetime, Decimal, Enum, dataclasses and everything else raise unless you convert them, which is what the default= hook is for.
  • A value nested deeper than 128 levels, or holding a circular reference, raises rather than recursing.

True crosses as true and not as 1. That sounds too obvious to state, and it is stated because it was not true before 0.4.0.

What a refusal looks like

The message says what was refused and how to get out of it:

>>> wv.emit('files', {'a', 'b'})
TypeError: set is outside the Bridge contract: JSON has no set, and a set does
not survive the round trip. Pass a list instead.

>>> wv.emit('id', 2**60)
ValueError: 1152921504606846976 is outside the Bridge contract: a JSON number
carries whole numbers only up to ±2**53, and the frontend would read this one
with digits missing.

A refusal on the way out of a Call reaches the frontend too: the Promise rejects with that same TypeError rather than hanging.

Why a closed set rather than a best-effort conversion: ADR-0002.

Converting your own types

Rather than converting at every call site, hand the Webview a default — the same hook json.dumps(default=...) takes. It is called with any value outside the Bridge contract and must return one inside it.

from dataclasses import asdict, is_dataclass
from datetime import datetime
from decimal import Decimal

from dry import Webview


def default(value):
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, Decimal):
        return float(value)
    if is_dataclass(value):
        return asdict(value)
    raise TypeError(f'{type(value).__name__} is not JSON serializable')


wv = Webview(app_id='com.example.myapp', html=HTML, api=api, default=default)
wv.run()

What the hook returns is checked in turn, so it may return another value the hook itself handles — a dataclass holding a Decimal converts in two steps.

Raise from it for anything you do not want to convert, and the Call rejects, or the emit raises, exactly as it would have without a hook.

It is consulted last

The hook is the final step, reached only once every other rule has declined the value. It is therefore never asked about:

  • a set, frozenset, bytes or bytearray — those are refused before it, because a silent conversion is what the contract exists to prevent;
  • an int outside ±2**53, NaN or Infinity;
  • a dictionary key. json.dumps does not pass keys to default either. Convert the keys yourself before the dictionary crosses.

One hook, for everything

default= is read while the Webview is being built and applies to every value Dry sends: Call return values, Event values, in every direction. Assigning it after run() raises.

It applies on the way out only. Values arriving from the frontend are already inside the contract by construction — JSON has nothing else in it.

Window options

from dry import Webview

wv = Webview(
    app_id='com.example.myapp',
    title='My App',
    size=(1080, 720),
    min_size=(800, 600),
    decorations=True,
    icon_path='assets/app.ico',
    dev_tools=True,
    html=HTML,
)
wv.run()

Sizes are logical pixels

size and min_size are logical pixels, independent of display scaling: a window declared 800 by 600 opens at that apparent size on a display scaled to 150% as on one scaled to 100%, and the page’s own window.innerWidth reports the same 800. They are the numbers CSS is working in.

Every size and position Dry reports back — in Window Events — is in the same unit, for the same reason.

decorations

decorations=False removes the native titlebar and borders, and the Webview draws its own resize edges instead. data-drag-region is what then moves the window.

icon_path

A path to an .ico file — a str or any os.PathLike. ICO is the only format the build decodes.

The icon is a Windows feature. macOS has no per-window icon, so the setting has no effect there.

An icon that cannot be read is a warning on the dry.webview logger, not a failure: the window opens with the platform’s default icon.

dev_tools

dev_tools=True enables the platform’s web inspector. Leave it off in a release build.

title

Purely cosmetic since 0.4.0. It no longer decides where your data is stored, so it may contain any character — see Where your data lives.

What can change after run()

Every option is also a property, and most of them are read once, while the Webview is being built. Assigning one of those after run() raises a RuntimeError naming the setting, rather than silently doing nothing:

wv.api = {'hello': hello}
# RuntimeError: api is fixed at construction and the Webview is already
# running, so assigning it now would change nothing. Pass api to Webview(...)
# instead.
SettingAfter run()
html, url, rootraises
api, defaultraises
dev_toolsraises
app_id, user_data_folderraises
on_closeraises
title, size, min_size, decorations, icon_pathassignable, and applied to the open window

Since run() never returns, “after run()” means from inside a callback: an Api callable, an Event listener or a close hook.

Beside those five settings are five states a window only has once it is on screen — position, visible, maximized, minimized and fullscreen — which are not constructor arguments at all, and a wv.state() that reads the window back in one piece. They are all in Runtime window control.

decorations assigned at runtime adds or removes the native titlebar, but does not add or remove the resize edges an undecorated Webview draws for itself: those are installed from the decorations the constructor was given. A window that means to toggle its titlebar should be built with decorations=False — see Runtime window control.

Custom titlebars

An undecorated Webview has no native titlebar and no native frame, and gets two things in their place: drag regions, which move the window, and resize edges, which resize it.

wv = Webview(app_id='com.example.myapp', decorations=False, html=HTML)

Drag regions

An element marked data-drag-region moves the window when dragged.

<div data-drag-region>
    <h1>My Application</h1>
    <button data-no-drag-region onclick="window.dry.minimize()">–</button>
    <button data-no-drag-region onclick="window.dry.toggleMaximize()">□</button>
    <button data-no-drag-region onclick="window.dry.close()">×</button>
</div>

The whole subtree drags. The heading above moves the window exactly as the bare margin around it does. An element marked data-no-drag-region opts itself and its own subtree back out, which is what the buttons inside a titlebar want: a click reaches them instead of moving the window.

The nearest marked ancestor wins, so an opt-out nested inside a drag region is honoured, and a drag region nested inside an opt-out drags again.

A double click inside a drag region toggles maximize, as a native titlebar does. A drag only starts once the pointer has actually moved, so a click inside a drag region stays a click.

Only the primary mouse button drags.

Resize edges

An undecorated Webview draws eight thin strips over its own border — 3px along each side, 7px at each corner — each with the cursor the direction implies. A grab on one resizes the window from that edge or corner.

They are ordinary fixed-position elements at z-index: 9999, and each carries data-no-drag-region, so a grab on the top edge resizes rather than moving the window even when the titlebar sits right behind it.

The mechanism differs by platform: Windows hands the grab to the operating system, which takes it over with a modal loop of its own, while macOS has no such path — tao answers NotSupported — so the frontend runs the drag itself and reports the pointer to Rust on every move. The behaviour is the same either way. The reasoning is in ADR-0004.

Window controls

<button onclick="window.dry.minimize()">Minimize</button>
<button onclick="window.dry.toggleMaximize()">Maximize</button>
<button onclick="window.dry.close()">Close</button>

These work with or without decorations. window.dry.close() goes through the close hook exactly as the native titlebar button does.

There is also window.dry.drag(), which starts a window drag from a handler of your own, and window.dry.resize(direction) for the eight directions — 'north', 'north-east', 'east', 'south-east', 'south', 'south-west', 'west', 'north-west' — if you would rather draw your own edges.

Keeping the titlebar in sync

A titlebar that can only command the window keeps its own guess at whether the window is maximized, and that guess is wrong the first time the user double-clicks the bar or reaches for an OS shortcut. Listen instead:

<script>
    window.dry.on('window:maximized', () => icon.src = RESTORE);
    window.dry.on('window:unmaximized', () => icon.src = MAXIMIZE);
</script>

A page that has just loaded has heard no Event yet, so ask once on the way in:

const { maximized } = await window.dry.state();
icon.src = maximized ? RESTORE : MAXIMIZE;

See Window Events and Runtime window control. A full example is examples/titlebar.py.

Window Events

The window reports what it is doing as ordinary Events under names Dry reserves for itself. They travel on the same bus as any Event of your own, reach both sides, and are subscribed to the same way.

window.dry.on('window:maximized', () => icon.src = RESTORE);
window.dry.on('window:resized', ({ width, height }) => show(width, height));
wv.on('window:resized', lambda size: print(size['width'], size['height']))
NameValue
window:maximized, window:unmaximizednull
window:minimized, window:restorednull
window:hidden, window:shownnull
window:focused, window:blurrednull
window:resized{width, height}
window:moved{x, y}
window:close-requestednull

The names come in opposed pairs rather than one name carrying a boolean, because a listener should read as the thing that happened rather than unwrap a value before it knows what it was told.

What you can rely on

  • Every one of them fires for a change the user made, not only for one your application made: a keyboard shortcut, the window menu, a double-click on the titlebar. The window’s state is read once per turn of the event loop and compared with the last reading, so a change with no platform event of its own is still caught.
  • A change your Python made is announced identically. wv.maximized = True and a double-click on the titlebar reach a listener as the same window:maximized, because both are read off the window rather than reported by whoever asked — and a change the platform refused announces nothing. See Runtime window control.
  • window:hidden and window:shown are reachable through wv.visible, which is the only thing in Dry that takes a window off the screen without closing it. A minimized window is minimized, not hidden.
  • Sizes and positions are logical pixels, the same unit size= and min_size= are given, so they are the numbers CSS is working in.
  • window:resized and window:moved are emitted at most once per turn of the event loop, and only when the value actually changed. A drag firing hundreds of platform events a second cannot produce more Events than the window had turns to draw in, so a listener cannot fall behind, and the last turn always carries the final geometry.
  • While the window is minimized or hidden, size, position and maximized state hold their last observed values. The platform’s answers there are not about the window the user will see again — Windows parks a minimized window at -32000 — so a minimize does not report a move to nowhere.
  • window:close-requested is a notification, not a vote. The close hook is the only thing that can refuse a close.

fullscreen has no Event. wv.fullscreen = True does enter it, but what arrives is what the platform makes of it — on macOS a window:maximized with a window:moved and a window:resized behind it — so a name of its own could not be told from what already comes. Read wv.fullscreen, or wv.state().fullscreen, instead of listening for it.

What is, rather than what changed

An Event only reaches a listener that was registered when it fired. A page that has just loaded, or a callback that was not listening, has observed nothing and still has to draw a maximize button one way round:

const { maximized } = await window.dry.state();
if wv.state().maximized:
    ...

Both answer from the last reading the event loop took — the same reading every Event above was a difference from — so the query and the Events can never disagree. See Runtime window control.

Reserved means reserved

A name beginning with window: belongs to Dry. Listening is unrestricted — that is exactly how these are heard, on both sides — but wv.emit and window.dry.emit refuse to emit one. A listener for window:resized is therefore hearing from the window and nothing else.

Runtime window control

The window can be commanded from Python while it is open, and read back the same way. Everything here is a property, so changing the window and asking what it is doing are the same word:

wv.title = f'{filename} — My App'
wv.size = (1080, 720)
wv.maximized = True

Since run() never returns, “while it is open” means from inside a callback: an Api callable, an Event listener or the close hook.

from dry import Webview


def open_settings() -> None:
    wv.size = (1080, 720)
    wv.title = 'My App — Settings'


wv = Webview(
    app_id='com.example.myapp',
    title='My App',
    html=HTML,
    api={'open_settings': open_settings},
)
wv.run()

open_settings names wv above the line that creates it, which is fine: Python looks a global up when the function runs, not when it is defined, and a callback cannot run until run() has the window open. Calling open_settings() yourself in between is the only way to reach a name that is not there yet, and nothing in the Bridge does that.

Settings that keep applying

title, size, min_size, decorations and icon_path are constructor arguments that go on working afterwards. Assigning one before run() decides what the window opens as; assigning it afterwards changes the open window. Every other constructor argument is read once while the Webview is built and raises if assigned later — see Window options.

SettingAssigned while running
titleRetitles the window
sizeResizes it, logical pixels
min_sizeSets the floor, and grows the window to meet it
decorationsAdds or removes the native titlebar and borders
icon_pathReplaces the icon; None restores the platform default

size reads back what the window currently measures, not the last number you gave it, so a window the user dragged reports the size it was dragged to. A min_size larger than the window resizes the window up to it:

wv.min_size = (900, 600)
wv.size  # (900, 600)

States a window only has once it is open

position, visible, maximized, minimized and fullscreen are not settings — a window that does not exist has no corner to sit in and no screen to fill. They are not constructor arguments, and touching one before run() raises rather than quietly storing a value that would never be applied:

wv = Webview(app_id='com.example.myapp', html=HTML)
wv.maximized = True
# RuntimeError: maximized belongs to a window that is on screen, and this
# Webview has not opened one yet. Ask for it once run() has, from inside an Api
# callable, an Event listener or the close hook.
StateAssignedRead
positionMoves the window, logical pixelsWhere its top-left corner is, decorations included
visibleTakes it off the screen, or puts it backWhether it is on screen
maximizedMaximizes, or restores the previous sizeWhether it fills its screen
minimizedMinimizes to the dock or taskbar, or restoresWhether it is minimized
fullscreenTakes over the screen, or comes backWhether it has

A platform that refuses part of what you asked for simply reports where the window actually went — macOS will not lift a window above the menu bar, and wv.position afterwards is the corner it settled on, not the one you named.

A minimized window is still visible. The user can see it in the dock or the taskbar and put it back; only visible = False takes it off the screen altogether.

fullscreen is borderless fullscreen on the window’s current monitor, which is what a desktop application wants: it does not change the display’s resolution under the user.

Reading the whole window at once

wv.state() answers with a WindowState, a NamedTuple of everything the window is doing, taken as one reading:

state = wv.state()
# WindowState(maximized=False, minimized=False, fullscreen=False, visible=True,
#             focused=True, size=(640, 480), position=(436, 144))

if not state.maximized:
    wv.maximized = True

Reading the properties one at a time is the same information, but a reading is taken at one instant, so its fields cannot contradict each other the way two properties read a moment apart can. size and position are pairs of logical pixels; size is the area the frontend renders into — the same measurement size= sets, and the same number the page reads back as window.innerWidth.

focused is in the reading and is not a property of its own: Dry reports which window has the keyboard, through window:focused and window:blurred as much as here, but does not take focus for you.

Like the five states, state() raises before run(): answering with the settings the window will be built from would be a guess dressed as a measurement.

Asking from the frontend

The same reading is a Promise in the page. A titlebar that has just loaded has observed no window Event at all and still has to draw its maximize button one way round:

const { maximized, size } = await window.dry.state();
icon.src = maximized ? RESTORE : MAXIMIZE;

The frontend gets the shape the window Events use — size is {width, height} and position is {x, y} — so a value from window.dry.on('window:resized', ...) and a value from dry.state() can go to the same code. Python prefers a pair, and that is the only difference between the two sides.

Every call waiting on the same trip resolves with the same reading, so a page polling the query costs one trip either way.

The frontend commands the window with window.dry.minimize(), window.dry.toggleMaximize() and window.dry.close(), which are the three a custom titlebar needs and are covered in Custom titlebars. Anything beyond those three is Python’s: give the page an Api callable that does it.

Every change announces itself

A change made from Python is announced exactly as a change the user made. Dry reads the window once per turn of its event loop and emits the window: Events for whatever moved, so wv.maximized = True and a double-click on the titlebar reach a listener identically, and a change the platform refused announces nothing:

wv.on('window:hidden', lambda _: tray.show_restore_item())
wv.on('window:shown', lambda _: tray.hide_restore_item())

window:hidden and window:shown are reachable exactly because visible is assignable — nothing else in Dry hides a window.

fullscreen is the one state with no Event of its own: macOS reports entering it as the platform’s own mix of a maximize, a window:moved and a window:resized, so a name for it could not be told from what already arrives. Read wv.fullscreen, or wv.state().fullscreen, instead of listening for it.

The reading can be one turn old

An assignment crosses to the thread that draws the window and is applied on its next turn of the event loop. The state query answers from the reading taken at the last turn — the same reading every window: Event was a difference from — so a query and an Event can never disagree, but a reading taken in the same breath as an assignment is still the state before it:

wv.maximized = True
wv.state().maximized  # False — the window has not had its turn yet

That is not a race to be won by sleeping. If the order matters, wait for the Event that announces the change:

maximized = threading.Event()
wv.on('window:maximized', lambda _: maximized.set())

wv.maximized = True
maximized.wait(timeout=5)

decorations at runtime does not add resize edges

An undecorated Webview draws its own resize edges, and the script that draws them is installed when the Webview is built, from the decorations the constructor was given. Assigning decorations later moves the native titlebar and borders and nothing else:

  • A window built with decorations=True and undecorated at runtime has no resize edges, and reloading the Content does not produce them. It can still be resized from Python and from window.dry.resize(direction), but there is nothing on its border to grab.
  • A window built with decorations=False keeps its eight edge strips even after decorations = True, sitting just inside the native frame.

So a window that means to toggle its titlebar should be built undecorated and draw its own, which is what Custom titlebars covers anyway. data-drag-region is unaffected either way: it is installed for every window.

Hiding a window instead of closing it

visible = False takes the window off the screen without closing it: no titlebar button, no dock entry, no close hook. The Webview goes on running — the event loop still turns, the Bridge still carries Calls and Events, and the page keeps its state — which is what makes a tray application possible, where closing the window would end the process.

def on_close() -> bool:
    wv.visible = False
    return False  # refuses the close; the window is hidden, not gone


wv = Webview(app_id='com.example.myapp', html=HTML, on_close=on_close)

Something else then has to bring it back with wv.visible = True — a tray icon, a global shortcut, a second instance handing over. A window hidden with nothing left to show it again is a process the user cannot see or quit.

Everything is logical pixels

Every dimension and coordinate here — size, min_size, position, and both values inside a WindowState — is in logical pixels, independent of display scaling, the same unit the constructor takes and the same unit window Events report. They are the numbers CSS is working in, so a window told to be 640 wide reports 640 from window.innerWidth on a display at any scale factor.

Closing the window

The close hook is the callable asked before the Webview closes, and the one thing that can refuse a close.

def on_close():
    if editor.is_dirty():
        return False  # keeps the window open
    editor.save()


wv = Webview(app_id='com.example.myapp', html=HTML, on_close=on_close)
wv.run()

Returning False — that value, not anything merely falsy — refuses the close. Anything else, None included, lets it go, so a hook that only saves state does not have to remember to return anything.

An async def hook works too, and is awaited on Dry’s loop before the answer is read.

Every route in is asked

The native titlebar button, the window manager, an OS shortcut and window.dry.close() all go through the same path. A refusal one route could skip would not be a guarantee.

The hook runs on the thread that owns the window, with the window held still, which is what makes the decision meaningful — a modal “you have unsaved changes” prompt is exactly the case it exists for, and it has to be answered before the close continues. Nothing is timed out: a hook that never returns keeps the window open, the same as one that refuses.

A hook that raises does not refuse

The close goes ahead, and the exception is logged on dry.bridge. Refusing is deliberate — it is False, returned on purpose — and a hook that raises has not made a decision. A decision it never made must not be the one that traps the user in a window that cannot be closed.

What happens after the hook agrees

In this order:

  1. In-flight Calls and Event deliveries are given up to 5 seconds to finish on their own. A Call halfway through writing a file is what the whole sequence exists to protect.
  2. What the grace period does not save is cut short, out loud. A coroutine is cancelled, so its finally: blocks run and its Call rejects with the CancelledError. A Call that had not started is cancelled the same way. A pool thread already inside a callable cannot be interrupted at all — Python has no such thing — so it is left, unanswered, with a warning logged.
  3. A Call arriving during shutdown is rejected with a reason, not left hanging: the window is going, so the reply could not be delivered even if the callable ran.
  4. The asyncio loop is drained and stopped.
  5. atexit handlers run. Dry runs them by hand, because the platform event loop exits the process from under the interpreter and they would otherwise never happen.
  6. The process exits.

What still does not run

A try: ... finally: wrapped around wv.run() does not run, because the event loop exits the process from inside run(). That is a consequence of Dry owning the process (ADR-0001), and the honest workaround is the close hook or an atexit handler, both of which do run.

finally: blocks inside callbacks do run.

Setting it later

on_close is a property as well as a constructor argument, and like the other settings read at build time, assigning it after run() raises. One Webview has one hook: setting it again before run() replaces the previous one.

The Portal

The Portal is where Python code that Dry calls actually runs: an asyncio loop on a daemon thread, beside a thread pool, off the thread drawing the window. Every Api callable and every Event listener crosses it.

You never touch it. What it decides, you have to know.

Why it exists

The GUI event loop must own the main thread — on macOS that is an AppKit requirement, not a preference — and tao::EventLoop::run never returns, exiting the process from inside itself. A callback that ran on that thread would hold the window still for its whole duration: no repaint, no input, no second Call.

So Dry takes every Call and every Event delivery off that thread. An async def is scheduled onto the loop; anything else goes into the pool.

The two consequences

Your callbacks run concurrently. Two Calls overlap, and finish in whatever order they finish in. State shared between Api callables, or between Event listeners, is yours to make thread-safe — a threading.Lock, or a design that does not share.

The single ordering guarantee: listeners for one Event are handed over in the order they registered. Nothing guarantees they finish in that order.

Your application cannot make asyncio.run(main()) its entry point. Dry owns the process and owns the loop. Your async code lives inside callbacks, and is awaited on Dry’s loop:

import asyncio

import httpx


async def fetch(url: str) -> str:
    async with httpx.AsyncClient() as client:
        return (await client.get(url)).text


wv = Webview(app_id='com.example.myapp', html=HTML, api={'fetch': fetch})
wv.run()

Work that has to start before the window opens and keep running can go on a thread you start yourself: since 0.4.0 run() releases the GIL, so ordinary Python threads keep running for the life of the window.

The full reasoning, and the alternatives that were rejected, is in ADR-0001.

Started lazily, shut down in order

Neither the loop nor the pool exists until the first Call or Event that needs one, so a Webview with no Api and no listeners never starts either.

Both are shut down when the window closes, in the order described in Closing the window.

The stdlib only

The Portal is dry/portal.py: asyncio, concurrent.futures, threading, inspect and logging. Depending on anyio would buy trio support this project does not need, at the cost of the zero-dependency promise. Dry therefore installs with no transitive dependencies at all.

Where your data lives

Cookies, local storage, IndexedDB and cache belong to an App id — a stable reverse-domain identifier such as com.example.myapp — not to the window title.

wv = Webview(app_id='com.example.myapp', html=HTML)

Rename the window and the session survives. Two applications that happen to share a title no longer share a cookie jar. A title containing a colon no longer produces a path Windows refuses.

The data lands under the directory the operating system keeps application data in, so nothing clears it between runs:

PlatformLocation
Windows%LOCALAPPDATA%\<app id>
macOS~/Library/Application Support/<app id>
Linux$XDG_DATA_HOME/<app id>, or ~/.local/share/<app id>

What an App id may look like

One path segment: letters, digits, dots, dashes and underscores, starting with a letter or a digit. That constraint is deliberate — nothing you pass can escape into a parent directory or name a drive:

Webview(app_id='../../etc')
# ValueError: app_id must be one path segment of letters, digits, dots, dashes
# and underscores, starting with a letter or a digit, such as com.example.myapp.
# Got: '../../etc'.

Leaving it out

An App id is derived from your entry-point script: dry.<script-stem>.<8 hex characters of the script's absolute path>. The digest keeps two different main.py files from sharing a cookie jar.

That is enough to develop against and not something to ship: the folder moves when the script moves. Declare your own before you release.

Overriding the location outright

Webview(app_id='com.example.myapp', user_data_folder='/var/tmp/myapp')

user_data_folder takes the location out of the App id’s hands entirely. It is rarely what you want; a portable application that keeps its state beside itself is the case it exists for. ~ is expanded, and the directory is created if it is not there.

wv.user_data_folder reads back the folder in use either way.

Both settings are read while the Webview is being built, so assigning either after run() raises.

Errors and logging

The exception hierarchy

Everything Dry can fail at raises a DryError, so you can catch what you mean:

from dry import BridgeError, DryError, PanicError, Webview, WebviewError

(They are also importable from dry.exceptions.)

ExceptionRaised for
WebviewErrorA window or web content that could not be built
BridgeErrorA message that could not cross to or from the frontend
PanicErrorA bug inside Dry itself, carrying the file and line

All three are DryError, which is an Exception.

from dry import Webview, WebviewError

try:
    Webview(html='<h1>Hi</h1>').run()
except WebviewError as error:
    fall_back(error)

A PanicError means a Rust panic was caught on its way out and turned into something you can handle rather than an aborted process. The event loop itself is deliberately outside that net: unwinding through a platform event loop is not safe to catch.

Values refused by the Bridge contract raise the ordinary TypeError and ValueError you would expect from json.dumps, not a BridgeError.

A callable that raises

The frontend’s Promise rejects with an Error whose name is the Python exception’s type:

try {
    await window.dry.api.loadFile('missing.txt');
} catch (error) {
    if (error.name === 'FileNotFoundError') { /* ... */ }
}

Logging

Dry writes to no stream of its own. No print, nothing on stdout or stderr. Its diagnostics go to the dry logger and its children, and a NullHandler keeps them silent until your application configures logging:

import logging

logging.basicConfig(level=logging.DEBUG)
LoggerCarries
dryThe parent. Configure this one to catch everything
dry.webviewThe window and the web content: a failed navigation, an unreadable icon
dry.bridgeMessages crossing the Bridge: a Call that raised, a listener that raised, a close hook that raised, Calls cut short by a close

The blank window

A URL Content that does not arrive within five seconds is diagnosed and reported on dry.webview, naming an untrusted certificate, a refused connection or an unresolvable host.

Read that report for what it is: a heuristic. wry has no failed-navigation hook, so Dry diagnoses the address from Python with urllib and ssl after the fact. Python and the platform’s web engine have different network stacks, different certificate stores and different proxy handling, so they can disagree. Nothing is logged above debug unless the diagnosis reproduces a concrete failure, so a page that is merely slow is never accused.

A Root is not watched: it is served from inside this process and answers its own failures with a 403 or a 404 you can see.

The Webview

One native window rendering a web frontend, and the Bridge to it. Dry’s one public object.

from dry import Webview

Every option is keyword-only, and every option is also a property.

Options

OptionTypeDefaultMeaning
titlestr'My Dry Webview'The window title. Cosmetic only
sizetuple[int, int](800, 600)Initial dimensions, logical pixels
min_sizetuple[int, int](800, 600)Minimum dimensions, logical pixels
decorationsboolTrueNative titlebar and borders
icon_pathstr | os.PathLike | NoneNoneWindow icon, .ico, Windows only
htmlstr | NoneNoneContent: an HTML string
urlstr | NoneNoneContent: an address to load
rootstr | os.PathLike | NoneNoneContent: a directory to serve
apidict[str, Callable] | NoneNoneThe names the frontend may Call
dev_toolsboolFalseEnable the web inspector
app_idstr | NonederivedDecides where this application’s data lives
user_data_folderstr | os.PathLike | Nonefrom app_idOverrides that location outright
defaultCallable[[Any], Any] | NoneNoneConverts a value outside the Bridge contract
on_closeCallable[[], object] | NoneNoneAsked before the window closes

Exactly one of html, url and root must be declared. Declaring a second raises immediately; declaring none raises at run().

Assigning an attribute that is not one of these raises AttributeError. Assigning any of them except title, size, min_size, decorations and icon_path after run() raises RuntimeError — see Window options.

Methods

MethodDoes
run()Opens the window and hands it the process. Never returns
on(name, listener)Registers a listener for an Event, and returns the listener
off(name, listener)Takes one registration off
emit(name, value=None)Emits an Event to the frontend
eval_js(script)Evaluates a script in the page, reading nothing back
state()Returns a WindowState: everything the window is doing, in one reading

on and off work before run(). emit and eval_js need a running window and raise a BridgeError without one; state() needs one and raises a RuntimeError without one.

The open window

Five of the options above go on applying once the window is open — title, size, min_size, decorations and icon_path — and beside them are five states that exist only then. These are not constructor arguments, and reading or assigning one before run() raises a RuntimeError naming it.

PropertyTypeMeans
positiontuple[int, int]Where the window’s top-left corner sits, logical pixels
visibleboolWhether it is on screen; False hides it without closing it
maximizedboolWhether it fills its screen
minimizedboolWhether it is minimized to the dock or taskbar
fullscreenboolWhether it has taken over its screen
from dry import WindowState

wv.state() returns a WindowState, a NamedTuple of maximized, minimized, fullscreen, visible, focused, size and position. The frontend asks for the same reading with await window.dry.state().

Every change made through these reaches the window Events exactly as a change the user made. See Runtime window control.

Read-only behaviour worth knowing

  • wv.root reads back a resolved pathlib.Path, whatever you assigned.
  • wv.icon_path reads back a POSIX-style str.
  • wv.user_data_folder reads back the folder in use, whether it came from the App id or from an override.

Exceptions

from dry import BridgeError, DryError, PanicError, WebviewError

See Errors and logging.

The window.dry namespace

Everything Dry exposes to the frontend hangs off one global, window.dry, so nothing the library injects can collide with a standard browser API or with your own globals.

window.dry and each of its members are defined non-writable and non-configurable: a page script cannot replace them.

The Bridge

MemberSignatureDoes
dry.apidry.api.<name>(...args) -> PromiseCalls the Python callable registered under <name>
dry.ondry.on(name, listener) -> () => voidRegisters a listener; returns an unsubscribe function
dry.offdry.off(name, listener)Takes one registration off
dry.emitdry.emit(name, value)Emits an Event to Python’s listeners

dry.api is a Proxy: any property read returns a function, so an unknown name fails when Python is asked, as a rejected Promise, not at the property access.

dry.emit refuses a name starting with window: with a TypeError, and dry.on and dry.emit refuse an empty name or a non-function listener the same way.

Values crossing in either direction obey the Bridge contract.

Window controls

MemberDoes
dry.minimize()Minimizes the window
dry.toggleMaximize()Maximizes, or restores if already maximized
dry.close()Asks to close, through the close hook
dry.drag()Starts a window drag from your own handler
dry.resize(direction)Starts a resize drag from an edge or corner
dry.state()Resolves with what the window is doing right now

dry.state() returns a Promise for {maximized, minimized, fullscreen, visible, focused, size: {width, height}, position: {x, y}} — the same reading wv.state() gives Python, in the shape the window Events use. Every call waiting on the same trip resolves with the same reading. See Runtime window control.

direction is one of 'north', 'north-east', 'east', 'south-east', 'south', 'south-west', 'west', 'north-west'. dry.resize is meant to be called from a mousedown handler: with no button down there is no grab, and it starts nothing.

HTML attributes

AttributeDoes
data-drag-regionThe element and its subtree drag the window; a double click toggles maximize
data-no-drag-regionThe element and its subtree opt back out

See Custom titlebars.

Reserved Event names

Names beginning with window: are Dry’s own. Listen for them freely; emitting one is refused. The full list is in Window Events.

Three members that are not yours

dry.resolveCall, dry.deliverEvent and dry.resolveState exist, non-enumerable, and are how Rust settles a Promise, hands an Event to your listeners and answers a state query. They are implementation, not surface: do not call them.

The pending-call store and the listener register live in closures, so no page script can read or tamper with another script’s in-flight Calls or listeners.

Migrating to 0.4.0

0.4.0 is deliberately breaking. It renames the JavaScript surface, replaces content sniffing with explicit modes, closes the set of values that may cross the Bridge, moves where your data is stored, and raises the Python floor.

Everything here is a change a working 0.3.x application can trip over. Each section says what to do.


1. Python 3.14 is the floor

requires-python is now >=3.14, and the wheels are stable-ABI (abi3-py314), one per platform. 3.11, 3.12 and 3.13 are no longer supported; pip on those will not find a 0.4.0 wheel.

Free-threaded builds (python3.14t) cannot install Dry either — abi3 does not cover them until abi3t arrives with Python 3.15.

What to do: upgrade the interpreter. Why the floor sits there, and why lowering it later would break nobody: ADR-0003.


2. The JavaScript surface moved to window.dry

Everything Dry injects now hangs off a single global, so nothing collides with a standard browser API or with your own.

0.3.x0.4.0
window.api.<name>(...)window.dry.api.<name>(...)
window.minimize()window.dry.minimize()
window.toggleMaximize()window.dry.toggleMaximize()
window.close()window.dry.close()
window.drag()window.dry.drag()
window.resize(direction)window.dry.resize(direction)
window.ipcCallback(...)gone from the public surface
window.ipcStoregone; the pending-call store is a closure-scoped Map

window.close, window.resize, window.resizeTo and window.resizeBy are the browser’s own again — the hijacking is gone. Note that the DOM defines no window.resize method at all, so window.resize is now undefined and the resize event fires normally. A page that called window.close() meaning Dry’s close now calls the browser’s.

window.dry and each of its members are non-writable and non-configurable, so a page script cannot replace them.

What to do: prefix every call with dry.. The full surface is in the window.dry reference.


3. Content is explicit: html, url or root

wv.content is gone. It sniffed: a string matching https?:// was a URL, a string that happened to name an existing file was a file, and anything else was markup. Now you say which mode you mean.

0.3.x0.4.0
wv.content = '<h1>Hi</h1>'wv.html = '<h1>Hi</h1>'
wv.content = 'http://localhost:8000'wv.url = 'http://localhost:8000'
wv.content = 'C:/app/index.html'wv.html = Path('C:/app/index.html').read_text(), or wv.root = 'C:/app'

Assigning wv.content now raises AttributeError, not silently nothing.

There is no single-file mode. root takes a directory and serves it, starting at its index.html, so relative assets resolve — which is what a compiled frontend needs and what the old single-file path could not do. A file passed to root raises NotADirectoryError naming the alternative.

Declaring two modes raises immediately; declaring none raises at run(). In 0.3.x an empty content rendered a built-in <h1>Hello, World!</h1> — that placeholder is gone.

What to do: The three Content modes, Serving a Root.


4. The constructor is keyword-only, and typos raise

0.3.x had no constructor arguments; everything was assigned afterwards. That still works, and now the same options can be passed to Webview(...):

wv = Webview(title='My App', app_id='com.example.myapp', html=HTML)

Two consequences:

  • Positional arguments are refused. Webview('My App') raises TypeError.
  • Assigning an unknown attribute raises AttributeError instead of quietly creating one that never applies. If you were setting a name Dry never read, you will hear about it now.

And a third: settings read while the window is being built now raise if you assign them afterwards, naming the setting, rather than doing nothing. That is html, url, root, api, dev_tools, default, app_id, user_data_folder and on_close. See Window options.


5. Your data moved, and the title no longer decides where

In 0.3.x the user data folder defaulted to <temp>/<window title>. Cookies, local storage and cache therefore lived in the temporary directory — cleared by the system whenever it felt like it — renaming the window lost the session, two applications sharing a title shared a cookie jar, and a title containing a colon produced a path Windows refuses.

0.4.0 keys the data to an App id under the OS’s application-data directory:

Platform0.4.0 location
Windows%LOCALAPPDATA%\<app id>
macOS~/Library/Application Support/<app id>
Linux$XDG_DATA_HOME/<app id>, or ~/.local/share/<app id>
wv = Webview(app_id='com.example.myapp', html=HTML)

Existing sessions do not carry over. Whatever was in the temporary folder stays there, and your users log in again once. That is the cost of the fix.

Leave app_id out and one is derived from your entry-point script, which is fine while developing and moves when the script moves. Declare your own before you ship. user_data_folder= still overrides the location outright, and wv.title is now purely cosmetic and may contain any character.

What to do: Where your data lives.


6. The Bridge contract refuses what JSON has no room for

The set of values that may cross is now the JSON data model, in both directions, and a value outside it raises rather than arriving as something you did not send.

Value0.3.x0.4.0
set, frozensetcrossed as an arrayraises TypeError — pass a list
bytes, bytearraycrossed as number[]raises TypeError — decode, or base64 to a str
True / Falsearrived as 1 / 0arrive as true / false
int beyond ±2**53crossed, silently losing digitsraises ValueError, in both directions
NaN, Infinitycrossedraise ValueError
datetime, Decimal, Enum, dataclassesraisedraise, unless a default= hook converts them
a dict key that is not a strcoerced to a string exactly as json.dumps does

The boolean row is the one to look for in a working application: a frontend written against 0.3.x may be comparing === 1, or relying on a number where a boolean now arrives.

What to do: The Bridge contract. For your own types, hand the Webview a default= hook — the same one json.dumps(default=...) takes — rather than converting at every call site.


7. Window sizes are logical pixels

size and min_size were physical pixels. They are now logical pixels, independent of display scaling, which is the unit CSS works in.

On a display scaled to 200%, size=(800, 600) used to give the page a 400×300 CSS viewport. It now gives it 800×600, so the window will look twice the size it used to on a scaled display. Divide your old numbers by the scale factor you were developing at, or — more usefully — pick the numbers you actually want the user to see.


8. A drag region now drags its whole subtree

data-drag-region used to match only the element that was clicked, so the README’s own example — a heading inside a drag region — did not drag; only the bare margin around it did.

Now the whole subtree drags. A button sitting inside your titlebar will move the window instead of being clicked unless you opt it out:

<div data-drag-region>
    <h1>My Application</h1>
    <button data-no-drag-region onclick="window.dry.close()">×</button>
</div>

What to do: add data-no-drag-region to every interactive element inside a drag region. Custom titlebars.


9. Failures are exceptions and log records, not printed text

0.3.x printed diagnostics to stdout and stderr, and a Rust panic aborted the process.

0.4.0 raises DryError and its three children — WebviewError, BridgeError, PanicError — and writes everything else to the dry logger and its children dry.webview and dry.bridge, silenced by a NullHandler until your application configures logging. Dry now writes nothing to stdout or stderr at all, so anything you were reading off the terminal needs logging.basicConfig(...).

A callable that raises now rejects the frontend’s Promise with an Error whose name is the Python exception’s type.

What to do: Errors and logging.


10. Api callables are checked, twice

Before the window opens: every entry in api must be callable, or run() raises BridgeError: Api entry '<name>' is not callable.

Before each Call runs: the arguments are checked against the callable’s declared annotations, and a mismatch is refused with a message naming the parameter — save_file expects str for path, received number instead. A 0.3.x frontend that was passing a string where the Python side declared int, and getting away with it, now gets a rejected Promise.

The check is shallow and timid — arity and the top level of each argument, with anything it cannot resolve left unchecked — so it will not refuse a Call it merely does not understand. Details, including how float, int and bool behave: Calls.


11. Callbacks run concurrently, off the window’s thread

In 0.3.x a callback ran on the thread drawing the window, one at a time, and the window froze for its duration.

Now an async def callable is scheduled onto an asyncio loop Dry owns and anything else goes into a thread pool. The window stays responsive and two Calls overlap — which means state shared between your callables is yours to make thread-safe. Code that was implicitly serialised by the old model is not any more.

Two related changes:

  • run() no longer holds the GIL, so ordinary Python threads keep running for the life of the window. If you moved a local server into a multiprocessing.Process to work around that, a thread is enough again.
  • An application still cannot make asyncio.run(main()) its entry point. Dry owns the process and the loop; your async code lives inside callbacks.

What to do: The Portal, ADR-0001.


12. Closing is ordered, and can be refused

New in 0.4.0, and worth adopting rather than migrating to:

wv = Webview(app_id='com.example.myapp', html=HTML, on_close=save_or_refuse)

The close hook is asked on every route in, including window.dry.close(), and returning False keeps the window open. After it agrees, in-flight Calls get up to five seconds, the loop is drained, and atexit handlers now run — in 0.3.x they never did.

Still true, and still a consequence of Dry owning the process: a finally: around wv.run() does not run. Closing the window.


Also new, breaking nothing

  • Events, in both directions. wv.on / wv.off / wv.emit in Python, window.dry.on / off / emit in the frontend. Events
  • Window Events. window:maximized, window:resized and the rest, on the same bus, so a custom titlebar can stop guessing. Window Events
  • root, a directory served over an internal protocol. Serving a Root
  • Resize edges on macOS, which tao does not support natively. ADR-0004
  • macOS support, built and tested in CI alongside Windows.
  • A failed navigation is reported instead of leaving you staring at a blank window. Errors and logging

Changelog

0.4.0

A deliberately breaking release. Every break is covered, with what to do about it, in the migration guide.

Breaking

  • Python 3.14 is the floor. Wheels are stable-ABI (abi3-py314), one per platform; 3.11–3.13 are no longer supported, and free-threaded builds cannot install Dry until abi3t.
  • The JavaScript surface is namespaced under window.dry. window.api, window.minimize, window.toggleMaximize, window.close, window.drag and window.resize move under it; window.ipcCallback and window.ipcStore are gone from the public surface. window.close, window.resizeTo and window.resizeBy are the browser’s own again.
  • Content is explicit. wv.content and its sniffing are replaced by three mutually exclusive modes: html, url and root. Declaring two raises; declaring none raises at run(). There is no single-file mode and no built-in placeholder page.
  • Webview(...) takes keyword arguments, and assigning an unknown attribute raises. Settings read while the window is built raise if assigned after run(), naming the setting.
  • An App id decides where data lives, replacing a folder derived from the window title under the temporary directory. Cookies, local storage and cache move to the OS application-data directory, and existing sessions do not carry over. title is now cosmetic.
  • The Bridge contract is the JSON data model, in both directions. set, frozenset, bytes and bytearray raise; integers beyond ±2**53, NaN and Infinity raise; booleans arrive as booleans rather than as 1 and 0; dictionary keys are coerced to strings as json.dumps coerces them.
  • size and min_size are logical pixels, so a window on a scaled display opens at the size it declares rather than at that size divided by the scale factor.
  • A drag region drags its whole subtree. Interactive elements inside one need data-no-drag-region.
  • Failures are exceptions and log records. DryError, WebviewError, BridgeError and PanicError replace printed diagnostics and an aborting panic; Dry writes nothing to stdout or stderr, logging instead to dry, dry.webview and dry.bridge.
  • Api entries must be callable, checked before the window opens, and a Call’s arguments are checked against the callable’s declared annotations before it runs.
  • Callbacks run off the window’s thread and concurrently, so state shared between them must be thread-safe.

Added

  • Events in both directions: wv.on, wv.off, wv.emit and wv.eval_js in Python; window.dry.on, off and emit in the frontend.
  • Window Events under reserved window: names — maximized, unmaximized, minimized, restored, hidden, shown, focused, blurred, resized, moved, close-requested — delivered on the same bus to both sides, and fired for OS-initiated changes as much as for library-initiated ones.
  • Runtime window control. title, size, min_size, decorations and icon_path now apply to the open window instead of only changing a stored value, and position, visible, maximized, minimized and fullscreen join them as properties of a window on screen, raising a RuntimeError naming the property before run(). Every change is announced through the window Events exactly as a change the user made.
  • A state query on both sides: wv.state() returns a WindowState NamedTuple, and await window.dry.state() resolves the same reading in the frontend, for a listener or a page that has observed no change yet.
  • A Root: a local directory served over an internal protocol so relative assets resolve, with per-extension content types, index.html for directories, 403 for a path escaping the Root and 404 for a missing file.
  • A close hook: on_close, asked on every route in, able to refuse a close by returning False, followed by an ordered shutdown that drains in-flight Calls and runs atexit handlers.
  • A default= hook, the one json.dumps(default=...) takes, for converting your own types on the way out.
  • data-no-drag-region, opting an element and its subtree out of a drag region.
  • macOS support, built and tested in CI alongside Windows, including resize edges on an undecorated window, which tao does not support natively.
  • A documentation site, and a README that is a README again.

Fixed

  • run() no longer holds the GIL, so Python threads keep running for the life of the window. A local server in a threading.Thread works.
  • A failed navigation is diagnosed and reported on dry.webview instead of leaving a blank window with no explanation.
  • A localfile:// request no longer ignores its path and answers with a content type that is not a media type.
  • A window declared 800×600 opens at that apparent size on a display of any scale factor.

Known gaps

  • The window-size fix above was verified on macOS across a real scale change, dragging between displays at backingScaleFactor 2.0 and 1.0. The scenario originally reported — Windows at 100% and 150% — has not been reproduced, because a CI runner has a single display at 100% where logical and physical pixels are indistinguishable. Reports from Windows are welcome.
  • On macOS, resize edges are drawn by Dry rather than the platform, because tao’s drag_resize_window is unimplemented there. A window built decorated and undecorated at runtime never draws them.

Releases before 0.4.0 predate this changelog. Their history is in the commit log.

Dry owns the process and the asyncio loop

The OS GUI event loop must own the main thread — on macOS this is an AppKit requirement, not a convention — and tao::EventLoop::run never returns, exiting the process directly. Dry therefore runs the GUI loop on the main thread and starts its own asyncio loop on a daemon child thread; async def callbacks are scheduled onto it and plain def callbacks go to a thread pool, so neither blocks the window. The consequence a reader will trip over: an application cannot make asyncio.run(main()) its entry point — Dry owns the process and the developer’s async code lives inside callbacks.

Considered Options

  • An asyncio loop on the main thread, GUI on a child thread — impossible on macOS.
  • Interleaving both loops via run_return and ControlFlow::Poll driven from an asyncio callback — burns CPU and is fragile.
  • Accepting a user-supplied loop (Webview(loop=...)) — deferred, not rejected; it can be added later without breaking the owned-loop default.

Consequences

Callbacks now run concurrently, so user code must be thread-safe. Because tao exits the process directly, ordered shutdown is Dry’s responsibility: CloseRequested is intercepted so Python close hooks run and the asyncio loop closes before exit.

The Bridge contract is the JSON data model

Values crossing the Bridge follow json.dumps / json.loads semantics exactly, and anything outside that set raises instead of converting. This replaces a best-effort scheme that guessed at the closest match and produced silent corruption: booleans arrived in JavaScript as numbers, dictionary keys of every type were silently coerced to strings, and bytes was documented as number[] while having no representation in the model at all.

The rule is chosen for the sentence it fits into rather than the table it generates — “whatever json accepts” is already in every Python developer’s head, and the default= hook they reach for is the same one json.dumps gives them.

Consequences

set and bytes leave the contract deliberately: neither has a JSON analogue, and both round-trip destructively. Integers outside ±2⁵³ raise rather than silently losing precision. Dictionary keys are coerced to strings exactly as json.dumps coerces them, so a round trip returns string keys. datetime, Decimal, Enum and dataclasses are the developer’s job, through the hook.

abi3 wheels with a Python 3.14 floor

Dry ships stable-ABI (abi3-py314) wheels, two per release — win_amd64 and macosx_universal2 — rather than a matrix of one build per Python version. A single wheel then imports on every future CPython, so a new October release needs no action; the previous per-version approach left the library claiming 3.13 support while its own author ran 3.14.

The floor sits at 3.14 rather than 3.11 for two reasons. Modern typing without typing_extensions keeps the zero-dependency promise intact, and PEP 649’s deferred annotations are what make reading a callback’s declared signature at runtime tractable — which is how Dry reports “save_file expects str for path, received number” instead of a raw TypeError from inside the bridge.

Consequences

Users on 3.11–3.13 are excluded, which is most installed CPython today. This is accepted because it is cheap to undo: lowering the floor later is purely additive — a cp311-abi3 wheel serves everyone the current one serves, and breaks nobody.

abi3 does not cover free-threaded builds, so python3.14t cannot install Dry. abi3t (PEP 803) arrives with Python 3.15 and can be published alongside without touching the existing wheel.

The limited API excludes some PyO3 types, PyFunction among them. The replacement — Py<PyAny> plus a callable() check — is wanted anyway, since it accepts functools.partial, objects with __call__, and built-in functions that the old signature rejected.

macOS resizes an undecorated window from the frontend

An undecorated window draws eight resize edges, and each one hands its grab to tao’s drag_resize_window, which lets the platform take the drag over. macOS has no such platform path: tao 0.36 implements drag_resize_window in its macOS backend as an unconditional Err(NotSupported). The message reached Rust, Rust logged a failure, and the window did not move — a frontend was being handed eight resize handles that could not resize anything, which reads as a bug in the frontend’s own code rather than in ours.

Three ways out were open: implement the drag natively through NSWindow, draw no edges on macOS at all, or document that they do nothing. Nothing is the worst of the three, so it was never really in the running. Drawing no edges is honest, but it costs every macOS user of an undecorated window the ability to resize it, which is most of the reason to reach for one. The native implementation needs objc2 in Cargo.toml, a hand-written NSEvent tracking loop, and unsafe message sends on a platform we ship — a large surface for the size of the problem.

So on macOS dry.resize(direction) runs the drag itself: it tracks the pointer, reports it to Rust on every move, and Rust moves the grabbed edges to it with set_outer_position and set_inner_size. Windows is untouched and still hands the grab to the platform, which is both cheaper and better behaved than anything we would write. The split lives in src/js/window_functions.js and src/window/resize.rs.

Consequences

The pointer travels in client coordinates. WebKit reports window.screenX as 0 for an undecorated window whatever the window’s real position, so screen coordinates are not available to be trusted; the window’s own frame of reference is.

Every report is absolute rather than incremental — each one asks for the geometry the pointer implies, given the frame the window has now — so a report that is coalesced, dropped or late costs a frame and not a permanent drift. What a drag holds still is pinned once, when the edge is grabbed: macOS refuses to lift a window’s top above the menu bar, and without a pinned bottom edge that refusal would walk the window down the screen for as long as the user kept pushing up.

A resize the platform drives is clamped to the window’s minimum size by the platform; a set_inner_size is not. The minimum is therefore recorded when the window is built and enforced in resize.rs.

The macOS path is a frontend loop rather than a platform one, so it repaints per report rather than per frame, and a resize under heavy JavaScript load will trail the cursor in a way the Windows path does not.