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,28 @@
|
|
|
1
|
+
"""Imports every third-party library a project lists. Heavy libraries load lazily, so a frozen program that lacks
|
|
2
|
+
one would only fail when the person reaches the feature; this finds it when the program is built."""
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class LibraryProblem:
|
|
10
|
+
name: str
|
|
11
|
+
detail: str
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
return f"{self.name}: {self.detail}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LibraryCheck:
|
|
18
|
+
def __init__(self, libraries: tuple[str, ...]):
|
|
19
|
+
self._libraries = libraries
|
|
20
|
+
|
|
21
|
+
def problems(self) -> list[LibraryProblem]:
|
|
22
|
+
found: list[LibraryProblem] = []
|
|
23
|
+
for name in self._libraries:
|
|
24
|
+
try:
|
|
25
|
+
importlib.import_module(name)
|
|
26
|
+
except Exception as error:
|
|
27
|
+
found.append(LibraryProblem(name, f"{type(error).__name__}: {error}"))
|
|
28
|
+
return found
|
webapp_shell/messages.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The few sentences the shell shows itself (in the splash page's error, and in what it prints), as one catalogue.
|
|
2
|
+
|
|
3
|
+
A project that translates its program gives its own `ShellMessages` (in the language it chose) to `ShellConfig`; the
|
|
4
|
+
frontend can read the same catalogue as JSON (`python -m webapp_shell messages`) to put the keys in its translation
|
|
5
|
+
files. The user interface itself belongs to the frontend; these are only the sentences that appear when there is no
|
|
6
|
+
frontend yet or any more.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
from .json_types import JsonObject
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MessageKey(StrEnum):
|
|
16
|
+
"""The name of each sentence. The member name, lower-cased, is the field of `ShellMessages`; the value is the key in
|
|
17
|
+
the JSON catalogue."""
|
|
18
|
+
|
|
19
|
+
FRONTEND_MISSING = "frontendMissing"
|
|
20
|
+
BACKEND_NOT_STARTED = "backendNotStarted"
|
|
21
|
+
BACKEND_EXITED = "backendExited"
|
|
22
|
+
VERSION_NOT_OBJECT = "versionNotObject"
|
|
23
|
+
BACKEND_GAVE_UP = "backendGaveUp"
|
|
24
|
+
LIBRARIES_PRESENT = "librariesPresent"
|
|
25
|
+
WEBVIEW2_MISSING = "webview2Missing"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ShellMessages:
|
|
30
|
+
"""Every sentence, with its English default. `{name}` and `{path}` are filled in when the sentence is shown."""
|
|
31
|
+
|
|
32
|
+
frontend_missing: str = "the compiled frontend is missing: {path}"
|
|
33
|
+
backend_not_started: str = (
|
|
34
|
+
"the backend did not answer {path} within {seconds} seconds; start it by hand to read what it says"
|
|
35
|
+
)
|
|
36
|
+
backend_exited: str = "the backend stopped while starting (exit code {code}); start it by hand to read what it says"
|
|
37
|
+
version_not_object: str = "the version path did not answer with an object"
|
|
38
|
+
backend_gave_up: str = "The backend stopped several times in a row. Close {name} and open it again."
|
|
39
|
+
libraries_present: str = "all libraries are present"
|
|
40
|
+
webview2_missing: str = (
|
|
41
|
+
"{name} needs the Microsoft WebView2 runtime, which is not installed. "
|
|
42
|
+
"Install it from {url} and open {name} again."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def text(self, key: MessageKey, **values: str) -> str:
|
|
46
|
+
"""The sentence for `key` with its placeholders filled."""
|
|
47
|
+
return self.sentences()[key].format(**values)
|
|
48
|
+
|
|
49
|
+
def sentences(self) -> dict[MessageKey, str]:
|
|
50
|
+
"""Each key with its sentence, in the order of `MessageKey`."""
|
|
51
|
+
return {key: getattr(self, key.name.lower()) for key in MessageKey}
|
|
52
|
+
|
|
53
|
+
def catalogue(self) -> JsonObject:
|
|
54
|
+
"""The JSON object a frontend reads: key → sentence, placeholders left as `{name}`."""
|
|
55
|
+
return {str(key): sentence for key, sentence in self.sentences().items()}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
ENGLISH = ShellMessages()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Turns the list of JSON values a page sent into the typed arguments of a native function.
|
|
2
|
+
|
|
3
|
+
A function declares its arguments as a dataclass; the page's positional arguments fill the fields in order, and a
|
|
4
|
+
field with a default may be left out. Supported field types: `str`, `int`, `float` (an integer is accepted),
|
|
5
|
+
`bool`, `X | None`, `list[X]`, `tuple[X, ...]` and another dataclass (sent as an object with camelCase keys, so the
|
|
6
|
+
field `glow_a` is `glowA` on the wire). Nothing is converted silently: a number where text is expected is refused.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import types
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Union, get_args, get_origin, get_type_hints
|
|
13
|
+
|
|
14
|
+
from ..json_types import JsonValue
|
|
15
|
+
from .errors import NativeError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def wire_name(field_name: str) -> str:
|
|
19
|
+
"""`glow_a` -> `glowA`: the name a field has on the wire."""
|
|
20
|
+
head, *rest = field_name.split("_")
|
|
21
|
+
return head + "".join(part.capitalize() for part in rest)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _has_default(field: dataclasses.Field[object]) -> bool:
|
|
25
|
+
return field.default is not dataclasses.MISSING or field.default_factory is not dataclasses.MISSING
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_union(hint: object) -> bool:
|
|
29
|
+
return get_origin(hint) in (Union, types.UnionType)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_supported(hint: object, where: str) -> None:
|
|
33
|
+
"""Fails when a function is declared, not when a page first calls it."""
|
|
34
|
+
if hint is str or hint is int or hint is float or hint is bool:
|
|
35
|
+
return
|
|
36
|
+
if _is_union(hint):
|
|
37
|
+
members = [member for member in get_args(hint) if member is not type(None)]
|
|
38
|
+
if len(members) != 1:
|
|
39
|
+
raise TypeError(f"{where}: only `X | None` unions are supported")
|
|
40
|
+
return _check_supported(members[0], where)
|
|
41
|
+
origin = get_origin(hint)
|
|
42
|
+
if origin is list:
|
|
43
|
+
return _check_supported(get_args(hint)[0], where)
|
|
44
|
+
if origin is tuple:
|
|
45
|
+
item, ellipsis = get_args(hint)
|
|
46
|
+
if ellipsis is not Ellipsis:
|
|
47
|
+
raise TypeError(f"{where}: only `tuple[X, ...]` is supported")
|
|
48
|
+
return _check_supported(item, where)
|
|
49
|
+
if isinstance(hint, type) and dataclasses.is_dataclass(hint):
|
|
50
|
+
for field_name, field_hint in get_type_hints(hint).items():
|
|
51
|
+
_check_supported(field_hint, f"{where}.{field_name}")
|
|
52
|
+
return
|
|
53
|
+
raise TypeError(f"{where}: the type {hint!r} cannot be an argument of a native function")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _convert(value: JsonValue, hint: object, label: str) -> object:
|
|
57
|
+
if _is_union(hint):
|
|
58
|
+
if value is None:
|
|
59
|
+
return None
|
|
60
|
+
(member,) = (member for member in get_args(hint) if member is not type(None))
|
|
61
|
+
return _convert(value, member, label)
|
|
62
|
+
if hint is str:
|
|
63
|
+
if isinstance(value, str):
|
|
64
|
+
return value
|
|
65
|
+
raise NativeError(f'"{label}" must be text')
|
|
66
|
+
if hint is bool:
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return value
|
|
69
|
+
raise NativeError(f'"{label}" must be true or false')
|
|
70
|
+
if hint is int:
|
|
71
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
72
|
+
return value
|
|
73
|
+
raise NativeError(f'"{label}" must be a whole number')
|
|
74
|
+
if hint is float:
|
|
75
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
76
|
+
return float(value)
|
|
77
|
+
raise NativeError(f'"{label}" must be a number')
|
|
78
|
+
origin = get_origin(hint)
|
|
79
|
+
if origin in (list, tuple):
|
|
80
|
+
if not isinstance(value, list):
|
|
81
|
+
raise NativeError(f'"{label}" must be a list')
|
|
82
|
+
item = get_args(hint)[0]
|
|
83
|
+
items = [_convert(entry, item, f"{label}[{index}]") for index, entry in enumerate(value)]
|
|
84
|
+
return items if origin is list else tuple(items)
|
|
85
|
+
if isinstance(hint, type) and dataclasses.is_dataclass(hint):
|
|
86
|
+
return _convert_object(value, hint, label)
|
|
87
|
+
raise AssertionError(f"unsupported type {hint!r}") # _check_supported refused it at declaration
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _convert_object(value: JsonValue, shape: type, label: str) -> object:
|
|
91
|
+
if not isinstance(value, dict):
|
|
92
|
+
raise NativeError(f'"{label}" must be an object')
|
|
93
|
+
hints = get_type_hints(shape)
|
|
94
|
+
fields = {wire_name(field.name): field for field in dataclasses.fields(shape)}
|
|
95
|
+
unknown = sorted(set(value) - set(fields))
|
|
96
|
+
if unknown:
|
|
97
|
+
raise NativeError(f'"{label}" has no field "{unknown[0]}"')
|
|
98
|
+
values: dict[str, object] = {}
|
|
99
|
+
for name, field in fields.items():
|
|
100
|
+
if name in value:
|
|
101
|
+
values[field.name] = _convert(value[name], hints[field.name], f"{label}.{name}")
|
|
102
|
+
elif not _has_default(field):
|
|
103
|
+
raise NativeError(f'"{label}" needs the field "{name}"')
|
|
104
|
+
build: Callable[..., object] = shape
|
|
105
|
+
return build(**values)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Arguments[A]:
|
|
109
|
+
"""The arguments of one native function, declared as a dataclass."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, shape: type[A]):
|
|
112
|
+
if not dataclasses.is_dataclass(shape):
|
|
113
|
+
raise TypeError(f"the arguments of a native function are a dataclass, not {shape!r}")
|
|
114
|
+
self._shape = shape
|
|
115
|
+
self._hints = get_type_hints(shape)
|
|
116
|
+
self._fields = dataclasses.fields(shape)
|
|
117
|
+
for field in self._fields:
|
|
118
|
+
_check_supported(self._hints[field.name], f"{shape.__name__}.{field.name}")
|
|
119
|
+
|
|
120
|
+
def parse(self, values: list[JsonValue]) -> A:
|
|
121
|
+
if len(values) > len(self._fields):
|
|
122
|
+
raise NativeError("too many arguments")
|
|
123
|
+
parsed: dict[str, object] = {}
|
|
124
|
+
for index, field in enumerate(self._fields):
|
|
125
|
+
label = wire_name(field.name)
|
|
126
|
+
if index < len(values):
|
|
127
|
+
parsed[field.name] = _convert(values[index], self._hints[field.name], label)
|
|
128
|
+
elif not _has_default(field):
|
|
129
|
+
raise NativeError(f'missing argument "{label}"')
|
|
130
|
+
build: Callable[..., A] = self._shape
|
|
131
|
+
return build(**parsed)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""File-type filters in the one shape the frontend sends, converted for each toolkit."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class FileFilter:
|
|
8
|
+
"""`{name: 'Excel', extensions: ['xlsx', 'xls']}` from the page. An extension may be written `xlsx` or `*.xlsx`."""
|
|
9
|
+
|
|
10
|
+
name: str
|
|
11
|
+
extensions: tuple[str, ...]
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def is_usable(self) -> bool:
|
|
15
|
+
return bool(self.name) and bool(self.extensions)
|
|
16
|
+
|
|
17
|
+
def _patterns(self) -> list[str]:
|
|
18
|
+
return ["*." + extension.lstrip("*.") for extension in self.extensions]
|
|
19
|
+
|
|
20
|
+
def to_qt(self) -> str:
|
|
21
|
+
return f"{self.name} ({' '.join(self._patterns())})"
|
|
22
|
+
|
|
23
|
+
def to_webview(self) -> str:
|
|
24
|
+
return f"{self.name} ({';'.join(self._patterns())})"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Operating-system actions both shells offer to the page. Inputs come from a web page, so they are validated."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OsActions:
|
|
11
|
+
ALLOWED_URL_SCHEMES = frozenset({"http", "https", "mailto"})
|
|
12
|
+
|
|
13
|
+
def reveal(self, path: str) -> bool:
|
|
14
|
+
"""Show a file or folder in the file manager."""
|
|
15
|
+
target = Path(path)
|
|
16
|
+
if not target.exists():
|
|
17
|
+
return False
|
|
18
|
+
subprocess.Popen(self._reveal_command(target))
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
def open_url(self, url: str) -> bool:
|
|
22
|
+
"""Open a web link in the default browser; other schemes (file:, javascript:, ...) are refused."""
|
|
23
|
+
if urlparse(url).scheme.lower() not in self.ALLOWED_URL_SCHEMES:
|
|
24
|
+
return False
|
|
25
|
+
return webbrowser.open(url)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _reveal_command(target: Path) -> list[str]:
|
|
29
|
+
if sys.platform == "darwin":
|
|
30
|
+
return ["open", "-R", str(target)]
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
return ["explorer", f"/select,{target}"]
|
|
33
|
+
return ["xdg-open", str(target if target.is_dir() else target.parent)]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The native bridge of the Qt shell: a QObject published to the page through QWebChannel.
|
|
2
|
+
|
|
3
|
+
The page does not load `qwebchannel.js` itself. The shell injects it, from Qt's own resources, together with a small
|
|
4
|
+
script that exposes the same promise API (`window.<slug>Native.<name>(...)`) the page sees in every shell. One slot,
|
|
5
|
+
`call`, carries every native function: the name and the arguments as JSON text in, the envelope as JSON text out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QFile, QIODevice, QObject, Slot
|
|
11
|
+
|
|
12
|
+
from ..identity import AppIdentity
|
|
13
|
+
from ..json_types import JsonValue
|
|
14
|
+
from .registry import NativeRegistry
|
|
15
|
+
from .window import NativeContext
|
|
16
|
+
|
|
17
|
+
CHANNEL_OBJECT = "bridge"
|
|
18
|
+
|
|
19
|
+
_GLUE_JS = """
|
|
20
|
+
new QWebChannel(qt.webChannelTransport, function (channel) {
|
|
21
|
+
var b = channel.objects.%(object)s;
|
|
22
|
+
var api = {};
|
|
23
|
+
%(names)s.forEach(function (name) {
|
|
24
|
+
api[name] = function () {
|
|
25
|
+
var args = JSON.stringify(Array.prototype.slice.call(arguments));
|
|
26
|
+
return new Promise(function (resolve) {
|
|
27
|
+
b.call(name, args, function (answer) { resolve(JSON.parse(answer)); });
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
window.%(global)s = api;
|
|
32
|
+
window.dispatchEvent(new Event('%(ready)s'));
|
|
33
|
+
});
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class QtBridge(QObject):
|
|
38
|
+
def __init__(self, registry: NativeRegistry, context: NativeContext, parent: QObject | None = None):
|
|
39
|
+
super().__init__(parent)
|
|
40
|
+
self._registry = registry
|
|
41
|
+
self._context = context
|
|
42
|
+
|
|
43
|
+
@Slot(str, str, result=str)
|
|
44
|
+
def call(self, name: str, arguments_json: str) -> str:
|
|
45
|
+
try:
|
|
46
|
+
arguments: JsonValue = json.loads(arguments_json or "[]")
|
|
47
|
+
except ValueError:
|
|
48
|
+
arguments = None
|
|
49
|
+
return json.dumps(self._registry.call(self._context, name, arguments))
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def page_script_source(identity: AppIdentity, registry: NativeRegistry) -> str:
|
|
53
|
+
"""qwebchannel.js (from Qt's resources) followed by the glue that defines the page's native object."""
|
|
54
|
+
resource = QFile(":/qtwebchannel/qwebchannel.js")
|
|
55
|
+
if not resource.open(QIODevice.OpenModeFlag.ReadOnly):
|
|
56
|
+
raise RuntimeError("qwebchannel.js is not available in this Qt installation")
|
|
57
|
+
return bytes(resource.readAll().data()).decode("utf-8") + _GLUE_JS % {
|
|
58
|
+
"object": CHANNEL_OBJECT,
|
|
59
|
+
"names": json.dumps(registry.names),
|
|
60
|
+
"global": identity.native_global,
|
|
61
|
+
"ready": identity.native_ready_event,
|
|
62
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""`NativeWindow` for the Qt shell: the dialogs of `QFileDialog` and the page zoom of the web view."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
6
|
+
from PySide6.QtWidgets import QFileDialog
|
|
7
|
+
|
|
8
|
+
from .filters import FileFilter
|
|
9
|
+
from .start_location import StartLocation
|
|
10
|
+
from .window import NativeWindow
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QtWindow(NativeWindow):
|
|
14
|
+
def __init__(self, view: QWebEngineView):
|
|
15
|
+
self._view = view
|
|
16
|
+
|
|
17
|
+
def pick_folder(self, start: str) -> str | None:
|
|
18
|
+
return QFileDialog.getExistingDirectory(self._view, dir=StartLocation.of(start).qt_path()) or None
|
|
19
|
+
|
|
20
|
+
def pick_file(self, filters: list[FileFilter], start: str) -> str | None:
|
|
21
|
+
patterns = ";;".join(f.to_qt() for f in filters)
|
|
22
|
+
location = StartLocation.of(start).qt_path()
|
|
23
|
+
return QFileDialog.getOpenFileName(self._view, dir=location, filter=patterns)[0] or None
|
|
24
|
+
|
|
25
|
+
def save_as(self, name: str, start: str) -> str | None:
|
|
26
|
+
"""Opens at the folder of the current choice (or the one it was in), proposing `name` unless a file is set."""
|
|
27
|
+
location = StartLocation.of(start)
|
|
28
|
+
proposed = location.filename or name
|
|
29
|
+
where = str(Path(location.directory, proposed)) if location.directory else name
|
|
30
|
+
return QFileDialog.getSaveFileName(self._view, dir=where)[0] or None
|
|
31
|
+
|
|
32
|
+
def set_zoom(self, factor: float) -> bool:
|
|
33
|
+
self._view.setZoomFactor(factor)
|
|
34
|
+
return True
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The native functions a page may call: a name, typed arguments, a handler, and one place that runs them for both
|
|
2
|
+
toolkits.
|
|
3
|
+
|
|
4
|
+
The page is untrusted, so a call is checked here: the name must be registered, the arguments must fit the declared
|
|
5
|
+
types, an error never leaks more than a plain message, and every result travels in the same envelope:
|
|
6
|
+
`{"ok": true, "value": ...}` or `{"ok": false, "error": "..."}`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
from ..json_types import JsonObject, JsonValue
|
|
16
|
+
from .arguments import Arguments
|
|
17
|
+
from .errors import NativeError
|
|
18
|
+
from .window import NativeContext
|
|
19
|
+
|
|
20
|
+
__all__ = ["NativeCallable", "NativeError", "NativeFunction", "NativeRegistry", "NoArguments"]
|
|
21
|
+
|
|
22
|
+
_NAME = re.compile(r"[a-z][A-Za-z0-9]*")
|
|
23
|
+
_log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class NoArguments:
|
|
28
|
+
"""The arguments of a function that takes none."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NativeCallable(Protocol):
|
|
32
|
+
"""What the registry stores: any `NativeFunction`, whatever its arguments are."""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str: ...
|
|
36
|
+
|
|
37
|
+
def run(self, context: NativeContext, values: list[JsonValue]) -> JsonValue: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NativeFunction[A]:
|
|
41
|
+
"""A function the page can call by `name`. `arguments` is a dataclass (see `native.arguments`); the handler is
|
|
42
|
+
called as `handler(context, arguments)` and returns anything JSON can carry."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str, arguments: type[A], handler: Callable[[NativeContext, A], JsonValue]):
|
|
45
|
+
if not _NAME.fullmatch(name):
|
|
46
|
+
raise ValueError(f"a native function name is camelCase letters and digits: {name!r}")
|
|
47
|
+
self._name = name
|
|
48
|
+
self._arguments = Arguments(arguments)
|
|
49
|
+
self._handler = handler
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def name(self) -> str: # camelCase, starts with a lowercase letter: it is the name on the wire and in the page
|
|
53
|
+
return self._name
|
|
54
|
+
|
|
55
|
+
def run(self, context: NativeContext, values: list[JsonValue]) -> JsonValue:
|
|
56
|
+
return self._handler(context, self._arguments.parse(values))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NativeRegistry:
|
|
60
|
+
def __init__(self, functions: tuple[NativeCallable, ...] = ()):
|
|
61
|
+
self._functions: dict[str, NativeCallable] = {}
|
|
62
|
+
for function in functions:
|
|
63
|
+
self.add(function)
|
|
64
|
+
|
|
65
|
+
def add(self, function: NativeCallable) -> None:
|
|
66
|
+
if function.name in self._functions:
|
|
67
|
+
raise ValueError(f"a native function named {function.name!r} is already registered")
|
|
68
|
+
self._functions[function.name] = function
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def names(self) -> list[str]:
|
|
72
|
+
return list(self._functions)
|
|
73
|
+
|
|
74
|
+
def call(self, context: NativeContext, name: str, arguments: JsonValue) -> JsonObject:
|
|
75
|
+
"""Runs one call from the page and returns the envelope."""
|
|
76
|
+
function = self._functions.get(name)
|
|
77
|
+
if function is None:
|
|
78
|
+
return self._failed("unknown native function")
|
|
79
|
+
if not isinstance(arguments, list):
|
|
80
|
+
return self._failed("the arguments must be a list")
|
|
81
|
+
try:
|
|
82
|
+
return {"ok": True, "value": function.run(context, arguments)}
|
|
83
|
+
except NativeError as error:
|
|
84
|
+
return self._failed(str(error))
|
|
85
|
+
except Exception:
|
|
86
|
+
_log.exception("native function %s failed", name)
|
|
87
|
+
return self._failed("the native function failed")
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _failed(message: str) -> JsonObject:
|
|
91
|
+
return {"ok": False, "error": message}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The native functions every project gets. Written once, against `NativeContext`; the page sees them under the names
|
|
2
|
+
of `NativeMethod`. Each declares its arguments as a dataclass."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from ..enums import NativeMethod
|
|
7
|
+
from ..preferences import BootColors
|
|
8
|
+
from .filters import FileFilter
|
|
9
|
+
from .registry import NativeCallable, NativeFunction
|
|
10
|
+
from .window import NativeContext
|
|
11
|
+
from .zoom_range import clamp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PickFolderArguments:
|
|
16
|
+
start: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PickFileArguments:
|
|
21
|
+
filters: list[FileFilter] = field(default_factory=list[FileFilter])
|
|
22
|
+
start: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SaveAsArguments:
|
|
27
|
+
name: str = ""
|
|
28
|
+
start: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RevealArguments:
|
|
33
|
+
path: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class OpenUrlArguments:
|
|
38
|
+
url: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class BootColorsArguments:
|
|
43
|
+
colors: BootColors
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ZoomArguments:
|
|
48
|
+
factor: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pick_folder(context: NativeContext, arguments: PickFolderArguments) -> str | None:
|
|
52
|
+
return context.window.pick_folder(arguments.start)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pick_file(context: NativeContext, arguments: PickFileArguments) -> str | None:
|
|
56
|
+
usable = [item for item in arguments.filters if item.is_usable]
|
|
57
|
+
return context.window.pick_file(usable, arguments.start)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_as(context: NativeContext, arguments: SaveAsArguments) -> str | None:
|
|
61
|
+
return context.window.save_as(arguments.name, arguments.start)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def reveal(context: NativeContext, arguments: RevealArguments) -> bool:
|
|
65
|
+
return context.actions.reveal(arguments.path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def open_url(context: NativeContext, arguments: OpenUrlArguments) -> bool:
|
|
69
|
+
return context.actions.open_url(arguments.url)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_boot_colors(context: NativeContext, arguments: BootColorsArguments) -> bool:
|
|
73
|
+
return context.preferences.save_boot_colors(arguments.colors)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_zoom(context: NativeContext, arguments: ZoomArguments) -> bool:
|
|
77
|
+
return context.window.set_zoom(clamp(arguments.factor))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def standard_functions() -> tuple[NativeCallable, ...]:
|
|
81
|
+
return (
|
|
82
|
+
NativeFunction(NativeMethod.PICK_FOLDER.value, PickFolderArguments, pick_folder),
|
|
83
|
+
NativeFunction(NativeMethod.PICK_FILE.value, PickFileArguments, pick_file),
|
|
84
|
+
NativeFunction(NativeMethod.SAVE_AS.value, SaveAsArguments, save_as),
|
|
85
|
+
NativeFunction(NativeMethod.REVEAL.value, RevealArguments, reveal),
|
|
86
|
+
NativeFunction(NativeMethod.OPEN_URL.value, OpenUrlArguments, open_url),
|
|
87
|
+
NativeFunction(NativeMethod.SET_BOOT_COLORS.value, BootColorsArguments, set_boot_colors),
|
|
88
|
+
NativeFunction(NativeMethod.SET_ZOOM.value, ZoomArguments, set_zoom),
|
|
89
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Where a native dialog opens: the path the field already holds, or the nearest folder that still exists."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class StartLocation:
|
|
9
|
+
directory: str = ""
|
|
10
|
+
filename: str = ""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def of(cls, path: str | None) -> "StartLocation":
|
|
14
|
+
"""A folder opens itself; a file opens its folder with the name preselected; a missing path falls back
|
|
15
|
+
to the closest parent that exists; anything else leaves the choice to the toolkit."""
|
|
16
|
+
if not path:
|
|
17
|
+
return cls()
|
|
18
|
+
target = Path(path).expanduser()
|
|
19
|
+
if target.is_dir():
|
|
20
|
+
return cls(str(target))
|
|
21
|
+
for parent in target.parents:
|
|
22
|
+
if parent.is_dir():
|
|
23
|
+
return cls(str(parent), target.name if parent == target.parent and target.suffix else "")
|
|
24
|
+
return cls()
|
|
25
|
+
|
|
26
|
+
def qt_path(self) -> str:
|
|
27
|
+
"""Qt takes one `dir` argument: a folder, or a folder plus the name to preselect."""
|
|
28
|
+
return str(Path(self.directory, self.filename)) if self.directory else ""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Makes the web view itself paint the app's background before a page loads, so no white shows between the
|
|
2
|
+
splash and the frontend. Platform specific, and always safe: a platform it does not know is left alone."""
|
|
3
|
+
|
|
4
|
+
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownLambdaType=false
|
|
5
|
+
# pyright: reportUnknownArgumentType=false, reportMissingTypeStubs=false, reportMissingImports=false
|
|
6
|
+
# pyright: reportAttributeAccessIssue=false
|
|
7
|
+
# The lines below talk to AppKit (PyObjC), .NET and GTK, which publish no type stubs. Their inside is untyped by
|
|
8
|
+
# necessity; the signatures of this module are typed.
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import webview
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebviewBackground:
|
|
16
|
+
def __init__(self, color: str):
|
|
17
|
+
self.color = color
|
|
18
|
+
|
|
19
|
+
def apply(self, window: webview.Window) -> None:
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
self._apply_macos(window)
|
|
22
|
+
|
|
23
|
+
def _apply_macos(self, window: webview.Window) -> None:
|
|
24
|
+
"""WKWebView paints white under a page until told otherwise (`underPageBackgroundColor`, macOS 12+)."""
|
|
25
|
+
try:
|
|
26
|
+
import AppKit
|
|
27
|
+
from PyObjCTools import AppHelper
|
|
28
|
+
|
|
29
|
+
red, green, blue = (int(self.color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
|
30
|
+
colour = AppKit.NSColor.colorWithSRGBRed_green_blue_alpha_(red, green, blue, 1.0)
|
|
31
|
+
|
|
32
|
+
def paint() -> None: # Cocoa views are touched on the main thread only
|
|
33
|
+
webview = window.native.webview
|
|
34
|
+
if webview.respondsToSelector_("setUnderPageBackgroundColor:"):
|
|
35
|
+
webview.setUnderPageBackgroundColor_(colour)
|
|
36
|
+
|
|
37
|
+
AppHelper.callAfter(paint)
|
|
38
|
+
except Exception: # cosmetic only: never stop the app for a colour
|
|
39
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The native bridge of the pywebview shell. pywebview turns every public callable of this object into
|
|
2
|
+
`window.pywebview.api.<name>()`; the registered native functions are attached by name, each answering in the envelope."""
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from ..json_types import JsonObject, JsonValue
|
|
7
|
+
from .registry import NativeRegistry
|
|
8
|
+
from .window import NativeContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WebviewBridge:
|
|
12
|
+
def __init__(self, registry: NativeRegistry, context: NativeContext):
|
|
13
|
+
# Private on purpose: pywebview walks every public attribute of the API object (and went into the .NET window
|
|
14
|
+
# when the window was public, which failed on Windows with thread and recursion errors).
|
|
15
|
+
self._registry = registry
|
|
16
|
+
self._context = context
|
|
17
|
+
for name in registry.names:
|
|
18
|
+
setattr(self, name, self._function(name))
|
|
19
|
+
|
|
20
|
+
def _function(self, name: str) -> Callable[..., JsonObject]:
|
|
21
|
+
def call(*arguments: JsonValue) -> JsonObject:
|
|
22
|
+
return self._registry.call(self._context, name, list(arguments))
|
|
23
|
+
|
|
24
|
+
call.__name__ = name
|
|
25
|
+
return call
|