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,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
@@ -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,4 @@
1
+ """The native bridge: what a web page cannot do itself (real-path dialogs, reveal in file manager, open URL).
2
+
3
+ `os_actions` and `filters` are shared; each shell adds only its own dialog implementation.
4
+ """
@@ -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,5 @@
1
+ """The error a native function raises to refuse a call in words the page may show."""
2
+
3
+
4
+ class NativeError(Exception):
5
+ """Raised by a handler to refuse a call in words the page may show."""
@@ -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