tauri-plugin-py-runtime 0.1.0

Tauri 2 plugin to write your app backend in Python: commands, events, channels and standalone packaging
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
# Tauri Plugin py-runtime

**Build Tauri desktop apps with a Python backend.**

py-runtime lets you write the backend of a [Tauri 2](https://tauri.app) application in Python
while keeping Tauri's small footprint, native webview and security model. Python functions
become commands your frontend can call with full type information, streaming, cancellation and
structured errors. When you ship, your backend is compiled into a self-contained executable, so
end users never need to install Python.

```python
# src-python/main.py
from tauri_runtime import App

app = App()

@app.command
async def greet(name: str) -> str:
    return f"Hello {name}, from Python!"

app.run()
```

```ts
// frontend
import { pyInvoke } from "tauri-plugin-py-runtime-api";

const message = await pyInvoke<string>("greet", { name: "Tauri" });
```

## Highlights

- **Python-first commands.** Plain `def` and `async def` functions become frontend commands.
  Arguments are validated with pydantic, and camelCase keys map to snake_case automatically.
- **Real-time communication.** Stream progress over channels, exchange events in both
  directions, and send binary data without base64 overhead.
- **Production-grade reliability.** Every call supports timeouts and cancellation. The runtime
  is supervised, can restart after a crash, and shuts down gracefully with bounded deadlines.
- **Zero-dependency distribution.** `tauri-runtime build` packages the backend with Nuitka
  into a standalone executable that ships inside your app bundle.
- **One-command setup.** `tauri-runtime create` and `tauri-runtime init` scaffold a project or
  add Python to an existing one, including dependencies, configuration and permissions.
- **Scales with your codebase.** Routers with prefixes organize large backends into modules.
- **Secure by default.** Access goes through Tauri capabilities, and a webview can only cancel
  its own calls.

## How it works

```mermaid
flowchart LR
    FE["Frontend<br/>tauri-plugin-py-runtime-api"] -- "invoke · events · channels" --> RS["Rust plugin<br/>tauri-plugin-py-runtime"]
    RS -- "framed messages over stdin/stdout" --> PY["Python process<br/>tauri-runtime SDK"]
    PY -- "responses · events · stream data" --> RS
    RS --> FE
```

The Rust plugin starts your Python backend as a child process and supervises it. The two talk
over stdin/stdout using a compact length-prefixed protocol with JSON headers and optional binary
bodies, described in [Protocol](#protocol). The frontend never talks to Python directly: every
call goes through Tauri's IPC and permission system.

| Component | Package | Install |
| --- | --- | --- |
| Rust plugin | `tauri-plugin-py-runtime` (plugin name `py-runtime`) | `cargo add tauri-plugin-py-runtime` |
| Python SDK | `tauri-runtime`, imported as `tauri_runtime` | `uv add tauri-runtime` |
| Frontend API | `tauri-plugin-py-runtime-api` | `pnpm add tauri-plugin-py-runtime-api` |

## Requirements

| Requirement | Version |
| --- | --- |
| Tauri | 2.x |
| Rust | 1.77.2 or newer |
| Python | 3.11 or newer (the SDK depends on `pydantic>=2.9`) |
| [uv]https://docs.astral.sh/uv/ | Recommended. The CLI uses it to manage the backend environment |
| C compiler | Needed only for packaging: Xcode Command Line Tools on macOS, MSVC or MinGW on Windows, gcc on Linux |

## Quick start

Create a new app with a Python backend:

```bash
uvx --from tauri-runtime tauri-runtime create my-app --template vue-ts --manager pnpm
cd my-app
pnpm tauri dev
```

To add a Python backend to an existing Tauri project instead, run this in the directory that
contains `src-tauri`:

```bash
uvx --from tauri-runtime tauri-runtime init
```

During development, `pnpm tauri dev` runs `src-python/main.py` with the project's virtual
environment. `pnpm tauri build` compiles the backend and bundles it into the app.

### What the CLI sets up

`create` runs `create-tauri-app` and then `init`. `init` does the following:

| Area | Change |
| --- | --- |
| Python | Creates `src-python/` with `pyproject.toml` (depends on `tauri-runtime`, with Nuitka as a dev dependency), a starter `main.py` and `.gitignore`, then runs `uv sync` |
| Rust | Adds the crate with `cargo add` and registers the plugin after `tauri::Builder::default()` in `src-tauri/src/lib.rs` |
| Frontend | Installs the API package with pnpm, yarn, bun or npm. The package manager is detected from the lockfile or `beforeDevCommand` |
| Permissions | Adds `py-runtime:default` to `capabilities/default.json` |
| Packaging | Configures `beforeBuildCommand` and `bundle.resources` in `tauri.conf.json`, and `build.rs`, as described in [Distribution]#distribution |

Re-running the CLI is safe: files that are already set up are left alone. If `src-python`
already exists, `init` stops; `--force` continues the setup without overwriting your files. If a
step can't be applied safely, for example an unusual `lib.rs`, `Tauri.toml` or list-form
`bundle.resources`, the CLI prints exact instructions to apply it by hand.

**Package sources.** By default the CLI installs released packages from crates.io, npm and
PyPI. There are two alternatives:

- `--git` installs all three packages from the GitHub repository. With pnpm this requires pnpm
  10 or newer; the CLI passes `--allow-build` so that pnpm can build the frontend package from
  git.
- `--path <checkout>` uses a local clone of this repository. Build its frontend package first
  with `pnpm install && pnpm build`.

## Guide

### Writing commands

```python
from tauri_runtime import App, Channel, Command, Raw, TauriError

app = App()

@app.command
async def search(query: str, limit: int = 20) -> list[str]:
    ...

@app.command
def checksum(data: Raw) -> str:          # receives a Uint8Array / ArrayBuffer from the frontend
    ...

@app.command
async def export(rows: int, on_progress: Channel[int]) -> str:
    for i in range(rows):
        await on_progress.send(i)
    return "done"

@app.command("files.remove")             # register under a custom name
async def remove(path: str, ctx: Command) -> None:
    if not path:
        raise TauriError("bad_path", "path is empty", {"path": path})

app.run()
```

**Execution model**

- `async def` commands run on the event loop. Plain `def` commands run in a thread pool.
- Cancelling a call cancels the asyncio task of an `async def` command. A plain `def` command
  keeps running in its worker thread until it returns, because Python can't interrupt threads,
  and its result is discarded.

**Arguments**

- Parameters are filled from the JSON arguments, with camelCase keys mapped to snake_case.
- A missing required parameter fails with `invalid_args`.
- Annotated parameters are validated with pydantic. A validation failure fails with
  `invalid_args`, and the validation errors are sent as `data`.

**Special parameter types.** Annotate a parameter with one of these types to receive the value
described instead of a JSON value:

| Annotation | Receives |
| --- | --- |
| `Raw` | The raw request body (`data.bytes`) when the frontend passes a `Uint8Array` or `ArrayBuffer` |
| `Channel[T]` | A stream to the frontend `Channel` passed under the same name (`onProgress` → `on_progress`). Send with `await ch.send(value)`, or `ch.send_sync(value)` from a plain `def` command. The channel is valid only until the command returns |
| `Command` | The call context: `id`, `cmd`, `window` (label of the calling webview) and `cancelled` |
| `App` | The app instance |

**Return values**

- Return any value pydantic can serialize to JSON, including dataclasses and pydantic models.
- Return `Raw(...)` or `bytes` to send binary data. The frontend receives a `Uint8Array`.
- `NaN` and `±Infinity` are not valid JSON and are rejected. Returning them fails the call with
  `internal`; sending them on a channel or with `emit` raises `ValueError`.

**Errors**

- Raise `TauriError(code, message, data)` to return a structured error with your own code.
- If `data` isn't JSON-serializable, the error is sent without `data` and a `[WARN]` is logged.
- Any other exception becomes `internal` with `data = {"type": "<ExceptionName>"}`. Set
  `TAURI_RUNTIME_DEBUG=1` in the Python environment to also include the traceback.

### Events and lifecycle hooks

```python
from tauri_runtime import Event

@app.listen("user:login")                # receives pyEmit("user:login", ...) from the frontend
async def on_login(event: Event):
    await app.emit("welcome", {"name": event.payload["name"]}, target=event.window)

@app.on("ready")
async def on_ready():
    print("Application started")
```

- A listener receives an `Event` with `name`, `payload` and `window`.
- `await app.emit(name, payload, target=None)` sends an event to the frontend. `target` is a
  webview label; without one the event is broadcast to all webviews.
- The name `exited` is reserved for the runtime's own `py://exited` event, so
  `app.emit("exited", ...)` raises `ValueError`.

There are three lifecycle hooks:

| Hook | When it runs |
| --- | --- |
| `startup` | Before the handshake. An exception aborts the runtime |
| `ready` | After the Rust side has confirmed the handshake |
| `shutdown` | When the app exits, within the shutdown timeout |

`print` is safe to use: stdout is reserved for the protocol, so `sys.stdout` is redirected to
stderr, and stderr is forwarded to the app log (see [Logging](#logging)).

### Organizing larger projects

Every decorator has an imperative counterpart: `app.add_command(func, name=None)`,
`app.add_commands(*funcs)`, `app.add_listener(event, func)` and `app.add_hook(hook, func)`.

To split a backend into modules without importing the `App` instance everywhere, group
registrations in a `Router` and mount it once in the entry file:

```python
# users.py
from tauri_runtime import Router

router = Router(prefix="user")

@router.command
async def login(name: str) -> str:  # invoked as pyInvoke("user.login", { name })
    return f"welcome {name}"

def logout() -> None: ...

router.add_command(logout)  # "user.logout"

@router.listen("app:theme")  # event names are not prefixed
def on_theme(event): ...
```

```python
# main.py
from tauri_runtime import App
import users

app = App()
app.include(users.router)
app.run()
```

- The prefix is joined with `.`. `router.include(other)` nests routers, which gives names such as
  `"user.session.login"`.
- A `Router` supports the same `command`, `listen` and `on` decorators and the same `add_*`
  methods as `App`.
- `app.include(...)` checks handler signatures and duplicate names when it mounts the router,
  and it takes a snapshot: anything added to the router afterwards is ignored.

### Calling Python from the frontend

```ts
import { Channel, pyEmit, pyInvoke, pyListen, pyStatus, PyRuntimeError } from "tauri-plugin-py-runtime-api";

const results = await pyInvoke<string[]>("search", { query: "tauri" }, { timeoutMs: 5000 });

const onProgress = new Channel<number>();
onProgress.onmessage = (n) => console.log(n);
await pyInvoke("export", { rows: 100, onProgress });

const unlisten = await pyListen("welcome", (e) => console.log(e.payload));
await pyEmit("user:login", { name: "Kylin" });

console.log(await pyStatus()); // "starting" | "running" | "crashed" | "stopped" | "failed"
```

#### `pyInvoke(cmd, args?, options?)`

**Arguments**

- `args` is either a plain object, which is sent as JSON, or a `Uint8Array` / `ArrayBuffer`,
  which is sent as raw bytes and received by a `Raw` parameter.
- `Channel` values are detected only at the **top level** of `args`. A channel nested inside an
  object or array is serialized as a plain value and is not connected to Python.
- Binary results come back as a `Uint8Array`.

**Timeouts**

- `options.timeoutMs` overrides the default invoke timeout for this call. It must be a
  non-negative integer; otherwise `pyInvoke` throws `invalid_args`.
- The value is capped at 10× the configured `invoke_timeout`, which is 5 minutes with the
  default of 30 seconds.
- On timeout the call rejects with `timeout`, and Python receives a cancel for it.

**Cancellation**

- `options.signal` takes an `AbortSignal`. On abort the promise rejects with `cancelled`
  immediately, without waiting for the backend, and a cancel is forwarded to Python.
- An already-aborted signal rejects before anything is sent.

**Call ids.** Each call carries a client call id that is scoped to the calling window:

- A webview can cancel only its own calls.
- A second call that reuses an id still pending in the same window is rejected with
  `invalid_args`.
- `pyInvoke` generates unique ids for you.

#### Channels

- Values sent with `ch.send(...)` arrive in `onmessage` in order.
- Raw bytes (`Raw` / `bytes`) arrive as an `ArrayBuffer`, not a `Uint8Array`, so declare the
  channel as `Channel<ArrayBuffer>`.
- Channel messages and the call result travel separately, so the last `onmessage` can run
  **after** `pyInvoke` has resolved. If you need strict ordering, send an explicit end marker
  on the channel (for example `{ done: true }`) and wait for it instead of relying on the
  promise.

#### Events

- `pyListen(event, handler)` listens to `py://<event>`. Python's `app.emit("x", ...)` arrives
  as `py://x`. `pyListen` returns an unlisten function.
- `pyListen("exited", ...)` (`py://exited`) fires whenever the Python process exits
  unexpectedly. Its payload is `{ code: number | null }`.
- `pyEmit(event, payload)` sends an event to Python's `@app.listen(event)` handlers. The Python
  `Event.window` is the label of the sending webview. If Python isn't running, `pyEmit` fails
  with `runtime_unavailable`.
- Event payloads are JSON. If Python emits raw bytes as an event, the frontend receives them as
  an array of numbers; use a channel or a `pyInvoke` result for binary data.

#### Error handling

Every failure rejects with a `PyRuntimeError` (`name`, `code`, `message`, optional `data`). It
mirrors the `{ code, message, data? }` error shape used across Rust and Python:

```ts
try {
  await pyInvoke("files.remove", { path: "" });
} catch (e) {
  if (e instanceof PyRuntimeError) console.log(e.code, e.message, e.data);
}
```

See [Error codes](#error-codes) for the full list.

### Configuring the Rust plugin

```rust
tauri::Builder::default()
    .plugin(
        tauri_plugin_py_runtime::Builder::new()
            .script("../src-python/main.py")
            .bundle("py-backend")
            .build(),
    )
```

| Method | Default | Description |
| --- | --- | --- |
| `script(path)` | Required unless a bundle is used | Python entry script, see [Script path resolution]#script-path-resolution |
| `python(program)` | `<script dir>/.venv` if present, otherwise `python3` (`python` on Windows) | Interpreter to run |
| `bundle(name)` | None | In release builds, run the Nuitka-packaged backend instead, see [Distribution]#distribution |
| `args(iter)` | None | Extra arguments passed after the script path, or to the packaged executable |
| `env(key, value)` | None | Extra environment variable for the Python process. Call repeatedly to set several |
| `invoke_timeout(Duration)` | 30 s | Default timeout per call. A per-call `timeoutMs` is capped at 10× this value |
| `shutdown_timeout(Duration)` | 3 s | Time Python gets to run its shutdown hooks |
| `handshake_timeout(Duration)` | 10 s | Time Python gets to start and send its handshake. Calls made while the runtime is starting wait this long too |
| `restart_on_crash(bool)` | `false` | Restart the process after a crash, see [Process lifecycle]#process-lifecycle |

The Python process always gets `PYTHONUNBUFFERED=1` and `PYTHONIOENCODING=utf-8`.

From Rust code you can reach the runtime with `app.py_runtime().supervisor()` (via
`PyRuntimeExt`), for example to call commands or read the status.

#### Configuration file

The same settings can be set in `plugins.py-runtime` in `tauri.conf.json`, except `env` and
`handshake_timeout`, which are available only on the `Builder`. Keys are camelCase, and unknown
keys are rejected so that a typo fails at startup instead of being silently ignored:

```json
{
  "plugins": {
    "py-runtime": {
      "python": "python3",
      "script": "../src-python/main.py",
      "bundle": "py-backend",
      "args": ["--verbose"],
      "invokeTimeoutMs": 30000,
      "shutdownTimeoutMs": 3000,
      "restartOnCrash": false
    }
  }
}
```

Values set on the `Builder` take precedence over the configuration file. To configure the plugin
only through the file, register it with `tauri_plugin_py_runtime::init()`.

#### Permissions

Add `"py-runtime:default"` to your capability. It allows `invoke`, `cancel`, `emit` and
`status`; the individual permissions are named `py-runtime:allow-<name>`.

#### Script path resolution

- An absolute path is used as is.
- A relative path is tried against the following locations, and the first existing file wins:
  1. Debug builds only: the process working directory. Under `tauri dev` that is `src-tauri`,
     so `"../src-python/main.py"` points at `<project>/src-python/main.py`.
  2. The app's resource directory, with every `..` component replaced by `_up_`. This is the
     same layout Tauri's bundler uses for `bundle.resources`.
- Release builds look only in the resource directory, so a script that runs in release builds
  has to be shipped via `bundle.resources`, in one of two forms:
  - List form: `"resources": ["../src-python/**/*"]` together with
    `script: "../src-python/main.py"`.
  - Map form: `"resources": { "../src-python/": "src-python/" }` together with
    `script: "src-python/main.py"`.
- If nothing is found, plugin setup fails with an error that lists every path it tried.
- Python runs with the script's directory as its working directory.

In script mode the interpreter must be able to import `tauri-runtime` (and therefore
`pydantic`). Without an explicit `python`, the plugin uses the virtual environment next to the
script (`.venv/bin/python`, or `.venv\Scripts\python.exe` on Windows), so running `uv sync` in
`src-python` is all `tauri dev` needs.

## Distribution

`tauri-runtime build` compiles the Python backend with [Nuitka](https://nuitka.net) in
standalone mode. The result is a folder containing an executable, the Python runtime and all
dependencies, so your users don't need Python installed. Debug builds keep running the script
for fast iteration; release builds with `bundle` set run the packaged executable.

Projects created with `tauri-runtime create` or `init` are already configured. To set up
packaging by hand:

1. **Add Nuitka** to the Python project:

   ```bash
   cd src-python && uv add --dev nuitka
   ```

2. **Configure the build** in `src-python/pyproject.toml`. This step is optional; the values
   below are the defaults:

   ```toml
   [tool.tauri-runtime.build]
   entry = "main.py"      # entry script
   name = "py-backend"    # executable and folder name
   output = "dist"        # output directory
   nuitka-args = []       # extra Nuitka arguments, e.g. ["--include-data-dir=assets=assets"]
   ```

   `uv run tauri-runtime build` produces `dist/py-backend/py-backend` (`py-backend.exe` on
   Windows). Arguments after `--` are passed to Nuitka. The build runs in a temporary directory
   and replaces the previous output only if it succeeds.

3. **Build before the app and bundle the output** as a resource in `tauri.conf.json`.
   `beforeBuildCommand` runs in the directory that contains `src-tauri`:

   ```json
   {
     "build": {
       "beforeBuildCommand": "pnpm build && cd src-python && uv run tauri-runtime build"
     },
     "bundle": {
       "resources": { "../src-python/dist/py-backend/": "py-backend/" }
     }
   }
   ```

   tauri-build checks that resource paths exist even for `tauri dev`, so create the folder in
   `src-tauri/build.rs` before the backend is first packaged:

   ```rust
   fn main() {
       std::fs::create_dir_all("../src-python/dist/py-backend").expect("failed to create the Python bundle directory");
       tauri_build::build()
   }
   ```

4. **Point the plugin at the bundle:**

   ```rust
   tauri_plugin_py_runtime::Builder::new()
       .script("../src-python/main.py") // debug builds
       .bundle("py-backend")            // release builds: <resources>/py-backend/py-backend
       .build()
   ```

**Notes**

- The switch between script and bundle follows `debug_assertions`, so `tauri build --debug`
  still runs the script.
- In release builds, `python` and `script` are ignored when `bundle` is set. If the executable
  isn't found, plugin setup fails with the path it looked for.
- Nuitka follows imports. Modules that are imported dynamically, and data files, need extra
  `nuitka-args` such as `--include-module`, `--include-package-data` or `--include-data-dir`.
- The first build takes a few minutes. Nuitka caches compiled C files (ccache), so later builds
  are faster.

## Reference

### Error codes

| Code | Meaning |
| --- | --- |
| `command_not_found` | No Python command with that name |
| `invalid_args` | Bad arguments: a missing parameter, pydantic validation failure or malformed request |
| `internal` | Unhandled Python exception or internal error |
| `cancelled` | The call was cancelled |
| `timeout` | The call exceeded its timeout |
| `runtime_unavailable` | Python isn't running: it failed to start, its handshake failed or timed out, or it was stopped |
| `runtime_exited` | Python exited while the call was in flight |
| `protocol_mismatch` | The Python SDK speaks a different protocol version |
| `frame_too_large` | A message exceeded the 64 MiB frame limit |

Codes raised with `TauriError` in Python are passed through unchanged.

### Process lifecycle

**Startup**

- The Python process starts during plugin setup.
- Python sends a handshake that lists its commands. Rust checks the protocol version and
  replies; then `ready` hooks run and the status becomes `running`.
- Calls made while the runtime is starting wait for the handshake.

**Crashes and restarts**

- If Python exits unexpectedly, in-flight calls fail with `runtime_exited`, `py://exited` is
  emitted and the status becomes `crashed`.
- With `restart_on_crash` / `restartOnCrash` enabled (it is off by default), the process is
  restarted with exponential backoff: 1 s, 2 s, 4 s and so on, capped at 30 s.
- The backoff resets once a process has stayed up for at least 30 s.
- If a restarted process fails its handshake, restarting stops and the status becomes
  `failed`.

**Shutdown**

- On app exit, Rust sends a `shutdown` message with `timeoutMs`, set to the shutdown timeout.
- Python cancels running calls and runs `shutdown` hooks, all within that one time budget. An
  invalid `timeoutMs`, or a closed stdin, falls back to 3 s. Python then exits.
- Handlers that ignore cancellation, and stuck `def` handlers, are abandoned rather than awaited.
- If the process is still alive after the shutdown timeout plus 1 s of grace, it is killed.

**`app.run()` always ends the process.** If an exception escapes, including `sys.exit()` or
`KeyboardInterrupt` raised from a handler, it flushes output and exits with the `sys.exit` code,
or with 1 for any other exception.

### Logging

Python's stderr, including `print` output (which is redirected there), is forwarded line by line
to the Rust `log` crate under target `py-runtime`. A leading `[DEBUG]`, `[INFO]`, `[WARN]` or
`[ERROR]` selects the level and is stripped; other lines are logged at `INFO`:

```python
print("[WARN] disk almost full")   # -> WARN  py-runtime: disk almost full
```

Install a logger such as `tauri-plugin-log` to see the output.

### Protocol

Rust and Python exchange frames over the child process's stdin/stdout. The current protocol
version is 2.

**Frame layout**

- Every frame is `[u32 BE length][u32 BE header length][header JSON][body]`.
- The outer length counts everything after itself and is capped at 64 MiB.
- The header is one JSON object tagged by `type`:

| `type` | Direction | Fields |
| --- | --- | --- |
| `hello` | Python → Rust | `protocol`, `sdk`, `commands` |
| `ready` | Rust → Python | `protocol`, `app: { name, version }` |
| `shutdown` | Rust → Python | `timeoutMs` |
| `request` | Rust → Python | `id`, `cmd`, `channels?` (argument name → channel id), `window?`, payload |
| `response` | Python → Rust | `id`, payload |
| `error` | Python → Rust | `id`, `code`, `message`, `data?` |
| `cancel` | Rust → Python | `id`, `reason` (`"client"` or `"timeout"`) |
| `event` | Both | `event`, `target?` (Python → Rust), `window?` (Rust → Python), payload |
| `channel_data` | Python → Rust | `channel`, payload |
| `channel_close` | Python → Rust | `channel` |

**Payloads**

- A payload is either a JSON value inline in the header as `value` (an absent `value` means
  none, or `null`), or binary data in the frame body with `"raw": true`.
- The body must be empty unless `raw` is set.
- `NaN` and `±Infinity` are never sent.

**Error handling**

- Unknown header fields are ignored.
- A frame whose header can't be decoded is logged and skipped. That covers invalid JSON, an
  unknown `type`, and a missing or mistyped field.
- An oversized frame ends the connection.
- If the first frame from Python isn't a decodable `hello` with the same protocol version,
  startup fails with `protocol_mismatch`.

**Implementation.** The message types are a serde enum on the Rust side
(`bridge::protocol::Message`) and pydantic models on the Python side (`tauri_runtime.protocol`).
`python-sdk/tests/fixtures/golden_frames.json` holds one encoded frame per message type, and
both test suites encode and decode them byte for byte.

## Platform support

| Platform | Development (`tauri dev`) | Packaged backend |
| --- | --- | --- |
| macOS (Apple silicon) | Supported | Supported and verified |
| Windows | Supported | Same layout (`.exe` suffix), not yet verified |
| Linux | Supported | Same layout, not yet verified |

Code signing and notarization of the packaged backend are not yet handled.

## Example

`examples/vue` is a complete Vue application that demonstrates commands, raw bytes,
concurrency, errors, timeouts, cancellation, channels and events. Its backend in
`examples/vue/src-python` is a standalone uv project that depends on the in-repo SDK in editable
mode.

```bash
cd examples/vue/src-python && uv sync   # create the backend environment (includes Nuitka)
cd .. && pnpm install && pnpm tauri dev
pnpm tauri build                         # release build with the packaged backend
```

## Development

```bash
cd python-sdk && uv sync                # SDK environment, used by the Rust integration tests
cargo test                              # Rust unit and integration tests ($PYTHON overrides the interpreter)
cd python-sdk && uv run pytest          # Python SDK tests
pnpm install && pnpm build              # build the frontend API (guest-js)
```

## Roadmap

| Stage | Scope | Status |
| --- | --- | --- |
| 1. Runtime core | Process supervision, protocol, commands, events, channels, Python SDK and frontend API | Available |
| 2. Build toolchain | Standalone packaging with Nuitka and project scaffolding | Available; signing and cross-platform verification pending |
| 3. Desktop native APIs | Native desktop capabilities exposed to Python | Planned |
| 4. System extensions | Deeper operating system integration | Planned |
| 5. Hardware access | Access to devices and peripherals | Planned |