zagred 0.1.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.
zagred/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Zagred — Python backends, web frontends, native desktop apps.
2
+
3
+ Public surface:
4
+
5
+ - :class:`zagred.App` — window, lifecycle and the JS bridge.
6
+ - :class:`zagred.Manifest` — parsed ``zagred.toml``.
7
+ - :func:`zagred.load_manifest` — read ``zagred.toml`` from a project root.
8
+
9
+ The CLI lives in :mod:`zagred.cli` and is not imported here, so ``import zagred``
10
+ stays cheap and never drags in build tooling.
11
+ """
12
+
13
+ from .app import App
14
+ from .manifest import Manifest, load_manifest
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = ["App", "Manifest", "load_manifest", "__version__"]
zagred/app.py ADDED
@@ -0,0 +1,530 @@
1
+ """Application object: window creation, lifecycle hooks, and the JS bridge.
2
+
3
+ ``zagred.toml`` only carries build metadata. Everything the window does at
4
+ runtime — size, chrome, visibility, lifecycle — is declared here in Python so
5
+ there is a single source of truth.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from .assets import resolve_ui
17
+ from .bridge import SHIM, Bridge
18
+
19
+ # Printed when no webview engine can be imported. Keyed by ``sys.platform``;
20
+ # the Linux text also explains the uv/`python3-gi` split, which is the single
21
+ # most common setup failure.
22
+ WEBVIEW_HINT = {
23
+ "linux": (
24
+ "Zagred needs GTK bindings that this interpreter can import.\n"
25
+ " sudo apt install python3-gi gir1.2-webkit2-4.1\n"
26
+ "uv's Python cannot see the distro's python3-gi, so recreate the venv:\n"
27
+ " rm -rf .venv && uv venv --python /usr/bin/python3 --system-site-packages && uv sync"
28
+ ),
29
+ "win32": (
30
+ "Zagred needs the WebView2 runtime:\n"
31
+ " https://developer.microsoft.com/microsoft-edge/webview2/"
32
+ ),
33
+ }
34
+
35
+
36
+ class App:
37
+ """A Zagred application: one window, one bridge, one lifecycle.
38
+
39
+ Typical use::
40
+
41
+ import zagred
42
+
43
+ app = zagred.App(title="Notes", size=(1024, 700))
44
+
45
+ @app.expose
46
+ def greet(name: str) -> str:
47
+ return f"Hello, {name}!"
48
+
49
+ @app.on_start
50
+ def boot() -> None:
51
+ print("ready")
52
+
53
+ Only ``@app.expose`` functions are reachable from JavaScript. Everything
54
+ else on this class is Python-side control (window manipulation, events,
55
+ hooks) and is never exposed to the UI.
56
+
57
+ The window itself only exists between :meth:`run` and the user closing it.
58
+ Calling a control method such as :meth:`minimize` before ``run`` — or from
59
+ a hook that fires after close — raises ``RuntimeError``.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ title: str = "Zagred",
65
+ size: tuple[int, int] = (1024, 700),
66
+ min_size: tuple[int, int] | None = None,
67
+ position: tuple[int, int] | None = None,
68
+ resizable: bool = True,
69
+ fullscreen: bool = False,
70
+ maximized: bool = False,
71
+ minimized: bool = False,
72
+ frameless: bool = False,
73
+ easy_drag: bool = True,
74
+ on_top: bool = False,
75
+ focus: bool = True,
76
+ shadow: bool = False,
77
+ confirm_close: bool = False,
78
+ transparent: bool = False,
79
+ text_select: bool = False,
80
+ zoomable: bool = False,
81
+ vibrancy: bool = False,
82
+ background_color: str = "#ffffff",
83
+ icon: str | None = None,
84
+ menu: list | None = None,
85
+ ) -> None:
86
+ """Configure the window that :meth:`run` will open.
87
+
88
+ All options are read once, when the window is created. Mutating the
89
+ attribute afterwards (``app.title = "x"``) does *not* update a running
90
+ window — use the matching control method instead, e.g.
91
+ :meth:`set_title`.
92
+
93
+ Args:
94
+ title: Window title, also shown in the taskbar or dock.
95
+ size: ``(width, height)`` in logical pixels. Initial window size.
96
+ min_size: ``(width, height)`` lower bound when the user resizes.
97
+ Defaults to ``(200, 100)`` when omitted.
98
+ position: ``(x, y)`` screen coordinates for the top-left corner.
99
+ ``None`` lets the window manager place the window.
100
+ resizable: Whether the user may resize the window. Set ``False``
101
+ for fixed-size dialogs and splash windows.
102
+ fullscreen: Start borderless and covering the screen.
103
+ maximized: Start maximized but still decorated. Mutually exclusive
104
+ with ``fullscreen`` in practice — pick one.
105
+ minimized: Start minimized (or hidden in the taskbar on some Linux
106
+ window managers).
107
+ frameless: Draw the window without a title bar or borders. Pair with
108
+ ``easy_drag`` and draw your own controls in the UI.
109
+ easy_drag: When ``frameless`` is set, let the user drag the window by
110
+ its background. No effect on decorated windows.
111
+ on_top: Keep the window above other windows. Useful for tools and
112
+ floating palettes; avoid for normal apps.
113
+ focus: Take keyboard focus on open. Set ``False`` for background or
114
+ notification-style windows.
115
+ shadow: Draw a drop shadow around a ``frameless`` window.
116
+ confirm_close: Ask the user to confirm before closing. Combine with
117
+ :meth:`on_closing` when you need to save state first.
118
+ transparent: Allow transparent pixels behind the webview. Requires a
119
+ transparent background in your CSS as well.
120
+ text_select: Allow selecting text. Defaults to ``False``, which
121
+ matches native app behaviour; enable it for document-style UIs.
122
+ zoomable: Enable pinch/ctrl-wheel zooming of the page.
123
+ vibrancy: macOS only — blurred, translucent background material.
124
+ Ignored on Linux and Windows.
125
+ background_color: Color painted before the page renders, as a CSS
126
+ color string. Set it to match your UI to avoid a flash of white.
127
+ icon: Path to the window icon (PNG on Linux/Windows, ICNS or PNG on
128
+ macOS). Also used by ``zagred build`` when ``[app] icon`` is set.
129
+ menu: Platform menu definition passed straight to pywebview. Empty
130
+ means "no custom menu".
131
+
132
+ Raises:
133
+ Nothing. Invalid paths and unsupported options surface when
134
+ :meth:`run` creates the window.
135
+ """
136
+ self.title = title
137
+ self.size = size
138
+ self.min_size = min_size
139
+ self.position = position
140
+ self.resizable = resizable
141
+ self.fullscreen = fullscreen
142
+ self.maximized = maximized
143
+ self.minimized = minimized
144
+ self.frameless = frameless
145
+ self.easy_drag = easy_drag
146
+ self.on_top = on_top
147
+ self.focus = focus
148
+ self.shadow = shadow
149
+ self.confirm_close = confirm_close
150
+ self.transparent = transparent
151
+ self.text_select = text_select
152
+ self.zoomable = zoomable
153
+ self.vibrancy = vibrancy
154
+ self.background_color = background_color
155
+ self.icon = icon
156
+ self.menu = menu or []
157
+
158
+ self._exposed: dict[str, Callable] = {}
159
+ self._start_hooks: list[Callable] = []
160
+ self._loaded_hooks: list[Callable] = []
161
+ self._closing_hooks: list[Callable] = []
162
+ self._closed_hooks: list[Callable] = []
163
+ self._window = None
164
+
165
+ # -- decorators -----------------------------------------------------
166
+
167
+ def expose(self, fn: Callable) -> Callable:
168
+ """Make ``fn`` callable from JavaScript.
169
+
170
+ The function is registered under its ``__name__``, which is the string
171
+ the UI passes to ``zagred.call(...)``. Exposing two functions with the
172
+ same name keeps the last one silently.
173
+
174
+ Arguments arrive as keyword arguments, and the return value must be
175
+ JSON-serialisable — that is checked before the value leaves Python, so a
176
+ mistake becomes an error in the UI instead of a blank window.
177
+
178
+ Args:
179
+ fn: The function to expose. May be a coroutine function; coroutines
180
+ are awaited on the bridge's worker thread.
181
+
182
+ Returns:
183
+ ``fn`` unchanged, so this works as a decorator.
184
+
185
+ Example:
186
+ >>> @app.expose
187
+ ... def greet(name: str) -> str:
188
+ ... return f"Hello, {name}!"
189
+ """
190
+ self._exposed[fn.__name__] = fn
191
+ return fn
192
+
193
+ def on_start(self, fn: Callable) -> Callable:
194
+ """Register a hook that runs once the GUI loop is up.
195
+
196
+ Fires before the page finishes loading, so it is the right place to
197
+ start background work whose results you will ``emit`` later. Runs on a
198
+ worker thread, not the UI thread.
199
+
200
+ Args:
201
+ fn: Zero-argument callable.
202
+
203
+ Returns:
204
+ ``fn`` unchanged, so this works as a decorator.
205
+ """
206
+ self._start_hooks.append(fn)
207
+ return fn
208
+
209
+ def on_loaded(self, fn: Callable) -> Callable:
210
+ """Register a hook that runs when the page has finished loading.
211
+
212
+ Runs on a worker thread. Use it to push initial state to the UI; the
213
+ bridge is live by this point.
214
+
215
+ Args:
216
+ fn: Zero-argument callable.
217
+
218
+ Returns:
219
+ ``fn`` unchanged, so this works as a decorator.
220
+ """
221
+ self._loaded_hooks.append(fn)
222
+ return fn
223
+
224
+ def on_closing(self, fn: Callable) -> Callable:
225
+ """Register a hook that runs when the user tries to close the window.
226
+
227
+ Return ``False`` from the hook to cancel the close. Returning ``None``
228
+ or any other value lets the close proceed. If several hooks are
229
+ registered, the first one returning ``False`` cancels it.
230
+
231
+ Args:
232
+ fn: Zero-argument callable returning ``bool | None``.
233
+
234
+ Returns:
235
+ ``fn`` unchanged, so this works as a decorator.
236
+
237
+ Example:
238
+ >>> @app.on_closing
239
+ ... def guard():
240
+ ... return save_unsaved_changes() or False
241
+ """
242
+ self._closing_hooks.append(fn)
243
+ return fn
244
+
245
+ def on_closed(self, fn: Callable) -> Callable:
246
+ """Register a hook that runs after the window is gone.
247
+
248
+ The window object is already invalid here, so window control methods
249
+ will raise. Use it for cleanup only.
250
+
251
+ Args:
252
+ fn: Zero-argument callable.
253
+
254
+ Returns:
255
+ ``fn`` unchanged, so this works as a decorator.
256
+ """
257
+ self._closed_hooks.append(fn)
258
+ return fn
259
+
260
+ # -- events ---------------------------------------------------------
261
+
262
+ def emit(self, name: str, payload=None) -> None:
263
+ """Push an event to the UI, to be received by ``zagred.on(name, fn)``.
264
+
265
+ Uses ``run_js`` with a JSON literal rather than ``evaluate_js``, so it
266
+ keeps working under a strict Content-Security-Policy that forbids
267
+ ``unsafe-eval``.
268
+
269
+ Silent no-op before the window exists, which makes ``emit`` safe to call
270
+ from hooks and background threads without null checks.
271
+
272
+ Args:
273
+ name: Event name. Must match the string the UI subscribed to.
274
+ payload: Any JSON-serialisable value. Sent as-is to every listener.
275
+
276
+ Raises:
277
+ TypeError: If ``payload`` is not JSON-serialisable (raised by
278
+ ``json.dumps``; nothing is sent in that case).
279
+ """
280
+ if self._window is None:
281
+ return
282
+ script = "window.zagred&&window.zagred._dispatch(%s,%s)" % (
283
+ json.dumps(name),
284
+ json.dumps(payload),
285
+ )
286
+ self._window.run_js(script)
287
+
288
+ # -- window control -------------------------------------------------
289
+
290
+ def _win(self):
291
+ """Return the live pywebview window, or fail with a useful message."""
292
+ if self._window is None:
293
+ raise RuntimeError("the window is not running yet")
294
+ return self._window
295
+
296
+ def minimize(self) -> None:
297
+ """Minimize the window to the taskbar or dock."""
298
+ self._win().minimize()
299
+
300
+ def maximize(self) -> None:
301
+ """Maximize the window to fill the screen, keeping decorations."""
302
+ self._win().maximize()
303
+
304
+ def restore(self) -> None:
305
+ """Undo a minimize or maximize."""
306
+ self._win().restore()
307
+
308
+ def toggle_fullscreen(self) -> None:
309
+ """Switch between fullscreen and the previous decorated state."""
310
+ self._win().toggle_fullscreen()
311
+
312
+ def resize(self, width: int, height: int) -> None:
313
+ """Resize the window and remember the new size.
314
+
315
+ Args:
316
+ width: New width in logical pixels.
317
+ height: New height in logical pixels.
318
+ """
319
+ self.size = (width, height)
320
+ self._win().resize(width, height)
321
+
322
+ def move(self, x: int, y: int) -> None:
323
+ """Move the window's top-left corner and remember the new position.
324
+
325
+ Args:
326
+ x: Screen x coordinate in logical pixels.
327
+ y: Screen y coordinate in logical pixels.
328
+ """
329
+ self.position = (x, y)
330
+ self._win().move(x, y)
331
+
332
+ def set_title(self, title: str) -> None:
333
+ """Change the window title and remember it.
334
+
335
+ Args:
336
+ title: New title text.
337
+ """
338
+ self.title = title
339
+ self._win().set_title(title)
340
+
341
+ def show(self) -> None:
342
+ """Show a window that was hidden with :meth:`hide`."""
343
+ self._win().show()
344
+
345
+ def hide(self) -> None:
346
+ """Hide the window without closing it."""
347
+ self._win().hide()
348
+
349
+ def destroy(self) -> None:
350
+ """Close the window programmatically, running the usual close hooks."""
351
+ self._win().destroy()
352
+
353
+ def load_url(self, url: str) -> None:
354
+ """Navigate the current window to another URL.
355
+
356
+ Args:
357
+ url: Absolute URL, or a path resolvable by the asset resolver.
358
+ """
359
+ self._win().load_url(url)
360
+
361
+ def evaluate_js(self, script: str):
362
+ """Run JavaScript in the page and return its last expression.
363
+
364
+ Blocks until the page answers, so call it from a worker thread rather
365
+ than from a blocking window event. Requires the page to allow ``eval``
366
+ unless you use ``window.run_js`` semantics.
367
+
368
+ Args:
369
+ script: JavaScript source. A raw string is recommended for scripts
370
+ containing backslashes.
371
+
372
+ Returns:
373
+ The last evaluated expression converted to Python types: JS objects
374
+ become ``dict``, arrays become ``list``, ``undefined`` becomes
375
+ ``None``.
376
+ """
377
+ return self._win().evaluate_js(script)
378
+
379
+ def create_file_dialog(
380
+ self,
381
+ allow_multiple: bool = False,
382
+ directory: str = "",
383
+ save_filename: str = "",
384
+ file_types: tuple = (),
385
+ ) -> tuple | None:
386
+ """Open the operating system's file picker.
387
+
388
+ Blocks until the user answers, so call it from inside an ``@app.expose``
389
+ function (which already runs on a worker thread) rather than from a hook
390
+ that must stay responsive.
391
+
392
+ Args:
393
+ allow_multiple: Let the user select more than one file.
394
+ directory: Directory the dialog opens in. Empty means the OS default.
395
+ save_filename: Pre-filled file name, for save-style dialogs.
396
+ file_types: Tuple of ``"Description (*.ext;*.ext)"`` strings used to
397
+ filter the list. Empty allows all files.
398
+
399
+ Returns:
400
+ A tuple of absolute paths, or ``None`` if the user cancelled.
401
+ """
402
+ import webview
403
+
404
+ return self._win().create_file_dialog(
405
+ webview.FileDialog.OPEN,
406
+ directory=directory,
407
+ allow_multiple=allow_multiple,
408
+ save_filename=save_filename,
409
+ file_types=file_types,
410
+ )
411
+
412
+ # -- run ------------------------------------------------------------
413
+
414
+ @property
415
+ def window(self):
416
+ """The underlying pywebview window, or ``None`` when not running."""
417
+ return self._window
418
+
419
+ def _window_kwargs(self) -> dict:
420
+ """Translate the constructor options into pywebview's keyword names.
421
+
422
+ Returns:
423
+ Keyword arguments for ``webview.create_window``. ``x`` and ``y`` are
424
+ only included when a position was given, so the window manager keeps
425
+ control otherwise.
426
+ """
427
+ kwargs = {
428
+ "width": self.size[0],
429
+ "height": self.size[1],
430
+ "min_size": self.min_size or (200, 100),
431
+ "resizable": self.resizable,
432
+ "fullscreen": self.fullscreen,
433
+ "maximized": self.maximized,
434
+ "minimized": self.minimized,
435
+ "frameless": self.frameless,
436
+ "easy_drag": self.easy_drag,
437
+ "on_top": self.on_top,
438
+ "focus": self.focus,
439
+ "shadow": self.shadow,
440
+ "confirm_close": self.confirm_close,
441
+ "transparent": self.transparent,
442
+ "text_select": self.text_select,
443
+ "zoomable": self.zoomable,
444
+ "vibrancy": self.vibrancy,
445
+ "background_color": self.background_color,
446
+ "menu": self.menu,
447
+ }
448
+ if self.position:
449
+ kwargs["x"], kwargs["y"] = self.position
450
+ return kwargs
451
+
452
+ def run(self, ui: str | Path | None = None, debug: bool = False) -> None:
453
+ """Create the window, start the GUI loop, and block until it closes.
454
+
455
+ The GUI loop owns the calling thread, so ``run`` is normally the last
456
+ statement of your entry module. ``zagred dev`` and ``zagred build`` both
457
+ call this for you.
458
+
459
+ Args:
460
+ ui: What to load. An ``http(s)://`` URL is used as-is (the dev
461
+ server). Anything else is resolved as a path — either a
462
+ directory containing ``index.html`` or an HTML file — searching
463
+ the working tree, the standalone ``.dist`` folder, and the
464
+ onefile extraction directory.
465
+ debug: Open the WebView inspector and stop suppressing the right-click
466
+ context menu. Development only; ``zagred build --debug`` bakes
467
+ this into a packaged app.
468
+
469
+ Raises:
470
+ FileNotFoundError: If ``ui`` cannot be resolved to an existing file.
471
+ SystemExit: If no webview engine is available. The platform-specific
472
+ fix is printed to stderr first.
473
+ """
474
+ import webview
475
+
476
+ # pywebview ships OPEN_DEVTOOLS_IN_DEBUG=True, so any debug run pops the
477
+ # inspector window. Tie it to the explicit flag instead of global state.
478
+ webview.settings["OPEN_DEVTOOLS_IN_DEBUG"] = debug
479
+
480
+ target = resolve_ui(ui)
481
+ window = webview.create_window(
482
+ self.title,
483
+ url=target,
484
+ js_api=Bridge(self._exposed),
485
+ **self._window_kwargs(),
486
+ )
487
+ self._window = window
488
+
489
+ def inject(*_args):
490
+ """Inject the JS shim off-thread, because this event blocks."""
491
+
492
+ # before_load is a *blocking* event on the main thread. Calling
493
+ # evaluate_js() there deadlocks: the renderer waits for this handler
494
+ # to return, so the page stays blank for seconds. Run it off-thread.
495
+ def work():
496
+ """Evaluate the shim on a worker thread and ignore late failures."""
497
+
498
+ try:
499
+ window.evaluate_js(SHIM)
500
+ except Exception: # noqa: BLE001 - the page may already be gone
501
+ pass
502
+
503
+ threading.Thread(target=work, daemon=True).start()
504
+
505
+ def closing(*_args) -> bool:
506
+ """Let any ``on_closing`` hook veto the close by returning False."""
507
+ for hook in self._closing_hooks:
508
+ if hook() is False:
509
+ return False
510
+ return True
511
+
512
+ window.events.before_load += inject
513
+ window.events.loaded += lambda *_: _run_all(self._loaded_hooks)
514
+ window.events.closing += closing
515
+ window.events.closed += lambda *_: _run_all(self._closed_hooks)
516
+
517
+ try:
518
+ webview.start(debug=debug, func=_run_all, args=(self._start_hooks,), icon=self.icon)
519
+ except webview.errors.WebViewException as exc:
520
+ print(f"\n{exc}\n", file=sys.stderr)
521
+ hint = WEBVIEW_HINT.get(sys.platform) or "Install GTK or Qt bindings for your system."
522
+ print(hint, file=sys.stderr)
523
+ raise SystemExit(1) from exc
524
+ self._window = None
525
+
526
+
527
+ def _run_all(hooks: list[Callable]) -> None:
528
+ """Call every hook in order, letting exceptions propagate to the caller."""
529
+ for hook in hooks:
530
+ hook()
zagred/assets.py ADDED
@@ -0,0 +1,99 @@
1
+ """Locating the frontend in every place it can end up.
2
+
3
+ The same app must find ``ui/index.html`` in three very different layouts:
4
+
5
+ 1. **Development** — plain files in the working tree (``web/index.html``).
6
+ 2. **Nuitka standalone** — next to the binary, in ``my-app.dist/ui/``.
7
+ 3. **Nuitka onefile** — inside a temporary directory that Nuitka unpacks at
8
+ startup. ``__compiled__.containing_dir`` still points at the *binary* there,
9
+ while ``__file__`` points inside the extraction directory, which is why the
10
+ search walks up from this module instead of trusting either alone.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ def _search_dirs() -> list[Path]:
21
+ """Directories to try, most specific first.
22
+
23
+ Order:
24
+
25
+ 1. ``$ZAGRED_ASSETS`` — explicit override, useful for tests and for hosts
26
+ that unpack assets somewhere unusual.
27
+ 2. This module's directory, then its parent and grandparent. In a onefile
28
+ build this covers ``<tmp>/zagred/assets.py`` → ``<tmp>/ui``; in a
29
+ standalone build it covers ``.../my-app.dist/zagred/assets.py`` →
30
+ ``.../my-app.dist/ui``.
31
+ 3. The directory holding the executable (``sys.argv[0]``), for layouts where
32
+ the binary sits beside its assets.
33
+ 4. The current working directory, which is what makes ``zagred dev`` work
34
+ from a project root.
35
+
36
+ Returns:
37
+ Candidate directories with duplicates removed, keeping first
38
+ occurrences so the most specific location still wins.
39
+ """
40
+ dirs: list[Path] = []
41
+ override = os.environ.get("ZAGRED_ASSETS")
42
+ if override:
43
+ dirs.append(Path(override))
44
+
45
+ here = Path(__file__).resolve().parent
46
+ dirs.extend([here, here.parent, here.parent.parent])
47
+ dirs.append(Path(sys.argv[0]).resolve().parent)
48
+ dirs.append(Path.cwd())
49
+
50
+ seen: set[Path] = set()
51
+ unique = []
52
+ for directory in dirs:
53
+ if directory not in seen:
54
+ seen.add(directory)
55
+ unique.append(directory)
56
+ return unique
57
+
58
+
59
+ def resolve_ui(ui: str | Path | None) -> str:
60
+ """Turn a UI specification into something pywebview can load.
61
+
62
+ Accepts a dev server URL, a directory (``index.html`` is appended), or a
63
+ direct path to an HTML file. Relative paths are searched across every
64
+ candidate from :func:`_search_dirs`, so the same call works in development
65
+ and inside a compiled binary.
66
+
67
+ Args:
68
+ ui: ``http(s)://`` URL, directory, or HTML file. ``None`` is a
69
+ programming error and raises.
70
+
71
+ Returns:
72
+ An absolute URL (passed through unchanged) or an absolute filesystem
73
+ path to an existing HTML file.
74
+
75
+ Raises:
76
+ ValueError: If ``ui`` is ``None``.
77
+ FileNotFoundError: If no candidate location contains the file. The
78
+ message lists every directory that was searched, which is usually
79
+ enough to spot a missing ``--include-data-dir`` in a build.
80
+ """
81
+ if ui is None:
82
+ raise ValueError("no UI given: pass a path, or set [ui] in zagred.toml")
83
+
84
+ text = str(ui)
85
+ if text.startswith(("http://", "https://")):
86
+ return text
87
+
88
+ path = Path(text)
89
+ candidates = [path] if path.is_absolute() else [d / text for d in _search_dirs()]
90
+ for candidate in candidates:
91
+ target = candidate / "index.html" if candidate.is_dir() else candidate
92
+ if target.is_file():
93
+ return str(target)
94
+
95
+ raise FileNotFoundError(
96
+ f"UI not found: {text!r}\n"
97
+ f"searched: {', '.join(str(p) for p in candidates)}\n"
98
+ "If this is a built binary, the frontend assets were not bundled."
99
+ )