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,106 @@
|
|
|
1
|
+
"""Access control for a Starlette or FastAPI backend: the launch token, from a header or a cookie.
|
|
2
|
+
|
|
3
|
+
app.add_middleware(TokenGuard, validator=TokenValidator(token, wire), wire=wire)
|
|
4
|
+
|
|
5
|
+
and for a WebSocket route, which middleware does not cover:
|
|
6
|
+
|
|
7
|
+
guard = WebSocketGuard(validator, wire)
|
|
8
|
+
|
|
9
|
+
@app.websocket("/api/events")
|
|
10
|
+
async def events(socket: WebSocket):
|
|
11
|
+
if not await guard.accept(socket):
|
|
12
|
+
return
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
Needs `starlette` (`pip install "webapp-shell[backend]"`).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
from starlette.responses import JSONResponse, RedirectResponse, Response
|
|
23
|
+
from starlette.types import ASGIApp
|
|
24
|
+
from starlette.websockets import WebSocket
|
|
25
|
+
|
|
26
|
+
__all__ = ["GuardResponses", "TokenGuard", "WebSocketGuard"]
|
|
27
|
+
|
|
28
|
+
from ..protocol import WireProtocol
|
|
29
|
+
from .tokens import OriginPolicy, TokenValidator
|
|
30
|
+
|
|
31
|
+
ORIGIN_HEADER = "origin"
|
|
32
|
+
HOST_HEADER = "host"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class GuardResponses:
|
|
37
|
+
"""What a refused request gets back. A project with its own error format replaces them."""
|
|
38
|
+
|
|
39
|
+
unauthorized: dict[str, str] = field(default_factory=lambda: {"code": "UNAUTHORIZED"})
|
|
40
|
+
foreign_origin: dict[str, str] = field(default_factory=lambda: {"code": "FOREIGN_ORIGIN"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TokenGuard(BaseHTTPMiddleware):
|
|
44
|
+
"""Protects the API with the launch token.
|
|
45
|
+
|
|
46
|
+
Opening the app with `/?token=...` turns the token into a cookie and redirects to the clean URL,
|
|
47
|
+
so a launcher only has to open one URL and needs no cookie code of its own.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
app: ASGIApp,
|
|
53
|
+
validator: TokenValidator,
|
|
54
|
+
wire: WireProtocol,
|
|
55
|
+
origins: OriginPolicy | None = None,
|
|
56
|
+
responses: GuardResponses | None = None,
|
|
57
|
+
):
|
|
58
|
+
super().__init__(app)
|
|
59
|
+
self._validator = validator
|
|
60
|
+
self._wire = wire
|
|
61
|
+
self._origins = origins or OriginPolicy()
|
|
62
|
+
self._responses = responses or GuardResponses()
|
|
63
|
+
|
|
64
|
+
def _bearer(self, request: Request) -> str | None:
|
|
65
|
+
return request.headers.get(self._wire.token_header) or request.cookies.get(self._wire.token_cookie)
|
|
66
|
+
|
|
67
|
+
def _bootstrap_session(self, request: Request) -> RedirectResponse:
|
|
68
|
+
response = RedirectResponse(str(request.url.remove_query_params(self._wire.token_query)))
|
|
69
|
+
response.set_cookie(self._wire.token_cookie, self._validator.token, httponly=True, samesite="strict")
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
73
|
+
wire = self._wire
|
|
74
|
+
path = request.url.path
|
|
75
|
+
in_api = path.startswith(wire.api_prefix)
|
|
76
|
+
if self._validator.is_valid(request.query_params.get(wire.token_query)) and not in_api:
|
|
77
|
+
return self._bootstrap_session(request)
|
|
78
|
+
if in_api and not self._origins.allows(request.headers.get(ORIGIN_HEADER), request.headers.get(HOST_HEADER)):
|
|
79
|
+
return JSONResponse(self._responses.foreign_origin, status_code=403)
|
|
80
|
+
if in_api and path != wire.health_path and not self._validator.is_valid(self._bearer(request)):
|
|
81
|
+
return JSONResponse(self._responses.unauthorized, status_code=401)
|
|
82
|
+
return await call_next(request)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WebSocketGuard:
|
|
86
|
+
"""The same rules for a socket as `TokenGuard` has for a request: the token (header, cookie or query string) and
|
|
87
|
+
the origin. A refused socket is closed with `wire.ws_close_unauthorized` before it is accepted."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, validator: TokenValidator, wire: WireProtocol, origins: OriginPolicy | None = None):
|
|
90
|
+
self._validator = validator
|
|
91
|
+
self._wire = wire
|
|
92
|
+
self._origins = origins or OriginPolicy()
|
|
93
|
+
|
|
94
|
+
def allows(self, socket: WebSocket) -> bool:
|
|
95
|
+
from_own_page = self._origins.allows(socket.headers.get(ORIGIN_HEADER), socket.headers.get(HOST_HEADER))
|
|
96
|
+
return from_own_page and self._validator.is_valid_connection(
|
|
97
|
+
socket.headers, socket.cookies, socket.query_params
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def accept(self, socket: WebSocket) -> bool:
|
|
101
|
+
"""Accepts the socket and returns True, or closes it and returns False: the route then just returns."""
|
|
102
|
+
if not self.allows(socket):
|
|
103
|
+
await socket.close(code=self._wire.ws_close_unauthorized)
|
|
104
|
+
return False
|
|
105
|
+
await socket.accept()
|
|
106
|
+
return True
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The command line the shell starts a backend with, read by the backend: the port, the compiled frontend, the
|
|
2
|
+
preferences file and the token. The argument names come from the same `BackendSpec` the shell builds the command from,
|
|
3
|
+
so the two cannot drift."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..backend import BackendSpec
|
|
10
|
+
from ..protocol import WireProtocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LaunchArguments:
|
|
15
|
+
port: int
|
|
16
|
+
token: str
|
|
17
|
+
frontend: Path | None
|
|
18
|
+
preferences_file: Path | None
|
|
19
|
+
host: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_launch_arguments(parser: argparse.ArgumentParser, backend: BackendSpec) -> None:
|
|
23
|
+
"""Adds the standard arguments to a backend's parser. Read them back with `LaunchArguments.of(args, ...)`."""
|
|
24
|
+
wire = backend.wire
|
|
25
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
26
|
+
parser.add_argument(backend.port_argument, dest="port", type=int, required=True)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--token",
|
|
29
|
+
default=wire.token_from_environment(),
|
|
30
|
+
help=f"the launch token; {wire.token_env} in the environment is safer, a command line is visible to other users",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(backend.frontend_argument, dest="frontend", type=Path, default=None)
|
|
33
|
+
if backend.preferences_argument:
|
|
34
|
+
parser.add_argument(backend.preferences_argument, dest="preferences_file", type=Path, default=None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_launch_arguments(
|
|
38
|
+
parser: argparse.ArgumentParser, args: argparse.Namespace, wire: WireProtocol
|
|
39
|
+
) -> LaunchArguments:
|
|
40
|
+
"""Checks the token is there (the parser reports the error) and gathers the standard arguments."""
|
|
41
|
+
if not args.token:
|
|
42
|
+
parser.error(f"a token is needed: --token or {wire.token_env}")
|
|
43
|
+
return LaunchArguments(
|
|
44
|
+
port=args.port,
|
|
45
|
+
token=args.token,
|
|
46
|
+
frontend=args.frontend,
|
|
47
|
+
preferences_file=getattr(args, "preferences_file", None),
|
|
48
|
+
host=args.host,
|
|
49
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Decides whether a candidate is the launch token, and whether a request comes from the app's own page. No HTTP
|
|
2
|
+
framework code here."""
|
|
3
|
+
|
|
4
|
+
import hmac
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from ..protocol import WireProtocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TokenValidator:
|
|
12
|
+
def __init__(self, token: str, wire: WireProtocol):
|
|
13
|
+
self._token = token
|
|
14
|
+
self._wire = wire
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def token(self) -> str:
|
|
18
|
+
return self._token
|
|
19
|
+
|
|
20
|
+
def is_valid(self, candidate: str | None) -> bool:
|
|
21
|
+
# Compared as bytes: `compare_digest` refuses text with non-ASCII characters, which a caller controls.
|
|
22
|
+
return bool(candidate) and hmac.compare_digest(candidate.encode(), self._token.encode())
|
|
23
|
+
|
|
24
|
+
def is_valid_connection(
|
|
25
|
+
self, headers: Mapping[str, str], cookies: Mapping[str, str], query: Mapping[str, str]
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""A socket can carry the token in a header (tools), the cookie (the browser) or the query string."""
|
|
28
|
+
wire = self._wire
|
|
29
|
+
candidate = headers.get(wire.token_header) or cookies.get(wire.token_cookie) or query.get(wire.token_query)
|
|
30
|
+
return self.is_valid(candidate)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OriginPolicy:
|
|
34
|
+
"""A browser tells which page made a request in `Origin`. Cookies are shared by every port of a host, so another
|
|
35
|
+
program's page on 127.0.0.1 would otherwise be allowed to call this one: only the app's own page is."""
|
|
36
|
+
|
|
37
|
+
def allows(self, origin: str | None, host: str | None) -> bool:
|
|
38
|
+
"""True when the request names no origin (a tool, not a page) or the origin is the host being called."""
|
|
39
|
+
if origin is None:
|
|
40
|
+
return True
|
|
41
|
+
return bool(host) and urlsplit(origin).netloc == host
|
webapp_shell/enums.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ShellKind(StrEnum):
|
|
5
|
+
"""The desktop shells. The value is what the command line and the run scripts use."""
|
|
6
|
+
|
|
7
|
+
QT = "qt"
|
|
8
|
+
WEBVIEW = "webview"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NativeMethod(StrEnum):
|
|
12
|
+
"""The native functions a page may call (02-architecture.md). The value is the name on the wire, in both shells."""
|
|
13
|
+
|
|
14
|
+
PICK_FOLDER = "pickFolder"
|
|
15
|
+
PICK_FILE = "pickFile"
|
|
16
|
+
SAVE_AS = "saveAs"
|
|
17
|
+
REVEAL = "reveal"
|
|
18
|
+
OPEN_URL = "openUrl"
|
|
19
|
+
SET_BOOT_COLORS = "setBootColors"
|
|
20
|
+
SET_ZOOM = "setZoom"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Freezing a program into one folder with PyInstaller: the project writes a `FreezePlan`, the library does the rest.
|
|
2
|
+
|
|
3
|
+
python -m webapp_shell freeze acme_freeze:PLAN --path . qt webview
|
|
4
|
+
|
|
5
|
+
Needs `pyinstaller` (`pip install "webapp-shell[freeze]"`); only `webapp_shell.freezing.spec` imports it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .plan import DataFolder, FreezePlan
|
|
9
|
+
|
|
10
|
+
__all__ = ["DataFolder", "FreezePlan"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Freezes the program for each shell and checks the result: `python -m webapp_shell freeze PLAN [qt] [webview]`.
|
|
2
|
+
|
|
3
|
+
Each shell is built into `<output>/<shell>/<product>/`: the other shell's libraries must not be in it, the files the
|
|
4
|
+
plan requires must be, and the frozen program's own `--check-libraries` must pass. Nothing here makes an installer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import importlib.util
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ..enums import ShellKind
|
|
16
|
+
from ..installation import Installation
|
|
17
|
+
from .plan import FreezePlan
|
|
18
|
+
from .profile import PROFILES
|
|
19
|
+
from .verify import BuildVerifier, Finding
|
|
20
|
+
|
|
21
|
+
PLAN_VARIABLE = "WEBAPP_SHELL_FREEZE_PLAN" # how the spec learns what to freeze: the plan's reference ...
|
|
22
|
+
KIND_VARIABLE = "WEBAPP_SHELL_FREEZE_KIND" # ... and the shell
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Builder:
|
|
26
|
+
def __init__(self, plan: FreezePlan, reference: str, paths: tuple[Path, ...], output: Path, work: Path):
|
|
27
|
+
self.plan, self.reference, self.paths = plan, reference, paths
|
|
28
|
+
self.output, self.work = output, work
|
|
29
|
+
|
|
30
|
+
def folder(self, kind: ShellKind) -> Path:
|
|
31
|
+
return self.output / kind.value / self.plan.product
|
|
32
|
+
|
|
33
|
+
def _program(self, kind: ShellKind) -> Path:
|
|
34
|
+
program = self.folder(kind) / self.plan.product
|
|
35
|
+
return program.with_suffix(".exe") if sys.platform == "win32" else program
|
|
36
|
+
|
|
37
|
+
def _library_problems(self, kind: ShellKind) -> str:
|
|
38
|
+
"""Runs the frozen program's own check: every library the program loads on first use must be in it."""
|
|
39
|
+
done = subprocess.run([str(self._program(kind)), Installation.CHECK_LIBRARIES], capture_output=True, text=True)
|
|
40
|
+
return "" if done.returncode == 0 else (done.stdout + done.stderr).strip()
|
|
41
|
+
|
|
42
|
+
def _environment(self, kind: ShellKind) -> dict[str, str]:
|
|
43
|
+
roots = [str(path.resolve()) for path in self.paths]
|
|
44
|
+
python_path = os.pathsep.join([*roots, *filter(None, [os.environ.get("PYTHONPATH")])])
|
|
45
|
+
return {**os.environ, "PYTHONPATH": python_path, PLAN_VARIABLE: self.reference, KIND_VARIABLE: kind.value}
|
|
46
|
+
|
|
47
|
+
def build(self, kind: ShellKind) -> int:
|
|
48
|
+
if importlib.util.find_spec("PyInstaller") is None:
|
|
49
|
+
print('PyInstaller is not installed: pip install "webapp-shell[freeze]"')
|
|
50
|
+
return 1
|
|
51
|
+
spec = resources.files("webapp_shell.freezing") / "app.spec"
|
|
52
|
+
with resources.as_file(spec) as spec_path:
|
|
53
|
+
command = [
|
|
54
|
+
sys.executable, "-m", "PyInstaller", str(spec_path), "--noconfirm", "--clean",
|
|
55
|
+
"--distpath", str(self.output / kind.value), "--workpath", str(self.work / kind.value),
|
|
56
|
+
] # fmt: skip
|
|
57
|
+
done = subprocess.run(command, env=self._environment(kind))
|
|
58
|
+
if done.returncode:
|
|
59
|
+
return done.returncode
|
|
60
|
+
findings = BuildVerifier(self.folder(kind), PROFILES[kind], self.plan).findings()
|
|
61
|
+
if not findings and (problem := self._library_problems(kind)):
|
|
62
|
+
findings = [Finding("libraries", problem)]
|
|
63
|
+
for finding in findings:
|
|
64
|
+
print(f"{kind.value}: {finding.path}: {finding.problem}")
|
|
65
|
+
print(f"{kind.value}: {self.folder(kind)} " + ("is not right" if findings else "checked"))
|
|
66
|
+
return 1 if findings else 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = argparse.ArgumentParser(prog="python -m webapp_shell freeze", description=(__doc__ or "").splitlines()[0])
|
|
71
|
+
parser.add_argument("plan", help="module:attribute of the project's FreezePlan")
|
|
72
|
+
parser.add_argument("shells", nargs="*", type=ShellKind, default=list(PROFILES), help="default: every shell")
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--path", type=Path, action="append", default=[], help="a folder to import the project from (repeatable)"
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument("--output", type=Path, default=Path("dist"))
|
|
77
|
+
parser.add_argument("--work", type=Path, default=Path("build"))
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
for path in args.path:
|
|
80
|
+
sys.path.insert(0, str(path.resolve()))
|
|
81
|
+
builder = Builder(FreezePlan.load(args.plan), args.plan, tuple(args.path), args.output, args.work)
|
|
82
|
+
return max(builder.build(kind) for kind in args.shells)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The entry point of a frozen program: a file written for each build."""
|
|
2
|
+
|
|
3
|
+
from ..enums import ShellKind
|
|
4
|
+
from .plan import FreezePlan
|
|
5
|
+
|
|
6
|
+
ENTRY_SOURCE = '''"""Entry point of the frozen {shell} build (generated by webapp_shell.freezing)."""
|
|
7
|
+
|
|
8
|
+
import multiprocessing
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from webapp_shell import Installation, run
|
|
12
|
+
from {module} import {attribute} as target
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
multiprocessing.freeze_support() # a child process of the program is the same executable
|
|
16
|
+
config = target() if callable(target) else target
|
|
17
|
+
args = sys.argv[1:]
|
|
18
|
+
sys.exit(run(config, args if args[:1] and args[0] in Installation.COMMANDS else ["{shell}", *args]))
|
|
19
|
+
'''
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def entry_source(plan: FreezePlan, kind: ShellKind) -> str:
|
|
23
|
+
module, _, attribute = plan.config.partition(":")
|
|
24
|
+
return ENTRY_SOURCE.format(shell=kind.value, module=module, attribute=attribute)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""What a project says about its frozen build. The rest (the entry file, what each shell leaves out, the checks of the
|
|
2
|
+
finished folder) belongs to the library."""
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class DataFolder:
|
|
11
|
+
"""A folder of the project that goes into the build as data (the compiled frontend, files the backend reads)."""
|
|
12
|
+
|
|
13
|
+
source: Path
|
|
14
|
+
target: str # the folder inside the build, e.g. "frontend/dist"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class FreezePlan:
|
|
19
|
+
product: str # the name of the folder and of the executable
|
|
20
|
+
config: str # "module:attribute": the `ShellConfig` (or a function returning one) the program runs with
|
|
21
|
+
packages: tuple[str, ...] # whose submodules are all collected: the project's own, and any that load by name
|
|
22
|
+
data_folders: tuple[DataFolder, ...] = ()
|
|
23
|
+
required: tuple[
|
|
24
|
+
str, ...
|
|
25
|
+
] = () # files that must be in the finished build, relative to its data, e.g. "frontend/dist/index.html"
|
|
26
|
+
collect_all: tuple[str, ...] = () # third-party packages with data files or native libraries
|
|
27
|
+
runtime_hooks: tuple[Path, ...] = () # scripts that run before anything else in the frozen program
|
|
28
|
+
excludes: tuple[str, ...] = () # more libraries to keep out, besides the developer tools the library leaves out
|
|
29
|
+
icon: Path | None = None # `.ico`, used for the Windows executable
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, reference: str) -> "FreezePlan":
|
|
33
|
+
"""The plan named `module:attribute`; the module must be importable (`--path`)."""
|
|
34
|
+
module, _, name = reference.partition(":")
|
|
35
|
+
plan = getattr(importlib.import_module(module), name)
|
|
36
|
+
if not isinstance(plan, cls):
|
|
37
|
+
raise TypeError(f"{reference} is not a FreezePlan")
|
|
38
|
+
return plan
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""What each shell brings and what it must leave out, so a build carries one window toolkit and not the other."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from ..enums import ShellKind
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ShellProfile:
|
|
10
|
+
"""One shell, with the libraries of the other kept out of its build."""
|
|
11
|
+
|
|
12
|
+
kind: ShellKind
|
|
13
|
+
modules: tuple[str, ...] # webapp_shell modules that belong to this shell only
|
|
14
|
+
forbidden: tuple[str, ...] # top-level names that must not be in the finished build
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def other_modules(self) -> tuple[str, ...]:
|
|
18
|
+
return tuple(m for p in PROFILES.values() if p.kind is not self.kind for m in p.modules)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def excludes(self) -> list[str]:
|
|
22
|
+
"""Libraries the freezer must leave out even if something imports them."""
|
|
23
|
+
return [*COMMON_EXCLUDES, *self.forbidden, *self.other_modules]
|
|
24
|
+
|
|
25
|
+
def wanted_module(self, name: str) -> bool:
|
|
26
|
+
return not any(name == m or name.startswith(f"{m}.") for m in self.other_modules)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PROFILES: dict[ShellKind, ShellProfile] = {
|
|
30
|
+
ShellKind.QT: ShellProfile(
|
|
31
|
+
ShellKind.QT,
|
|
32
|
+
modules=("webapp_shell.qt_shell", "webapp_shell.native.qt_bridge", "webapp_shell.native.qt_window"),
|
|
33
|
+
forbidden=("webview", "pythonnet", "clr_loader", "proxy_tools", "bottle"),
|
|
34
|
+
),
|
|
35
|
+
ShellKind.WEBVIEW: ShellProfile(
|
|
36
|
+
ShellKind.WEBVIEW,
|
|
37
|
+
modules=(
|
|
38
|
+
"webapp_shell.webview_shell",
|
|
39
|
+
"webapp_shell.native.webview_bridge",
|
|
40
|
+
"webapp_shell.native.webview_background",
|
|
41
|
+
"webapp_shell.native.webview_window",
|
|
42
|
+
"webapp_shell.native.zoom",
|
|
43
|
+
),
|
|
44
|
+
forbidden=("PySide6", "shiboken6", "PyQt5", "PyQt6", "qtpy"),
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Tools of the developer, never of the user.
|
|
49
|
+
COMMON_EXCLUDES = ["tkinter", "pytest", "ruff", "pip", "IPython", "notebook"] # not setuptools: pkg_resources needs it
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# pyright: basic
|
|
2
|
+
"""The only module that imports PyInstaller: runs inside a PyInstaller build and describes it from the `FreezePlan`."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from PyInstaller.building.api import COLLECT, EXE, PYZ
|
|
10
|
+
from PyInstaller.building.build_main import Analysis
|
|
11
|
+
from PyInstaller.config import CONF
|
|
12
|
+
from PyInstaller.utils.hooks import collect_all, collect_submodules
|
|
13
|
+
|
|
14
|
+
from ..enums import ShellKind
|
|
15
|
+
from .builder import KIND_VARIABLE, PLAN_VARIABLE
|
|
16
|
+
from .entry import entry_source
|
|
17
|
+
from .plan import FreezePlan
|
|
18
|
+
from .profile import PROFILES
|
|
19
|
+
|
|
20
|
+
LIBRARY_PACKAGES = ("webapp_shell",) # the registry loads its shells by name, so every submodule of this shell is in
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def freeze() -> None:
|
|
24
|
+
plan = FreezePlan.load(os.environ[PLAN_VARIABLE])
|
|
25
|
+
kind = ShellKind(os.environ[KIND_VARIABLE])
|
|
26
|
+
profile = PROFILES[kind]
|
|
27
|
+
|
|
28
|
+
workpath = Path(cast(str, CONF["workpath"])) # PyInstaller types CONF loosely; it is a path
|
|
29
|
+
entry = workpath / f"entry_{kind.value}.py"
|
|
30
|
+
entry.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
entry.write_text(entry_source(plan, kind), encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
datas = [(str(folder.source), folder.target) for folder in plan.data_folders]
|
|
34
|
+
binaries = []
|
|
35
|
+
hiddenimports = []
|
|
36
|
+
for package in plan.collect_all:
|
|
37
|
+
found = collect_all(package)
|
|
38
|
+
datas += found[0]
|
|
39
|
+
binaries += found[1]
|
|
40
|
+
hiddenimports += found[2]
|
|
41
|
+
for package in (*LIBRARY_PACKAGES, *plan.packages):
|
|
42
|
+
hiddenimports += collect_submodules(package, filter=profile.wanted_module)
|
|
43
|
+
|
|
44
|
+
analysis = Analysis(
|
|
45
|
+
[str(entry)],
|
|
46
|
+
pathex=[],
|
|
47
|
+
binaries=binaries,
|
|
48
|
+
datas=datas,
|
|
49
|
+
hiddenimports=hiddenimports,
|
|
50
|
+
hookspath=[],
|
|
51
|
+
runtime_hooks=[str(hook) for hook in plan.runtime_hooks],
|
|
52
|
+
excludes=[*profile.excludes, *plan.excludes],
|
|
53
|
+
noarchive=False,
|
|
54
|
+
)
|
|
55
|
+
pyz = PYZ(analysis.pure)
|
|
56
|
+
exe = EXE(
|
|
57
|
+
pyz,
|
|
58
|
+
analysis.scripts,
|
|
59
|
+
[],
|
|
60
|
+
exclude_binaries=True,
|
|
61
|
+
name=plan.product,
|
|
62
|
+
debug=False,
|
|
63
|
+
strip=False,
|
|
64
|
+
upx=False,
|
|
65
|
+
console=False,
|
|
66
|
+
icon=str(plan.icon) if plan.icon and sys.platform == "win32" else None,
|
|
67
|
+
)
|
|
68
|
+
COLLECT(exe, analysis.binaries, analysis.datas, strip=False, upx=False, name=plan.product)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Looks inside a finished build and fails when it holds something it must not, or lacks something it needs."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .plan import FreezePlan
|
|
7
|
+
from .profile import ShellProfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Finding:
|
|
12
|
+
path: str
|
|
13
|
+
problem: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildVerifier:
|
|
17
|
+
"""`folder` is the program's folder (the one that is handed over for installing)."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, folder: Path, profile: ShellProfile, plan: FreezePlan):
|
|
20
|
+
self.folder, self.profile, self.plan = Path(folder), profile, plan
|
|
21
|
+
|
|
22
|
+
def findings(self) -> list[Finding]:
|
|
23
|
+
return [*self._missing(), *self._forbidden()]
|
|
24
|
+
|
|
25
|
+
def _inside(self) -> Path:
|
|
26
|
+
"""PyInstaller puts the bundled files in `_internal` from version 6, beside the executable before."""
|
|
27
|
+
internal = self.folder / "_internal"
|
|
28
|
+
return internal if internal.is_dir() else self.folder
|
|
29
|
+
|
|
30
|
+
def _missing(self) -> list[Finding]:
|
|
31
|
+
found = [Finding(name, "missing") for name in self.plan.required if not (self._inside() / name).exists()]
|
|
32
|
+
if not any((self.folder / f"{self.plan.product}{suffix}").exists() for suffix in ("", ".exe")):
|
|
33
|
+
found.append(Finding(self.plan.product, "the executable is missing"))
|
|
34
|
+
return found
|
|
35
|
+
|
|
36
|
+
def _forbidden(self) -> list[Finding]:
|
|
37
|
+
names = {name.lower() for name in self.profile.forbidden}
|
|
38
|
+
found: list[Finding] = []
|
|
39
|
+
for path in self.folder.rglob("*"):
|
|
40
|
+
parts = {part.lower().split(".")[0] for part in path.relative_to(self.folder).parts}
|
|
41
|
+
if parts & names:
|
|
42
|
+
found.append(Finding(path.relative_to(self.folder).as_posix(), "belongs to the other shell"))
|
|
43
|
+
return found
|
webapp_shell/identity.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Who the program is: the one place a project says its name, where it keeps things and how its opening looks."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .preferences import BootColors
|
|
8
|
+
|
|
9
|
+
_SLUG = re.compile(r"[a-z][a-z0-9]*")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AppIdentity:
|
|
14
|
+
"""What the shell shows and stores for one program. Nothing else in the library names the program."""
|
|
15
|
+
|
|
16
|
+
name: str # shown in the window title, the splash and the messages: "Acme Notes"
|
|
17
|
+
slug: str # lowercase letters and digits: names the page's native object, the Qt script, the default wire names
|
|
18
|
+
storage_dir: Path # the program's own folder for what the shell remembers: "~/.acme"
|
|
19
|
+
boot_colors: BootColors # the opening screen's colours until the page reports the user's own
|
|
20
|
+
icon_png: Path | None = None # window icon (macOS, Linux) and the splash mark
|
|
21
|
+
icon_ico: Path | None = None # window icon on Windows, which draws only `.ico` files
|
|
22
|
+
window_size: tuple[int, int] = field(default=(1280, 800))
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if not _SLUG.fullmatch(self.slug):
|
|
26
|
+
raise ValueError(f"slug must be lowercase letters and digits, starting with a letter: {self.slug!r}")
|
|
27
|
+
if not self.name.strip():
|
|
28
|
+
raise ValueError("name must not be empty")
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def native_global(self) -> str:
|
|
32
|
+
"""The name of the object the Qt shell puts on the page: `window.<slug>Native`."""
|
|
33
|
+
return f"{self.slug}Native"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def native_ready_event(self) -> str:
|
|
37
|
+
"""The event the page waits for before using `native_global`."""
|
|
38
|
+
return f"{self.slug}nativeready"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def shell_preferences_path(self) -> Path:
|
|
42
|
+
"""What the shell itself needs before the app exists (the opening colours)."""
|
|
43
|
+
return self.storage_dir / "shell.json"
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def app_preferences_path(self) -> Path:
|
|
47
|
+
"""The program's own preferences, one file for every shell, owned by the backend."""
|
|
48
|
+
return self.storage_dir / "preferences.json"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Where the program lives and how its backend is started: from the source tree, or frozen into one folder."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .backend import BackendSpec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Installation:
|
|
11
|
+
"""A frozen program (PyInstaller) has one executable and no `python -m`: it starts its own backend by running
|
|
12
|
+
itself with `RUN_BACKEND`. From the source tree the backend is `python -m <module>`, as always."""
|
|
13
|
+
|
|
14
|
+
RUN_BACKEND = "--run-backend"
|
|
15
|
+
CHECK_LIBRARIES = "--check-libraries" # imports every required library; a build runs it on the frozen program
|
|
16
|
+
COMMANDS = (RUN_BACKEND, CHECK_LIBRARIES) # what a frozen program does instead of opening a window
|
|
17
|
+
|
|
18
|
+
def __init__(self, source_root: Path, frozen: bool | None = None, bundle: Path | None = None):
|
|
19
|
+
self.frozen = bool(getattr(sys, "frozen", False)) if frozen is None else frozen
|
|
20
|
+
self._bundle = bundle if bundle is not None else Path(getattr(sys, "_MEIPASS", source_root))
|
|
21
|
+
self._source_root = source_root
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def root(self) -> Path:
|
|
25
|
+
"""The folder that holds the compiled frontend (and whatever else the program ships)."""
|
|
26
|
+
return self._bundle if self.frozen else self._source_root
|
|
27
|
+
|
|
28
|
+
def package_dirs(self, backend: BackendSpec) -> tuple[Path, ...]:
|
|
29
|
+
"""Import roots to add by hand. A frozen program carries its packages inside."""
|
|
30
|
+
return () if self.frozen else backend.package_dirs
|
|
31
|
+
|
|
32
|
+
def backend_command(self, backend: BackendSpec) -> list[str]:
|
|
33
|
+
return [sys.executable, self.RUN_BACKEND] if self.frozen else [sys.executable, "-m", backend.module]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_standard_streams() -> None:
|
|
37
|
+
"""A windowed program has no console: `sys.stdout` and `sys.stderr` are None, and libraries that ask a stream
|
|
38
|
+
whether it is a terminal (the backend's web server does) fail. They get a stream that discards its text."""
|
|
39
|
+
for name in ("stdout", "stderr"):
|
|
40
|
+
if getattr(sys, name) is None:
|
|
41
|
+
setattr(sys, name, open(os.devnull, "w", encoding="utf-8")) # noqa: SIM115 - kept open for the whole run
|