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,32 @@
1
+ """The application icon in the Dock. pywebview applies its `icon` option on Qt and GTK only, so on macOS the Dock
2
+ shows a generic icon until it is set here. A packaged program has its icon from the bundle; this serves a run
3
+ from the source tree. Always safe: a platform it does not know is left alone."""
4
+
5
+ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownLambdaType=false
6
+ # pyright: reportUnknownArgumentType=false, reportMissingTypeStubs=false, reportMissingImports=false
7
+ # pyright: reportAttributeAccessIssue=false
8
+ # The lines below talk to AppKit (PyObjC), .NET and GTK, which publish no type stubs. Their inside is untyped by
9
+ # necessity; the signatures of this module are typed.
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+
15
+ class WebviewIcon:
16
+ def __init__(self, path: Path | None):
17
+ self.path = path
18
+
19
+ def apply(self) -> bool:
20
+ if sys.platform != "darwin" or self.path is None:
21
+ return False
22
+ try:
23
+ import AppKit
24
+ from PyObjCTools import AppHelper
25
+
26
+ image = AppKit.NSImage.alloc().initWithContentsOfFile_(str(self.path))
27
+ if image is None:
28
+ return False
29
+ AppHelper.callAfter(lambda: AppKit.NSApplication.sharedApplication().setApplicationIconImage_(image))
30
+ return True
31
+ except Exception: # cosmetic only: never stop the app for an icon
32
+ return False
@@ -0,0 +1,54 @@
1
+ """`NativeWindow` for the pywebview shell. The window is attached after pywebview creates it."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import webview
6
+
7
+ from .filters import FileFilter
8
+ from .start_location import StartLocation
9
+ from .window import NativeWindow
10
+ from .zoom import WebviewZoom
11
+
12
+
13
+ class WebviewWindow(NativeWindow):
14
+ def __init__(self, zoom: WebviewZoom | None = None):
15
+ self._window: webview.Window | None = None
16
+ self._zoom = zoom or WebviewZoom()
17
+
18
+ def bind(self, window: webview.Window) -> None:
19
+ self._window = window
20
+
21
+ @property
22
+ def window(self) -> webview.Window:
23
+ if self._window is None:
24
+ raise RuntimeError("the window is not attached yet")
25
+ return self._window
26
+
27
+ def pick_folder(self, start: str) -> str | None:
28
+ location = StartLocation.of(start)
29
+ return self._first(self.window.create_file_dialog(webview.FileDialog.FOLDER, directory=location.directory))
30
+
31
+ def pick_file(self, filters: list[FileFilter], start: str) -> str | None:
32
+ location = StartLocation.of(start)
33
+ types = tuple(f.to_webview() for f in filters)
34
+ return self._first(
35
+ self.window.create_file_dialog(webview.FileDialog.OPEN, directory=location.directory, file_types=types)
36
+ )
37
+
38
+ def save_as(self, name: str, start: str) -> str | None:
39
+ location = StartLocation.of(start)
40
+ return self._first(
41
+ self.window.create_file_dialog(
42
+ webview.FileDialog.SAVE, directory=location.directory, save_filename=location.filename or name
43
+ )
44
+ )
45
+
46
+ def set_zoom(self, factor: float) -> bool:
47
+ return self._zoom.apply(self.window, factor)
48
+
49
+ @staticmethod
50
+ def _first(result: Sequence[str] | str | None) -> str | None:
51
+ """pywebview returns a path, a list of paths, or nothing when cancelled."""
52
+ if not result:
53
+ return None
54
+ return result if isinstance(result, str) else result[0]
@@ -0,0 +1,34 @@
1
+ """What a native function can use: the window it opens dialogs on, the shell's preferences and the OS actions. Each
2
+ toolkit implements `NativeWindow`; the functions are written once, against it."""
3
+
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+
7
+ from ..preferences import ShellPreferences
8
+ from .filters import FileFilter
9
+ from .os_actions import OsActions
10
+
11
+
12
+ class NativeWindow(ABC):
13
+ """The window of one toolkit. A dialog returns the chosen path, or None when the person cancelled."""
14
+
15
+ @abstractmethod
16
+ def pick_folder(self, start: str) -> str | None: ...
17
+
18
+ @abstractmethod
19
+ def pick_file(self, filters: list[FileFilter], start: str) -> str | None: ...
20
+
21
+ @abstractmethod
22
+ def save_as(self, name: str, start: str) -> str | None: ...
23
+
24
+ @abstractmethod
25
+ def set_zoom(self, factor: float) -> bool: ...
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class NativeContext:
30
+ """Handed to every native function as its first argument."""
31
+
32
+ window: NativeWindow
33
+ preferences: ShellPreferences
34
+ actions: OsActions
@@ -0,0 +1,104 @@
1
+ """Page zoom (Cmd/Ctrl +, -, 0). The page decides the factor and keeps it as an app preference; each shell only
2
+ applies it to its web view. Qt has `setZoomFactor`; pywebview has no zoom call, so each system's own web view is
3
+ reached here, one small class per system."""
4
+
5
+ # pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownLambdaType=false
6
+ # pyright: reportUnknownArgumentType=false, reportMissingTypeStubs=false, reportMissingImports=false
7
+ # pyright: reportAttributeAccessIssue=false
8
+ # The lines below talk to AppKit (PyObjC), .NET and GTK, which publish no type stubs. Their inside is untyped by
9
+ # necessity; the signatures of this module are typed.
10
+
11
+ import sys
12
+ import threading
13
+ from abc import ABC, abstractmethod
14
+ from collections.abc import Callable
15
+
16
+ import webview
17
+
18
+ MAIN_THREAD_WAIT = 2.0 # seconds to wait for the system's UI thread
19
+
20
+
21
+ class ZoomSurface(ABC):
22
+ """How to set the page zoom of a pywebview window on one system. `apply` says whether it was applied."""
23
+
24
+ @abstractmethod
25
+ def apply(self, window: webview.Window, factor: float) -> bool: ...
26
+
27
+
28
+ class MacZoom(ZoomSurface):
29
+ """The window's content view is the WKWebView; its `pageZoom` reflows the page like a browser's zoom does.
30
+ Cocoa views are touched on the main thread only, and the call waits so a failure is reported."""
31
+
32
+ def __init__(self, on_main: Callable[[Callable[[], None]], None] | None = None):
33
+ self._on_main = on_main or self._app_helper
34
+
35
+ def apply(self, window: webview.Window, factor: float) -> bool:
36
+ done = threading.Event()
37
+ result: list[bool] = []
38
+
39
+ def zoom() -> None:
40
+ try:
41
+ window.native.contentView().setPageZoom_(factor)
42
+ result.append(True)
43
+ except Exception: # cosmetic only: never stop the app for a zoom
44
+ result.append(False)
45
+ finally:
46
+ done.set()
47
+
48
+ try:
49
+ self._on_main(zoom)
50
+ except Exception:
51
+ return False
52
+ return done.wait(MAIN_THREAD_WAIT) and bool(result and result[0])
53
+
54
+ @staticmethod
55
+ def _app_helper(call: Callable[[], None]) -> None:
56
+ from PyObjCTools import AppHelper
57
+
58
+ AppHelper.callAfter(call)
59
+
60
+
61
+ class WindowsZoom(ZoomSurface):
62
+ """WebView2's `ZoomFactor`. The browser's own Ctrl +/- are off in a released program, the page's shortcuts do it."""
63
+
64
+ def apply(self, window: webview.Window, factor: float) -> bool:
65
+ try:
66
+ from System import Func, Type
67
+
68
+ form = window.native
69
+
70
+ def zoom() -> None:
71
+ form.webview.ZoomFactor = factor
72
+
73
+ form.Invoke(Func[Type](zoom)) # waits for the form's thread
74
+ return True
75
+ except Exception:
76
+ return False
77
+
78
+
79
+ class GtkZoom(ZoomSurface):
80
+ """WebKitGTK's `set_zoom_level`, called on the GTK main loop."""
81
+
82
+ def apply(self, window: webview.Window, factor: float) -> bool:
83
+ try:
84
+ from gi.repository import GLib
85
+
86
+ view = window.native.webview
87
+ GLib.idle_add(lambda: view.set_zoom_level(factor) and False)
88
+ return True
89
+ except Exception:
90
+ return False
91
+
92
+
93
+ class WebviewZoom:
94
+ def __init__(self, surface: ZoomSurface | None = None):
95
+ self._surface = surface or self._for_this_system()
96
+
97
+ @staticmethod
98
+ def _for_this_system() -> ZoomSurface:
99
+ if sys.platform == "darwin":
100
+ return MacZoom()
101
+ return WindowsZoom() if sys.platform == "win32" else GtkZoom()
102
+
103
+ def apply(self, window: webview.Window, factor: float) -> bool:
104
+ return self._surface.apply(window, factor)
@@ -0,0 +1,9 @@
1
+ """The range of the page zoom, for every shell (no toolkit is imported here, so a Qt-only build can read it)."""
2
+
3
+ MIN_ZOOM = 0.5
4
+ MAX_ZOOM = 3.0
5
+
6
+
7
+ def clamp(value: float) -> float:
8
+ """A usable zoom factor: the value, kept between the smallest and the largest the shells allow."""
9
+ return min(MAX_ZOOM, max(MIN_ZOOM, value))
@@ -0,0 +1,84 @@
1
+ """What the shell itself remembers between launches: the colours of the app's opening screen, so the window and
2
+ the splash start in the colours of the theme the user chose (the shell cannot read the web page's storage before
3
+ the page loads)."""
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+
10
+ from .json_types import JsonObject, JsonValue
11
+
12
+ _HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{6}")
13
+
14
+
15
+ def is_colour(value: object) -> bool:
16
+ """Only `#rrggbb` is accepted: these values are later written into HTML and CSS."""
17
+ return isinstance(value, str) and _HEX_COLOUR.fullmatch(value) is not None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class BootColors:
22
+ """The three colours of the navy gradient (frontend tokens `--nav`, `--hero-a`, `--hero-b`)."""
23
+
24
+ base: str
25
+ glow_a: str
26
+ glow_b: str
27
+
28
+ @classmethod
29
+ def from_values(cls, base: JsonValue, glow_a: JsonValue, glow_b: JsonValue) -> "BootColors | None":
30
+ """From three stored values. None when one is not text or the three are not `#rrggbb` colours."""
31
+ if not (isinstance(base, str) and isinstance(glow_a, str) and isinstance(glow_b, str)):
32
+ return None
33
+ colors = cls(base, glow_a, glow_b)
34
+ return colors if colors.is_valid else None
35
+
36
+ @property
37
+ def is_valid(self) -> bool:
38
+ return all(is_colour(c) for c in (self.base, self.glow_a, self.glow_b))
39
+
40
+ def normalised(self) -> "BootColors":
41
+ return BootColors(self.base.lower(), self.glow_a.lower(), self.glow_b.lower())
42
+
43
+
44
+ class ShellPreferences:
45
+ """A tiny JSON file. With `path=None` it lives in memory only (headless checks must not touch the user's file)."""
46
+
47
+ BOOT_KEY = "bootColors"
48
+
49
+ def __init__(self, path: Path | None):
50
+ self._path = path
51
+ self._values: JsonObject = self._read()
52
+
53
+ @property
54
+ def boot_colors(self) -> BootColors | None:
55
+ stored = self._values.get(self.BOOT_KEY)
56
+ if not isinstance(stored, dict):
57
+ return None
58
+ return BootColors.from_values(stored.get("base"), stored.get("glow_a"), stored.get("glow_b"))
59
+
60
+ def save_boot_colors(self, colors: BootColors) -> bool:
61
+ if not colors.is_valid:
62
+ return False
63
+ self._values[self.BOOT_KEY] = asdict(colors.normalised())
64
+ self._write()
65
+ return True
66
+
67
+ # storage -----------------------------------------------------------------------------------
68
+ def _read(self) -> JsonObject:
69
+ if self._path is None or not self._path.is_file():
70
+ return {}
71
+ try:
72
+ data: JsonValue = json.loads(self._path.read_text(encoding="utf-8"))
73
+ except (OSError, ValueError):
74
+ return {}
75
+ return data if isinstance(data, dict) else {}
76
+
77
+ def _write(self) -> None:
78
+ if self._path is None:
79
+ return
80
+ try:
81
+ self._path.parent.mkdir(parents=True, exist_ok=True)
82
+ self._path.write_text(json.dumps(self._values, indent=2), encoding="utf-8")
83
+ except OSError: # colours are not worth failing for
84
+ pass
@@ -0,0 +1,34 @@
1
+ """The names the shell and a backend must agree on. The backend reads the same names; the library never invents them
2
+ silently: `for_slug` gives a consistent default set and a project may write its own."""
3
+
4
+ import os
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class WireProtocol:
11
+ token_header: str # how an API call proves it comes from this launch
12
+ token_cookie: str # what the backend turns the first token into
13
+ token_query: str # where the launch URL carries the token once
14
+ token_env: str # how the shell hands the token over (a command line is visible to other users)
15
+ api_prefix: str # everything below it needs the token, except the health path
16
+ health_path: str # answers 200 when the backend is ready
17
+ version_path: str | None # answers JSON with a "version"; None when the backend has none
18
+ ws_close_unauthorized: int = 4401 # the close code of a socket that came without a valid token
19
+
20
+ @classmethod
21
+ def for_slug(cls, slug: str) -> "WireProtocol":
22
+ return cls(
23
+ token_header=f"x-{slug}-token",
24
+ token_cookie=f"{slug}_token",
25
+ token_query="token",
26
+ token_env=f"{slug.upper()}_TOKEN",
27
+ api_prefix="/api",
28
+ health_path="/api/health",
29
+ version_path="/api/version",
30
+ )
31
+
32
+ def token_from_environment(self, environ: Mapping[str, str] | None = None) -> str | None:
33
+ """The token the shell handed over, read by the backend."""
34
+ return (os.environ if environ is None else environ).get(self.token_env)
webapp_shell/py.typed ADDED
File without changes
@@ -0,0 +1,126 @@
1
+ """Shell 1: Qt WebEngine (bundled Chromium)."""
2
+
3
+ import os
4
+ import sys
5
+ import threading
6
+ from collections.abc import Callable
7
+
8
+ from PySide6.QtCore import QObject, QTimer, QUrl, Signal
9
+ from PySide6.QtGui import QColor, QIcon
10
+ from PySide6.QtWebChannel import QWebChannel
11
+ from PySide6.QtWebEngineCore import QWebEngineScript
12
+ from PySide6.QtWebEngineWidgets import QWebEngineView
13
+ from PySide6.QtWidgets import QApplication
14
+
15
+ from .backend_process import BackendProcess
16
+ from .enums import ShellKind
17
+ from .native.os_actions import OsActions
18
+ from .native.qt_bridge import CHANNEL_OBJECT, QtBridge
19
+ from .native.qt_window import QtWindow
20
+ from .native.window import NativeContext
21
+ from .preferences import ShellPreferences
22
+ from .settings import Settings
23
+ from .shell import Shell
24
+ from .smoke import SETTLE_SECONDS, page_probe_js
25
+
26
+
27
+ class _Dispatcher(QObject):
28
+ """Lets the worker thread ask the GUI thread to act (Qt widgets must only be touched there)."""
29
+
30
+ navigate = Signal(str)
31
+ show_page = Signal(str)
32
+ set_title = Signal(str)
33
+ quit = Signal()
34
+
35
+
36
+ class QtShell(Shell):
37
+ name = ShellKind.QT
38
+
39
+ def __init__(
40
+ self,
41
+ settings: Settings,
42
+ backend: BackendProcess,
43
+ preferences: ShellPreferences | None = None,
44
+ smoke: bool = False,
45
+ ):
46
+ super().__init__(settings, backend, preferences, smoke)
47
+ if self.smoke:
48
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
49
+ self._app = QApplication(sys.argv)
50
+ self._view = QWebEngineView()
51
+ self._apply_icon()
52
+ self._apply_background()
53
+ self._dispatcher = _Dispatcher()
54
+ self._wire_dispatcher()
55
+ self._install_native_bridge()
56
+
57
+ def event_loop(self, boot: Callable[[], None]) -> None:
58
+ self._view.setWindowTitle(self.settings.identity.name)
59
+ self._view.resize(*self.settings.window_size)
60
+ self._view.setHtml(self.splash.loading())
61
+ if self.smoke: # headless: never show the window (offscreen GPU rendering is unreliable)
62
+ self._view.loadFinished.connect(self._on_load_finished)
63
+ QTimer.singleShot(30_000, self._app.quit) # pyright: ignore[reportUnknownMemberType] # PySide6 leaves the callable open
64
+ else:
65
+ self._view.show()
66
+ QTimer.singleShot(0, lambda: threading.Thread(target=boot, daemon=True).start()) # pyright: ignore[reportUnknownMemberType] # PySide6 leaves the callable open
67
+ self._app.exec()
68
+
69
+ def navigate(self, url: str) -> None:
70
+ self._dispatcher.navigate.emit(url)
71
+
72
+ def show_page(self, html: str) -> None:
73
+ self._dispatcher.show_page.emit(html)
74
+
75
+ def set_title(self, title: str) -> None:
76
+ self._dispatcher.set_title.emit(title)
77
+
78
+ def quit(self) -> None:
79
+ self._dispatcher.quit.emit()
80
+
81
+ def _apply_icon(self) -> None:
82
+ if self.settings.icon_png:
83
+ icon = QIcon(str(self.settings.icon_png))
84
+ self._app.setWindowIcon(icon)
85
+ self._view.setWindowIcon(icon)
86
+
87
+ def _apply_background(self) -> None:
88
+ """The page and the widget behind it start in the app's colour, so nothing flashes white."""
89
+ colour = self.settings.background_color
90
+ self._view.page().setBackgroundColor(QColor(colour))
91
+ self._view.setStyleSheet(f"background:{colour}")
92
+
93
+ def _wire_dispatcher(self) -> None:
94
+ d = self._dispatcher
95
+ d.navigate.connect(self._load)
96
+ d.show_page.connect(self._view.setHtml)
97
+ d.set_title.connect(self._view.setWindowTitle)
98
+ d.quit.connect(self._app.quit)
99
+
100
+ def _load(self, url: str) -> None:
101
+ self._view.load(QUrl(url))
102
+
103
+ def _install_native_bridge(self) -> None:
104
+ self._channel = QWebChannel(self._view.page())
105
+ context = NativeContext(QtWindow(self._view), self.preferences, OsActions())
106
+ self._channel.registerObject(CHANNEL_OBJECT, QtBridge(self.native, context, self._view))
107
+ self._view.page().setWebChannel(self._channel)
108
+ script = QWebEngineScript()
109
+ script.setName(f"{self.settings.identity.slug}-native")
110
+ script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
111
+ script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
112
+ script.setSourceCode(QtBridge.page_script_source(self.settings.identity, self.native))
113
+ self._view.page().scripts().insert(script) # pyright: ignore[reportUnknownMemberType] # overload with an open list type
114
+
115
+ def _on_load_finished(self, _ok: bool) -> None:
116
+ if self._view.url().scheme() == "http": # the frontend, not the splash
117
+ QTimer.singleShot(int(SETTLE_SECONDS * 1000), self._probe) # pyright: ignore[reportUnknownMemberType] # PySide6 leaves the callable open
118
+
119
+ def _probe(self) -> None:
120
+ self._view.page().runJavaScript( # pyright: ignore[reportUnknownMemberType] # the result callback type is open
121
+ page_probe_js(self.settings.identity.native_global), self._on_probe
122
+ )
123
+
124
+ def _on_probe(self, page: str) -> None:
125
+ self.finish_smoke(page)
126
+ self._app.quit()
@@ -0,0 +1,20 @@
1
+ """Available shells, imported lazily so choosing one never requires the other's dependencies."""
2
+
3
+ import importlib
4
+
5
+ from .enums import ShellKind
6
+ from .shell import Shell
7
+
8
+ _SHELLS = {
9
+ ShellKind.QT: ("webapp_shell.qt_shell", "QtShell"),
10
+ ShellKind.WEBVIEW: ("webapp_shell.webview_shell", "WebviewShell"),
11
+ }
12
+
13
+
14
+ def kinds() -> list[ShellKind]:
15
+ return list(_SHELLS)
16
+
17
+
18
+ def shell_class(kind: ShellKind) -> type[Shell]:
19
+ module, cls = _SHELLS[kind]
20
+ return getattr(importlib.import_module(module), cls)
@@ -0,0 +1,78 @@
1
+ """Where things are and how the shell behaves, in one place: the project's config on this installation."""
2
+
3
+ import sys
4
+ from dataclasses import dataclass, replace
5
+ from pathlib import Path
6
+
7
+ from .config import ShellConfig
8
+ from .installation import Installation
9
+ from .preferences import BootColors, ShellPreferences
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Settings:
14
+ config: ShellConfig
15
+ installation: Installation
16
+ boot_colors: BootColors # the opening colours: the identity's, or the ones the user's theme left behind
17
+
18
+ @classmethod
19
+ def of(
20
+ cls,
21
+ config: ShellConfig,
22
+ preferences: ShellPreferences | None = None,
23
+ installation: Installation | None = None,
24
+ splash_seconds: float | None = None,
25
+ ) -> "Settings":
26
+ """The config on this installation, with the opening colours the user's theme left behind last time."""
27
+ installation = installation or Installation(config.source_root)
28
+ remembered = preferences.boot_colors if preferences else None
29
+ if splash_seconds is not None:
30
+ config = replace(config, splash_seconds=splash_seconds)
31
+ return cls(config, installation, remembered or config.identity.boot_colors)
32
+
33
+ @property
34
+ def identity(self):
35
+ return self.config.identity
36
+
37
+ @property
38
+ def host(self) -> str:
39
+ return self.config.host
40
+
41
+ @property
42
+ def startup_timeout(self) -> float:
43
+ return self.config.startup_timeout
44
+
45
+ @property
46
+ def splash_seconds(self) -> float:
47
+ return self.config.splash_seconds
48
+
49
+ @property
50
+ def window_size(self) -> tuple[int, int]:
51
+ return self.identity.window_size
52
+
53
+ @property
54
+ def background_color(self) -> str:
55
+ """The solid colour behind everything (the gradient's base), used for the window and the web view."""
56
+ return self.boot_colors.base
57
+
58
+ @property
59
+ def package_dirs(self) -> tuple[Path, ...]:
60
+ return self.installation.package_dirs(self.config.backend)
61
+
62
+ @property
63
+ def frontend_dist(self) -> Path:
64
+ return self.installation.root / self.config.backend.frontend_dist
65
+
66
+ @property
67
+ def icon_png(self) -> Path | None:
68
+ return self._existing(self.identity.icon_png)
69
+
70
+ @property
71
+ def window_icon(self) -> Path | None:
72
+ """The icon for the window frame. Windows draws it with `System.Drawing.Icon`, which reads only `.ico` files;
73
+ the other systems take the PNG."""
74
+ return self._existing(self.identity.icon_ico if sys.platform == "win32" else self.identity.icon_png)
75
+
76
+ @staticmethod
77
+ def _existing(path: Path | None) -> Path | None:
78
+ return path if path is not None and path.is_file() else None