pyweft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyweft/desktop.py ADDED
@@ -0,0 +1,606 @@
1
+ """The desktop host: an app in a native window, Python running as ordinary
2
+ CPython on the user's machine (``pyweft run APP --desktop``).
3
+
4
+ The window is a webview (pywebview: WebView2 on Windows, WebKit on macOS,
5
+ GTK or Qt on Linux) showing a small page with the same runtime.js the
6
+ browser uses. This file is the bridge the architecture was built for: the
7
+ core's patch ops cross pywebview's JavaScript bridge as JSON, and events come
8
+ back the same way. Nothing in the core changes.
9
+
10
+ Threads. pywebview's GUI loop owns the main thread; its JavaScript-to-Python
11
+ calls arrive on threads of their own. Every piece of app code runs on one
12
+ asyncio loop thread instead, one thing at a time, as in the browser: the
13
+ bridge only hands work to that loop. Python then drives each cycle itself,
14
+ as shell.html does in the browser: render, push the ops (evaluate_js waits
15
+ until they're applied), settle effects, push focus requests.
16
+
17
+ Host-only, like cli.py: never shipped to the browser.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import concurrent.futures
24
+ import json
25
+ import re
26
+ import sys
27
+ import threading
28
+ import traceback
29
+ from html import escape
30
+ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
31
+ from pathlib import Path
32
+ from typing import Any, Callable
33
+ from urllib.parse import unquote
34
+
35
+ from . import dialogs, routing
36
+ from .component import load_component
37
+ from .differ import diff
38
+ from .hotreload import format_error, hot_swap
39
+ from .renderer import Renderer
40
+ from .styles import collect_styles
41
+ from .vdom import to_dicts
42
+
43
+ _STATIC = Path(__file__).parent / "static"
44
+
45
+ _PAGE = """<!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="utf-8" />
49
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
50
+ <title>__TITLE__</title>
51
+ <style>
52
+ #pyweft-dev-error {
53
+ position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 2147483647;
54
+ max-height: 50vh; overflow: auto; padding: 0.9rem 1.1rem;
55
+ border-left: 4px solid #e5484d; border-radius: 8px;
56
+ background: #2b1213; color: #ffd8d8; white-space: pre-wrap;
57
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
58
+ }
59
+ </style>
60
+ __HEAD__
61
+ __CSS__
62
+ <style id="pyweft-component-styles"></style>
63
+ <script src="runtime.js"></script>
64
+ </head>
65
+ <body>
66
+ <div id="app"></div>
67
+ <script>
68
+ // The desktop bridge: events go to Python through pywebview's js_api;
69
+ // Python calls apply()/commands() back with evaluate_js.
70
+ const pyweftDesktop = (() => {
71
+ let sent = 0; // events handed to Python
72
+ let done = 0; // events Python has rendered
73
+ // Still typing: the input's latest keystrokes haven't come back yet.
74
+ PyWeftRuntime.options.typing = (el) => sent > done && el === document.activeElement;
75
+
76
+ async function start() {
77
+ const api = window.pywebview.api;
78
+ const here = location.hash.startsWith("#/") ? location.hash.slice(1) : "/";
79
+ const first = await api.mount(here);
80
+ setCss(first.css);
81
+ PyWeftRuntime.init(document.getElementById("app"), (id, event) => {
82
+ sent += 1;
83
+ api.dispatch(id, JSON.stringify(event), sent);
84
+ });
85
+ PyWeftRuntime.mount(first.tree);
86
+ window.addEventListener("hashchange", () => {
87
+ if (!location.hash.startsWith("#/")) return; // an in-page anchor
88
+ api.location(location.hash.slice(1));
89
+ window.scrollTo(0, 0);
90
+ });
91
+ await api.mounted();
92
+ }
93
+
94
+ function setCss(text) {
95
+ document.getElementById("pyweft-component-styles").textContent = text;
96
+ }
97
+
98
+ window.addEventListener("pywebviewready", start);
99
+ return {
100
+ apply(ops, seq) {
101
+ PyWeftRuntime.applyOps(ops);
102
+ if (seq) done = Math.max(done, seq);
103
+ },
104
+ commands: (list) => PyWeftRuntime.runCommands(list),
105
+ css: setCss,
106
+ error(text) {
107
+ let el = document.getElementById("pyweft-dev-error");
108
+ if (!el) {
109
+ el = document.createElement("div");
110
+ el.id = "pyweft-dev-error";
111
+ document.body.appendChild(el);
112
+ }
113
+ el.textContent = text;
114
+ },
115
+ clearError: () => document.getElementById("pyweft-dev-error")?.remove(),
116
+ refreshCss(name) {
117
+ for (const link of document.querySelectorAll("link[rel=stylesheet]")) {
118
+ if (link.getAttribute("href").split("?")[0] === "app/" + name) {
119
+ link.href = "app/" + name + "?v=" + Date.now();
120
+ }
121
+ }
122
+ },
123
+ };
124
+ })();
125
+ </script>
126
+ </body>
127
+ </html>
128
+ """
129
+
130
+
131
+ class _Location:
132
+ """routing's host: the location after the # in the window's address."""
133
+
134
+ def __init__(self, app: DesktopApp):
135
+ self.app = app
136
+
137
+ def current(self) -> str:
138
+ return self.app.here
139
+
140
+ def push(self, url: str) -> None:
141
+ self.app.here = url
142
+ self.app.js(f"location.hash = {json.dumps(url)}")
143
+
144
+ def replace(self, url: str) -> None:
145
+ self.app.here = url
146
+ self.app.js(f"history.replaceState(null, '', {json.dumps('#' + url)})")
147
+
148
+ def back(self) -> None:
149
+ self.app.js("history.back()") # comes back through hashchange
150
+
151
+
152
+ class _Dialogs:
153
+ """pyweft.dialogs in a window: the system's own dialogs. They wait for
154
+ the user, so they run off the loop thread and the app keeps going."""
155
+
156
+ def __init__(self, app: DesktopApp):
157
+ self.app = app
158
+
159
+ @staticmethod
160
+ def _file_types(types: list[str]) -> tuple[str, ...]:
161
+ if not types:
162
+ return ()
163
+ patterns = ";".join(f"*.{ext}" for ext in types)
164
+ return (f"{', '.join(types).upper()} files ({patterns})", "All files (*.*)")
165
+
166
+ async def open(self, types: list[str], multiple: bool) -> list[tuple]:
167
+ import webview
168
+
169
+ paths = await asyncio.to_thread(
170
+ self.app.window.create_file_dialog,
171
+ webview.FileDialog.OPEN,
172
+ allow_multiple=multiple,
173
+ file_types=self._file_types(types),
174
+ )
175
+
176
+ def read() -> list[tuple]:
177
+ return [(Path(p).name, Path(p).read_bytes(), str(p)) for p in paths or ()]
178
+
179
+ return await asyncio.to_thread(read)
180
+
181
+ async def save(self, name: str, data: bytes, types: list[str]) -> bool:
182
+ import webview
183
+
184
+ chosen = await asyncio.to_thread(
185
+ self.app.window.create_file_dialog,
186
+ webview.FileDialog.SAVE,
187
+ save_filename=name,
188
+ file_types=self._file_types(types),
189
+ )
190
+ if not chosen:
191
+ return False
192
+ path = chosen if isinstance(chosen, str) else chosen[0]
193
+ await asyncio.to_thread(Path(path).write_bytes, data)
194
+ return True
195
+
196
+ async def confirm(self, message: str, title: str) -> bool:
197
+ return bool(
198
+ await asyncio.to_thread(
199
+ self.app.window.create_confirmation_dialog, title or "Confirm", message
200
+ )
201
+ )
202
+
203
+
204
+ class _Api:
205
+ """What the page can call (window.pywebview.api.*). pywebview exposes
206
+ every public method, so this holds nothing else."""
207
+
208
+ def __init__(self, app: DesktopApp):
209
+ self._app = app
210
+
211
+ def mount(self, here: str) -> dict:
212
+ return self._app.call(self._app.mount, here)
213
+
214
+ def mounted(self) -> None:
215
+ self._app.call(self._app.settle)
216
+
217
+ def dispatch(self, handler_id: str, event_json: str, seq: int) -> None:
218
+ self._app.post(self._app.dispatch, handler_id, json.loads(event_json), seq)
219
+
220
+ def location(self, url: str) -> None:
221
+ self._app.post(self._app.location, url)
222
+
223
+
224
+ class DesktopApp:
225
+ """One app, one window. ``window`` is anything with ``evaluate_js``:
226
+ a pywebview window, or a stand-in in tests."""
227
+
228
+ def __init__(self, app_dir: Path, window: Any = None):
229
+ self.app_dir = Path(app_dir).resolve()
230
+ self.window = window
231
+ self.here = "/"
232
+ self.tree: list = []
233
+ self.renderer: Renderer | None = None
234
+ self.cls: type | None = None
235
+ self._render_queued = False
236
+ self.loop = asyncio.new_event_loop()
237
+ self._thread = threading.Thread(target=self.loop.run_forever, daemon=True)
238
+ self.api = _Api(self)
239
+
240
+ # ---------------------------------------------------- the loop thread
241
+
242
+ def start(self) -> None:
243
+ self._thread.start()
244
+ self.call(self._load)
245
+
246
+ def stop(self) -> None:
247
+ self.loop.call_soon_threadsafe(self.loop.stop)
248
+ self._thread.join(timeout=5)
249
+
250
+ def call(self, fn: Callable, *args: Any) -> Any:
251
+ """Run ``fn`` on the loop thread and wait for its result."""
252
+ future: concurrent.futures.Future = concurrent.futures.Future()
253
+
254
+ def run() -> None:
255
+ try:
256
+ future.set_result(fn(*args))
257
+ except BaseException as exc: # noqa: BLE001 - re-raised in the caller
258
+ future.set_exception(exc)
259
+
260
+ self.loop.call_soon_threadsafe(run)
261
+ return future.result()
262
+
263
+ def post(self, fn: Callable, *args: Any) -> None:
264
+ """Run ``fn`` on the loop thread, without waiting."""
265
+ self.loop.call_soon_threadsafe(self._guarded, fn, *args)
266
+
267
+ def _guarded(self, fn: Callable, *args: Any) -> None:
268
+ try:
269
+ fn(*args)
270
+ except Exception as exc: # noqa: BLE001 - shown, the app keeps running
271
+ self.report(exc)
272
+
273
+ def js(self, script: str) -> Any:
274
+ if self.window is not None:
275
+ return self.window.evaluate_js(script)
276
+ return None
277
+
278
+ # ------------------------------------------------------- the app cycle
279
+
280
+ def _load(self) -> None:
281
+ sys.path.insert(0, str(self.app_dir)) # the app's own modules
282
+ routing.set_host(_Location(self))
283
+ dialogs.set_host(_Dialogs(self))
284
+ self.cls = load_component(self.app_dir / "app.weft")
285
+ self.renderer = Renderer(self.cls())
286
+ # Already on the loop thread whenever app code changes state, except
287
+ # code that made its own thread: call_soon_threadsafe covers both.
288
+ self.renderer.on_invalidate = self._schedule_render
289
+ self.renderer.on_error = self.report
290
+ self.renderer.init_root()
291
+
292
+ def mount(self, here: str) -> dict:
293
+ """The page (re)loaded: the whole current page, where it is now."""
294
+ routing.location_changed(here)
295
+ self.here = here
296
+ self.tree = self.renderer.render()
297
+ return {"tree": to_dicts(self.tree), "css": collect_styles(self.cls)}
298
+
299
+ def _render(self) -> list[dict]:
300
+ new = self.renderer.render()
301
+ ops = diff(self.tree, new)
302
+ self.tree = new
303
+ return ops
304
+
305
+ def _push(self, ops: list[dict], seq: int = 0) -> None:
306
+ if ops or seq:
307
+ self.js(f"pyweftDesktop.apply({json.dumps(ops)}, {seq})")
308
+
309
+ def settle(self) -> None:
310
+ """What happens after every render is on the page: effects and
311
+ on_after_render (rendering again while they change state), then
312
+ focus and scroll requests."""
313
+ while self.renderer.after_render():
314
+ self._push(self._render())
315
+ commands = self.renderer.take_commands()
316
+ for command in commands:
317
+ # The page's <title> is set too, but the window keeps its own.
318
+ if command["op"] == "set_title" and hasattr(self.window, "set_title"):
319
+ self.window.set_title(command["text"])
320
+ if commands:
321
+ self.js(f"pyweftDesktop.commands({json.dumps(commands)})")
322
+
323
+ def dispatch(self, handler_id: str, event: dict, seq: int) -> None:
324
+ try:
325
+ self.renderer.dispatch(handler_id, event)
326
+ finally:
327
+ # Rendered even when the handler failed: it may have changed
328
+ # state first, and the page must learn the event was handled.
329
+ self._push(self._render(), seq)
330
+ self.settle()
331
+
332
+ def location(self, url: str) -> None:
333
+ self.here = url
334
+ routing.location_changed(url)
335
+
336
+ def _schedule_render(self) -> None:
337
+ # Many state changes in one go become one render.
338
+ if not self._render_queued:
339
+ self._render_queued = True
340
+ self.loop.call_soon_threadsafe(self._rerender)
341
+
342
+ def _rerender(self) -> None:
343
+ self._render_queued = False
344
+ if self.renderer.needs_render():
345
+ self._guarded(lambda: (self._push(self._render()), self.settle()))
346
+
347
+ def report(self, exc: BaseException) -> None:
348
+ summary = format_error(exc, root=str(self.app_dir))
349
+ print(f"pyweft: {summary}", file=sys.stderr)
350
+ traceback.print_exception(exc, file=sys.stderr)
351
+ self.js(f"pyweftDesktop.error({json.dumps(summary)})")
352
+
353
+ # ------------------------------------------------------- hot reload
354
+
355
+ def hot_reload(self) -> None:
356
+ """A .weft file changed: recompile, move live components onto the
357
+ new classes keeping their state, and patch the page."""
358
+ new_cls = load_component(self.app_dir / "app.weft")
359
+ changed = hot_swap(self.renderer, new_cls)
360
+ ops = self._render()
361
+ self.cls = new_cls
362
+ self.js(f"pyweftDesktop.css({json.dumps(collect_styles(new_cls))})")
363
+ self._push(ops)
364
+ self.js("pyweftDesktop.clearError()")
365
+ self.settle()
366
+ names = [str(Path(f).relative_to(self.app_dir)) for f in changed]
367
+ print(f"pyweft: hot-reloaded {', '.join(names) or 'app'}")
368
+
369
+
370
+ # --------------------------------------------------------------- settings
371
+
372
+ # [tool.pyweft.desktop] in the app's pyproject.toml: setting -> (kind, default).
373
+ _WINDOW_SETTINGS: dict[str, tuple[str, Any]] = {
374
+ "width": ("pixels", 1100),
375
+ "height": ("pixels", 800),
376
+ "min_size": ("pair", None),
377
+ "resizable": ("bool", True),
378
+ "maximized": ("bool", False),
379
+ "fullscreen": ("bool", False),
380
+ "on_top": ("bool", False),
381
+ "frameless": ("bool", False),
382
+ "background_color": ("color", None),
383
+ }
384
+ _KINDS = {
385
+ "pixels": "a whole number of pixels, like 1200",
386
+ "pair": "two whole numbers, like [800, 600]",
387
+ "bool": "true or false",
388
+ "color": 'a colour like "#1e293b"',
389
+ }
390
+
391
+
392
+ def _fits(kind: str, value: Any) -> bool:
393
+ def pixels(v: Any) -> bool:
394
+ return isinstance(v, int) and not isinstance(v, bool) and v > 0
395
+
396
+ if kind == "pixels":
397
+ return pixels(value)
398
+ if kind == "pair":
399
+ return isinstance(value, list) and len(value) == 2 and all(pixels(v) for v in value)
400
+ if kind == "bool":
401
+ return isinstance(value, bool)
402
+ return isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) is not None
403
+
404
+
405
+ def window_options(app_dir: Path) -> dict[str, Any]:
406
+ """``webview.create_window`` arguments from ``[tool.pyweft.desktop]``;
407
+ anything the app leaves out keeps its default. A setting that doesn't
408
+ exist or doesn't fit is a CliError naming it."""
409
+ from .cli import CliError, app_settings
410
+
411
+ settings = app_settings(app_dir).get("desktop", {})
412
+ unknown = sorted(set(settings) - set(_WINDOW_SETTINGS))
413
+ if unknown:
414
+ raise CliError(
415
+ f"pyproject.toml [tool.pyweft.desktop] has no setting {unknown[0]!r}; "
416
+ f"the settings are {', '.join(_WINDOW_SETTINGS)}"
417
+ )
418
+ options: dict[str, Any] = {}
419
+ for name, (kind, default) in _WINDOW_SETTINGS.items():
420
+ value = settings.get(name, default)
421
+ if value is None:
422
+ continue
423
+ if not _fits(kind, value):
424
+ raise CliError(
425
+ f"pyproject.toml [tool.pyweft.desktop] {name} must be {_KINDS[kind]}, "
426
+ f"not {value!r}"
427
+ )
428
+ options[name] = tuple(value) if kind == "pair" else value
429
+ return options
430
+
431
+
432
+ def window_icon(app_dir: Path, icon: str | None) -> str | None:
433
+ """The icon file for pywebview, where the platform can use it: an .ico
434
+ on Windows, any image on Linux. macOS takes the Dock icon from a packaged
435
+ app, so there it waits for packaging."""
436
+ if icon is None or sys.platform == "darwin":
437
+ return None
438
+ if sys.platform == "win32" and not icon.lower().endswith(".ico"):
439
+ print(f"pyweft: {icon} isn't an .ico file, which a Windows window icon needs; "
440
+ "the window keeps the default icon")
441
+ return None
442
+ return str((Path(app_dir) / icon).resolve())
443
+
444
+
445
+ # ---------------------------------------------------------------- serving
446
+
447
+
448
+ def page(app_dir: Path) -> str:
449
+ from .cli import app_css_files, app_identity, head_extras, linked_app_css
450
+
451
+ head = head_extras(app_dir)
452
+ linked = linked_app_css(head)
453
+ css = "\n ".join(
454
+ f'<link rel="stylesheet" href="app/{name}" />'
455
+ for name in app_css_files(app_dir)
456
+ if name not in linked
457
+ )
458
+ html = _PAGE.replace("__CSS__", css)
459
+ # Last, in one pass: the app's own text is never taken for a placeholder.
460
+ user = {"__TITLE__": escape(app_identity(app_dir).title), "__HEAD__": head}
461
+ return re.sub("__(?:TITLE|HEAD)__", lambda m: user[m.group(0)], html)
462
+
463
+
464
+ def make_server(app_dir: Path) -> ThreadingHTTPServer:
465
+ """The window's page, runtime.js, the app's CSS and assets, on a free
466
+ local port. Only those: nothing else on disk is reachable."""
467
+ from .cli import _asset_path, app_css_files, content_type
468
+
469
+ class Handler(SimpleHTTPRequestHandler):
470
+ def do_GET(self): # noqa: N802 (stdlib naming)
471
+ route = unquote(self.path.split("?", 1)[0])
472
+ if route in ("/", "/index.html"):
473
+ return self._send("text/html", page(app_dir).encode())
474
+ if route == "/runtime.js":
475
+ return self._send("text/javascript", (_STATIC / "runtime.js").read_bytes())
476
+ if route.startswith("/app/") and route[5:] in app_css_files(app_dir):
477
+ return self._send("text/css", (app_dir / route[5:]).read_bytes())
478
+ if route.startswith("/assets/"):
479
+ path = _asset_path(app_dir, route[len("/assets/") :])
480
+ if path is not None:
481
+ return self._send(content_type(path), path.read_bytes())
482
+ self.send_error(404)
483
+
484
+ def _send(self, ctype: str, body: bytes):
485
+ self.send_response(200)
486
+ self.send_header("Content-Type", ctype)
487
+ self.send_header("Content-Length", str(len(body)))
488
+ self.send_header("Cache-Control", "no-store")
489
+ self.end_headers()
490
+ self.wfile.write(body)
491
+
492
+ def log_message(self, fmt, *args):
493
+ pass
494
+
495
+ return ThreadingHTTPServer(("127.0.0.1", 0), Handler)
496
+
497
+
498
+ def _retitle(app: DesktopApp) -> None:
499
+ # The title can follow an edit; size and the other window settings are
500
+ # fixed when the window opens.
501
+ from .cli import CliError, app_identity
502
+
503
+ try:
504
+ title = app_identity(app.app_dir).title
505
+ except CliError as exc:
506
+ print(f"pyweft: {exc}", file=sys.stderr)
507
+ return
508
+ if app.window is not None:
509
+ app.window.set_title(title)
510
+ print("pyweft: window settings other than the title apply after a restart")
511
+
512
+
513
+ def watch(app: DesktopApp, stop: threading.Event, interval: float = 0.3) -> None:
514
+ """Hot reload for the window, from the same file snapshot as the
515
+ browser's dev server."""
516
+ from .cli import snapshot
517
+
518
+ current = snapshot(app.app_dir)
519
+ while not stop.wait(interval):
520
+ new = snapshot(app.app_dir)
521
+ changed = {k for k in current.keys() | new.keys() if current.get(k) != new.get(k)}
522
+ current = new
523
+ if not changed:
524
+ continue
525
+ if any(k.endswith(".py") or k.startswith("pyweft:") for k in changed):
526
+ # Imported Python stays cached in this process; only a restart
527
+ # picks up a changed module or a changed PyWeft.
528
+ names = ", ".join(sorted(k.removeprefix("pyweft:") for k in changed))
529
+ print(f"pyweft: {names} changed; restart `pyweft run --desktop` to use it")
530
+ continue
531
+ if "pyproject.toml" in changed:
532
+ _retitle(app)
533
+ weft = [k for k in changed if k.endswith(".weft")]
534
+ if weft:
535
+ app.post(app.hot_reload)
536
+ for name in sorted(k for k in changed if k.endswith(".css")):
537
+ app.js(f"pyweftDesktop.refreshCss({json.dumps(name)})")
538
+ if any(not k.endswith((".weft", ".css")) for k in changed):
539
+ app.js("location.reload()") # head.html, assets: the page remounts
540
+
541
+
542
+ def run_desktop(app_dir: Path, dev: bool = True) -> None:
543
+ """The app in a window until it closes. ``dev`` (``pyweft run``) adds
544
+ hot reload and terminal messages; a packaged app runs without them."""
545
+ from .cli import CliError, app_identity, app_pyweft_files
546
+
547
+ app_pyweft_files(app_dir) # fails fast without an app.weft
548
+ identity = app_identity(app_dir) # and on a mistake in the settings
549
+ options = window_options(app_dir)
550
+ try:
551
+ import webview
552
+ except ImportError:
553
+ raise CliError(
554
+ "the desktop window needs pywebview: "
555
+ 'uv add "pyweft[desktop]" (or: pip install "pyweft[desktop]")'
556
+ ) from None
557
+
558
+ app = DesktopApp(app_dir)
559
+ try:
560
+ app.start()
561
+ except Exception as exc: # noqa: BLE001 - shown as a CLI error
562
+ app.stop()
563
+ raise CliError(format_error(exc, root=str(Path(app_dir).resolve()))) from None
564
+ server = make_server(app_dir)
565
+ threading.Thread(target=server.serve_forever, daemon=True).start()
566
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
567
+ app.window = webview.create_window(identity.title, url, js_api=app.api, **options)
568
+ stop = threading.Event()
569
+ if dev:
570
+ threading.Thread(target=watch, args=(app, stop), daemon=True).start()
571
+ print(f"pyweft: {app_dir} in a desktop window (close it to stop)")
572
+ print(" hot reload is on: saved .weft and .css files appear in the window")
573
+ icon = window_icon(app_dir, identity.icon)
574
+ try:
575
+ webview.start(**({"icon": icon} if icon else {})) # until the window closes
576
+ finally:
577
+ stop.set()
578
+ server.shutdown()
579
+ app.stop()
580
+
581
+
582
+ _STARTUP_ERROR = """<!DOCTYPE html><html><head><meta charset="utf-8"><title>{title}</title>
583
+ <style>body {{ font: 15px/1.5 system-ui, sans-serif; margin: 2rem; color: #1f2328; }}
584
+ pre {{ background: #f6f8fa; padding: 1rem; border-radius: 8px; white-space: pre-wrap; }}</style>
585
+ </head><body><h1>{title} couldn't start</h1><pre>{error}</pre></body></html>"""
586
+
587
+
588
+ def run_packaged(app_dir: Path) -> None:
589
+ """Entry point of an app built with ``pyweft build --desktop``. There's
590
+ no terminal to print to, so a failure to start shows in a window."""
591
+ try:
592
+ run_desktop(app_dir, dev=False)
593
+ except Exception as exc: # noqa: BLE001 - shown to the user instead
594
+ import webview
595
+
596
+ from .cli import CliError, app_identity
597
+
598
+ detail = str(exc) if isinstance(exc, CliError) else traceback.format_exc()
599
+ try:
600
+ title = escape(app_identity(app_dir).title)
601
+ except Exception: # noqa: BLE001 - the settings may be what failed
602
+ title = "The app"
603
+ webview.create_window(
604
+ "Couldn't start", html=_STARTUP_ERROR.format(title=title, error=escape(detail))
605
+ )
606
+ webview.start()