webapp-shell 0.1__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.
- webapp_shell/__init__.py +50 -0
- webapp_shell/__main__.py +53 -0
- webapp_shell/backend.py +30 -0
- webapp_shell/backend_process.py +136 -0
- webapp_shell/bridge/webapp-shell-bridge.d.ts +48 -0
- webapp_shell/bridge/webapp-shell-bridge.js +146 -0
- webapp_shell/cli.py +76 -0
- webapp_shell/config.py +27 -0
- webapp_shell/contract/__init__.py +10 -0
- webapp_shell/contract/guard.py +106 -0
- webapp_shell/contract/launch.py +49 -0
- webapp_shell/contract/tokens.py +41 -0
- webapp_shell/enums.py +20 -0
- webapp_shell/freezing/__init__.py +10 -0
- webapp_shell/freezing/app.spec +5 -0
- webapp_shell/freezing/builder.py +82 -0
- webapp_shell/freezing/entry.py +24 -0
- webapp_shell/freezing/plan.py +38 -0
- webapp_shell/freezing/profile.py +49 -0
- webapp_shell/freezing/spec.py +68 -0
- webapp_shell/freezing/verify.py +43 -0
- webapp_shell/identity.py +48 -0
- webapp_shell/installation.py +41 -0
- webapp_shell/json_types.py +4 -0
- webapp_shell/library_check.py +28 -0
- webapp_shell/messages.py +58 -0
- webapp_shell/native/__init__.py +4 -0
- webapp_shell/native/arguments.py +131 -0
- webapp_shell/native/errors.py +5 -0
- webapp_shell/native/filters.py +24 -0
- webapp_shell/native/os_actions.py +33 -0
- webapp_shell/native/qt_bridge.py +62 -0
- webapp_shell/native/qt_window.py +34 -0
- webapp_shell/native/registry.py +91 -0
- webapp_shell/native/standard.py +89 -0
- webapp_shell/native/start_location.py +28 -0
- webapp_shell/native/webview_background.py +39 -0
- webapp_shell/native/webview_bridge.py +25 -0
- webapp_shell/native/webview_icon.py +32 -0
- webapp_shell/native/webview_window.py +54 -0
- webapp_shell/native/window.py +34 -0
- webapp_shell/native/zoom.py +104 -0
- webapp_shell/native/zoom_range.py +9 -0
- webapp_shell/preferences.py +84 -0
- webapp_shell/protocol.py +34 -0
- webapp_shell/py.typed +0 -0
- webapp_shell/qt_shell.py +126 -0
- webapp_shell/registry.py +20 -0
- webapp_shell/settings.py +78 -0
- webapp_shell/shell.py +119 -0
- webapp_shell/smoke.py +30 -0
- webapp_shell/splash.py +52 -0
- webapp_shell/watchdog.py +60 -0
- webapp_shell/webview2.py +80 -0
- webapp_shell/webview_shell.py +86 -0
- webapp_shell-0.1.dist-info/METADATA +132 -0
- webapp_shell-0.1.dist-info/RECORD +59 -0
- webapp_shell-0.1.dist-info/WHEEL +4 -0
- webapp_shell-0.1.dist-info/licenses/LICENSE +21 -0
webapp_shell/shell.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The shell contract. A concrete shell only knows how to show a window; everything else lives here."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from .backend_process import BackendError, BackendProcess
|
|
8
|
+
from .json_types import JsonObject
|
|
9
|
+
from .native.registry import NativeRegistry
|
|
10
|
+
from .native.standard import standard_functions
|
|
11
|
+
from .preferences import ShellPreferences
|
|
12
|
+
from .settings import Settings
|
|
13
|
+
from .smoke import SmokeReport
|
|
14
|
+
from .splash import SplashPage
|
|
15
|
+
from .watchdog import BackendWatchdog
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Shell(ABC):
|
|
19
|
+
"""Template: show a splash at once, start the backend off the UI thread, then show the frontend.
|
|
20
|
+
|
|
21
|
+
`boot()` runs on a worker thread, so every method it calls on a subclass must be safe to call from there.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str = "shell"
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
settings: Settings,
|
|
29
|
+
backend: BackendProcess,
|
|
30
|
+
preferences: ShellPreferences | None = None,
|
|
31
|
+
smoke: bool = False,
|
|
32
|
+
):
|
|
33
|
+
self.settings = settings
|
|
34
|
+
self.preferences = preferences or ShellPreferences(None)
|
|
35
|
+
self.backend = backend
|
|
36
|
+
self.smoke = smoke
|
|
37
|
+
self.backend_version: JsonObject = {}
|
|
38
|
+
self.failed = False
|
|
39
|
+
self.splash = SplashPage(settings.identity.name, settings.icon_png, settings.boot_colors)
|
|
40
|
+
self.watchdog = BackendWatchdog(backend, self.report_error, settings.identity.name, settings.config.messages)
|
|
41
|
+
self.native = NativeRegistry((*standard_functions(), *settings.config.native_functions))
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def window_title(self) -> str:
|
|
45
|
+
"""The program's name. Running from the source it also names the shell and the backend version, for the team."""
|
|
46
|
+
|
|
47
|
+
name = self.settings.identity.name
|
|
48
|
+
if self.settings.installation.frozen:
|
|
49
|
+
return name
|
|
50
|
+
return f"{name} ({self.name}) · backend {self.backend_version.get('version', '?')}"
|
|
51
|
+
|
|
52
|
+
def run(self) -> int:
|
|
53
|
+
try:
|
|
54
|
+
self.event_loop(self.boot)
|
|
55
|
+
finally:
|
|
56
|
+
self.watchdog.stop()
|
|
57
|
+
self.backend.stop()
|
|
58
|
+
return 1 if self.failed else 0
|
|
59
|
+
|
|
60
|
+
def boot(self) -> None:
|
|
61
|
+
started = time.monotonic()
|
|
62
|
+
try:
|
|
63
|
+
self.backend.start()
|
|
64
|
+
self.backend_version = self.backend.version()
|
|
65
|
+
except BackendError as error:
|
|
66
|
+
self.report_error(str(error))
|
|
67
|
+
return
|
|
68
|
+
self.hold_splash(started)
|
|
69
|
+
self.set_title(self.window_title)
|
|
70
|
+
self.navigate(self.backend.launch_url)
|
|
71
|
+
self.on_navigated()
|
|
72
|
+
if not self.smoke:
|
|
73
|
+
self.watchdog.start()
|
|
74
|
+
|
|
75
|
+
def hold_splash(
|
|
76
|
+
self,
|
|
77
|
+
started: float,
|
|
78
|
+
now: Callable[[], float] = time.monotonic,
|
|
79
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Keep the splash up until `splash_seconds` have passed since `started` (the backend may be ready sooner)."""
|
|
82
|
+
remaining = self.settings.splash_seconds - (now() - started)
|
|
83
|
+
if remaining > 0:
|
|
84
|
+
sleep(remaining)
|
|
85
|
+
|
|
86
|
+
def report_error(self, message: str) -> None:
|
|
87
|
+
self.failed = True
|
|
88
|
+
print(message)
|
|
89
|
+
self.show_page(self.splash.error(message))
|
|
90
|
+
if self.smoke:
|
|
91
|
+
self.quit()
|
|
92
|
+
|
|
93
|
+
def finish_smoke(self, page: str) -> None:
|
|
94
|
+
print(SmokeReport(self.name, self.backend_version, page))
|
|
95
|
+
|
|
96
|
+
# --- what a concrete shell provides ---------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def event_loop(self, boot: Callable[[], None]) -> None:
|
|
100
|
+
"""Show the window with the loading splash, call `boot()` on a worker thread, block until closed."""
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def navigate(self, url: str) -> None:
|
|
104
|
+
"""Load `url` in the window. Thread-safe."""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def show_page(self, html: str) -> None:
|
|
108
|
+
"""Replace the window content with an HTML page. Thread-safe."""
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def set_title(self, title: str) -> None:
|
|
112
|
+
"""Thread-safe."""
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
def quit(self) -> None:
|
|
116
|
+
"""Close the window. Thread-safe."""
|
|
117
|
+
|
|
118
|
+
def on_navigated(self) -> None: # noqa: B027 - an optional hook, empty on purpose
|
|
119
|
+
"""Hook run on the worker thread after `navigate`; shells use it for the headless self-check."""
|
webapp_shell/smoke.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The headless self-check both shells share: what to ask the loaded page and how to report it."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .json_types import JsonObject
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def page_probe_js(native_global: str) -> str:
|
|
9
|
+
"""What to ask the loaded page: its title, whether the app rendered, and which native bridge it can see."""
|
|
10
|
+
return (
|
|
11
|
+
"JSON.stringify({"
|
|
12
|
+
"title: document.title,"
|
|
13
|
+
"rootChildren: document.getElementById('root') ? document.getElementById('root').children.length : -1,"
|
|
14
|
+
"hash: location.hash,"
|
|
15
|
+
f"native: window.{native_global} ? '{native_global}' : "
|
|
16
|
+
"(window.pywebview && window.pywebview.api ? 'pywebview' : 'none')})"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SETTLE_SECONDS = 2.5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SmokeReport:
|
|
25
|
+
shell: str
|
|
26
|
+
backend: JsonObject
|
|
27
|
+
page: str
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"{self.shell}: backend={self.backend} page={self.page}"
|
webapp_shell/splash.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The two pages the shell shows itself, before and instead of the frontend. Deliberately text-light: the real
|
|
2
|
+
user interface (and its translations) belongs to the frontend."""
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
from html import escape
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .preferences import BootColors
|
|
9
|
+
|
|
10
|
+
# A soft two-glow gradient over the base colour; a project's frontend can use the same geometry for its hero.
|
|
11
|
+
HERO_GRADIENT = (
|
|
12
|
+
"radial-gradient(900px 600px at 20% 10%, {glow_a} 0, transparent 60%), "
|
|
13
|
+
"radial-gradient(700px 500px at 90% 90%, {glow_b} 0, transparent 55%), {base}"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_PAGE = """<!doctype html><meta charset="utf-8"><title>%(title)s</title>
|
|
17
|
+
<style>
|
|
18
|
+
html,body{height:100%%;margin:0}
|
|
19
|
+
body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;
|
|
20
|
+
font:15px -apple-system,'Segoe UI',system-ui,sans-serif;background:%(background)s;color:#fff}
|
|
21
|
+
.mark{width:64px;height:64px;image-rendering:auto}
|
|
22
|
+
.spin{width:26px;height:26px;border:3px solid rgba(255,255,255,.25);border-top-color:#fff;border-radius:50%%;animation:s 0.9s linear infinite}
|
|
23
|
+
@keyframes s{to{transform:rotate(360deg)}}
|
|
24
|
+
.err{max-width:520px;text-align:center;color:#ffb4ab}
|
|
25
|
+
</style>
|
|
26
|
+
%(icon)s%(body)s"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SplashPage:
|
|
30
|
+
def __init__(self, title: str, icon_png: Path | None, colors: BootColors):
|
|
31
|
+
self._title = escape(title)
|
|
32
|
+
self._icon_png = icon_png
|
|
33
|
+
self._colors = colors
|
|
34
|
+
|
|
35
|
+
def loading(self) -> str:
|
|
36
|
+
return self._render('<div class="spin"></div>')
|
|
37
|
+
|
|
38
|
+
def error(self, message: str) -> str:
|
|
39
|
+
return self._render(f'<div class="err">{escape(message)}</div>')
|
|
40
|
+
|
|
41
|
+
def _render(self, body: str) -> str:
|
|
42
|
+
return _PAGE % {"title": self._title, "background": self._background(), "icon": self._icon_html(), "body": body}
|
|
43
|
+
|
|
44
|
+
def _background(self) -> str:
|
|
45
|
+
c = self._colors
|
|
46
|
+
return HERO_GRADIENT.format(base=c.base, glow_a=c.glow_a, glow_b=c.glow_b)
|
|
47
|
+
|
|
48
|
+
def _icon_html(self) -> str:
|
|
49
|
+
if not self._icon_png:
|
|
50
|
+
return ""
|
|
51
|
+
data = base64.b64encode(self._icon_png.read_bytes()).decode()
|
|
52
|
+
return f'<img class="mark" alt="" src="data:image/png;base64,{data}">'
|
webapp_shell/watchdog.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Notices when the engine has stopped by itself and starts it again, a few times, before giving up."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from .backend_process import BackendError, BackendProcess
|
|
9
|
+
from .messages import ENGLISH, MessageKey, ShellMessages
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BackendWatchdog:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
backend: BackendProcess,
|
|
16
|
+
give_up: Callable[[str], None],
|
|
17
|
+
name: str = "the program",
|
|
18
|
+
messages: ShellMessages = ENGLISH,
|
|
19
|
+
interval: float = 1.0,
|
|
20
|
+
max_restarts: int = 3,
|
|
21
|
+
window: float = 120.0,
|
|
22
|
+
clock: Callable[[], float] = time.monotonic,
|
|
23
|
+
):
|
|
24
|
+
self.backend, self.give_up = backend, give_up
|
|
25
|
+
self._give_up_message = messages.text(MessageKey.BACKEND_GAVE_UP, name=name)
|
|
26
|
+
self.interval, self.max_restarts, self.window = interval, max_restarts, window
|
|
27
|
+
self._clock = clock
|
|
28
|
+
self._restarts: deque[float] = deque()
|
|
29
|
+
self._stopped = threading.Event()
|
|
30
|
+
self._thread: threading.Thread | None = None
|
|
31
|
+
|
|
32
|
+
def start(self) -> None:
|
|
33
|
+
self._thread = threading.Thread(target=self._loop, name="backend-watchdog", daemon=True)
|
|
34
|
+
self._thread.start()
|
|
35
|
+
|
|
36
|
+
def stop(self) -> None:
|
|
37
|
+
self._stopped.set()
|
|
38
|
+
|
|
39
|
+
def check(self) -> None:
|
|
40
|
+
"""One look. A dead engine is restarted unless that already happened `max_restarts` times within `window`."""
|
|
41
|
+
if not self.backend.died:
|
|
42
|
+
return
|
|
43
|
+
now = self._clock()
|
|
44
|
+
while self._restarts and now - self._restarts[0] > self.window:
|
|
45
|
+
self._restarts.popleft()
|
|
46
|
+
if len(self._restarts) >= self.max_restarts:
|
|
47
|
+
return self._give_up(self._give_up_message)
|
|
48
|
+
self._restarts.append(now)
|
|
49
|
+
try:
|
|
50
|
+
self.backend.restart()
|
|
51
|
+
except BackendError as error:
|
|
52
|
+
self._give_up(str(error))
|
|
53
|
+
|
|
54
|
+
def _give_up(self, message: str) -> None:
|
|
55
|
+
self.stop()
|
|
56
|
+
self.give_up(message)
|
|
57
|
+
|
|
58
|
+
def _loop(self) -> None:
|
|
59
|
+
while not self._stopped.wait(self.interval):
|
|
60
|
+
self.check()
|
webapp_shell/webview2.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""On Windows the pywebview window is drawn by the Microsoft WebView2 runtime (on Windows 11 and on most up-to-date
|
|
2
|
+
Windows 10). Without it the window cannot open, and the person would see nothing at all; this finds it first, so the
|
|
3
|
+
program can say what is missing in plain words."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
from .messages import MessageKey, ShellMessages
|
|
11
|
+
|
|
12
|
+
DOWNLOAD_URL = "https://developer.microsoft.com/microsoft-edge/webview2/"
|
|
13
|
+
RUNTIME_CLIENT = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" # how the Edge updater lists the WebView2 runtime
|
|
14
|
+
NOT_INSTALLED = "0.0.0.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RegistryHive(StrEnum):
|
|
18
|
+
MACHINE = "HKEY_LOCAL_MACHINE"
|
|
19
|
+
USER = "HKEY_CURRENT_USER"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RegistryLocation:
|
|
24
|
+
hive: RegistryHive
|
|
25
|
+
subkey: str # holds the value `pv`: the runtime's version
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
RUNTIME_LOCATIONS = (
|
|
29
|
+
RegistryLocation(RegistryHive.MACHINE, rf"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{RUNTIME_CLIENT}"),
|
|
30
|
+
RegistryLocation(RegistryHive.MACHINE, rf"SOFTWARE\Microsoft\EdgeUpdate\Clients\{RUNTIME_CLIENT}"),
|
|
31
|
+
RegistryLocation(RegistryHive.USER, rf"Software\Microsoft\EdgeUpdate\Clients\{RUNTIME_CLIENT}"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_registry_version(location: RegistryLocation) -> str | None:
|
|
36
|
+
"""The runtime's version at `location`, or None when the key or the value is not there."""
|
|
37
|
+
if sys.platform != "win32":
|
|
38
|
+
return None
|
|
39
|
+
import winreg
|
|
40
|
+
|
|
41
|
+
hive = winreg.HKEY_LOCAL_MACHINE if location.hive is RegistryHive.MACHINE else winreg.HKEY_CURRENT_USER
|
|
42
|
+
try:
|
|
43
|
+
with winreg.OpenKey(hive, location.subkey) as key:
|
|
44
|
+
value, _kind = winreg.QueryValueEx(key, "pv")
|
|
45
|
+
except OSError:
|
|
46
|
+
return None
|
|
47
|
+
return value if isinstance(value, str) else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Webview2Check:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
platform: str = sys.platform,
|
|
54
|
+
read_version: Callable[[RegistryLocation], str | None] = read_registry_version,
|
|
55
|
+
):
|
|
56
|
+
self._platform = platform
|
|
57
|
+
self._read_version = read_version
|
|
58
|
+
|
|
59
|
+
def is_missing(self) -> bool:
|
|
60
|
+
"""True only on Windows, when no WebView2 runtime is listed."""
|
|
61
|
+
if self._platform != "win32":
|
|
62
|
+
return False
|
|
63
|
+
versions = (self._read_version(location) for location in RUNTIME_LOCATIONS)
|
|
64
|
+
return not any(version and version != NOT_INSTALLED for version in versions)
|
|
65
|
+
|
|
66
|
+
def problem(self, name: str, messages: ShellMessages) -> str | None:
|
|
67
|
+
"""The sentence to show when the runtime is missing, else None."""
|
|
68
|
+
if not self.is_missing():
|
|
69
|
+
return None
|
|
70
|
+
return messages.text(MessageKey.WEBVIEW2_MISSING, name=name, url=DOWNLOAD_URL)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def show_message_box(title: str, text: str) -> None:
|
|
74
|
+
"""A windowed program has no console, so a problem before any window exists is shown in the system's own box."""
|
|
75
|
+
if sys.platform == "win32":
|
|
76
|
+
import ctypes
|
|
77
|
+
|
|
78
|
+
ctypes.windll.user32.MessageBoxW(0, text, title, 0x10) # 0x10: the error icon
|
|
79
|
+
else:
|
|
80
|
+
print(text, file=sys.stderr)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Shell 2: pywebview (no Qt): the operating system's own web view."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import webview
|
|
7
|
+
|
|
8
|
+
from .backend_process import BackendProcess
|
|
9
|
+
from .enums import ShellKind
|
|
10
|
+
from .native.os_actions import OsActions
|
|
11
|
+
from .native.webview_background import WebviewBackground
|
|
12
|
+
from .native.webview_bridge import WebviewBridge
|
|
13
|
+
from .native.webview_icon import WebviewIcon
|
|
14
|
+
from .native.webview_window import WebviewWindow
|
|
15
|
+
from .native.window import NativeContext
|
|
16
|
+
from .preferences import ShellPreferences
|
|
17
|
+
from .settings import Settings
|
|
18
|
+
from .shell import Shell
|
|
19
|
+
from .smoke import SETTLE_SECONDS, page_probe_js
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WebviewShell(Shell):
|
|
23
|
+
name = ShellKind.WEBVIEW
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
settings: Settings,
|
|
28
|
+
backend: BackendProcess,
|
|
29
|
+
preferences: ShellPreferences | None = None,
|
|
30
|
+
smoke: bool = False,
|
|
31
|
+
):
|
|
32
|
+
super().__init__(settings, backend, preferences, smoke)
|
|
33
|
+
self._window: webview.Window | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def window(self) -> webview.Window:
|
|
37
|
+
if self._window is None:
|
|
38
|
+
raise RuntimeError("the window is not created yet")
|
|
39
|
+
return self._window
|
|
40
|
+
|
|
41
|
+
def event_loop(self, boot: Callable[[], None]) -> None:
|
|
42
|
+
native_window = WebviewWindow()
|
|
43
|
+
bridge = WebviewBridge(self.native, NativeContext(native_window, self.preferences, OsActions()))
|
|
44
|
+
window = webview.create_window( # pyright: ignore[reportUnknownMemberType] # pywebview's `url` is left open
|
|
45
|
+
self.settings.identity.name,
|
|
46
|
+
html=self.splash.loading(),
|
|
47
|
+
js_api=bridge,
|
|
48
|
+
background_color=self.settings.background_color,
|
|
49
|
+
width=self.settings.window_size[0],
|
|
50
|
+
height=self.settings.window_size[1],
|
|
51
|
+
)
|
|
52
|
+
if window is None:
|
|
53
|
+
raise RuntimeError("pywebview did not create the window")
|
|
54
|
+
self._window = window
|
|
55
|
+
native_window.bind(window)
|
|
56
|
+
icon = str(self.settings.window_icon) if self.settings.window_icon else None
|
|
57
|
+
background = WebviewBackground(self.settings.background_color)
|
|
58
|
+
dock_icon = WebviewIcon(self.settings.icon_png)
|
|
59
|
+
|
|
60
|
+
def start() -> None:
|
|
61
|
+
background.apply(window)
|
|
62
|
+
dock_icon.apply()
|
|
63
|
+
boot()
|
|
64
|
+
|
|
65
|
+
webview.start(
|
|
66
|
+
start, icon=icon
|
|
67
|
+
) # `boot` runs on a worker thread once the GUI is up; the icon is a .ico on Windows, a PNG elsewhere
|
|
68
|
+
|
|
69
|
+
def navigate(self, url: str) -> None:
|
|
70
|
+
self.window.load_url(url)
|
|
71
|
+
|
|
72
|
+
def show_page(self, html: str) -> None:
|
|
73
|
+
self.window.load_html(html)
|
|
74
|
+
|
|
75
|
+
def set_title(self, title: str) -> None:
|
|
76
|
+
self.window.set_title(title)
|
|
77
|
+
|
|
78
|
+
def quit(self) -> None:
|
|
79
|
+
self.window.destroy()
|
|
80
|
+
|
|
81
|
+
def on_navigated(self) -> None:
|
|
82
|
+
if not self.smoke:
|
|
83
|
+
return
|
|
84
|
+
time.sleep(SETTLE_SECONDS)
|
|
85
|
+
self.finish_smoke(self.window.evaluate_js(page_probe_js(self.settings.identity.native_global)))
|
|
86
|
+
self.quit()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: webapp-shell
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: A desktop window for a web app made of a Python backend and a compiled frontend.
|
|
5
|
+
Project-URL: Homepage, https://github.com/prmpsmart/webapp_shell
|
|
6
|
+
Project-URL: Documentation, https://prmpsmart.github.io/webapp_shell
|
|
7
|
+
Project-URL: Source, https://github.com/prmpsmart/webapp_shell
|
|
8
|
+
Project-URL: Issues, https://github.com/prmpsmart/webapp_shell/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/prmpsmart/webapp_shell/blob/main/CHANGE_LOG.md
|
|
10
|
+
Author-email: Miracle Apata <prmpsmart@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: backend,desktop,frontend,native,python,pywebview,shell,webview
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.13
|
|
25
|
+
Requires-Dist: httpx>=0.28
|
|
26
|
+
Requires-Dist: pywebview>=6.2
|
|
27
|
+
Provides-Extra: backend
|
|
28
|
+
Requires-Dist: starlette>=0.40; extra == 'backend'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pyinstaller>=6; extra == 'dev'
|
|
31
|
+
Requires-Dist: pyside6>=6.9; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
34
|
+
Requires-Dist: starlette>=0.40; extra == 'dev'
|
|
35
|
+
Requires-Dist: uvicorn>=0.30; extra == 'dev'
|
|
36
|
+
Provides-Extra: freeze
|
|
37
|
+
Requires-Dist: pyinstaller>=6; extra == 'freeze'
|
|
38
|
+
Provides-Extra: qt
|
|
39
|
+
Requires-Dist: pyside6>=6.9; extra == 'qt'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# webapp-shell
|
|
43
|
+
|
|
44
|
+
[](https://github.com/prmpsmart/webapp_shell/actions/workflows/ci.yml)
|
|
45
|
+
[](https://pypi.org/project/webapp-shell/)
|
|
46
|
+
[](https://pypi.org/project/webapp-shell/)
|
|
47
|
+
[](LICENSE)
|
|
48
|
+
[](https://prmpsmart.github.io/webapp_shell)
|
|
49
|
+
|
|
50
|
+
A desktop window for a web app made of a **Python backend** and a **compiled frontend**. Your project describes itself
|
|
51
|
+
in one config object; the library starts the backend, waits until it is healthy, opens the frontend in a native window
|
|
52
|
+
(pywebview, or Qt on request) and gives the page a small set of native functions (file and folder dialogs, reveal in the
|
|
53
|
+
file manager, open a link, zoom).
|
|
54
|
+
|
|
55
|
+
> Documentation: <https://prmpsmart.github.io/webapp_shell>. The same site is what `examples/site` opens in the
|
|
56
|
+
> library's own window, where each feature's demonstration button does the real thing.
|
|
57
|
+
>
|
|
58
|
+
> By Miracle Apata ([@prmpsmart](https://github.com/prmpsmart)). MIT licence.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
|
|
63
|
+
from webapp_shell import AppIdentity, BackendSpec, BootColors, ShellConfig, WireProtocol, run
|
|
64
|
+
|
|
65
|
+
ROOT = Path(__file__).resolve().parent
|
|
66
|
+
|
|
67
|
+
config = ShellConfig(
|
|
68
|
+
identity=AppIdentity(
|
|
69
|
+
name="Acme Notes",
|
|
70
|
+
slug="acme",
|
|
71
|
+
storage_dir=Path.home() / ".acme",
|
|
72
|
+
boot_colors=BootColors(base="#102030", glow_a="#204060", glow_b="#4060a0"),
|
|
73
|
+
),
|
|
74
|
+
backend=BackendSpec(module="acme_backend", wire=WireProtocol.for_slug("acme"), package_dirs=(ROOT / "backend",)),
|
|
75
|
+
source_root=ROOT, # holds frontend/dist, the compiled frontend
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(run(config))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The backend reads the same contract with the library's helpers (Starlette or FastAPI):
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
parser = argparse.ArgumentParser()
|
|
86
|
+
add_launch_arguments(parser, config.backend) # --port, --frontend, --preferences-file, --token
|
|
87
|
+
launch = read_launch_arguments(parser, parser.parse_args(), config.backend.wire)
|
|
88
|
+
app.add_middleware(TokenGuard, validator=TokenValidator(launch.token, wire), wire=wire)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Native functions: the standard set (file and folder dialogs, save as, reveal, open a link, zoom, boot colours) is always
|
|
92
|
+
there; a project adds its own and the page calls them by name.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from dataclasses import dataclass
|
|
96
|
+
from webapp_shell import JsonValue, NativeContext, NativeFunction
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Greeting: # the page's arguments, in order; each is type-checked before your code runs
|
|
101
|
+
who: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def greet(context: NativeContext, arguments: Greeting) -> JsonValue: # context.window, .preferences, .actions
|
|
105
|
+
return f"hello {arguments.who}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
config = ShellConfig(..., native_functions=(NativeFunction("greet", Greeting, greet),))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
// copy the bridge into the frontend once: python -m webapp_shell bridge --out src/
|
|
113
|
+
import { getBridge } from "./webapp-shell-bridge.js";
|
|
114
|
+
|
|
115
|
+
const bridge = await getBridge({ nativeGlobal: "acmeNative" });
|
|
116
|
+
const path = await bridge.pickFile([{ name: "Excel", extensions: ["xlsx"] }]);
|
|
117
|
+
const hello = await bridge.call("greet", "Ada");
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Install: `pip install webapp-shell` (pywebview is included); `pip install "webapp-shell[qt]"` adds Qt, and
|
|
121
|
+
`pip install "webapp-shell[backend]"` adds Starlette for the token guard.
|
|
122
|
+
|
|
123
|
+
On Windows the pywebview window needs the WebView2 runtime (present on Windows 11); `pythonnet` is installed by pywebview.
|
|
124
|
+
|
|
125
|
+
A runnable minimal program is in `examples/minimal/`, and `examples/site/` is the documentation website, opened by the
|
|
126
|
+
library's own shell, with a live demonstration of every feature.
|
|
127
|
+
|
|
128
|
+
Guides (`docs/`): [the contract](docs/CONTRACT.md), [configuration](docs/CONFIGURATION.md),
|
|
129
|
+
[native functions](docs/NATIVE_FUNCTIONS.md), [freezing with PyInstaller](docs/FREEZING.md),
|
|
130
|
+
[translating](docs/TRANSLATING.md), [troubleshooting](docs/TROUBLESHOOTING.md), and the generated
|
|
131
|
+
[API listing](docs/API.md) (the public API: nothing that is not listed there is promised). The same guides are pages of
|
|
132
|
+
the documentation site. To work on the library, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
webapp_shell/__init__.py,sha256=oix7R1hN5tTcIBf4yQusfma4dVQj3KGQoyjzDzh52gM,1519
|
|
2
|
+
webapp_shell/__main__.py,sha256=bj_37hL3oNMtSCalRVjFDGmUn7g6aYvflpfy4E3NJf0,2162
|
|
3
|
+
webapp_shell/backend.py,sha256=Ge_BJwPL-Em3yamR9CtlDxLu9N0L6HiW6oQ22-Yq418,1391
|
|
4
|
+
webapp_shell/backend_process.py,sha256=BUFG1ULeeu8n2EbGQqypioIgrRAl7B0EEgAb3BMSX_0,5418
|
|
5
|
+
webapp_shell/cli.py,sha256=XorAL_WK-aDjxSF1sU1ch-eQY-TZ30getR0p0gan1_8,3414
|
|
6
|
+
webapp_shell/config.py,sha256=xwttvGnV3p-66TOCuSa4Kx_CJRzkzm1EgJQY-H8KMZM,1300
|
|
7
|
+
webapp_shell/enums.py,sha256=3ITML33MUF24bU_UdVO0pu1X6sdOTIK3BlvxGaLZvKI,525
|
|
8
|
+
webapp_shell/identity.py,sha256=8QbetY8VtpqUniScVzdWp88C1u6UcMfzv_1o10175t8,2077
|
|
9
|
+
webapp_shell/installation.py,sha256=DG3PGtkwcEr-BYFXYB_2vZgRNcRVvKdG7WPHC0dyYN8,2077
|
|
10
|
+
webapp_shell/json_types.py,sha256=z0hS169QEBLsERucHjF6OMj3BU_bcKUl_itmqOaj2QI,247
|
|
11
|
+
webapp_shell/library_check.py,sha256=Qewe0T596bAdkPe1CXqGxmOvBuobujOrWtTh6hI-wtU,868
|
|
12
|
+
webapp_shell/messages.py,sha256=dJNgziyTm5zxlG8Jmh_sR23_9Kn_NwBQR2Zn8IYUgjI,2601
|
|
13
|
+
webapp_shell/preferences.py,sha256=QiSApswZfnZKeRarqk1HcG7o_Dl4suDWb0vvKABFxyU,3070
|
|
14
|
+
webapp_shell/protocol.py,sha256=en_z8aZ8EAQ1sECSV5HceSya9xLVyVysxCGzZmYNrK8,1602
|
|
15
|
+
webapp_shell/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
webapp_shell/qt_shell.py,sha256=FaWYSSL9Rq3xi1iSKlz9Vrh7LxyjseqEvfjKpbV0rzo,5072
|
|
17
|
+
webapp_shell/registry.py,sha256=cfdZ-qOWrjqykLvB-aA5S-pWmm_xbiVtP5jwyj-EAms,511
|
|
18
|
+
webapp_shell/settings.py,sha256=DDLwM2y6krMZSzbuP-q-ssVTccLTpyYBQDbTZ-7Z3is,2683
|
|
19
|
+
webapp_shell/shell.py,sha256=f2Bjg9mdbM2ymuipopdUzEfNrFul0DuOAwhMDcDREDA,4256
|
|
20
|
+
webapp_shell/smoke.py,sha256=U_lnQxevLHuCLTYfJnAQsc1gkrrPA06vtXfUFj9uccs,919
|
|
21
|
+
webapp_shell/splash.py,sha256=yO63XD7BU-JUTM7pwv1RKAZfYS-oWzTpqyrMYxsafr0,2153
|
|
22
|
+
webapp_shell/watchdog.py,sha256=khXvB_xPy8fPcjcjxRBFLbM9AGu-DQ8Xnj3Gwy9gjqA,2098
|
|
23
|
+
webapp_shell/webview2.py,sha256=f-DGvR-SYzLHZkomJeWBDTznp-eboGzbZe5yfKEQ_Zw,3053
|
|
24
|
+
webapp_shell/webview_shell.py,sha256=i0IxfTgeg0WirqSuCKSf8BDKVg4znempAnPy9NUjELQ,2972
|
|
25
|
+
webapp_shell/bridge/webapp-shell-bridge.d.ts,sha256=6M62jAe74dnvrsS0OyoOOosDpRFQ5MJJGbCarFMQ43I,1860
|
|
26
|
+
webapp_shell/bridge/webapp-shell-bridge.js,sha256=ZJpYs82tiS7aqhS_OXWWlZf5TtYup-ueHYE1S0tf42s,4890
|
|
27
|
+
webapp_shell/contract/__init__.py,sha256=JvlMHds4WVEGzE1rKfNCfCBCpmqT4Z1Llme835pL0mk,519
|
|
28
|
+
webapp_shell/contract/guard.py,sha256=2_4Er6OyBvMPAXhvY48RQEIl0QwqvTQvs1H_cwyd1OY,4405
|
|
29
|
+
webapp_shell/contract/launch.py,sha256=SQtLfuH1CV9wpa20ARnlazL1tSeXmwuXZLsXTlAEmEU,1920
|
|
30
|
+
webapp_shell/contract/tokens.py,sha256=YK3RxGp4kiYBpzJpWVU-bsArtiP7RLzPFy6dz6YHtv8,1699
|
|
31
|
+
webapp_shell/freezing/__init__.py,sha256=BmWyjr4GZuPzpZlHSxkBgkbi9xNoJF2a3Poqo7Rx0Tg,383
|
|
32
|
+
webapp_shell/freezing/app.spec,sha256=ji5UEaazfgHSkbDhgQDBtoPat35zLpZInHieGp_qHOM,206
|
|
33
|
+
webapp_shell/freezing/builder.py,sha256=11oaGWkxvHrm1uX5OVGkV24vC3LUWfiqXR2IJY8K4jU,4175
|
|
34
|
+
webapp_shell/freezing/entry.py,sha256=ouHpSrYIIuq6Lpcjfo3EkOV5AF46gM6akPOP6PObJHE,869
|
|
35
|
+
webapp_shell/freezing/plan.py,sha256=I_RzWJuU4n0Oe7lxxjGv8Cn1A0mvVQ7g8jIs27JOCHI,1800
|
|
36
|
+
webapp_shell/freezing/profile.py,sha256=o80oY06P5fXZNM2yQPIOUQYHtyt3-7Ge_KpFkU1bA_o,1875
|
|
37
|
+
webapp_shell/freezing/spec.py,sha256=TUHILhcFOa0i-lo6Bfs-9Y3isCocifULeQm0SZkjyJc,2299
|
|
38
|
+
webapp_shell/freezing/verify.py,sha256=LeDUncKtqOC0BJTZy-Lm4-cXmX4V5bRluFXVi3aqo8M,1729
|
|
39
|
+
webapp_shell/native/__init__.py,sha256=64okxF2yLE1XQrYhEl0JCaGwTK-L2oLPywavQH8GP54,206
|
|
40
|
+
webapp_shell/native/arguments.py,sha256=HXew_DJJBC8C_Q_sIDaRuQ5BH7PTgUwNGKLeteyGY5o,5735
|
|
41
|
+
webapp_shell/native/errors.py,sha256=bG-p4iwMYbZstJTBd0C9HJF8r2Br2ZgZDf8Lrp_Mp3c,193
|
|
42
|
+
webapp_shell/native/filters.py,sha256=ybLhGmiFy7-V-gYPWPXs047KNmUVU2u92ndY4ARluzY,744
|
|
43
|
+
webapp_shell/native/os_actions.py,sha256=ezphiwMGEmWb8CEyZ3QTqmgwlET5_SOgd9PXHuPXDYk,1171
|
|
44
|
+
webapp_shell/native/qt_bridge.py,sha256=BtcIa2_hXenE8NuHhfw8orpEy8uQA_9L5UyCyr3mRPE,2427
|
|
45
|
+
webapp_shell/native/qt_window.py,sha256=PRK5K3vTFrVe2V64Ytd8_3YnihC6apxqTxHbCcZYx6g,1427
|
|
46
|
+
webapp_shell/native/registry.py,sha256=hn2J0SWAupxHjA2ufKmKoQxxbjKuvZWVEtnD6TMS130,3467
|
|
47
|
+
webapp_shell/native/standard.py,sha256=NykWqjK5spwQ5z9qgrdp9Y5FhA_sLGPTz2gLCe-MBjQ,2724
|
|
48
|
+
webapp_shell/native/start_location.py,sha256=5PNbAGLcWw3bCfGsC9vyxUxN9vtLpcBZjUAu8zaWS0s,1095
|
|
49
|
+
webapp_shell/native/webview_background.py,sha256=OMuq8L54ku2l18Q04eqckMb28c3YQak1Yl7I5T6azuk,1744
|
|
50
|
+
webapp_shell/native/webview_bridge.py,sha256=KZ10cl7oHnooBASPVBWNUuPTaLKg0tkc_6oXvc0JaGs,1098
|
|
51
|
+
webapp_shell/native/webview_icon.py,sha256=lpU7RcMwWoRCIHRrEbPz3MQeoAG5FX93haFXNSFlxqg,1428
|
|
52
|
+
webapp_shell/native/webview_window.py,sha256=OydIhmuyPxM3pPmQWi2FzsuqDBMZH_7mjG9_avDsT6s,1977
|
|
53
|
+
webapp_shell/native/window.py,sha256=CQB1KzZvBsEuhYyojJyvcWi7z9jVOMKugpCaQLMoHuc,1045
|
|
54
|
+
webapp_shell/native/zoom.py,sha256=kbIhLQ2xNljrZ-Zv3spghI6Q9-uTmt9SDgMqZOVHyGU,3667
|
|
55
|
+
webapp_shell/native/zoom_range.py,sha256=cR5nvZQa7E0WvXDagPZAsBSM_U4Zg5xqHdxYXOgEIXg,330
|
|
56
|
+
webapp_shell-0.1.dist-info/METADATA,sha256=1Gnx7KWsmEWEusY57rQBLN64LIB4cyb8ki9TAaqeoYA,6046
|
|
57
|
+
webapp_shell-0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
58
|
+
webapp_shell-0.1.dist-info/licenses/LICENSE,sha256=JKHQ_y0Zji_sAXiKzQcHaU0g0PnYKifYY87trR5Si5w,1082
|
|
59
|
+
webapp_shell-0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miracle Apata (prmpsmart)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|