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.
Files changed (59) hide show
  1. webapp_shell/__init__.py +50 -0
  2. webapp_shell/__main__.py +53 -0
  3. webapp_shell/backend.py +30 -0
  4. webapp_shell/backend_process.py +136 -0
  5. webapp_shell/bridge/webapp-shell-bridge.d.ts +48 -0
  6. webapp_shell/bridge/webapp-shell-bridge.js +146 -0
  7. webapp_shell/cli.py +76 -0
  8. webapp_shell/config.py +27 -0
  9. webapp_shell/contract/__init__.py +10 -0
  10. webapp_shell/contract/guard.py +106 -0
  11. webapp_shell/contract/launch.py +49 -0
  12. webapp_shell/contract/tokens.py +41 -0
  13. webapp_shell/enums.py +20 -0
  14. webapp_shell/freezing/__init__.py +10 -0
  15. webapp_shell/freezing/app.spec +5 -0
  16. webapp_shell/freezing/builder.py +82 -0
  17. webapp_shell/freezing/entry.py +24 -0
  18. webapp_shell/freezing/plan.py +38 -0
  19. webapp_shell/freezing/profile.py +49 -0
  20. webapp_shell/freezing/spec.py +68 -0
  21. webapp_shell/freezing/verify.py +43 -0
  22. webapp_shell/identity.py +48 -0
  23. webapp_shell/installation.py +41 -0
  24. webapp_shell/json_types.py +4 -0
  25. webapp_shell/library_check.py +28 -0
  26. webapp_shell/messages.py +58 -0
  27. webapp_shell/native/__init__.py +4 -0
  28. webapp_shell/native/arguments.py +131 -0
  29. webapp_shell/native/errors.py +5 -0
  30. webapp_shell/native/filters.py +24 -0
  31. webapp_shell/native/os_actions.py +33 -0
  32. webapp_shell/native/qt_bridge.py +62 -0
  33. webapp_shell/native/qt_window.py +34 -0
  34. webapp_shell/native/registry.py +91 -0
  35. webapp_shell/native/standard.py +89 -0
  36. webapp_shell/native/start_location.py +28 -0
  37. webapp_shell/native/webview_background.py +39 -0
  38. webapp_shell/native/webview_bridge.py +25 -0
  39. webapp_shell/native/webview_icon.py +32 -0
  40. webapp_shell/native/webview_window.py +54 -0
  41. webapp_shell/native/window.py +34 -0
  42. webapp_shell/native/zoom.py +104 -0
  43. webapp_shell/native/zoom_range.py +9 -0
  44. webapp_shell/preferences.py +84 -0
  45. webapp_shell/protocol.py +34 -0
  46. webapp_shell/py.typed +0 -0
  47. webapp_shell/qt_shell.py +126 -0
  48. webapp_shell/registry.py +20 -0
  49. webapp_shell/settings.py +78 -0
  50. webapp_shell/shell.py +119 -0
  51. webapp_shell/smoke.py +30 -0
  52. webapp_shell/splash.py +52 -0
  53. webapp_shell/watchdog.py +60 -0
  54. webapp_shell/webview2.py +80 -0
  55. webapp_shell/webview_shell.py +86 -0
  56. webapp_shell-0.1.dist-info/METADATA +132 -0
  57. webapp_shell-0.1.dist-info/RECORD +59 -0
  58. webapp_shell-0.1.dist-info/WHEEL +4 -0
  59. webapp_shell-0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,50 @@
1
+ """webapp_shell: a desktop window for a web app made of a Python backend and a compiled frontend.
2
+
3
+ A project describes itself with a `ShellConfig` and calls `run(config)`; the library starts the backend, waits until it
4
+ is healthy, opens the frontend in a window (pywebview by default, Qt on request) and gives the page a small set of
5
+ native functions.
6
+ """
7
+
8
+ from .backend import BackendSpec, CliFlag
9
+ from .cli import build_parser, run
10
+ from .config import ShellConfig
11
+ from .enums import NativeMethod, ShellKind
12
+ from .identity import AppIdentity
13
+ from .installation import Installation
14
+ from .json_types import JsonObject, JsonValue
15
+ from .library_check import LibraryProblem
16
+ from .messages import MessageKey, ShellMessages
17
+ from .native.errors import NativeError
18
+ from .native.filters import FileFilter
19
+ from .native.registry import NativeCallable, NativeFunction, NoArguments
20
+ from .native.standard import standard_functions
21
+ from .native.window import NativeContext, NativeWindow
22
+ from .preferences import BootColors
23
+ from .protocol import WireProtocol
24
+
25
+ __all__ = [
26
+ "AppIdentity",
27
+ "BackendSpec",
28
+ "BootColors",
29
+ "CliFlag",
30
+ "FileFilter",
31
+ "Installation",
32
+ "JsonObject",
33
+ "JsonValue",
34
+ "LibraryProblem",
35
+ "MessageKey",
36
+ "NativeCallable",
37
+ "NativeContext",
38
+ "NativeError",
39
+ "NativeFunction",
40
+ "NativeMethod",
41
+ "NativeWindow",
42
+ "NoArguments",
43
+ "ShellConfig",
44
+ "ShellKind",
45
+ "ShellMessages",
46
+ "WireProtocol",
47
+ "build_parser",
48
+ "run",
49
+ "standard_functions",
50
+ ]
@@ -0,0 +1,53 @@
1
+ """`python -m webapp_shell bridge --out DIR` copies the page-side bridge (one `.js` file and its `.d.ts`) into a frontend;
2
+ `messages` prints the shell's sentences as JSON; `freeze PLAN` freezes a program with PyInstaller."""
3
+
4
+ import argparse
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from importlib import resources
9
+ from pathlib import Path
10
+
11
+ from .messages import ShellMessages
12
+
13
+ BRIDGE_FILES = ("webapp-shell-bridge.js", "webapp-shell-bridge.d.ts")
14
+
15
+
16
+ def copy_bridge(out: Path) -> list[Path]:
17
+ """Copies the bridge files into `out` (created if needed) and returns where they went."""
18
+ out.mkdir(parents=True, exist_ok=True)
19
+ source = resources.files("webapp_shell") / "bridge"
20
+ copied: list[Path] = []
21
+ for name in BRIDGE_FILES:
22
+ target = out / name
23
+ with resources.as_file(source / name) as path:
24
+ shutil.copyfile(path, target)
25
+ copied.append(target)
26
+ return copied
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ parser = argparse.ArgumentParser(prog="python -m webapp_shell", description="webapp-shell tools")
31
+ commands = parser.add_subparsers(dest="command", required=True)
32
+ bridge = commands.add_parser("bridge", help="copy the page-side bridge into a frontend")
33
+ bridge.add_argument("--out", type=Path, required=True, help="the folder to copy into, e.g. frontend/src")
34
+ commands.add_parser(
35
+ "messages", help="print the sentences the shell shows, as JSON, for a frontend's translation files"
36
+ )
37
+ commands.add_parser("freeze", help="freeze a program with PyInstaller (webapp_shell.freezing); see freeze --help")
38
+ arguments = sys.argv[1:] if argv is None else argv
39
+ if arguments[:1] == ["freeze"]: # its own arguments: the plan, the shells, the folders
40
+ from .freezing.builder import main as freeze
41
+
42
+ return freeze(arguments[1:])
43
+ args = parser.parse_args(arguments)
44
+ if args.command == "bridge":
45
+ for path in copy_bridge(args.out):
46
+ print(path)
47
+ if args.command == "messages":
48
+ print(json.dumps(ShellMessages().catalogue(), indent=2, ensure_ascii=False))
49
+ return 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ raise SystemExit(main())
@@ -0,0 +1,30 @@
1
+ """How a project's backend is started: a Python module run as `python -m <module>` (or, in a frozen program, by the
2
+ program running itself)."""
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .protocol import WireProtocol
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CliFlag:
12
+ """A command-line switch the project adds to its program (`--sandbox`). When given, the backend gets `passes`."""
13
+
14
+ name: str
15
+ help: str
16
+ passes: tuple[str, ...] # what the backend is started with when the switch is on
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class BackendSpec:
21
+ module: str # the backend's package: started as `python -m <module>`; its `__main__.main(argv)` runs when frozen
22
+ wire: WireProtocol
23
+ frontend_dist: Path = Path("frontend/dist") # the compiled frontend, relative to the program's root
24
+ frontend_argument: str = "--frontend" # how the backend is told where the compiled frontend is
25
+ port_argument: str = "--port"
26
+ preferences_argument: str | None = "--preferences-file" # None when the backend keeps no preferences file
27
+ arguments: tuple[str, ...] = () # always passed
28
+ source_only_arguments: tuple[str, ...] = () # passed only when running from the source, not when frozen
29
+ flags: tuple[CliFlag, ...] = () # the project's own command-line switches
30
+ package_dirs: tuple[Path, ...] = () # import roots to add by hand when running from the source
@@ -0,0 +1,136 @@
1
+ """Owns the backend child process: start it, wait until it is healthy, ask it things, stop it."""
2
+
3
+ import os
4
+ import secrets
5
+ import socket
6
+ import subprocess
7
+ import time
8
+
9
+ import httpx
10
+
11
+ from .backend import CliFlag
12
+ from .json_types import JsonObject, JsonValue
13
+ from .messages import MessageKey
14
+ from .settings import Settings
15
+
16
+
17
+ class BackendError(RuntimeError):
18
+ """The backend could not be started or reached."""
19
+
20
+
21
+ class BackendProcess:
22
+ def __init__(self, settings: Settings, keeps_preferences: bool = True, flags: tuple[CliFlag, ...] = ()):
23
+ self._settings = settings
24
+ self._wire = settings.config.backend.wire
25
+ self._keeps_preferences = keeps_preferences
26
+ self._flags = flags # the project's switches that are on
27
+ self._proc: subprocess.Popen[bytes] | None = None
28
+ self._port = 0
29
+ self.token = secrets.token_urlsafe(24)
30
+
31
+ @property
32
+ def base_url(self) -> str:
33
+ return f"http://{self._settings.host}:{self._port}"
34
+
35
+ @property
36
+ def launch_url(self) -> str:
37
+ """The URL a shell opens: it carries the token once; the backend turns it into a cookie."""
38
+ return f"{self.base_url}/?{self._wire.token_query}={self.token}"
39
+
40
+ def __enter__(self) -> "BackendProcess":
41
+ self.start()
42
+ return self
43
+
44
+ def __exit__(self, *_exc: object) -> None:
45
+ self.stop()
46
+
47
+ def start(self) -> None:
48
+ if not (self._settings.frontend_dist / "index.html").is_file():
49
+ raise BackendError(self._say(MessageKey.FRONTEND_MISSING, path=str(self._settings.frontend_dist)))
50
+ self._port = self._port or self._free_port() # a restart keeps the address the open page knows
51
+ self._proc = subprocess.Popen(self._command(), env=self._environment())
52
+ self._wait_until_healthy()
53
+
54
+ @property
55
+ def died(self) -> bool:
56
+ """True when the process ended without `stop()` having been asked."""
57
+ return self._proc is not None and self._proc.poll() is not None
58
+
59
+ def restart(self) -> None:
60
+ """Start the engine again on the same address and with the same token, so the page that is open reconnects
61
+ by itself. What was open in it is closed (the person opens the database again); what was saved is safe."""
62
+ self.stop()
63
+ self.start()
64
+
65
+ def stop(self) -> None:
66
+ if self._proc is None:
67
+ return
68
+ self._proc.terminate()
69
+ try:
70
+ self._proc.wait(5)
71
+ except subprocess.TimeoutExpired:
72
+ self._proc.kill()
73
+ self._proc = None
74
+
75
+ def version(self) -> JsonObject:
76
+ """What the backend says about itself, or nothing when the project has no version path."""
77
+ if self._wire.version_path is None:
78
+ return {}
79
+ response = httpx.get(f"{self.base_url}{self._wire.version_path}", headers={self._wire.token_header: self.token})
80
+ response.raise_for_status()
81
+ body: JsonValue = response.json()
82
+ if not isinstance(body, dict):
83
+ raise BackendError(self._say(MessageKey.VERSION_NOT_OBJECT))
84
+ return body
85
+
86
+ def _say(self, key: MessageKey, **values: str) -> str:
87
+ return self._settings.config.messages.text(key, **values)
88
+
89
+ def _command(self) -> list[str]:
90
+ backend = self._settings.config.backend
91
+ installation = self._settings.installation
92
+ return [
93
+ *installation.backend_command(backend),
94
+ backend.port_argument, str(self._port),
95
+ backend.frontend_argument, str(self._settings.frontend_dist),
96
+ *self._preferences_arguments(),
97
+ *backend.arguments,
98
+ *([] if installation.frozen else backend.source_only_arguments),
99
+ *(argument for flag in self._flags for argument in flag.passes),
100
+ ] # fmt: skip
101
+
102
+ def _preferences_arguments(self) -> list[str]:
103
+ """A headless check keeps its preferences in memory, so it never touches the user's file."""
104
+ argument = self._settings.config.backend.preferences_argument
105
+ if argument is None or not self._keeps_preferences:
106
+ return []
107
+ return [argument, str(self._settings.identity.app_preferences_path)]
108
+
109
+ def _environment(self) -> dict[str, str]:
110
+ paths = [str(p) for p in self._settings.package_dirs] + [os.environ.get("PYTHONPATH", "")]
111
+ return {**os.environ, "PYTHONPATH": os.pathsep.join(paths), self._wire.token_env: self.token}
112
+
113
+ def _free_port(self) -> int:
114
+ with socket.socket() as s:
115
+ s.bind((self._settings.host, 0))
116
+ return s.getsockname()[1]
117
+
118
+ def _wait_until_healthy(self) -> None:
119
+ deadline = time.time() + self._settings.startup_timeout
120
+ while time.time() < deadline and self._proc is not None and self._proc.poll() is None:
121
+ try:
122
+ if httpx.get(f"{self.base_url}{self._wire.health_path}", timeout=1).status_code == 200:
123
+ return
124
+ except httpx.HTTPError:
125
+ time.sleep(0.2)
126
+ code = self._proc.poll() if self._proc is not None else None
127
+ self.stop()
128
+ if code is not None:
129
+ raise BackendError(self._say(MessageKey.BACKEND_EXITED, code=str(code)))
130
+ raise BackendError(
131
+ self._say(
132
+ MessageKey.BACKEND_NOT_STARTED,
133
+ path=self._wire.health_path,
134
+ seconds=f"{self._settings.startup_timeout:g}",
135
+ )
136
+ )
@@ -0,0 +1,48 @@
1
+ export declare const STANDARD_METHODS: readonly string[]
2
+
3
+ export declare const BridgeKind: Readonly<{ Qt: 'qt'; Pywebview: 'pywebview'; Browser: 'browser' }>
4
+ export type BridgeKind = (typeof BridgeKind)[keyof typeof BridgeKind]
5
+
6
+ export interface FileFilter {
7
+ name: string
8
+ extensions: string[]
9
+ }
10
+
11
+ /** The colours of the opening screen, `#rrggbb` each. */
12
+ export interface BootColors {
13
+ base: string
14
+ glowA: string
15
+ glowB: string
16
+ }
17
+
18
+ /** An error answered by the shell. */
19
+ export declare class NativeCallError extends Error {}
20
+
21
+ export interface Bridge {
22
+ readonly kind: BridgeKind
23
+ /** True inside a desktop shell, where the user can pick real absolute paths. */
24
+ readonly canPickPaths: boolean
25
+ readonly canZoom: boolean
26
+ /** Calls any native function, standard or the project's own. Rejects with NativeCallError. */
27
+ call<T = unknown>(name: string, ...args: unknown[]): Promise<T>
28
+ /** `start` is the path the field holds now: the dialog opens there, or at the nearest folder that exists. */
29
+ pickFolder(start?: string): Promise<string | null>
30
+ pickFile(filters?: FileFilter[], start?: string): Promise<string | null>
31
+ saveAs(defaultName?: string, start?: string): Promise<string | null>
32
+ reveal(path: string): Promise<boolean>
33
+ openUrl(url: string): Promise<boolean>
34
+ setBootColors(colors: BootColors): Promise<boolean>
35
+ setZoom(factor: number): Promise<boolean>
36
+ }
37
+
38
+ export interface BridgeOptions {
39
+ /** The Qt shell's object on the window: `<slug>Native`. */
40
+ nativeGlobal: string
41
+ /** How long to wait for a shell before assuming a plain browser (default 1500). */
42
+ waitMs?: number
43
+ /** The project's own functions for a plain browser, by name. */
44
+ fallbacks?: Record<string, (...args: unknown[]) => unknown>
45
+ }
46
+
47
+ export declare function getBridge(options: BridgeOptions): Promise<Bridge>
48
+ export declare function resetBridge(): void
@@ -0,0 +1,146 @@
1
+ /**
2
+ * webapp-shell bridge: how a page calls the native functions of its desktop shell.
3
+ *
4
+ * import { getBridge } from './webapp-shell-bridge.js'
5
+ * const bridge = await getBridge({ nativeGlobal: 'acmeNative' })
6
+ * const path = await bridge.pickFile([{ name: 'Excel', extensions: ['xlsx'] }])
7
+ *
8
+ * One file, no dependencies. Copy it into the frontend with `python -m webapp_shell bridge --out src/`.
9
+ */
10
+
11
+ /** The standard native functions, by their names on the wire (a test keeps this list equal to the Python one). */
12
+ export const STANDARD_METHODS = [
13
+ 'pickFolder',
14
+ 'pickFile',
15
+ 'saveAs',
16
+ 'reveal',
17
+ 'openUrl',
18
+ 'setBootColors',
19
+ 'setZoom',
20
+ ]
21
+
22
+ export const BridgeKind = Object.freeze({ Qt: 'qt', Pywebview: 'pywebview', Browser: 'browser' })
23
+
24
+ const DEFAULT_WAIT_MS = 1500
25
+
26
+ /** An error answered by the shell (`{ ok: false, error }`). */
27
+ export class NativeCallError extends Error {}
28
+
29
+ /** Runs one call on a shell's raw api object and unwraps the envelope. */
30
+ async function callApi(api, name, args) {
31
+ if (typeof api[name] !== 'function') throw new NativeCallError(`the shell has no native function "${name}"`)
32
+ const answer = await api[name](...args)
33
+ if (!answer || typeof answer !== 'object' || !('ok' in answer)) return answer
34
+ if (!answer.ok) throw new NativeCallError(answer.error)
35
+ return answer.value
36
+ }
37
+
38
+ const pathOrNull = (value) => (value ? value : null)
39
+
40
+ class Bridge {
41
+ /**
42
+ * @param kind one of BridgeKind
43
+ * @param api the shell's raw object, or null in a plain browser
44
+ * @param fallbacks functions used instead of a missing shell, by name
45
+ */
46
+ constructor(kind, api, fallbacks = {}) {
47
+ this.kind = kind
48
+ this.canPickPaths = kind !== BridgeKind.Browser
49
+ this.canZoom = kind !== BridgeKind.Browser
50
+ this._api = api
51
+ this._fallbacks = fallbacks
52
+ }
53
+
54
+ /** Calls any native function, standard or the project's own. */
55
+ call(name, ...args) {
56
+ if (this._api) return callApi(this._api, name, args)
57
+ const fallback = this._fallbacks[name]
58
+ if (fallback) return Promise.resolve(fallback(...args))
59
+ return Promise.reject(new NativeCallError(`"${name}" is not available in a plain browser`))
60
+ }
61
+
62
+ pickFolder(start = '') {
63
+ return this.call('pickFolder', start).then(pathOrNull)
64
+ }
65
+ pickFile(filters = [], start = '') {
66
+ return this.call('pickFile', filters, start).then(pathOrNull)
67
+ }
68
+ saveAs(defaultName = '', start = '') {
69
+ return this.call('saveAs', defaultName, start).then(pathOrNull)
70
+ }
71
+ reveal(path) {
72
+ return this.call('reveal', path)
73
+ }
74
+ openUrl(url) {
75
+ return this.call('openUrl', url)
76
+ }
77
+ setBootColors(colors) {
78
+ return this.call('setBootColors', colors)
79
+ }
80
+ setZoom(factor) {
81
+ return this.call('setZoom', factor)
82
+ }
83
+ }
84
+
85
+ /** What a plain browser does instead of the shell: nothing for paths, a new tab for links. */
86
+ const BROWSER_FALLBACKS = {
87
+ pickFolder: async () => null,
88
+ pickFile: async () => null,
89
+ saveAs: async () => null,
90
+ reveal: async () => false,
91
+ setBootColors: async () => false,
92
+ setZoom: async () => false,
93
+ openUrl: async (url) => {
94
+ window.open(url, '_blank', 'noopener')
95
+ return true
96
+ },
97
+ }
98
+
99
+ function found(nativeGlobal) {
100
+ if (window[nativeGlobal]) return new Bridge(BridgeKind.Qt, window[nativeGlobal])
101
+ if (window.pywebview && window.pywebview.api) return new Bridge(BridgeKind.Pywebview, window.pywebview.api)
102
+ return null
103
+ }
104
+
105
+ /** The shells inject their object after the page starts, so wait for their ready events before giving up. */
106
+ function waitForShell(nativeGlobal, readyEvent, waitMs, fallbacks) {
107
+ return new Promise((resolve) => {
108
+ const events = ['pywebviewready', readyEvent]
109
+ const settle = () => {
110
+ const bridge = found(nativeGlobal)
111
+ if (!bridge) return
112
+ cleanup()
113
+ resolve(bridge)
114
+ }
115
+ const timer = setTimeout(() => {
116
+ cleanup()
117
+ resolve(new Bridge(BridgeKind.Browser, null, fallbacks))
118
+ }, waitMs)
119
+ const cleanup = () => {
120
+ clearTimeout(timer)
121
+ events.forEach((event) => window.removeEventListener(event, settle))
122
+ }
123
+ events.forEach((event) => window.addEventListener(event, settle))
124
+ })
125
+ }
126
+
127
+ let cached = null
128
+
129
+ /**
130
+ * The bridge of the shell that hosts the page, resolved once and shared.
131
+ * @param options.nativeGlobal the Qt shell's object: `<slug>Native` (the slug of the project's AppIdentity)
132
+ * @param options.waitMs how long to wait for a shell before assuming a plain browser
133
+ * @param options.fallbacks the project's own functions for a plain browser, by name
134
+ */
135
+ export function getBridge({ nativeGlobal, waitMs = DEFAULT_WAIT_MS, fallbacks = {} }) {
136
+ cached ??= Promise.resolve(
137
+ found(nativeGlobal) ??
138
+ waitForShell(nativeGlobal, nativeGlobal.toLowerCase() + 'ready', waitMs, { ...BROWSER_FALLBACKS, ...fallbacks }),
139
+ )
140
+ return cached
141
+ }
142
+
143
+ /** For tests: forget the shared bridge. */
144
+ export function resetBridge() {
145
+ cached = null
146
+ }
webapp_shell/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ """The command line every project gets: `<program> [webview|qt] [--smoke] [--splash-seconds N] [project switches]`."""
2
+
3
+ import argparse
4
+ import importlib
5
+ import sys
6
+
7
+ from . import registry
8
+ from .backend_process import BackendProcess
9
+ from .config import ShellConfig
10
+ from .enums import ShellKind
11
+ from .installation import Installation, ensure_standard_streams
12
+ from .library_check import LibraryCheck
13
+ from .messages import MessageKey
14
+ from .preferences import ShellPreferences
15
+ from .settings import Settings
16
+ from .webview2 import Webview2Check, show_message_box
17
+
18
+
19
+ def build_parser(config: ShellConfig) -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(description=f"Open {config.identity.name} in one of the desktop shells.")
21
+ parser.add_argument(
22
+ "shell", type=ShellKind, choices=registry.kinds(), nargs="?", default=ShellKind.WEBVIEW,
23
+ help="the window toolkit (default: webview)",
24
+ ) # fmt: skip
25
+ parser.add_argument("--smoke", action="store_true", help="headless check: load the page, print the result, exit")
26
+ parser.add_argument(
27
+ "--splash-seconds",
28
+ type=float,
29
+ default=None,
30
+ help="how long the splash stays at least (default: the setting; 0 with --smoke)",
31
+ )
32
+ for flag in config.backend.flags:
33
+ parser.add_argument(flag.name, action="store_true", help=flag.help)
34
+ return parser
35
+
36
+
37
+ def run_backend(config: ShellConfig, argv: list[str]) -> int:
38
+ """A frozen program starts its backend by running itself with `Installation.RUN_BACKEND`."""
39
+ importlib.import_module(f"{config.backend.module}.__main__").main(argv)
40
+ return 0
41
+
42
+
43
+ def check_libraries(config: ShellConfig) -> int:
44
+ problems = LibraryCheck(config.required_libraries).problems()
45
+ problems += [problem for check in config.library_checks for problem in check()]
46
+ print("\n".join(map(str, problems)) or config.messages.text(MessageKey.LIBRARIES_PRESENT))
47
+ return 1 if problems else 0
48
+
49
+
50
+ def run(config: ShellConfig, argv: list[str] | None = None, installation: Installation | None = None) -> int:
51
+ """The entry point of a project's program: its launcher builds a `ShellConfig` and calls this."""
52
+ ensure_standard_streams()
53
+ argv = sys.argv[1:] if argv is None else argv
54
+ if argv[:1] == [Installation.RUN_BACKEND]:
55
+ return run_backend(config, argv[1:])
56
+ if argv[:1] == [Installation.CHECK_LIBRARIES]:
57
+ return check_libraries(config)
58
+ args = build_parser(config).parse_args(argv)
59
+ if args.shell is ShellKind.WEBVIEW and not args.smoke:
60
+ problem = Webview2Check().problem(config.identity.name, config.messages)
61
+ if problem:
62
+ show_message_box(config.identity.name, problem)
63
+ return 1
64
+
65
+ # a headless check never reads or writes the user's own preferences file
66
+ preferences = ShellPreferences(None if args.smoke else config.identity.shell_preferences_path)
67
+ splash = 0.0 if args.smoke and args.splash_seconds is None else args.splash_seconds
68
+ settings = Settings.of(config, preferences, installation, splash_seconds=splash)
69
+ flags = tuple(flag for flag in config.backend.flags if getattr(args, flag.name.lstrip("-").replace("-", "_")))
70
+ shell = registry.shell_class(args.shell)(
71
+ settings,
72
+ BackendProcess(settings, keeps_preferences=not args.smoke, flags=flags),
73
+ preferences=preferences,
74
+ smoke=args.smoke,
75
+ )
76
+ return shell.run()
webapp_shell/config.py ADDED
@@ -0,0 +1,27 @@
1
+ """The one object a project gives the library."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from .backend import BackendSpec
8
+ from .identity import AppIdentity
9
+ from .library_check import LibraryProblem
10
+ from .messages import ENGLISH, ShellMessages
11
+ from .native.registry import NativeCallable
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ShellConfig:
16
+ identity: AppIdentity
17
+ backend: BackendSpec
18
+ source_root: Path # running from the source: the folder that holds the compiled frontend (`frontend/dist`)
19
+ host: str = "127.0.0.1"
20
+ startup_timeout: float = 20.0 # seconds to wait for the backend to answer its health path
21
+ # The splash stays at least this long, so the opening always reads as a deliberate, steady start.
22
+ splash_seconds: float = 0.0
23
+ native_functions: tuple[NativeCallable, ...] = () # the project's own, added to the standard ones
24
+ required_libraries: tuple[str, ...] = () # imported by `--check-libraries` on a frozen program
25
+ # Checks of the project's own, run by `--check-libraries` after the imports (a codec, a data file): problems found.
26
+ library_checks: tuple[Callable[[], list[LibraryProblem]], ...] = ()
27
+ messages: ShellMessages = ENGLISH # the shell's own sentences, in the project's language
@@ -0,0 +1,10 @@
1
+ """The backend's side of the contract with the shell.
2
+
3
+ The core (`tokens`, `launch`) needs no web framework. `guard` is a ready-made Starlette middleware (FastAPI apps are
4
+ Starlette apps) and is imported on its own: `from webapp_shell.contract.guard import TokenGuard`.
5
+ """
6
+
7
+ from .launch import LaunchArguments, add_launch_arguments, read_launch_arguments
8
+ from .tokens import OriginPolicy, TokenValidator
9
+
10
+ __all__ = ["LaunchArguments", "OriginPolicy", "TokenValidator", "add_launch_arguments", "read_launch_arguments"]