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/__init__.py
ADDED
|
@@ -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
|
+
]
|
webapp_shell/__main__.py
ADDED
|
@@ -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())
|
webapp_shell/backend.py
ADDED
|
@@ -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"]
|