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,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,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()
|