patera-desktop 0.8.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera-desktop
3
+ Version: 0.8.0
4
+ Requires-Dist: patera
5
+ Requires-Dist: pywebview
6
+ Requires-Python: >=3.13
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "patera-desktop"
3
+ version = "0.8.0"
4
+ requires-python = ">=3.13"
5
+ dependencies = [
6
+ "patera",
7
+ "pywebview",
8
+ ]
9
+
10
+ [tool.uv.sources]
11
+ patera = { workspace = true }
12
+ patera-auth = { workspace = true }
13
+
14
+ [tool.uv.build-backend]
15
+ module-name = "patera.desktop"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.10.9,<0.11.0"]
19
+ build-backend = "uv_build"
@@ -0,0 +1,3 @@
1
+ from .desktop import Desktop, DesktopConfig
2
+
3
+ __all__ = ["Desktop", "DesktopConfig"]
@@ -0,0 +1,245 @@
1
+ from typing import Optional, TypeVar, Generic, cast, Any
2
+ from patera import Patera, BaseExtension
3
+ from pydantic import BaseModel, Field
4
+ import webview
5
+ from webview.menu import Menu
6
+
7
+ from .desktop_cli import DesktopCLI
8
+
9
+ AppT = TypeVar("AppT", bound="Patera[Any]")
10
+
11
+
12
+ class DesktopConfig(BaseModel):
13
+ """
14
+ Configuration for the Patera desktop extension.
15
+
16
+ The WINDOW_* options are passed to pywebview.create_window(...)
17
+ when the main desktop window is created.
18
+ """
19
+
20
+ HOME_URL: str = Field(
21
+ ...,
22
+ description="Home screen URL for the desktop app.",
23
+ )
24
+
25
+ STARTUP_WAIT_TIME: int = Field(
26
+ default=5,
27
+ description=(
28
+ "Maximum time in seconds to wait for the Patera HTTP server "
29
+ "to become reachable before opening the desktop window."
30
+ ),
31
+ )
32
+
33
+ WINDOW_WIDTH: int = Field(
34
+ default=1200,
35
+ description="Initial desktop window width in logical pixels.",
36
+ )
37
+
38
+ WINDOW_HEIGHT: int = Field(
39
+ default=800,
40
+ description="Initial desktop window height in logical pixels.",
41
+ )
42
+
43
+ WINDOW_X: Optional[int] = Field(
44
+ default=None,
45
+ description="Initial X coordinate of the window. None means centered.",
46
+ )
47
+
48
+ WINDOW_Y: Optional[int] = Field(
49
+ default=None,
50
+ description="Initial Y coordinate of the window. None means centered.",
51
+ )
52
+
53
+ WINDOW_MIN_WIDTH: int = Field(
54
+ default=800,
55
+ description="Minimum desktop window width in logical pixels.",
56
+ )
57
+
58
+ WINDOW_MIN_HEIGHT: int = Field(
59
+ default=600,
60
+ description="Minimum desktop window height in logical pixels.",
61
+ )
62
+
63
+ WINDOW_RESIZABLE: bool = Field(
64
+ default=True,
65
+ description="Whether the desktop window can be resized.",
66
+ )
67
+
68
+ WINDOW_FULLSCREEN: bool = Field(
69
+ default=False,
70
+ description="Whether the desktop window starts in fullscreen mode.",
71
+ )
72
+
73
+ WINDOW_MAXIMIZED: bool = Field(
74
+ default=False,
75
+ description="Whether the desktop window starts maximized.",
76
+ )
77
+
78
+ WINDOW_MINIMIZED: bool = Field(
79
+ default=False,
80
+ description="Whether the desktop window starts minimized.",
81
+ )
82
+
83
+ WINDOW_HIDDEN: bool = Field(
84
+ default=False,
85
+ description="Whether the desktop window is initially hidden.",
86
+ )
87
+
88
+ WINDOW_FRAMELESS: bool = Field(
89
+ default=False,
90
+ description="Whether to create a frameless desktop window.",
91
+ )
92
+
93
+ WINDOW_EASY_DRAG: bool = Field(
94
+ default=True,
95
+ description=(
96
+ "Whether a frameless window can be dragged from any point. "
97
+ "Has no effect for normal framed windows."
98
+ ),
99
+ )
100
+
101
+ WINDOW_SHADOW: bool = Field(
102
+ default=True,
103
+ description="Whether to add a window shadow where supported.",
104
+ )
105
+
106
+ WINDOW_FOCUS: bool = Field(
107
+ default=True,
108
+ description="Whether the desktop window should receive focus.",
109
+ )
110
+
111
+ WINDOW_ON_TOP: bool = Field(
112
+ default=False,
113
+ description="Whether the desktop window should stay above other windows.",
114
+ )
115
+
116
+ WINDOW_CONFIRM_CLOSE: bool = Field(
117
+ default=False,
118
+ description="Whether pywebview should ask for confirmation before closing.",
119
+ )
120
+
121
+ WINDOW_BACKGROUND_COLOR: str = Field(
122
+ default="#FFFFFF",
123
+ description="Background color shown before the webview content is loaded.",
124
+ )
125
+
126
+ WINDOW_TRANSPARENT: bool = Field(
127
+ default=False,
128
+ description=(
129
+ "Whether to create a transparent window where supported. "
130
+ "For custom chrome, usually combine with WINDOW_FRAMELESS."
131
+ ),
132
+ )
133
+
134
+ WINDOW_TEXT_SELECT: bool = Field(
135
+ default=True,
136
+ description="Whether document text selection is enabled.",
137
+ )
138
+
139
+ WINDOW_ZOOMABLE: bool = Field(
140
+ default=False,
141
+ description="Whether document zooming is enabled.",
142
+ )
143
+
144
+ WINDOW_DRAGGABLE: bool = Field(
145
+ default=False,
146
+ description="Whether image and link dragging is enabled.",
147
+ )
148
+
149
+ WINDOW_TITLE: Optional[str] = Field(
150
+ default=None,
151
+ description=(
152
+ "Optional desktop window title. If not set, APP_NAME from the "
153
+ "Patera application config is used."
154
+ ),
155
+ )
156
+
157
+ WINDOW_LOCALIZATION: Optional[dict[str, str]] = Field(
158
+ default=None,
159
+ description="Optional pywebview localization dictionary for this window.",
160
+ )
161
+
162
+ def window_kwargs(self) -> dict[str, Any]:
163
+ """
164
+ Return keyword arguments suitable for webview.create_window(...).
165
+
166
+ title and url are intentionally excluded because they are passed
167
+ separately by the Desktop extension.
168
+ """
169
+ return {
170
+ "url": self.HOME_URL,
171
+ "width": self.WINDOW_WIDTH,
172
+ "height": self.WINDOW_HEIGHT,
173
+ "x": self.WINDOW_X,
174
+ "y": self.WINDOW_Y,
175
+ "resizable": self.WINDOW_RESIZABLE,
176
+ "fullscreen": self.WINDOW_FULLSCREEN,
177
+ "min_size": (
178
+ self.WINDOW_MIN_WIDTH,
179
+ self.WINDOW_MIN_HEIGHT,
180
+ ),
181
+ "hidden": self.WINDOW_HIDDEN,
182
+ "frameless": self.WINDOW_FRAMELESS,
183
+ "easy_drag": self.WINDOW_EASY_DRAG,
184
+ "shadow": self.WINDOW_SHADOW,
185
+ "focus": self.WINDOW_FOCUS,
186
+ "minimized": self.WINDOW_MINIMIZED,
187
+ "maximized": self.WINDOW_MAXIMIZED,
188
+ "on_top": self.WINDOW_ON_TOP,
189
+ "confirm_close": self.WINDOW_CONFIRM_CLOSE,
190
+ "background_color": self.WINDOW_BACKGROUND_COLOR,
191
+ "transparent": self.WINDOW_TRANSPARENT,
192
+ "text_select": self.WINDOW_TEXT_SELECT,
193
+ "zoomable": self.WINDOW_ZOOMABLE,
194
+ "draggable": self.WINDOW_DRAGGABLE,
195
+ "localization": self.WINDOW_LOCALIZATION,
196
+ }
197
+
198
+
199
+ class Desktop(BaseExtension[AppT, DesktopConfig], Generic[AppT]):
200
+ def __init__(self, app: AppT) -> None:
201
+ self._window: webview.Window = cast(webview.Window, None)
202
+ self._active_windows: list[webview.Window] = []
203
+ super().__init__(app)
204
+
205
+ def init(self) -> None:
206
+ self._cli_controller = DesktopCLI(self._app, self) # type: ignore
207
+ self._app.register_cli_controller(self._cli_controller)
208
+
209
+ def create_window(
210
+ self, *, url: str, title: Optional[str] = None, **kwargs
211
+ ) -> webview.Window:
212
+ if title is None:
213
+ title = cast(str, self.app.configs.APP_NAME)
214
+ new_window: webview.Window = cast(
215
+ webview.Window,
216
+ webview.create_window(
217
+ title, # type: ignore
218
+ url,
219
+ **kwargs,
220
+ ),
221
+ )
222
+ self.add_active_window(new_window)
223
+ return new_window
224
+
225
+ def menu(self) -> list[Menu]:
226
+ """Override this method to provide an application menu"""
227
+ return []
228
+
229
+ def get_active_window(self) -> Optional[webview.Window]:
230
+ return webview.active_window()
231
+
232
+ def add_active_window(self, window: webview.Window) -> None:
233
+ self._active_windows.append(window)
234
+
235
+ @property
236
+ def active_windows(self) -> list[webview.Window]:
237
+ return self._active_windows
238
+
239
+ @property
240
+ def main_window(self) -> webview.Window:
241
+ return self._window
242
+
243
+ @main_window.setter
244
+ def main_window(self, window: webview.Window) -> None:
245
+ self._window = window
@@ -0,0 +1,126 @@
1
+ import time
2
+ import socket
3
+ import sys
4
+ import os
5
+ import signal
6
+ from typing import TYPE_CHECKING, Any, cast
7
+ import subprocess
8
+ from patera.cli import CLIController, cli_controller, command
9
+ from patera import Patera
10
+ import webview
11
+
12
+ if TYPE_CHECKING:
13
+ from .desktop import Desktop
14
+
15
+
16
+ @cli_controller(name="desktop")
17
+ class DesktopCLI(CLIController[Patera]):
18
+ def __init__(self, app: Patera, desktop_ext: "Desktop"):
19
+ self._desktop_ext = desktop_ext
20
+ super().__init__(app)
21
+
22
+ def wait_for_server(self, host: str, port: int) -> None:
23
+ timeout: int = self._desktop_ext.configs.STARTUP_WAIT_TIME
24
+ deadline = time.time() + timeout
25
+
26
+ while time.time() < deadline:
27
+ try:
28
+ with socket.create_connection((host, port), timeout=0.5):
29
+ return
30
+ except OSError:
31
+ time.sleep(0.2)
32
+
33
+ raise RuntimeError(f"Patera server did not start on time. Timeout: {timeout} s")
34
+
35
+ def start_server_process(self) -> subprocess.Popen[Any]:
36
+ MODE: str = "dev" if self._desktop_ext.app.configs.DEBUG else "prod"
37
+
38
+ if sys.platform == "win32":
39
+ return subprocess.Popen(
40
+ ["patera", MODE],
41
+ creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
42
+ )
43
+
44
+ return subprocess.Popen(
45
+ ["patera", MODE],
46
+ start_new_session=True,
47
+ )
48
+
49
+ def stop_server_process(
50
+ self,
51
+ server: subprocess.Popen[Any],
52
+ *,
53
+ timeout: float = 10.0,
54
+ ) -> None:
55
+ if server.poll() is not None:
56
+ return
57
+
58
+ try:
59
+ if sys.platform == "win32":
60
+ server.send_signal(signal.CTRL_BREAK_EVENT)
61
+ else:
62
+ os.killpg(server.pid, signal.SIGINT)
63
+
64
+ server.wait(timeout=timeout)
65
+ return
66
+
67
+ except subprocess.TimeoutExpired:
68
+ pass
69
+
70
+ except ProcessLookupError:
71
+ return
72
+
73
+ if server.poll() is not None:
74
+ return
75
+
76
+ try:
77
+ if sys.platform == "win32":
78
+ server.terminate()
79
+ else:
80
+ os.killpg(server.pid, signal.SIGTERM)
81
+
82
+ server.wait(timeout=5)
83
+ return
84
+
85
+ except subprocess.TimeoutExpired:
86
+ pass
87
+
88
+ except ProcessLookupError:
89
+ return
90
+
91
+ if server.poll() is not None:
92
+ return
93
+
94
+ if sys.platform == "win32":
95
+ server.kill()
96
+ else:
97
+ os.killpg(server.pid, signal.SIGKILL)
98
+
99
+ server.wait(timeout=5)
100
+
101
+ def start_desktop_app(self) -> None:
102
+ server = self.start_server_process()
103
+ name: str = self._desktop_ext.app.configs.APP_NAME
104
+ host: str = self._desktop_ext.app.configs.HOST
105
+ port: int = self._desktop_ext.app.configs.PORT
106
+ window_kwargs: dict[str, Any] = self._desktop_ext.configs.window_kwargs()
107
+
108
+ try:
109
+ self.wait_for_server(host, port)
110
+ window: webview.Window = cast(
111
+ webview.Window,
112
+ webview.create_window(
113
+ name,
114
+ **window_kwargs,
115
+ ),
116
+ )
117
+ self._desktop_ext.main_window = window
118
+ webview.start(
119
+ menu=self._desktop_ext.menu(),
120
+ )
121
+ finally:
122
+ self.stop_server_process(server)
123
+
124
+ @command("start")
125
+ async def start(self) -> None:
126
+ self.start_desktop_app()