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.
- {tempestweb-0.2.0 → tempestweb-0.4.0}/CHANGELOG.md +26 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/PKG-INFO +1 -1
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/index.js +3 -0
- tempestweb-0.4.0/client/native/install.js +51 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/pyproject.toml +1 -1
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/build.py +94 -6
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/__init__.py +9 -0
- tempestweb-0.4.0/tempestweb/native/install.py +57 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/native-onnx-file.test.js +17 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/native.test.js +2 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/.gitignore +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/README.md +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/constants.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/dom.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/events.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/livereload.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/audio.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/camera.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/clipboard.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/file.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/geolocation.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/http.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/notifications.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/onnx.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/share.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/native/storage.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/offline/store.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/offline/sync.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/push/web-push-client.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/pwa/install-prompt.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/pwa/manifest.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/router.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/style.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/sw/register.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/sw/sw.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/tempestweb.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport-sse.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport-wasm.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport-ws.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/transport.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/client/virtualize.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/_core/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/dev.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/new.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/commands/run.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/config.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/loader.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/main.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/cli/scaffold.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/components/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/components/fields.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/components/forms.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/core/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/core/constants.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/http.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/reload.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/devserver/watcher.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/audio.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/bridges.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/camera.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/clipboard.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/dispatch.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/file.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/geolocation.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/http.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/notifications.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/onnx.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/share.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/native/storage.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/auth.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/error_boundary.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/feature_flags.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/logger.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/observability/telemetry.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/icons.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/manifest.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/pwa/pyodide_vendor.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/events.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/serialize.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/session.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/wasm.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/runtime/wasm_main.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/server/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/server/app.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/server/webpush.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/__init__.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/base.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/sse.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/wasm.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tempestweb/transports/websocket.py +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/dom.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/events.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/mount.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/offline-store.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/offline-sync.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/pwa-install-prompt.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/pwa-manifest.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/router.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/setup.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/smoke.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/style.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/sw-register.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/sw-strategies.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/transport-sse.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/transport-wasm.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/transport-ws.test.js +0 -0
- {tempestweb-0.2.0 → tempestweb-0.4.0}/tests/client/virtualize.test.js +0 -0
- {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.
|
|
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
|
+
}
|
|
@@ -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:
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|