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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The application icon in the Dock. pywebview applies its `icon` option on Qt and GTK only, so on macOS the Dock
|
|
2
|
+
shows a generic icon until it is set here. A packaged program has its icon from the bundle; this serves a run
|
|
3
|
+
from the source tree. Always safe: a platform it does not know is left alone."""
|
|
4
|
+
|
|
5
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownLambdaType=false
|
|
6
|
+
# pyright: reportUnknownArgumentType=false, reportMissingTypeStubs=false, reportMissingImports=false
|
|
7
|
+
# pyright: reportAttributeAccessIssue=false
|
|
8
|
+
# The lines below talk to AppKit (PyObjC), .NET and GTK, which publish no type stubs. Their inside is untyped by
|
|
9
|
+
# necessity; the signatures of this module are typed.
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebviewIcon:
|
|
16
|
+
def __init__(self, path: Path | None):
|
|
17
|
+
self.path = path
|
|
18
|
+
|
|
19
|
+
def apply(self) -> bool:
|
|
20
|
+
if sys.platform != "darwin" or self.path is None:
|
|
21
|
+
return False
|
|
22
|
+
try:
|
|
23
|
+
import AppKit
|
|
24
|
+
from PyObjCTools import AppHelper
|
|
25
|
+
|
|
26
|
+
image = AppKit.NSImage.alloc().initWithContentsOfFile_(str(self.path))
|
|
27
|
+
if image is None:
|
|
28
|
+
return False
|
|
29
|
+
AppHelper.callAfter(lambda: AppKit.NSApplication.sharedApplication().setApplicationIconImage_(image))
|
|
30
|
+
return True
|
|
31
|
+
except Exception: # cosmetic only: never stop the app for an icon
|
|
32
|
+
return False
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""`NativeWindow` for the pywebview shell. The window is attached after pywebview creates it."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
import webview
|
|
6
|
+
|
|
7
|
+
from .filters import FileFilter
|
|
8
|
+
from .start_location import StartLocation
|
|
9
|
+
from .window import NativeWindow
|
|
10
|
+
from .zoom import WebviewZoom
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WebviewWindow(NativeWindow):
|
|
14
|
+
def __init__(self, zoom: WebviewZoom | None = None):
|
|
15
|
+
self._window: webview.Window | None = None
|
|
16
|
+
self._zoom = zoom or WebviewZoom()
|
|
17
|
+
|
|
18
|
+
def bind(self, window: webview.Window) -> None:
|
|
19
|
+
self._window = window
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def window(self) -> webview.Window:
|
|
23
|
+
if self._window is None:
|
|
24
|
+
raise RuntimeError("the window is not attached yet")
|
|
25
|
+
return self._window
|
|
26
|
+
|
|
27
|
+
def pick_folder(self, start: str) -> str | None:
|
|
28
|
+
location = StartLocation.of(start)
|
|
29
|
+
return self._first(self.window.create_file_dialog(webview.FileDialog.FOLDER, directory=location.directory))
|
|
30
|
+
|
|
31
|
+
def pick_file(self, filters: list[FileFilter], start: str) -> str | None:
|
|
32
|
+
location = StartLocation.of(start)
|
|
33
|
+
types = tuple(f.to_webview() for f in filters)
|
|
34
|
+
return self._first(
|
|
35
|
+
self.window.create_file_dialog(webview.FileDialog.OPEN, directory=location.directory, file_types=types)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def save_as(self, name: str, start: str) -> str | None:
|
|
39
|
+
location = StartLocation.of(start)
|
|
40
|
+
return self._first(
|
|
41
|
+
self.window.create_file_dialog(
|
|
42
|
+
webview.FileDialog.SAVE, directory=location.directory, save_filename=location.filename or name
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def set_zoom(self, factor: float) -> bool:
|
|
47
|
+
return self._zoom.apply(self.window, factor)
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _first(result: Sequence[str] | str | None) -> str | None:
|
|
51
|
+
"""pywebview returns a path, a list of paths, or nothing when cancelled."""
|
|
52
|
+
if not result:
|
|
53
|
+
return None
|
|
54
|
+
return result if isinstance(result, str) else result[0]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""What a native function can use: the window it opens dialogs on, the shell's preferences and the OS actions. Each
|
|
2
|
+
toolkit implements `NativeWindow`; the functions are written once, against it."""
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ..preferences import ShellPreferences
|
|
8
|
+
from .filters import FileFilter
|
|
9
|
+
from .os_actions import OsActions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NativeWindow(ABC):
|
|
13
|
+
"""The window of one toolkit. A dialog returns the chosen path, or None when the person cancelled."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def pick_folder(self, start: str) -> str | None: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def pick_file(self, filters: list[FileFilter], start: str) -> str | None: ...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def save_as(self, name: str, start: str) -> str | None: ...
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def set_zoom(self, factor: float) -> bool: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class NativeContext:
|
|
30
|
+
"""Handed to every native function as its first argument."""
|
|
31
|
+
|
|
32
|
+
window: NativeWindow
|
|
33
|
+
preferences: ShellPreferences
|
|
34
|
+
actions: OsActions
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Page zoom (Cmd/Ctrl +, -, 0). The page decides the factor and keeps it as an app preference; each shell only
|
|
2
|
+
applies it to its web view. Qt has `setZoomFactor`; pywebview has no zoom call, so each system's own web view is
|
|
3
|
+
reached here, one small class per system."""
|
|
4
|
+
|
|
5
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownLambdaType=false
|
|
6
|
+
# pyright: reportUnknownArgumentType=false, reportMissingTypeStubs=false, reportMissingImports=false
|
|
7
|
+
# pyright: reportAttributeAccessIssue=false
|
|
8
|
+
# The lines below talk to AppKit (PyObjC), .NET and GTK, which publish no type stubs. Their inside is untyped by
|
|
9
|
+
# necessity; the signatures of this module are typed.
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import webview
|
|
17
|
+
|
|
18
|
+
MAIN_THREAD_WAIT = 2.0 # seconds to wait for the system's UI thread
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ZoomSurface(ABC):
|
|
22
|
+
"""How to set the page zoom of a pywebview window on one system. `apply` says whether it was applied."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def apply(self, window: webview.Window, factor: float) -> bool: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MacZoom(ZoomSurface):
|
|
29
|
+
"""The window's content view is the WKWebView; its `pageZoom` reflows the page like a browser's zoom does.
|
|
30
|
+
Cocoa views are touched on the main thread only, and the call waits so a failure is reported."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, on_main: Callable[[Callable[[], None]], None] | None = None):
|
|
33
|
+
self._on_main = on_main or self._app_helper
|
|
34
|
+
|
|
35
|
+
def apply(self, window: webview.Window, factor: float) -> bool:
|
|
36
|
+
done = threading.Event()
|
|
37
|
+
result: list[bool] = []
|
|
38
|
+
|
|
39
|
+
def zoom() -> None:
|
|
40
|
+
try:
|
|
41
|
+
window.native.contentView().setPageZoom_(factor)
|
|
42
|
+
result.append(True)
|
|
43
|
+
except Exception: # cosmetic only: never stop the app for a zoom
|
|
44
|
+
result.append(False)
|
|
45
|
+
finally:
|
|
46
|
+
done.set()
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
self._on_main(zoom)
|
|
50
|
+
except Exception:
|
|
51
|
+
return False
|
|
52
|
+
return done.wait(MAIN_THREAD_WAIT) and bool(result and result[0])
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _app_helper(call: Callable[[], None]) -> None:
|
|
56
|
+
from PyObjCTools import AppHelper
|
|
57
|
+
|
|
58
|
+
AppHelper.callAfter(call)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class WindowsZoom(ZoomSurface):
|
|
62
|
+
"""WebView2's `ZoomFactor`. The browser's own Ctrl +/- are off in a released program, the page's shortcuts do it."""
|
|
63
|
+
|
|
64
|
+
def apply(self, window: webview.Window, factor: float) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
from System import Func, Type
|
|
67
|
+
|
|
68
|
+
form = window.native
|
|
69
|
+
|
|
70
|
+
def zoom() -> None:
|
|
71
|
+
form.webview.ZoomFactor = factor
|
|
72
|
+
|
|
73
|
+
form.Invoke(Func[Type](zoom)) # waits for the form's thread
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GtkZoom(ZoomSurface):
|
|
80
|
+
"""WebKitGTK's `set_zoom_level`, called on the GTK main loop."""
|
|
81
|
+
|
|
82
|
+
def apply(self, window: webview.Window, factor: float) -> bool:
|
|
83
|
+
try:
|
|
84
|
+
from gi.repository import GLib
|
|
85
|
+
|
|
86
|
+
view = window.native.webview
|
|
87
|
+
GLib.idle_add(lambda: view.set_zoom_level(factor) and False)
|
|
88
|
+
return True
|
|
89
|
+
except Exception:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class WebviewZoom:
|
|
94
|
+
def __init__(self, surface: ZoomSurface | None = None):
|
|
95
|
+
self._surface = surface or self._for_this_system()
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _for_this_system() -> ZoomSurface:
|
|
99
|
+
if sys.platform == "darwin":
|
|
100
|
+
return MacZoom()
|
|
101
|
+
return WindowsZoom() if sys.platform == "win32" else GtkZoom()
|
|
102
|
+
|
|
103
|
+
def apply(self, window: webview.Window, factor: float) -> bool:
|
|
104
|
+
return self._surface.apply(window, factor)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The range of the page zoom, for every shell (no toolkit is imported here, so a Qt-only build can read it)."""
|
|
2
|
+
|
|
3
|
+
MIN_ZOOM = 0.5
|
|
4
|
+
MAX_ZOOM = 3.0
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def clamp(value: float) -> float:
|
|
8
|
+
"""A usable zoom factor: the value, kept between the smallest and the largest the shells allow."""
|
|
9
|
+
return min(MAX_ZOOM, max(MIN_ZOOM, value))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""What the shell itself remembers between launches: the colours of the app's opening screen, so the window and
|
|
2
|
+
the splash start in the colours of the theme the user chose (the shell cannot read the web page's storage before
|
|
3
|
+
the page loads)."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .json_types import JsonObject, JsonValue
|
|
11
|
+
|
|
12
|
+
_HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{6}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_colour(value: object) -> bool:
|
|
16
|
+
"""Only `#rrggbb` is accepted: these values are later written into HTML and CSS."""
|
|
17
|
+
return isinstance(value, str) and _HEX_COLOUR.fullmatch(value) is not None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class BootColors:
|
|
22
|
+
"""The three colours of the navy gradient (frontend tokens `--nav`, `--hero-a`, `--hero-b`)."""
|
|
23
|
+
|
|
24
|
+
base: str
|
|
25
|
+
glow_a: str
|
|
26
|
+
glow_b: str
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_values(cls, base: JsonValue, glow_a: JsonValue, glow_b: JsonValue) -> "BootColors | None":
|
|
30
|
+
"""From three stored values. None when one is not text or the three are not `#rrggbb` colours."""
|
|
31
|
+
if not (isinstance(base, str) and isinstance(glow_a, str) and isinstance(glow_b, str)):
|
|
32
|
+
return None
|
|
33
|
+
colors = cls(base, glow_a, glow_b)
|
|
34
|
+
return colors if colors.is_valid else None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_valid(self) -> bool:
|
|
38
|
+
return all(is_colour(c) for c in (self.base, self.glow_a, self.glow_b))
|
|
39
|
+
|
|
40
|
+
def normalised(self) -> "BootColors":
|
|
41
|
+
return BootColors(self.base.lower(), self.glow_a.lower(), self.glow_b.lower())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ShellPreferences:
|
|
45
|
+
"""A tiny JSON file. With `path=None` it lives in memory only (headless checks must not touch the user's file)."""
|
|
46
|
+
|
|
47
|
+
BOOT_KEY = "bootColors"
|
|
48
|
+
|
|
49
|
+
def __init__(self, path: Path | None):
|
|
50
|
+
self._path = path
|
|
51
|
+
self._values: JsonObject = self._read()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def boot_colors(self) -> BootColors | None:
|
|
55
|
+
stored = self._values.get(self.BOOT_KEY)
|
|
56
|
+
if not isinstance(stored, dict):
|
|
57
|
+
return None
|
|
58
|
+
return BootColors.from_values(stored.get("base"), stored.get("glow_a"), stored.get("glow_b"))
|
|
59
|
+
|
|
60
|
+
def save_boot_colors(self, colors: BootColors) -> bool:
|
|
61
|
+
if not colors.is_valid:
|
|
62
|
+
return False
|
|
63
|
+
self._values[self.BOOT_KEY] = asdict(colors.normalised())
|
|
64
|
+
self._write()
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# storage -----------------------------------------------------------------------------------
|
|
68
|
+
def _read(self) -> JsonObject:
|
|
69
|
+
if self._path is None or not self._path.is_file():
|
|
70
|
+
return {}
|
|
71
|
+
try:
|
|
72
|
+
data: JsonValue = json.loads(self._path.read_text(encoding="utf-8"))
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
return {}
|
|
75
|
+
return data if isinstance(data, dict) else {}
|
|
76
|
+
|
|
77
|
+
def _write(self) -> None:
|
|
78
|
+
if self._path is None:
|
|
79
|
+
return
|
|
80
|
+
try:
|
|
81
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
self._path.write_text(json.dumps(self._values, indent=2), encoding="utf-8")
|
|
83
|
+
except OSError: # colours are not worth failing for
|
|
84
|
+
pass
|
webapp_shell/protocol.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The names the shell and a backend must agree on. The backend reads the same names; the library never invents them
|
|
2
|
+
silently: `for_slug` gives a consistent default set and a project may write its own."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class WireProtocol:
|
|
11
|
+
token_header: str # how an API call proves it comes from this launch
|
|
12
|
+
token_cookie: str # what the backend turns the first token into
|
|
13
|
+
token_query: str # where the launch URL carries the token once
|
|
14
|
+
token_env: str # how the shell hands the token over (a command line is visible to other users)
|
|
15
|
+
api_prefix: str # everything below it needs the token, except the health path
|
|
16
|
+
health_path: str # answers 200 when the backend is ready
|
|
17
|
+
version_path: str | None # answers JSON with a "version"; None when the backend has none
|
|
18
|
+
ws_close_unauthorized: int = 4401 # the close code of a socket that came without a valid token
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def for_slug(cls, slug: str) -> "WireProtocol":
|
|
22
|
+
return cls(
|
|
23
|
+
token_header=f"x-{slug}-token",
|
|
24
|
+
token_cookie=f"{slug}_token",
|
|
25
|
+
token_query="token",
|
|
26
|
+
token_env=f"{slug.upper()}_TOKEN",
|
|
27
|
+
api_prefix="/api",
|
|
28
|
+
health_path="/api/health",
|
|
29
|
+
version_path="/api/version",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def token_from_environment(self, environ: Mapping[str, str] | None = None) -> str | None:
|
|
33
|
+
"""The token the shell handed over, read by the backend."""
|
|
34
|
+
return (os.environ if environ is None else environ).get(self.token_env)
|
webapp_shell/py.typed
ADDED
|
File without changes
|
webapp_shell/qt_shell.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shell 1: Qt WebEngine (bundled Chromium)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QObject, QTimer, QUrl, Signal
|
|
9
|
+
from PySide6.QtGui import QColor, QIcon
|
|
10
|
+
from PySide6.QtWebChannel import QWebChannel
|
|
11
|
+
from PySide6.QtWebEngineCore import QWebEngineScript
|
|
12
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
13
|
+
from PySide6.QtWidgets import QApplication
|
|
14
|
+
|
|
15
|
+
from .backend_process import BackendProcess
|
|
16
|
+
from .enums import ShellKind
|
|
17
|
+
from .native.os_actions import OsActions
|
|
18
|
+
from .native.qt_bridge import CHANNEL_OBJECT, QtBridge
|
|
19
|
+
from .native.qt_window import QtWindow
|
|
20
|
+
from .native.window import NativeContext
|
|
21
|
+
from .preferences import ShellPreferences
|
|
22
|
+
from .settings import Settings
|
|
23
|
+
from .shell import Shell
|
|
24
|
+
from .smoke import SETTLE_SECONDS, page_probe_js
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Dispatcher(QObject):
|
|
28
|
+
"""Lets the worker thread ask the GUI thread to act (Qt widgets must only be touched there)."""
|
|
29
|
+
|
|
30
|
+
navigate = Signal(str)
|
|
31
|
+
show_page = Signal(str)
|
|
32
|
+
set_title = Signal(str)
|
|
33
|
+
quit = Signal()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class QtShell(Shell):
|
|
37
|
+
name = ShellKind.QT
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
settings: Settings,
|
|
42
|
+
backend: BackendProcess,
|
|
43
|
+
preferences: ShellPreferences | None = None,
|
|
44
|
+
smoke: bool = False,
|
|
45
|
+
):
|
|
46
|
+
super().__init__(settings, backend, preferences, smoke)
|
|
47
|
+
if self.smoke:
|
|
48
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
49
|
+
self._app = QApplication(sys.argv)
|
|
50
|
+
self._view = QWebEngineView()
|
|
51
|
+
self._apply_icon()
|
|
52
|
+
self._apply_background()
|
|
53
|
+
self._dispatcher = _Dispatcher()
|
|
54
|
+
self._wire_dispatcher()
|
|
55
|
+
self._install_native_bridge()
|
|
56
|
+
|
|
57
|
+
def event_loop(self, boot: Callable[[], None]) -> None:
|
|
58
|
+
self._view.setWindowTitle(self.settings.identity.name)
|
|
59
|
+
self._view.resize(*self.settings.window_size)
|
|
60
|
+
self._view.setHtml(self.splash.loading())
|
|
61
|
+
if self.smoke: # headless: never show the window (offscreen GPU rendering is unreliable)
|
|
62
|
+
self._view.loadFinished.connect(self._on_load_finished)
|
|
63
|
+
QTimer.singleShot(30_000, self._app.quit) # pyright: ignore[reportUnknownMemberType] # PySide6 leaves the callable open
|
|
64
|
+
else:
|
|
65
|
+
self._view.show()
|
|
66
|
+
QTimer.singleShot(0, lambda: threading.Thread(target=boot, daemon=True).start()) # pyright: ignore[reportUnknownMemberType] # PySide6 leaves the callable open
|
|
67
|
+
self._app.exec()
|
|
68
|
+
|
|
69
|
+
def navigate(self, url: str) -> None:
|
|
70
|
+
self._dispatcher.navigate.emit(url)
|
|
71
|
+
|
|
72
|
+
def show_page(self, html: str) -> None:
|
|
73
|
+
self._dispatcher.show_page.emit(html)
|
|
74
|
+
|
|
75
|
+
def set_title(self, title: str) -> None:
|
|
76
|
+
self._dispatcher.set_title.emit(title)
|
|
77
|
+
|
|
78
|
+
def quit(self) -> None:
|
|
79
|
+
self._dispatcher.quit.emit()
|
|
80
|
+
|
|
81
|
+
def _apply_icon(self) -> None:
|
|
82
|
+
if self.settings.icon_png:
|
|
83
|
+
icon = QIcon(str(self.settings.icon_png))
|
|
84
|
+
self._app.setWindowIcon(icon)
|
|
85
|
+
self._view.setWindowIcon(icon)
|
|
86
|
+
|
|
87
|
+
def _apply_background(self) -> None:
|
|
88
|
+
"""The page and the widget behind it start in the app's colour, so nothing flashes white."""
|
|
89
|
+
colour = self.settings.background_color
|
|
90
|
+
self._view.page().setBackgroundColor(QColor(colour))
|
|
91
|
+
self._view.setStyleSheet(f"background:{colour}")
|
|
92
|
+
|
|
93
|
+
def _wire_dispatcher(self) -> None:
|
|
94
|
+
d = self._dispatcher
|
|
95
|
+
d.navigate.connect(self._load)
|
|
96
|
+
d.show_page.connect(self._view.setHtml)
|
|
97
|
+
d.set_title.connect(self._view.setWindowTitle)
|
|
98
|
+
d.quit.connect(self._app.quit)
|
|
99
|
+
|
|
100
|
+
def _load(self, url: str) -> None:
|
|
101
|
+
self._view.load(QUrl(url))
|
|
102
|
+
|
|
103
|
+
def _install_native_bridge(self) -> None:
|
|
104
|
+
self._channel = QWebChannel(self._view.page())
|
|
105
|
+
context = NativeContext(QtWindow(self._view), self.preferences, OsActions())
|
|
106
|
+
self._channel.registerObject(CHANNEL_OBJECT, QtBridge(self.native, context, self._view))
|
|
107
|
+
self._view.page().setWebChannel(self._channel)
|
|
108
|
+
script = QWebEngineScript()
|
|
109
|
+
script.setName(f"{self.settings.identity.slug}-native")
|
|
110
|
+
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
|
111
|
+
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
|
112
|
+
script.setSourceCode(QtBridge.page_script_source(self.settings.identity, self.native))
|
|
113
|
+
self._view.page().scripts().insert(script) # pyright: ignore[reportUnknownMemberType] # overload with an open list type
|
|
114
|
+
|
|
115
|
+
def _on_load_finished(self, _ok: bool) -> None:
|
|
116
|
+
if self._view.url().scheme() == "http": # the frontend, not the splash
|
|
117
|
+
QTimer.singleShot(int(SETTLE_SECONDS * 1000), self._probe) # pyright: ignore[reportUnknownMemberType] # PySide6 leaves the callable open
|
|
118
|
+
|
|
119
|
+
def _probe(self) -> None:
|
|
120
|
+
self._view.page().runJavaScript( # pyright: ignore[reportUnknownMemberType] # the result callback type is open
|
|
121
|
+
page_probe_js(self.settings.identity.native_global), self._on_probe
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _on_probe(self, page: str) -> None:
|
|
125
|
+
self.finish_smoke(page)
|
|
126
|
+
self._app.quit()
|
webapp_shell/registry.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Available shells, imported lazily so choosing one never requires the other's dependencies."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
from .enums import ShellKind
|
|
6
|
+
from .shell import Shell
|
|
7
|
+
|
|
8
|
+
_SHELLS = {
|
|
9
|
+
ShellKind.QT: ("webapp_shell.qt_shell", "QtShell"),
|
|
10
|
+
ShellKind.WEBVIEW: ("webapp_shell.webview_shell", "WebviewShell"),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def kinds() -> list[ShellKind]:
|
|
15
|
+
return list(_SHELLS)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def shell_class(kind: ShellKind) -> type[Shell]:
|
|
19
|
+
module, cls = _SHELLS[kind]
|
|
20
|
+
return getattr(importlib.import_module(module), cls)
|
webapp_shell/settings.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Where things are and how the shell behaves, in one place: the project's config on this installation."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import ShellConfig
|
|
8
|
+
from .installation import Installation
|
|
9
|
+
from .preferences import BootColors, ShellPreferences
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
config: ShellConfig
|
|
15
|
+
installation: Installation
|
|
16
|
+
boot_colors: BootColors # the opening colours: the identity's, or the ones the user's theme left behind
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def of(
|
|
20
|
+
cls,
|
|
21
|
+
config: ShellConfig,
|
|
22
|
+
preferences: ShellPreferences | None = None,
|
|
23
|
+
installation: Installation | None = None,
|
|
24
|
+
splash_seconds: float | None = None,
|
|
25
|
+
) -> "Settings":
|
|
26
|
+
"""The config on this installation, with the opening colours the user's theme left behind last time."""
|
|
27
|
+
installation = installation or Installation(config.source_root)
|
|
28
|
+
remembered = preferences.boot_colors if preferences else None
|
|
29
|
+
if splash_seconds is not None:
|
|
30
|
+
config = replace(config, splash_seconds=splash_seconds)
|
|
31
|
+
return cls(config, installation, remembered or config.identity.boot_colors)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def identity(self):
|
|
35
|
+
return self.config.identity
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def host(self) -> str:
|
|
39
|
+
return self.config.host
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def startup_timeout(self) -> float:
|
|
43
|
+
return self.config.startup_timeout
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def splash_seconds(self) -> float:
|
|
47
|
+
return self.config.splash_seconds
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def window_size(self) -> tuple[int, int]:
|
|
51
|
+
return self.identity.window_size
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def background_color(self) -> str:
|
|
55
|
+
"""The solid colour behind everything (the gradient's base), used for the window and the web view."""
|
|
56
|
+
return self.boot_colors.base
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def package_dirs(self) -> tuple[Path, ...]:
|
|
60
|
+
return self.installation.package_dirs(self.config.backend)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def frontend_dist(self) -> Path:
|
|
64
|
+
return self.installation.root / self.config.backend.frontend_dist
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def icon_png(self) -> Path | None:
|
|
68
|
+
return self._existing(self.identity.icon_png)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def window_icon(self) -> Path | None:
|
|
72
|
+
"""The icon for the window frame. Windows draws it with `System.Drawing.Icon`, which reads only `.ico` files;
|
|
73
|
+
the other systems take the PNG."""
|
|
74
|
+
return self._existing(self.identity.icon_ico if sys.platform == "win32" else self.identity.icon_png)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _existing(path: Path | None) -> Path | None:
|
|
78
|
+
return path if path is not None and path.is_file() else None
|