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 Python | Python to frontend | |
|---|---|---|
| Call — returns a value | window.dry.api.name(...) | deliberately absent; use wv.eval_js |
| Event — returns nothing | window.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
- Installing and your first Webview.
- Content modes — an HTML string, a URL, or a directory.
- The Bridge — Calls, Events, and the contract on what may cross.
- Migrating to 0.4.0 if you are on 0.3.x. It is a breaking release.
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
| Platform | Wheel | Status |
|---|---|---|
| Windows (x86-64) | win_amd64 | Built and tested on every commit |
| macOS (Intel and Apple silicon) | macosx_universal2 | Built and tested on every commit |
| Linux | none | Not 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
| Request | Answer |
|---|---|
| A file inside the Root | 200, with the content type its extension implies |
| A directory inside the Root | its index.html |
| A path that resolves outside the Root | 403 Outside the root: <path> |
| A path inside the Root with no file there | 404 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.jsis 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.htmlwill get a404instead. 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.
| Python | JavaScript |
|---|---|
None | null |
bool | boolean |
int | number |
float | number |
str | string |
list | array |
tuple | array |
dict | object |
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
tupleis written as an array, so a round trip returns alist. - A
dictkeeps its insertion order across the Bridge. - Dictionary keys are coerced to strings, exactly as
json.dumpscoerces them, so a round trip returns string keys. Onlystr,int,float,boolandNonemay be keys; anything else raises. - An
intoutside ±2**53 raises, in both directions, because JavaScript would read it with digits missing. Pass it as astrif you need the digits. NaNandInfinityraise. JSON has neither.set,frozenset,bytesandbytearrayraise. JSON has none of them and none survives the round trip. Pass alist, or astr— base64 if the bytes are binary.datetime,Decimal,Enum, dataclasses and everything else raise unless you convert them, which is what thedefault=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,bytesorbytearray— those are refused before it, because a silent conversion is what the contract exists to prevent; - an
intoutside ±2**53,NaNorInfinity; - a dictionary key.
json.dumpsdoes not pass keys todefaulteither. 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.
| Setting | After run() |
|---|---|
html, url, root | raises |
api, default | raises |
dev_tools | raises |
app_id, user_data_folder | raises |
on_close | raises |
title, size, min_size, decorations, icon_path | assignable, 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']))
| Name | Value |
|---|---|
window:maximized, window:unmaximized | null |
window:minimized, window:restored | null |
window:hidden, window:shown | null |
window:focused, window:blurred | null |
window:resized | {width, height} |
window:moved | {x, y} |
window:close-requested | null |
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 = Trueand a double-click on the titlebar reach a listener as the samewindow: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:hiddenandwindow:shownare reachable throughwv.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=andmin_size=are given, so they are the numbers CSS is working in. window:resizedandwindow:movedare 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-requestedis 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.
| Setting | Assigned while running |
|---|---|
title | Retitles the window |
size | Resizes it, logical pixels |
min_size | Sets the floor, and grows the window to meet it |
decorations | Adds or removes the native titlebar and borders |
icon_path | Replaces 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.
| State | Assigned | Read |
|---|---|---|
position | Moves the window, logical pixels | Where its top-left corner is, decorations included |
visible | Takes it off the screen, or puts it back | Whether it is on screen |
maximized | Maximizes, or restores the previous size | Whether it fills its screen |
minimized | Minimizes to the dock or taskbar, or restores | Whether it is minimized |
fullscreen | Takes over the screen, or comes back | Whether 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=Trueand undecorated at runtime has no resize edges, and reloading the Content does not produce them. It can still be resized from Python and fromwindow.dry.resize(direction), but there is nothing on its border to grab. - A window built with
decorations=Falsekeeps its eight edge strips even afterdecorations = 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:
- 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.
- 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 theCancelledError. 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. - 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.
- The asyncio loop is drained and stopped.
atexithandlers run. Dry runs them by hand, because the platform event loop exits the process from under the interpreter and they would otherwise never happen.- 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:
| Platform | Location |
|---|---|
| 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.)
| Exception | Raised for |
|---|---|
WebviewError | A window or web content that could not be built |
BridgeError | A message that could not cross to or from the frontend |
PanicError | A 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)
| Logger | Carries |
|---|---|
dry | The parent. Configure this one to catch everything |
dry.webview | The window and the web content: a failed navigation, an unreadable icon |
dry.bridge | Messages 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
| Option | Type | Default | Meaning |
|---|---|---|---|
title | str | 'My Dry Webview' | The window title. Cosmetic only |
size | tuple[int, int] | (800, 600) | Initial dimensions, logical pixels |
min_size | tuple[int, int] | (800, 600) | Minimum dimensions, logical pixels |
decorations | bool | True | Native titlebar and borders |
icon_path | str | os.PathLike | None | None | Window icon, .ico, Windows only |
html | str | None | None | Content: an HTML string |
url | str | None | None | Content: an address to load |
root | str | os.PathLike | None | None | Content: a directory to serve |
api | dict[str, Callable] | None | None | The names the frontend may Call |
dev_tools | bool | False | Enable the web inspector |
app_id | str | None | derived | Decides where this application’s data lives |
user_data_folder | str | os.PathLike | None | from app_id | Overrides that location outright |
default | Callable[[Any], Any] | None | None | Converts a value outside the Bridge contract |
on_close | Callable[[], object] | None | None | Asked 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
| Method | Does |
|---|---|
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.
| Property | Type | Means |
|---|---|---|
position | tuple[int, int] | Where the window’s top-left corner sits, logical pixels |
visible | bool | Whether it is on screen; False hides it without closing it |
maximized | bool | Whether it fills its screen |
minimized | bool | Whether it is minimized to the dock or taskbar |
fullscreen | bool | Whether 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.rootreads back a resolvedpathlib.Path, whatever you assigned.wv.icon_pathreads back a POSIX-stylestr.wv.user_data_folderreads 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
| Member | Signature | Does |
|---|---|---|
dry.api | dry.api.<name>(...args) -> Promise | Calls the Python callable registered under <name> |
dry.on | dry.on(name, listener) -> () => void | Registers a listener; returns an unsubscribe function |
dry.off | dry.off(name, listener) | Takes one registration off |
dry.emit | dry.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
| Member | Does |
|---|---|
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
| Attribute | Does |
|---|---|
data-drag-region | The element and its subtree drag the window; a double click toggles maximize |
data-no-drag-region | The 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.x | 0.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.ipcStore | gone; 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.x | 0.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')raisesTypeError. - Assigning an unknown attribute raises
AttributeErrorinstead 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:
| Platform | 0.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.
| Value | 0.3.x | 0.4.0 |
|---|---|---|
set, frozenset | crossed as an array | raises TypeError — pass a list |
bytes, bytearray | crossed as number[] | raises TypeError — decode, or base64 to a str |
True / False | arrived as 1 / 0 | arrive as true / false |
int beyond ±2**53 | crossed, silently losing digits | raises ValueError, in both directions |
NaN, Infinity | crossed | raise ValueError |
datetime, Decimal, Enum, dataclasses | raised | raise, unless a default= hook converts them |
a dict key that is not a str | — | coerced 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 amultiprocessing.Processto 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.emitin Python,window.dry.on/off/emitin the frontend. Events - Window Events.
window:maximized,window:resizedand 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 untilabi3t. - The JavaScript surface is namespaced under
window.dry.window.api,window.minimize,window.toggleMaximize,window.close,window.dragandwindow.resizemove under it;window.ipcCallbackandwindow.ipcStoreare gone from the public surface.window.close,window.resizeToandwindow.resizeByare the browser’s own again. - Content is explicit.
wv.contentand its sniffing are replaced by three mutually exclusive modes:html,urlandroot. Declaring two raises; declaring none raises atrun(). 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 afterrun(), 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.
titleis now cosmetic. - The Bridge contract is the JSON data model, in both directions.
set,frozenset,bytesandbytearrayraise; integers beyond ±2**53,NaNandInfinityraise; booleans arrive as booleans rather than as1and0; dictionary keys are coerced to strings asjson.dumpscoerces them. sizeandmin_sizeare 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,BridgeErrorandPanicErrorreplace printed diagnostics and an aborting panic; Dry writes nothing to stdout or stderr, logging instead todry,dry.webviewanddry.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.emitandwv.eval_jsin Python;window.dry.on,offandemitin 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,decorationsandicon_pathnow apply to the open window instead of only changing a stored value, andposition,visible,maximized,minimizedandfullscreenjoin them as properties of a window on screen, raising aRuntimeErrornaming the property beforerun(). Every change is announced through the window Events exactly as a change the user made. - A state query on both sides:
wv.state()returns aWindowStateNamedTuple, andawait 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.htmlfor directories,403for a path escaping the Root and404for a missing file. - A close hook:
on_close, asked on every route in, able to refuse a close by returningFalse, followed by an ordered shutdown that drains in-flight Calls and runsatexithandlers. - A
default=hook, the onejson.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 athreading.Threadworks.- A failed navigation is diagnosed and reported on
dry.webviewinstead 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
backingScaleFactor2.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_windowis 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_returnandControlFlow::Polldriven 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.