tempestweb 0.2.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (115) hide show
  1. {tempestweb-0.2.0 → tempestweb-0.4.0}/CHANGELOG.md +26 -0
  2. {tempestweb-0.2.0 → tempestweb-0.4.0}/PKG-INFO +1 -1
  3. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/index.js +3 -0
  4. tempestweb-0.4.0/client/native/install.js +51 -0
  5. {tempestweb-0.2.0 → tempestweb-0.4.0}/pyproject.toml +1 -1
  6. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/build.py +94 -6
  7. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/__init__.py +9 -0
  8. tempestweb-0.4.0/tempestweb/native/install.py +57 -0
  9. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/native-onnx-file.test.js +17 -0
  10. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/native.test.js +2 -0
  11. {tempestweb-0.2.0 → tempestweb-0.4.0}/.gitignore +0 -0
  12. {tempestweb-0.2.0 → tempestweb-0.4.0}/README.md +0 -0
  13. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/constants.js +0 -0
  14. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/dom.js +0 -0
  15. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/events.js +0 -0
  16. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/livereload.js +0 -0
  17. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/audio.js +0 -0
  18. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/camera.js +0 -0
  19. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/clipboard.js +0 -0
  20. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/file.js +0 -0
  21. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/geolocation.js +0 -0
  22. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/http.js +0 -0
  23. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/notifications.js +0 -0
  24. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/onnx.js +0 -0
  25. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/share.js +0 -0
  26. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/storage.js +0 -0
  27. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/offline/store.js +0 -0
  28. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/offline/sync.js +0 -0
  29. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/push/web-push-client.js +0 -0
  30. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/pwa/install-prompt.js +0 -0
  31. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/pwa/manifest.js +0 -0
  32. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/router.js +0 -0
  33. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/style.js +0 -0
  34. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/sw/register.js +0 -0
  35. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/sw/sw.js +0 -0
  36. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/tempestweb.js +0 -0
  37. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport-sse.js +0 -0
  38. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport-wasm.js +0 -0
  39. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport-ws.js +0 -0
  40. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport.js +0 -0
  41. {tempestweb-0.2.0 → tempestweb-0.4.0}/client/virtualize.js +0 -0
  42. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/__init__.py +0 -0
  43. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/_core/__init__.py +0 -0
  44. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/__init__.py +0 -0
  45. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/__init__.py +0 -0
  46. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/dev.py +0 -0
  47. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/new.py +0 -0
  48. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/run.py +0 -0
  49. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/config.py +0 -0
  50. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/loader.py +0 -0
  51. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/main.py +0 -0
  52. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/scaffold.py +0 -0
  53. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/components/__init__.py +0 -0
  54. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/components/fields.py +0 -0
  55. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/components/forms.py +0 -0
  56. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/core/__init__.py +0 -0
  57. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/core/constants.py +0 -0
  58. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/__init__.py +0 -0
  59. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/http.py +0 -0
  60. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/reload.py +0 -0
  61. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/watcher.py +0 -0
  62. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/audio.py +0 -0
  63. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/bridges.py +0 -0
  64. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/camera.py +0 -0
  65. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/clipboard.py +0 -0
  66. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/dispatch.py +0 -0
  67. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/file.py +0 -0
  68. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/geolocation.py +0 -0
  69. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/http.py +0 -0
  70. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/notifications.py +0 -0
  71. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/onnx.py +0 -0
  72. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/share.py +0 -0
  73. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/storage.py +0 -0
  74. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/__init__.py +0 -0
  75. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/auth.py +0 -0
  76. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/error_boundary.py +0 -0
  77. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/feature_flags.py +0 -0
  78. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/logger.py +0 -0
  79. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/telemetry.py +0 -0
  80. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/__init__.py +0 -0
  81. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/icons.py +0 -0
  82. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/manifest.py +0 -0
  83. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/pyodide_vendor.py +0 -0
  84. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/__init__.py +0 -0
  85. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/events.py +0 -0
  86. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/serialize.py +0 -0
  87. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/session.py +0 -0
  88. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/wasm.py +0 -0
  89. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/wasm_main.py +0 -0
  90. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/server/__init__.py +0 -0
  91. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/server/app.py +0 -0
  92. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/server/webpush.py +0 -0
  93. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/__init__.py +0 -0
  94. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/base.py +0 -0
  95. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/sse.py +0 -0
  96. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/wasm.py +0 -0
  97. {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/websocket.py +0 -0
  98. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/dom.test.js +0 -0
  99. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/events.test.js +0 -0
  100. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/mount.test.js +0 -0
  101. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/offline-store.test.js +0 -0
  102. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/offline-sync.test.js +0 -0
  103. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/pwa-install-prompt.test.js +0 -0
  104. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/pwa-manifest.test.js +0 -0
  105. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/router.test.js +0 -0
  106. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/setup.js +0 -0
  107. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/smoke.test.js +0 -0
  108. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/style.test.js +0 -0
  109. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/sw-register.test.js +0 -0
  110. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/sw-strategies.test.js +0 -0
  111. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/transport-sse.test.js +0 -0
  112. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/transport-wasm.test.js +0 -0
  113. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/transport-ws.test.js +0 -0
  114. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/virtualize.test.js +0 -0
  115. {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/web-push-client.test.js +0 -0
@@ -4,6 +4,32 @@ All notable changes to **tempestweb** are documented here. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project adheres to semantic
5
5
  versioning.
6
6
 
7
+ ## [0.4.0] — 2026-06-13
8
+
9
+ ### Added
10
+
11
+ - **`[wasm].modules` resolves from the installed environment** — each entry is now
12
+ resolved in two steps: a vendored copy beside `app.py`
13
+ (`<project>/<module>/`) still wins, but when none exists the module is pulled
14
+ straight from the project's `.venv` `site-packages` via `importlib`. A
15
+ dependency you `uv add` no longer has to be cloned and committed at the repo
16
+ root to make it into the wasm bundle — just list it in `modules`. A name that is
17
+ neither vendored nor importable fails the build with a clear message.
18
+ Backward compatible: existing vendored layouts build unchanged. A stale project
19
+ directory holding only `__pycache__` (real source deleted, bytecode lingering)
20
+ no longer shadows the installed package and silently bundles nothing — it falls
21
+ through to the installed copy.
22
+
23
+ ## [0.3.0] — 2026-06-13
24
+
25
+ ### Added
26
+
27
+ - **`native.install` capability** — the PWA install flow in Python:
28
+ `install.state()` → `InstallState(can_install, installed)` and
29
+ `install.prompt()` → `"accepted" | "dismissed" | "unavailable"`. Wraps the soft
30
+ controller in `client/pwa/install-prompt.js` (now copied into the wasm
31
+ artifact) via `client/native/install.js`.
32
+
7
33
  ## [0.2.0] — 2026-06-13
8
34
 
9
35
  Real-app capabilities, driven by building a full on-device vision PWA (FAMACHApp)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tempestweb
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Build web apps in typed Python — one tree, a DOM renderer, two execution modes (WASM + server).
5
5
  Author-email: Mauricio Benjamin <mauricio.benjamin@reloverelations.com>
6
6
  License: MIT
@@ -19,6 +19,7 @@ import { storageGet, storageList, storagePut, storageRemove } from "./storage.js
19
19
  import { cameraCapture } from "./camera.js";
20
20
  import { onnxLoad, onnxRun } from "./onnx.js";
21
21
  import { fileSave, filePick } from "./file.js";
22
+ import { installState, installPrompt } from "./install.js";
22
23
  import {
23
24
  notificationsNotify,
24
25
  notificationsRequestPermission,
@@ -80,6 +81,8 @@ export const HANDLERS = {
80
81
  "onnx.run": onnxRun,
81
82
  "file.save": fileSave,
82
83
  "file.pick": filePick,
84
+ "install.state": installState,
85
+ "install.prompt": installPrompt,
83
86
  "notifications.notify": notificationsNotify,
84
87
  "notifications.request_permission": notificationsRequestPermission,
85
88
  "notifications.subscribe": notificationsSubscribe,
@@ -0,0 +1,51 @@
1
+ // native/install.js — PWA install-prompt capability.
2
+ //
3
+ // Wraps the soft install controller (client/pwa/install-prompt.js) in a single
4
+ // process-wide instance so Python can ask whether the app is installable and
5
+ // fire the stashed `beforeinstallprompt` after a real gesture. The controller is
6
+ // created on first import so its listeners attach as early as the native bundle
7
+ // loads (suppressing the browser mini-infobar when it catches the event).
8
+
9
+ import { createInstallPrompt } from "../pwa/install-prompt.js";
10
+
11
+ /** @type {ReturnType<typeof createInstallPrompt> | null} */
12
+ let _controller = null;
13
+
14
+ /**
15
+ * Lazily create (and cache) the install-prompt controller.
16
+ * @param {import("./index.js").NativeDeps} [deps]
17
+ * @returns {ReturnType<typeof createInstallPrompt>}
18
+ */
19
+ function controller(deps) {
20
+ if (_controller) return _controller;
21
+ const win = (deps && /** @type {any} */ (deps).window) || globalThis;
22
+ _controller = createInstallPrompt({ window: win });
23
+ return _controller;
24
+ }
25
+
26
+ // Attach listeners as early as this module loads.
27
+ if (typeof globalThis !== "undefined" && globalThis.addEventListener) {
28
+ controller();
29
+ }
30
+
31
+ /**
32
+ * Report the current install state.
33
+ * @param {Object} _args
34
+ * @param {import("./index.js").NativeDeps} deps
35
+ * @returns {Promise<{can_install:boolean, installed:boolean}>}
36
+ */
37
+ export async function installState(_args, deps) {
38
+ const state = controller(deps).getState();
39
+ return { can_install: Boolean(state.canInstall), installed: Boolean(state.installed) };
40
+ }
41
+
42
+ /**
43
+ * Fire the stashed native install prompt.
44
+ * @param {Object} _args
45
+ * @param {import("./index.js").NativeDeps} deps
46
+ * @returns {Promise<{outcome:"accepted"|"dismissed"|"unavailable"}>}
47
+ */
48
+ export async function installPrompt(_args, deps) {
49
+ const outcome = await controller(deps).promptInstall();
50
+ return { outcome };
51
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tempestweb"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Build web apps in typed Python — one tree, a DOM renderer, two execution modes (WASM + server)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -23,6 +23,7 @@ WebSocket transport — ``python server.py`` (or ``uvicorn server:app``) serves
23
23
  from __future__ import annotations
24
24
 
25
25
  import hashlib
26
+ import importlib.util
26
27
  import json
27
28
  import shutil
28
29
  import zipfile
@@ -74,6 +75,7 @@ _NATIVE_ASSETS: tuple[str, ...] = (
74
75
  "file.js",
75
76
  "geolocation.js",
76
77
  "http.js",
78
+ "install.js",
77
79
  "notifications.js",
78
80
  "onnx.js",
79
81
  "share.js",
@@ -119,6 +121,7 @@ WASM_ARTIFACT_FILES: tuple[str, ...] = (
119
121
  *(f"client/{asset}" for asset in (*_CLIENT_ASSETS, "transport-wasm.js")),
120
122
  *(f"client/native/{asset}" for asset in _NATIVE_ASSETS),
121
123
  "client/push/web-push-client.js",
124
+ "client/pwa/install-prompt.js",
122
125
  )
123
126
 
124
127
  # Files a server artifact must contain, relative to the artifact root.
@@ -234,6 +237,80 @@ def _zip_tree(
234
237
  archive.write(path, str(path.relative_to(root)))
235
238
 
236
239
 
240
+ def _is_vendored(candidate: Path) -> bool:
241
+ """Tell whether ``candidate`` is a usable vendored module/package.
242
+
243
+ A single file counts. A directory counts only if it holds at least one
244
+ bundlable file (anything outside ``__pycache__``) — so a stale directory
245
+ left holding only ``__pycache__`` after the real source was deleted does
246
+ **not** shadow the installed package and silently bundle nothing.
247
+
248
+ Args:
249
+ candidate: The ``project_root/module`` path to test.
250
+
251
+ Returns:
252
+ ``True`` if the path is a file or a directory with real content.
253
+ """
254
+ if candidate.is_file():
255
+ return True
256
+ if not candidate.is_dir():
257
+ return False
258
+ return any(
259
+ path.is_file() and "__pycache__" not in path.parts
260
+ for path in candidate.rglob("*")
261
+ )
262
+
263
+
264
+ def _resolve_module(module: str, project_root: Path | None) -> tuple[Path, str]:
265
+ """Resolve a ``[wasm].modules`` entry to its ``(root, top)`` for bundling.
266
+
267
+ Resolution order:
268
+
269
+ 1. A **vendored copy** under ``project_root`` (``project_root/module``) that
270
+ carries real content — preserves the historical behavior where a copy
271
+ sitting beside ``app.py`` wins. A stale directory holding only
272
+ ``__pycache__`` is skipped (see :func:`_is_vendored`).
273
+ 2. An **installed** package or module on ``sys.path`` (resolved via
274
+ ``importlib``) — so a dependency declared in the project's environment
275
+ (e.g. an ``uv``-managed ``.venv``) is pulled straight from site-packages
276
+ with no vendored copy committed to the repository.
277
+
278
+ Args:
279
+ module: The top-level module or package name from ``[wasm].modules``.
280
+ project_root: The project directory, when available.
281
+
282
+ Returns:
283
+ A ``(root, top)`` pair where ``root`` is the parent directory archive
284
+ entries are made relative to and ``top`` is the file or directory name
285
+ under it — fed straight into :func:`_zip_tree`.
286
+
287
+ Raises:
288
+ BuildError: If the module is neither vendored under ``project_root`` nor
289
+ importable from the current environment.
290
+ """
291
+ if project_root is not None and _is_vendored(project_root / module):
292
+ return project_root, module
293
+
294
+ try:
295
+ spec = importlib.util.find_spec(module)
296
+ except (ImportError, ValueError):
297
+ spec = None
298
+ if spec is not None:
299
+ locations = list(spec.submodule_search_locations or ())
300
+ if locations:
301
+ package_dir = Path(locations[0]).resolve()
302
+ return package_dir.parent, module
303
+ if spec.origin and spec.origin not in ("built-in", "frozen"):
304
+ origin = Path(spec.origin).resolve()
305
+ return origin.parent, origin.name
306
+
307
+ vendored = f"{project_root / module}" if project_root is not None else "<none>"
308
+ raise BuildError(
309
+ f"wasm module {module!r} not found: no vendored copy at {vendored} "
310
+ f"and not importable from the current environment"
311
+ )
312
+
313
+
237
314
  def _zip_package(
238
315
  dest: Path,
239
316
  *,
@@ -254,11 +331,16 @@ def _zip_package(
254
331
  dest: The ``.zip`` path to write.
255
332
  project_root: The project directory the ``modules`` are relative to.
256
333
  Required when ``modules`` is non-empty.
257
- modules: Project-relative names (files or package dirs) to bundle next to
258
- ``app.py`` (e.g. ``("famacha",)``).
334
+ modules: Names (files or package dirs) to bundle next to ``app.py``
335
+ (e.g. ``("famacha",)``). Each is resolved by :func:`_resolve_module`:
336
+ a vendored copy under ``project_root`` wins, otherwise the module is
337
+ pulled from the installed environment (site-packages) via importlib —
338
+ so a dependency declared in the project's ``.venv`` need not be
339
+ vendored into the repository.
259
340
 
260
341
  Raises:
261
- BuildError: If an expected package part or a declared module is missing.
342
+ BuildError: If an expected package part is missing, or a declared module
343
+ is neither vendored nor importable.
262
344
  """
263
345
  tempestweb_root = _package_dir().parent
264
346
  tempest_core_root = _tempest_core_dir().parent
@@ -266,9 +348,8 @@ def _zip_package(
266
348
  _zip_tree(archive, tempestweb_root, "tempestweb", _WASM_PACKAGE_PARTS)
267
349
  _zip_tree(archive, tempest_core_root, "tempest_core", None)
268
350
  for module in modules:
269
- if project_root is None:
270
- raise BuildError("project_root is required to bundle wasm modules")
271
- _zip_tree(archive, project_root, module, None)
351
+ root, top = _resolve_module(module, project_root)
352
+ _zip_tree(archive, root, top, None)
272
353
 
273
354
 
274
355
  def _build_pwa(out: Path, client: Path, name: str, precache: tuple[str, ...]) -> None:
@@ -773,6 +854,13 @@ def _build_wasm(
773
854
  push_dest = out / "client" / "push"
774
855
  push_dest.mkdir(parents=True, exist_ok=True)
775
856
  shutil.copyfile(push_source, push_dest / "web-push-client.js")
857
+ # The install capability imports the soft install-prompt controller.
858
+ install_source = client / "pwa" / "install-prompt.js"
859
+ if not install_source.is_file():
860
+ raise BuildError(f"missing pwa asset: {install_source}")
861
+ pwa_dest = out / "client" / "pwa"
862
+ pwa_dest.mkdir(parents=True, exist_ok=True)
863
+ shutil.copyfile(install_source, pwa_dest / "install-prompt.js")
776
864
  # Project static assets (ONNX models, vendored JS libs) copied verbatim,
777
865
  # preserving their relative path, and precached for the offline second load.
778
866
  assets: list[str] = []
@@ -46,6 +46,7 @@ from tempestweb.native import (
46
46
  file,
47
47
  geolocation,
48
48
  http,
49
+ install,
49
50
  notifications,
50
51
  onnx,
51
52
  storage,
@@ -78,6 +79,9 @@ from tempestweb.native.http import (
78
79
  request,
79
80
  upload,
80
81
  )
82
+ from tempestweb.native.install import InstallState
83
+ from tempestweb.native.install import prompt as install_prompt
84
+ from tempestweb.native.install import state as install_state
81
85
  from tempestweb.native.notifications import (
82
86
  NotificationPermission,
83
87
  notify,
@@ -109,6 +113,7 @@ __all__ = [
109
113
  "file",
110
114
  "geolocation",
111
115
  "http",
116
+ "install",
112
117
  "notifications",
113
118
  "onnx",
114
119
  "storage",
@@ -161,6 +166,10 @@ __all__ = [
161
166
  "SaveResult",
162
167
  "file_pick",
163
168
  "file_save",
169
+ # install (PWA install prompt)
170
+ "InstallState",
171
+ "install_prompt",
172
+ "install_state",
164
173
  # notifications
165
174
  "NotificationPermission",
166
175
  "notify",
@@ -0,0 +1,57 @@
1
+ """Native PWA install-prompt capability (P0).
2
+
3
+ Exposes the browser's soft install flow to Python: whether the app is installable
4
+ (a ``beforeinstallprompt`` was captured) or already installed, and a call to fire
5
+ the stashed prompt after a real user gesture. ``client/native/install.js`` wraps
6
+ the tested ``client/pwa/install-prompt.js`` controller.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+ from tempestweb.native.dispatch import send_native_call
14
+
15
+ __all__ = ["InstallState", "prompt", "state"]
16
+
17
+
18
+ class InstallState(BaseModel):
19
+ """The current PWA install state.
20
+
21
+ Attributes:
22
+ can_install: A deferred prompt is available to fire.
23
+ installed: The app reports as installed (standalone / appinstalled).
24
+ """
25
+
26
+ model_config = ConfigDict(frozen=True)
27
+
28
+ can_install: bool = False
29
+ installed: bool = False
30
+
31
+
32
+ async def state() -> InstallState:
33
+ """Report whether the app is installable and/or already installed.
34
+
35
+ Returns:
36
+ The current :class:`InstallState`.
37
+
38
+ Raises:
39
+ BrowserUnavailableError: If called with no native bridge installed.
40
+ """
41
+ value = await send_native_call("install.state", {})
42
+ return InstallState.model_validate(value)
43
+
44
+
45
+ async def prompt() -> str:
46
+ """Fire the stashed native install prompt after a user gesture.
47
+
48
+ Returns:
49
+ The outcome: ``"accepted"``, ``"dismissed"``, or ``"unavailable"`` (no
50
+ prompt was captured, or it was already used).
51
+
52
+ Raises:
53
+ BrowserUnavailableError: If called with no native bridge installed.
54
+ """
55
+ value = await send_native_call("install.prompt", {})
56
+ outcome = value.get("outcome", "unavailable")
57
+ return str(outcome)
@@ -163,6 +163,23 @@ test("file.pick: reads the chosen file back as base64", async () => {
163
163
  assert.equal(res.value.name, "ovino.png");
164
164
  });
165
165
 
166
+ test("install.state/prompt: no captured event → not installable, unavailable", async () => {
167
+ const fakeWindow = {
168
+ addEventListener() {},
169
+ removeEventListener() {},
170
+ matchMedia: () => ({ matches: false }),
171
+ navigator: {},
172
+ };
173
+ const deps = { window: fakeWindow };
174
+ const s = await dispatch(call("install.state"), deps);
175
+ assert.equal(s.ok, true);
176
+ assert.equal(s.value.can_install, false);
177
+ assert.equal(s.value.installed, false);
178
+ const p = await dispatch(call("install.prompt"), deps);
179
+ assert.equal(p.ok, true);
180
+ assert.equal(p.value.outcome, "unavailable");
181
+ });
182
+
166
183
  test("file.save: uses the Web Share API when it accepts files", async () => {
167
184
  const dom = new JSDOM("<!doctype html><html><body></body></html>");
168
185
  globalThis.Blob = dom.window.Blob;
@@ -58,6 +58,8 @@ test("HANDLERS covers every documented capability name", () => {
58
58
  "onnx.run",
59
59
  "file.save",
60
60
  "file.pick",
61
+ "install.state",
62
+ "install.prompt",
61
63
  "notifications.notify",
62
64
  "notifications.request_permission",
63
65
  "notifications.subscribe",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes