wesktop 0.1.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.
- wesktop/__init__.py +86 -0
- wesktop/asgi.py +404 -0
- wesktop/desktop.py +39 -0
- wesktop/entries.py +297 -0
- wesktop/server.py +167 -0
- wesktop/sse.py +119 -0
- wesktop-0.1.1.dist-info/METADATA +12 -0
- wesktop-0.1.1.dist-info/RECORD +10 -0
- wesktop-0.1.1.dist-info/WHEEL +4 -0
- wesktop-0.1.1.dist-info/licenses/LICENSE +21 -0
wesktop/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""wesktop — A Python framework for building web-based desktop applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from wesktop.entries import create_entry, remove_entry
|
|
9
|
+
from wesktop.asgi import (
|
|
10
|
+
Router,
|
|
11
|
+
Request,
|
|
12
|
+
JSONResponse,
|
|
13
|
+
TextResponse,
|
|
14
|
+
HTMLResponse,
|
|
15
|
+
BytesResponse,
|
|
16
|
+
StreamResponse,
|
|
17
|
+
create_app,
|
|
18
|
+
add_ws_route,
|
|
19
|
+
)
|
|
20
|
+
from wesktop.sse import Broadcaster, sse_route
|
|
21
|
+
|
|
22
|
+
__version__ = importlib.metadata.version("wesktop")
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# asgi
|
|
26
|
+
"Router",
|
|
27
|
+
"Request",
|
|
28
|
+
"JSONResponse",
|
|
29
|
+
"TextResponse",
|
|
30
|
+
"HTMLResponse",
|
|
31
|
+
"BytesResponse",
|
|
32
|
+
"StreamResponse",
|
|
33
|
+
"create_app",
|
|
34
|
+
"add_ws_route",
|
|
35
|
+
# sse
|
|
36
|
+
"Broadcaster",
|
|
37
|
+
"sse_route",
|
|
38
|
+
# entries
|
|
39
|
+
"create_entry",
|
|
40
|
+
"remove_entry",
|
|
41
|
+
# top-level
|
|
42
|
+
"run",
|
|
43
|
+
"serve",
|
|
44
|
+
# metadata
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(
|
|
50
|
+
target: str,
|
|
51
|
+
*,
|
|
52
|
+
title: str = "wesktop",
|
|
53
|
+
width: int = 1280,
|
|
54
|
+
height: int = 800,
|
|
55
|
+
icon: str | None = None,
|
|
56
|
+
host: str = "127.0.0.1",
|
|
57
|
+
port: int = 8000,
|
|
58
|
+
pid_path: Path | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Start server + native desktop window."""
|
|
61
|
+
from wesktop.desktop import run as _run
|
|
62
|
+
|
|
63
|
+
_run(
|
|
64
|
+
target,
|
|
65
|
+
title=title,
|
|
66
|
+
width=width,
|
|
67
|
+
height=height,
|
|
68
|
+
icon=icon,
|
|
69
|
+
host=host,
|
|
70
|
+
port=port,
|
|
71
|
+
pid_path=pid_path,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def serve(
|
|
76
|
+
target: str,
|
|
77
|
+
*,
|
|
78
|
+
host: str = "127.0.0.1",
|
|
79
|
+
port: int = 8000,
|
|
80
|
+
pid_path: Path | None = None,
|
|
81
|
+
name: str = "wesktop",
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Start server in blocking/headless mode."""
|
|
84
|
+
from wesktop.server import start_server
|
|
85
|
+
|
|
86
|
+
start_server(target, host, port, pid_path=pid_path, name=name)
|
wesktop/asgi.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal ASGI micro-framework with zero external dependencies (stdlib + msgspec).
|
|
3
|
+
Provides routing, static file serving, SPA fallback, middleware, and lifespan support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import mimetypes
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, AsyncGenerator, Callable
|
|
12
|
+
from urllib.parse import parse_qs
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Response types
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
class JSONResponse:
|
|
22
|
+
"""JSON response with optional status code."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, data: Any, status: int = 200):
|
|
25
|
+
self.data = data
|
|
26
|
+
self.status = status
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TextResponse:
|
|
30
|
+
"""Plain text or CSS response.
|
|
31
|
+
|
|
32
|
+
``headers`` (optional) is merged with the framework's default response
|
|
33
|
+
headers; values must already be plain strings.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
text: str,
|
|
39
|
+
content_type: str = "text/plain",
|
|
40
|
+
status: int = 200,
|
|
41
|
+
headers: dict[str, str] | None = None,
|
|
42
|
+
):
|
|
43
|
+
self.text = text
|
|
44
|
+
self.content_type = content_type
|
|
45
|
+
self.status = status
|
|
46
|
+
self.headers = headers or {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HTMLResponse:
|
|
50
|
+
"""HTML response."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, html: str, status: int = 200):
|
|
53
|
+
self.html = html
|
|
54
|
+
self.status = status
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BytesResponse:
|
|
58
|
+
"""Raw bytes response with an explicit content type."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, data: bytes, content_type: str = "application/octet-stream", status: int = 200):
|
|
61
|
+
self.data = data
|
|
62
|
+
self.content_type = content_type
|
|
63
|
+
self.status = status
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StreamResponse:
|
|
67
|
+
"""Streaming response (for SSE)."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
generator: AsyncGenerator,
|
|
72
|
+
content_type: str,
|
|
73
|
+
headers: dict[str, str] | None = None,
|
|
74
|
+
):
|
|
75
|
+
self.generator = generator
|
|
76
|
+
self.content_type = content_type
|
|
77
|
+
self.headers = headers or {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Request wrapper
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class Request:
|
|
85
|
+
"""Wraps ASGI scope with parsed body and params."""
|
|
86
|
+
|
|
87
|
+
__slots__ = ("scope", "path_params", "json", "_body")
|
|
88
|
+
|
|
89
|
+
def __init__(self, scope: dict, path_params: dict, body: bytes | None):
|
|
90
|
+
self.scope = scope
|
|
91
|
+
self.path_params = path_params
|
|
92
|
+
self._body = body
|
|
93
|
+
try:
|
|
94
|
+
self.json: dict | list | None = (
|
|
95
|
+
msgspec.json.decode(body) if body else None
|
|
96
|
+
)
|
|
97
|
+
except (msgspec.DecodeError, ValueError):
|
|
98
|
+
self.json = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def body(self) -> bytes | None:
|
|
102
|
+
"""Raw request body bytes."""
|
|
103
|
+
return self._body
|
|
104
|
+
|
|
105
|
+
def query(self, name: str, default: Any = None, type_: type = str) -> Any:
|
|
106
|
+
"""Get a query parameter by name, with optional type conversion."""
|
|
107
|
+
qs = parse_qs(self.scope.get("query_string", b"").decode())
|
|
108
|
+
values = qs.get(name)
|
|
109
|
+
if not values:
|
|
110
|
+
return default
|
|
111
|
+
try:
|
|
112
|
+
return type_(values[0])
|
|
113
|
+
except (ValueError, TypeError):
|
|
114
|
+
return default
|
|
115
|
+
|
|
116
|
+
def header(self, name: str, default: str | None = None) -> str | None:
|
|
117
|
+
"""Return a request header by name (case-insensitive).
|
|
118
|
+
|
|
119
|
+
ASGI headers arrive as a list of ``(bytes, bytes)`` tuples. This
|
|
120
|
+
helper decodes both to ``str`` and looks the name up
|
|
121
|
+
case-insensitively, matching HTTP semantics.
|
|
122
|
+
"""
|
|
123
|
+
target = name.lower().encode()
|
|
124
|
+
for k, v in self.scope.get("headers", []):
|
|
125
|
+
if k.lower() == target:
|
|
126
|
+
return v.decode("latin-1")
|
|
127
|
+
return default
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def body_size(self) -> int:
|
|
131
|
+
"""Length in bytes of the raw request body (0 if no body)."""
|
|
132
|
+
return len(self._body) if self._body is not None else 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Router
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
class Router:
|
|
140
|
+
"""Simple path-based HTTP router using {param} placeholders."""
|
|
141
|
+
|
|
142
|
+
def __init__(self) -> None:
|
|
143
|
+
# List of (method, pattern_segments, handler)
|
|
144
|
+
self._routes: list[tuple[str, list[str], Callable]] = []
|
|
145
|
+
|
|
146
|
+
def get(self, path: str) -> Callable:
|
|
147
|
+
"""Decorator to register a GET handler."""
|
|
148
|
+
def decorator(fn: Callable) -> Callable:
|
|
149
|
+
self.add_route("GET", path, fn)
|
|
150
|
+
return fn
|
|
151
|
+
return decorator
|
|
152
|
+
|
|
153
|
+
def post(self, path: str) -> Callable:
|
|
154
|
+
"""Decorator to register a POST handler."""
|
|
155
|
+
def decorator(fn: Callable) -> Callable:
|
|
156
|
+
self.add_route("POST", path, fn)
|
|
157
|
+
return fn
|
|
158
|
+
return decorator
|
|
159
|
+
|
|
160
|
+
def delete(self, path: str) -> Callable:
|
|
161
|
+
"""Decorator to register a DELETE handler."""
|
|
162
|
+
def decorator(fn: Callable) -> Callable:
|
|
163
|
+
self.add_route("DELETE", path, fn)
|
|
164
|
+
return fn
|
|
165
|
+
return decorator
|
|
166
|
+
|
|
167
|
+
def add_route(self, method: str, path: str, handler: Callable) -> None:
|
|
168
|
+
"""Programmatic route registration."""
|
|
169
|
+
segments = path.strip("/").split("/")
|
|
170
|
+
self._routes.append((method, segments, handler))
|
|
171
|
+
|
|
172
|
+
def match(self, method: str, path: str) -> tuple[Callable, dict[str, str]] | None:
|
|
173
|
+
"""Return (handler, path_params) or None if no route matches."""
|
|
174
|
+
segments = path.strip("/").split("/")
|
|
175
|
+
for route_method, pattern, handler in self._routes:
|
|
176
|
+
if route_method != method or len(pattern) != len(segments):
|
|
177
|
+
continue
|
|
178
|
+
params: dict[str, str] = {}
|
|
179
|
+
matched = True
|
|
180
|
+
for pat_seg, req_seg in zip(pattern, segments):
|
|
181
|
+
if pat_seg.startswith("{") and pat_seg.endswith("}"):
|
|
182
|
+
params[pat_seg[1:-1]] = req_seg
|
|
183
|
+
elif pat_seg != req_seg:
|
|
184
|
+
matched = False
|
|
185
|
+
break
|
|
186
|
+
if matched:
|
|
187
|
+
return handler, params
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# ASGI send helpers
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
async def _send_response(
|
|
196
|
+
send: Callable,
|
|
197
|
+
status: int,
|
|
198
|
+
body: bytes,
|
|
199
|
+
content_type: str,
|
|
200
|
+
extra_headers: dict[str, str] | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Send a complete HTTP response (headers + body)."""
|
|
203
|
+
headers: list[list[bytes]] = [
|
|
204
|
+
[b"content-type", content_type.encode()],
|
|
205
|
+
[b"content-length", str(len(body)).encode()],
|
|
206
|
+
]
|
|
207
|
+
if extra_headers:
|
|
208
|
+
for k, v in extra_headers.items():
|
|
209
|
+
headers.append([k.encode(), v.encode()])
|
|
210
|
+
await send({
|
|
211
|
+
"type": "http.response.start",
|
|
212
|
+
"status": status,
|
|
213
|
+
"headers": headers,
|
|
214
|
+
})
|
|
215
|
+
await send({"type": "http.response.body", "body": body})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def _send_stream(send: Callable, resp: StreamResponse) -> None:
|
|
219
|
+
"""Send a streaming HTTP response, iterating the async generator."""
|
|
220
|
+
headers = [
|
|
221
|
+
[b"content-type", resp.content_type.encode()],
|
|
222
|
+
]
|
|
223
|
+
for key, value in resp.headers.items():
|
|
224
|
+
headers.append([key.encode(), value.encode()])
|
|
225
|
+
await send({"type": "http.response.start", "status": 200, "headers": headers})
|
|
226
|
+
async for chunk in resp.generator:
|
|
227
|
+
payload = chunk.encode() if isinstance(chunk, str) else chunk
|
|
228
|
+
await send({"type": "http.response.body", "body": payload, "more_body": True})
|
|
229
|
+
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def _send_result(send: Callable, result: Any) -> None:
|
|
233
|
+
"""Dispatch a handler return value to the appropriate sender."""
|
|
234
|
+
# Auto-wrap plain dicts/lists as JSON responses
|
|
235
|
+
if isinstance(result, (dict, list)):
|
|
236
|
+
result = JSONResponse(result)
|
|
237
|
+
|
|
238
|
+
if isinstance(result, JSONResponse):
|
|
239
|
+
await _send_response(send, result.status, msgspec.json.encode(result.data), "application/json")
|
|
240
|
+
elif isinstance(result, TextResponse):
|
|
241
|
+
await _send_response(
|
|
242
|
+
send, result.status, result.text.encode(),
|
|
243
|
+
result.content_type, extra_headers=result.headers,
|
|
244
|
+
)
|
|
245
|
+
elif isinstance(result, HTMLResponse):
|
|
246
|
+
await _send_response(send, result.status, result.html.encode(), "text/html")
|
|
247
|
+
elif isinstance(result, BytesResponse):
|
|
248
|
+
await _send_response(send, result.status, result.data, result.content_type)
|
|
249
|
+
elif isinstance(result, StreamResponse):
|
|
250
|
+
await _send_stream(send, result)
|
|
251
|
+
else:
|
|
252
|
+
# Fallback: try JSON-encoding anything else
|
|
253
|
+
await _send_response(send, 200, msgspec.json.encode(result), "application/json")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Static file + SPA helpers
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
async def _serve_static(send: Callable, static_dir: Path, rel_path: str) -> bool:
|
|
261
|
+
"""Serve a static file. Returns True if served, False if not found."""
|
|
262
|
+
file_path = (static_dir / rel_path).resolve()
|
|
263
|
+
# Prevent path traversal
|
|
264
|
+
if not str(file_path).startswith(str(static_dir.resolve())):
|
|
265
|
+
return False
|
|
266
|
+
if not file_path.is_file():
|
|
267
|
+
return False
|
|
268
|
+
mime, _ = mimetypes.guess_type(str(file_path))
|
|
269
|
+
body = file_path.read_bytes()
|
|
270
|
+
await _send_response(send, 200, body, mime or "application/octet-stream")
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _serve_spa_fallback(send: Callable, spa_fallback: Path) -> None:
|
|
275
|
+
"""Serve the SPA fallback file (typically index.html)."""
|
|
276
|
+
if spa_fallback.is_file():
|
|
277
|
+
body = spa_fallback.read_bytes()
|
|
278
|
+
await _send_response(send, 200, body, "text/html")
|
|
279
|
+
else:
|
|
280
|
+
await _send_response(send, 404, msgspec.json.encode({"error": "not found"}), "application/json")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# WebSocket route registry
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
# Maps exact paths (e.g. "/ws/echo") to raw ASGI WebSocket handlers
|
|
288
|
+
# with signature (scope, receive, send) -> None.
|
|
289
|
+
_ws_routes: dict[str, Callable] = {}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def add_ws_route(path: str, handler: Callable) -> None:
|
|
293
|
+
"""Register a WebSocket handler for an exact path."""
|
|
294
|
+
_ws_routes[path] = handler
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# App factory
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def create_app(
|
|
302
|
+
router: Router,
|
|
303
|
+
middleware: list[type] | None = None,
|
|
304
|
+
static_dir: Path | None = None,
|
|
305
|
+
static_path: str = "/assets",
|
|
306
|
+
spa_fallback: Path | None = None,
|
|
307
|
+
lifespan: Callable | None = None,
|
|
308
|
+
name: str | None = None,
|
|
309
|
+
) -> Callable:
|
|
310
|
+
"""Create an ASGI application callable."""
|
|
311
|
+
|
|
312
|
+
log = logging.getLogger(name or "wesktop.asgi")
|
|
313
|
+
|
|
314
|
+
async def app(scope: dict, receive: Callable, send: Callable) -> None:
|
|
315
|
+
# -- Lifespan protocol --
|
|
316
|
+
if scope["type"] == "lifespan":
|
|
317
|
+
message = await receive()
|
|
318
|
+
if message["type"] == "lifespan.startup":
|
|
319
|
+
if lifespan is not None:
|
|
320
|
+
# Enter the context manager; it stays open until shutdown
|
|
321
|
+
ctx = lifespan(app)
|
|
322
|
+
await ctx.__aenter__()
|
|
323
|
+
await send({"type": "lifespan.startup.complete"})
|
|
324
|
+
await receive() # blocks until lifespan.shutdown
|
|
325
|
+
await ctx.__aexit__(None, None, None)
|
|
326
|
+
else:
|
|
327
|
+
await send({"type": "lifespan.startup.complete"})
|
|
328
|
+
await receive()
|
|
329
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# -- WebSocket routing --
|
|
333
|
+
if scope["type"] == "websocket":
|
|
334
|
+
path = scope.get("path", "")
|
|
335
|
+
handler = _ws_routes.get(path)
|
|
336
|
+
if handler:
|
|
337
|
+
await handler(scope, receive, send)
|
|
338
|
+
else:
|
|
339
|
+
# Reject unknown WebSocket paths
|
|
340
|
+
await receive() # consume websocket.connect per ASGI spec
|
|
341
|
+
await send({"type": "websocket.close", "code": 4004})
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
if scope["type"] != "http":
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
method = scope["method"]
|
|
348
|
+
path = scope["path"]
|
|
349
|
+
|
|
350
|
+
# -- Route matching --
|
|
351
|
+
match = router.match(method, path)
|
|
352
|
+
if match:
|
|
353
|
+
handler, path_params = match
|
|
354
|
+
try:
|
|
355
|
+
# Read body for POST, skip for GET/DELETE
|
|
356
|
+
body = None
|
|
357
|
+
if method == "POST":
|
|
358
|
+
body = b""
|
|
359
|
+
while True:
|
|
360
|
+
msg = await receive()
|
|
361
|
+
body += msg.get("body", b"")
|
|
362
|
+
if not msg.get("more_body", False):
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
request = Request(scope, path_params, body)
|
|
366
|
+
result = await handler(request)
|
|
367
|
+
await _send_result(send, result)
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
log.exception("Handler error on %s %s", method, path)
|
|
370
|
+
await _send_response(
|
|
371
|
+
send, 500,
|
|
372
|
+
msgspec.json.encode({"error": str(exc)}),
|
|
373
|
+
"application/json",
|
|
374
|
+
)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# -- Static files --
|
|
378
|
+
if static_dir and path.startswith(static_path + "/"):
|
|
379
|
+
rel = path[len(static_path) + 1:]
|
|
380
|
+
if await _serve_static(send, static_dir, rel):
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
# -- SPA fallback (GET only) --
|
|
384
|
+
if spa_fallback and method == "GET":
|
|
385
|
+
# Before returning index.html, check if the path maps to an
|
|
386
|
+
# actual file under the static root (spa_fallback's parent dir).
|
|
387
|
+
# This lets sub-directories be served without registering each
|
|
388
|
+
# one as a separate static_path prefix.
|
|
389
|
+
static_root = spa_fallback.parent
|
|
390
|
+
candidate = path.lstrip("/")
|
|
391
|
+
if candidate and await _serve_static(send, static_root, candidate):
|
|
392
|
+
return
|
|
393
|
+
await _serve_spa_fallback(send, spa_fallback)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# -- 404 --
|
|
397
|
+
await _send_response(send, 404, msgspec.json.encode({"error": "not found"}), "application/json")
|
|
398
|
+
|
|
399
|
+
# -- Apply middleware in reverse so the first in the list is outermost --
|
|
400
|
+
wrapped = app
|
|
401
|
+
for mw in reversed(middleware or []):
|
|
402
|
+
wrapped = mw(wrapped)
|
|
403
|
+
|
|
404
|
+
return wrapped
|
wesktop/desktop.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Native desktop window via pywebview, backed by a Granian server in a daemon thread."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run(
|
|
9
|
+
target: str,
|
|
10
|
+
*,
|
|
11
|
+
title: str = "wesktop",
|
|
12
|
+
width: int = 1280,
|
|
13
|
+
height: int = 800,
|
|
14
|
+
icon: str | None = None,
|
|
15
|
+
host: str = "127.0.0.1",
|
|
16
|
+
port: int = 8000,
|
|
17
|
+
pid_path: Path | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Start server + open native desktop window. Blocks until window closes."""
|
|
20
|
+
from wesktop.server import start_server_in_background
|
|
21
|
+
|
|
22
|
+
url = start_server_in_background(target, host, port, pid_path=pid_path)
|
|
23
|
+
|
|
24
|
+
# Late import so headless mode (serve) has no pywebview dependency
|
|
25
|
+
import webview
|
|
26
|
+
|
|
27
|
+
window = webview.create_window(
|
|
28
|
+
title=title,
|
|
29
|
+
url=url,
|
|
30
|
+
width=width,
|
|
31
|
+
height=height,
|
|
32
|
+
)
|
|
33
|
+
if icon:
|
|
34
|
+
# pywebview supports icon parameter on some platforms
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
webview.start()
|
|
38
|
+
# When webview.start() returns, the window was closed.
|
|
39
|
+
# Daemon thread (server) auto-exits with main thread.
|
wesktop/entries.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Cross-platform desktop entry creation and removal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import textwrap
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_entry(
|
|
16
|
+
name: str,
|
|
17
|
+
command: str,
|
|
18
|
+
*,
|
|
19
|
+
icon: str | Path | None = None,
|
|
20
|
+
comment: str = "",
|
|
21
|
+
categories: str = "Utility;",
|
|
22
|
+
) -> Path:
|
|
23
|
+
"""Create a platform-native desktop entry. Returns the path of the created entry."""
|
|
24
|
+
system = platform.system()
|
|
25
|
+
if system == "Linux":
|
|
26
|
+
return _create_linux(name, command, icon=icon, comment=comment, categories=categories)
|
|
27
|
+
elif system == "Darwin":
|
|
28
|
+
return _create_macos(name, command, icon=icon, comment=comment)
|
|
29
|
+
elif system == "Windows":
|
|
30
|
+
return _create_windows(name, command, icon=icon, comment=comment)
|
|
31
|
+
else:
|
|
32
|
+
raise OSError(f"Unsupported platform: {system}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def remove_entry(name: str) -> bool:
|
|
36
|
+
"""Remove a desktop entry. Returns True if something was removed."""
|
|
37
|
+
system = platform.system()
|
|
38
|
+
if system == "Linux":
|
|
39
|
+
return _remove_linux(name)
|
|
40
|
+
elif system == "Darwin":
|
|
41
|
+
return _remove_macos(name)
|
|
42
|
+
elif system == "Windows":
|
|
43
|
+
return _remove_windows(name)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Linux
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _linux_apps_dir() -> Path:
|
|
52
|
+
return Path.home() / ".local" / "share" / "applications"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _linux_icons_dir() -> Path:
|
|
56
|
+
return Path.home() / ".local" / "share" / "icons" / "hicolor" / "256x256" / "apps"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _create_linux(
|
|
60
|
+
name: str,
|
|
61
|
+
command: str,
|
|
62
|
+
*,
|
|
63
|
+
icon: str | Path | None = None,
|
|
64
|
+
comment: str = "",
|
|
65
|
+
categories: str = "Utility;",
|
|
66
|
+
) -> Path:
|
|
67
|
+
apps_dir = _linux_apps_dir()
|
|
68
|
+
apps_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
icon_value = ""
|
|
71
|
+
if icon is not None:
|
|
72
|
+
icon_path = Path(icon)
|
|
73
|
+
if icon_path.is_file():
|
|
74
|
+
icons_dir = _linux_icons_dir()
|
|
75
|
+
icons_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
dest = icons_dir / f"{name}.png"
|
|
77
|
+
shutil.copy2(icon_path, dest)
|
|
78
|
+
icon_value = str(dest)
|
|
79
|
+
else:
|
|
80
|
+
# Treat as a theme icon name
|
|
81
|
+
icon_value = str(icon)
|
|
82
|
+
|
|
83
|
+
desktop_path = apps_dir / f"{name}.desktop"
|
|
84
|
+
desktop_path.write_text(
|
|
85
|
+
textwrap.dedent(f"""\
|
|
86
|
+
[Desktop Entry]
|
|
87
|
+
Type=Application
|
|
88
|
+
Name={name}
|
|
89
|
+
Exec={command}
|
|
90
|
+
Icon={icon_value}
|
|
91
|
+
Comment={comment}
|
|
92
|
+
Categories={categories}
|
|
93
|
+
Terminal=false
|
|
94
|
+
""")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Validate if tool is available
|
|
98
|
+
if shutil.which("desktop-file-validate"):
|
|
99
|
+
result = subprocess.run(
|
|
100
|
+
["desktop-file-validate", str(desktop_path)],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
)
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
log.warning("desktop-file-validate: %s", result.stderr.strip() or result.stdout.strip())
|
|
106
|
+
|
|
107
|
+
# Update database if tool is available
|
|
108
|
+
if shutil.which("update-desktop-database"):
|
|
109
|
+
subprocess.run(
|
|
110
|
+
["update-desktop-database", str(apps_dir)],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return desktop_path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _remove_linux(name: str) -> bool:
|
|
118
|
+
removed = False
|
|
119
|
+
desktop_path = _linux_apps_dir() / f"{name}.desktop"
|
|
120
|
+
if desktop_path.exists():
|
|
121
|
+
desktop_path.unlink()
|
|
122
|
+
removed = True
|
|
123
|
+
icon_path = _linux_icons_dir() / f"{name}.png"
|
|
124
|
+
if icon_path.exists():
|
|
125
|
+
icon_path.unlink()
|
|
126
|
+
removed = True
|
|
127
|
+
return removed
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# macOS
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def _macos_apps_dir() -> Path:
|
|
135
|
+
return Path.home() / "Applications"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _create_macos(
|
|
139
|
+
name: str,
|
|
140
|
+
command: str,
|
|
141
|
+
*,
|
|
142
|
+
icon: str | Path | None = None,
|
|
143
|
+
comment: str = "",
|
|
144
|
+
) -> Path:
|
|
145
|
+
bundle_id = "com.wesktop." + name.lower().replace(" ", "-")
|
|
146
|
+
app_dir = _macos_apps_dir() / f"{name}.app"
|
|
147
|
+
contents = app_dir / "Contents"
|
|
148
|
+
macos_dir = contents / "MacOS"
|
|
149
|
+
resources_dir = contents / "Resources"
|
|
150
|
+
|
|
151
|
+
macos_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
resources_dir.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
|
|
154
|
+
# Info.plist
|
|
155
|
+
icon_filename = ""
|
|
156
|
+
if icon is not None:
|
|
157
|
+
icon_path = Path(icon)
|
|
158
|
+
if icon_path.is_file():
|
|
159
|
+
dest = resources_dir / "icon.icns"
|
|
160
|
+
shutil.copy2(icon_path, dest)
|
|
161
|
+
icon_filename = "icon"
|
|
162
|
+
|
|
163
|
+
plist = textwrap.dedent(f"""\
|
|
164
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
165
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
166
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
167
|
+
<plist version="1.0">
|
|
168
|
+
<dict>
|
|
169
|
+
<key>CFBundleExecutable</key>
|
|
170
|
+
<string>launcher</string>
|
|
171
|
+
<key>CFBundleName</key>
|
|
172
|
+
<string>{name}</string>
|
|
173
|
+
<key>CFBundleIdentifier</key>
|
|
174
|
+
<string>{bundle_id}</string>
|
|
175
|
+
<key>CFBundleVersion</key>
|
|
176
|
+
<string>1.0</string>
|
|
177
|
+
<key>CFBundlePackageType</key>
|
|
178
|
+
<string>APPL</string>
|
|
179
|
+
<key>CFBundleIconFile</key>
|
|
180
|
+
<string>{icon_filename}</string>
|
|
181
|
+
<key>NSHighResolutionCapable</key>
|
|
182
|
+
<true/>
|
|
183
|
+
</dict>
|
|
184
|
+
</plist>
|
|
185
|
+
""")
|
|
186
|
+
(contents / "Info.plist").write_text(plist)
|
|
187
|
+
|
|
188
|
+
# Launcher script
|
|
189
|
+
launcher = macos_dir / "launcher"
|
|
190
|
+
launcher.write_text(f"#!/bin/bash\nexec {command}\n")
|
|
191
|
+
launcher.chmod(0o755)
|
|
192
|
+
|
|
193
|
+
return app_dir
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _remove_macos(name: str) -> bool:
|
|
197
|
+
app_dir = _macos_apps_dir() / f"{name}.app"
|
|
198
|
+
if app_dir.exists():
|
|
199
|
+
shutil.rmtree(app_dir)
|
|
200
|
+
return True
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Windows
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _windows_start_menu_dir() -> Path:
|
|
209
|
+
import os
|
|
210
|
+
|
|
211
|
+
appdata = os.environ.get("APPDATA", "")
|
|
212
|
+
if not appdata:
|
|
213
|
+
raise OSError("APPDATA environment variable not set")
|
|
214
|
+
return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _create_windows(
|
|
218
|
+
name: str,
|
|
219
|
+
command: str,
|
|
220
|
+
*,
|
|
221
|
+
icon: str | Path | None = None,
|
|
222
|
+
comment: str = "",
|
|
223
|
+
) -> Path:
|
|
224
|
+
start_menu = _windows_start_menu_dir()
|
|
225
|
+
start_menu.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
lnk_path = start_menu / f"{name}.lnk"
|
|
227
|
+
|
|
228
|
+
# Split command into target and arguments
|
|
229
|
+
parts = command.split(None, 1)
|
|
230
|
+
target = parts[0]
|
|
231
|
+
arguments = parts[1] if len(parts) > 1 else ""
|
|
232
|
+
|
|
233
|
+
icon_location = str(icon) if icon else ""
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
return _create_windows_com(lnk_path, target, arguments, icon_location, comment)
|
|
237
|
+
except Exception:
|
|
238
|
+
log.debug("win32com unavailable, falling back to PowerShell")
|
|
239
|
+
return _create_windows_powershell(lnk_path, target, arguments, icon_location, comment)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _create_windows_com(
|
|
243
|
+
lnk_path: Path,
|
|
244
|
+
target: str,
|
|
245
|
+
arguments: str,
|
|
246
|
+
icon_location: str,
|
|
247
|
+
comment: str,
|
|
248
|
+
) -> Path:
|
|
249
|
+
import win32com.client # type: ignore[import-untyped]
|
|
250
|
+
|
|
251
|
+
shell = win32com.client.Dispatch("WScript.Shell")
|
|
252
|
+
shortcut = shell.CreateShortCut(str(lnk_path))
|
|
253
|
+
shortcut.TargetPath = target
|
|
254
|
+
shortcut.Arguments = arguments
|
|
255
|
+
shortcut.Description = comment
|
|
256
|
+
if icon_location:
|
|
257
|
+
shortcut.IconLocation = icon_location
|
|
258
|
+
shortcut.save()
|
|
259
|
+
return lnk_path
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _create_windows_powershell(
|
|
263
|
+
lnk_path: Path,
|
|
264
|
+
target: str,
|
|
265
|
+
arguments: str,
|
|
266
|
+
icon_location: str,
|
|
267
|
+
comment: str,
|
|
268
|
+
) -> Path:
|
|
269
|
+
# Escape single quotes for PowerShell strings
|
|
270
|
+
def ps_escape(s: str) -> str:
|
|
271
|
+
return s.replace("'", "''")
|
|
272
|
+
|
|
273
|
+
script = (
|
|
274
|
+
"$ws = New-Object -ComObject WScript.Shell; "
|
|
275
|
+
f"$s = $ws.CreateShortcut('{ps_escape(str(lnk_path))}'); "
|
|
276
|
+
f"$s.TargetPath = '{ps_escape(target)}'; "
|
|
277
|
+
f"$s.Arguments = '{ps_escape(arguments)}'; "
|
|
278
|
+
f"$s.Description = '{ps_escape(comment)}'; "
|
|
279
|
+
)
|
|
280
|
+
if icon_location:
|
|
281
|
+
script += f"$s.IconLocation = '{ps_escape(icon_location)}'; "
|
|
282
|
+
script += "$s.Save()"
|
|
283
|
+
|
|
284
|
+
subprocess.run(
|
|
285
|
+
["powershell", "-NoProfile", "-Command", script],
|
|
286
|
+
check=True,
|
|
287
|
+
capture_output=True,
|
|
288
|
+
)
|
|
289
|
+
return lnk_path
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _remove_windows(name: str) -> bool:
|
|
293
|
+
lnk_path = _windows_start_menu_dir() / f"{name}.lnk"
|
|
294
|
+
if lnk_path.exists():
|
|
295
|
+
lnk_path.unlink()
|
|
296
|
+
return True
|
|
297
|
+
return False
|
wesktop/server.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Granian ASGI server lifecycle -- PID file management, port checks, start/stop."""
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from granian import Granian
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _write_pid(pid_path: Path) -> None:
|
|
20
|
+
"""Write the current PID to disk, become process group leader, and register cleanup handlers.
|
|
21
|
+
|
|
22
|
+
Becoming a process group leader (via os.setpgid(0, 0)) lets the 'stop'
|
|
23
|
+
subcommand signal our entire process group with os.killpg, so granian
|
|
24
|
+
worker subprocesses die with us instead of being orphaned.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
os.setpgid(0, 0) # 0,0 = current process becomes its own group leader
|
|
28
|
+
except OSError:
|
|
29
|
+
# Already a group leader, or not permitted in this context (e.g. some
|
|
30
|
+
# supervised contexts) -- not fatal.
|
|
31
|
+
pass
|
|
32
|
+
pid_path.write_text(str(os.getpid()))
|
|
33
|
+
atexit.register(_remove_pid, pid_path)
|
|
34
|
+
# Clean up PID file on SIGTERM and SIGINT (Ctrl+C).
|
|
35
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
36
|
+
signal.signal(sig, lambda signum, _frame: _signal_handler(signum, _frame, pid_path))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _remove_pid(pid_path: Path) -> None:
|
|
40
|
+
"""Remove the PID file if it exists."""
|
|
41
|
+
try:
|
|
42
|
+
pid_path.unlink(missing_ok=True)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _signal_handler(signum: int, _frame: object, pid_path: Path) -> None:
|
|
48
|
+
"""Handle SIGTERM/SIGINT: forward to our process group, clean up, exit."""
|
|
49
|
+
# Restore default disposition so the forwarded signal doesn't recurse into us.
|
|
50
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
51
|
+
try:
|
|
52
|
+
os.killpg(os.getpgrp(), signum)
|
|
53
|
+
except (ProcessLookupError, PermissionError):
|
|
54
|
+
pass
|
|
55
|
+
_remove_pid(pid_path)
|
|
56
|
+
sys.exit(0)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_already_running(pid_path: Path, name: str = "server") -> None:
|
|
60
|
+
"""Exit if another instance is already running (based on the PID file)."""
|
|
61
|
+
if not pid_path.exists():
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
pid = int(pid_path.read_text().strip())
|
|
65
|
+
except (ValueError, OSError):
|
|
66
|
+
# Corrupt or unreadable PID file -- treat as stale.
|
|
67
|
+
_remove_pid(pid_path)
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
os.kill(pid, 0) # probe whether the process is alive
|
|
71
|
+
except ProcessLookupError:
|
|
72
|
+
# Process is dead; stale PID file.
|
|
73
|
+
_remove_pid(pid_path)
|
|
74
|
+
return
|
|
75
|
+
except PermissionError:
|
|
76
|
+
# Process exists but we can't signal it -- still running.
|
|
77
|
+
pass
|
|
78
|
+
log.error(
|
|
79
|
+
"%s is already running (PID %d). Stop it before starting another.",
|
|
80
|
+
name,
|
|
81
|
+
pid,
|
|
82
|
+
)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_port_available(host: str, port: int) -> int:
|
|
87
|
+
"""Check that *port* on *host* is available; exit with a diagnostic error if not.
|
|
88
|
+
|
|
89
|
+
Returns *port* on success.
|
|
90
|
+
"""
|
|
91
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
92
|
+
# SO_REUSEADDR matches Granian's behavior so our probe doesn't spuriously
|
|
93
|
+
# fail during the kernel's brief TIME_WAIT after a recent stop. Without
|
|
94
|
+
# this the bind raises EADDRINUSE while no process is actually listening.
|
|
95
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
96
|
+
try:
|
|
97
|
+
s.bind((host, port))
|
|
98
|
+
return port
|
|
99
|
+
except OSError:
|
|
100
|
+
log.error(
|
|
101
|
+
"Port %d on %s is already in use. Use a different port "
|
|
102
|
+
"or stop the process holding it.",
|
|
103
|
+
port,
|
|
104
|
+
host,
|
|
105
|
+
)
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _make_server(target: str, host: str, port: int) -> Granian:
|
|
110
|
+
"""Create a Granian instance bound to *host* on *port*.
|
|
111
|
+
|
|
112
|
+
*target* is an ASGI module path, e.g. ``"myapp:app"``.
|
|
113
|
+
"""
|
|
114
|
+
return Granian(
|
|
115
|
+
target=target,
|
|
116
|
+
address=host,
|
|
117
|
+
port=port,
|
|
118
|
+
interface="asgi",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def start_server(
|
|
123
|
+
target: str,
|
|
124
|
+
host: str = "127.0.0.1",
|
|
125
|
+
port: int = 8000,
|
|
126
|
+
pid_path: Path | None = None,
|
|
127
|
+
name: str = "server",
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Start Granian serving the ASGI app (blocks the calling thread).
|
|
130
|
+
|
|
131
|
+
If *pid_path* is given, PID file management is enabled: an existing
|
|
132
|
+
running instance is detected, the PID is written, and cleanup handlers
|
|
133
|
+
are registered.
|
|
134
|
+
"""
|
|
135
|
+
if pid_path is not None:
|
|
136
|
+
check_already_running(pid_path, name)
|
|
137
|
+
ensure_port_available(host, port)
|
|
138
|
+
if pid_path is not None:
|
|
139
|
+
_write_pid(pid_path)
|
|
140
|
+
server = _make_server(target, host, port)
|
|
141
|
+
log.info("Starting %s on http://%s:%d", name, host, port)
|
|
142
|
+
server.serve()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def start_server_in_background(
|
|
146
|
+
target: str,
|
|
147
|
+
host: str = "127.0.0.1",
|
|
148
|
+
port: int = 8000,
|
|
149
|
+
pid_path: Path | None = None,
|
|
150
|
+
name: str = "server",
|
|
151
|
+
) -> str:
|
|
152
|
+
"""Start Granian in a daemon thread and return the URL it listens on.
|
|
153
|
+
|
|
154
|
+
The daemon thread dies automatically when the main thread exits.
|
|
155
|
+
If *pid_path* is given, PID file management is enabled.
|
|
156
|
+
"""
|
|
157
|
+
if pid_path is not None:
|
|
158
|
+
check_already_running(pid_path, name)
|
|
159
|
+
ensure_port_available(host, port)
|
|
160
|
+
if pid_path is not None:
|
|
161
|
+
_write_pid(pid_path)
|
|
162
|
+
server = _make_server(target, host, port)
|
|
163
|
+
url = f"http://{host}:{port}"
|
|
164
|
+
thread = threading.Thread(target=server.serve, daemon=True)
|
|
165
|
+
thread.start()
|
|
166
|
+
log.info("%s started in background on %s", name, url)
|
|
167
|
+
return url
|
wesktop/sse.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""SSE (Server-Sent Events) broadcaster with typed event registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncGenerator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from wesktop.asgi import Request, StreamResponse
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Broadcaster:
|
|
17
|
+
"""Manages SSE client connections and broadcasts typed events.
|
|
18
|
+
|
|
19
|
+
Event types must be registered via ``register_event`` before they can be
|
|
20
|
+
broadcast. In strict mode (the default), broadcasting an unregistered
|
|
21
|
+
event raises ``ValueError``. Pass ``strict=False`` to skip validation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, buffer_size: int = 256, *, strict: bool = True):
|
|
25
|
+
self._clients: list[asyncio.Queue[str]] = []
|
|
26
|
+
self._buffer_size = buffer_size
|
|
27
|
+
self._event_types: set[str] = set()
|
|
28
|
+
self._strict = strict
|
|
29
|
+
|
|
30
|
+
# -- Event type registry --------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def register_event(self, name: str) -> None:
|
|
33
|
+
"""Declare an allowed event type."""
|
|
34
|
+
self._event_types.add(name)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def event_types(self) -> frozenset[str]:
|
|
38
|
+
"""Currently registered event types."""
|
|
39
|
+
return frozenset(self._event_types)
|
|
40
|
+
|
|
41
|
+
# -- Broadcasting ---------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _format_sse(self, event: str, data: dict[str, Any] | str) -> str:
|
|
44
|
+
"""Format a payload as an SSE wire message."""
|
|
45
|
+
payload = json.dumps(data) if isinstance(data, dict) else data
|
|
46
|
+
return f"event: {event}\ndata: {payload}\n\n"
|
|
47
|
+
|
|
48
|
+
def broadcast(self, event: str, data: dict[str, Any] | str) -> None:
|
|
49
|
+
"""Send an event to all connected clients.
|
|
50
|
+
|
|
51
|
+
Prunes clients whose queues are full (they fell behind and are
|
|
52
|
+
presumed disconnected or stuck).
|
|
53
|
+
|
|
54
|
+
Raises ``ValueError`` if *event* was not previously registered and
|
|
55
|
+
the broadcaster is in strict mode.
|
|
56
|
+
"""
|
|
57
|
+
if self._strict and event not in self._event_types:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"unregistered event type {event!r}; "
|
|
60
|
+
f"call register_event({event!r}) first "
|
|
61
|
+
f"(registered: {sorted(self._event_types)})"
|
|
62
|
+
)
|
|
63
|
+
message = self._format_sse(event, data)
|
|
64
|
+
disconnected: list[asyncio.Queue[str]] = []
|
|
65
|
+
for q in self._clients:
|
|
66
|
+
try:
|
|
67
|
+
q.put_nowait(message)
|
|
68
|
+
except asyncio.QueueFull:
|
|
69
|
+
disconnected.append(q)
|
|
70
|
+
for q in disconnected:
|
|
71
|
+
self._clients.remove(q)
|
|
72
|
+
log.debug("pruned full SSE client queue (%d remain)", len(self._clients))
|
|
73
|
+
|
|
74
|
+
# -- Client streaming -----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
async def _event_generator(self, queue: asyncio.Queue[str]) -> AsyncGenerator[str, None]:
|
|
77
|
+
"""Yield SSE messages from a per-client queue."""
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
msg = await queue.get()
|
|
81
|
+
yield msg
|
|
82
|
+
except asyncio.CancelledError:
|
|
83
|
+
return
|
|
84
|
+
finally:
|
|
85
|
+
if queue in self._clients:
|
|
86
|
+
self._clients.remove(queue)
|
|
87
|
+
|
|
88
|
+
async def stream(self, request: Request) -> StreamResponse:
|
|
89
|
+
"""Return a ``StreamResponse`` for an SSE endpoint.
|
|
90
|
+
|
|
91
|
+
Creates a per-client queue, registers it, and wraps the async
|
|
92
|
+
generator in the framework's streaming response type.
|
|
93
|
+
"""
|
|
94
|
+
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=self._buffer_size)
|
|
95
|
+
self._clients.append(queue)
|
|
96
|
+
return StreamResponse(
|
|
97
|
+
self._event_generator(queue),
|
|
98
|
+
content_type="text/event-stream",
|
|
99
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# -- Introspection --------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def client_count(self) -> int:
|
|
106
|
+
"""Number of currently connected SSE clients."""
|
|
107
|
+
return len(self._clients)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# -- Convenience --------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sse_route(broadcaster: Broadcaster):
|
|
114
|
+
"""Return an async handler suitable for ``router.add_route("GET", "/events", handler)``."""
|
|
115
|
+
|
|
116
|
+
async def handler(request: Request) -> StreamResponse:
|
|
117
|
+
return await broadcaster.stream(request)
|
|
118
|
+
|
|
119
|
+
return handler
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wesktop
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A Python framework for building web-based desktop applications
|
|
5
|
+
Author-email: smm-h <smmh72@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: asgi,desktop,granian,gui,pywebview,rlsbl,web
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: granian>=2.7.4
|
|
11
|
+
Requires-Dist: msgspec>=0.21.1
|
|
12
|
+
Requires-Dist: pywebview>=6.2.1
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
wesktop/__init__.py,sha256=LsxctXitK3J83UefEcTA_Y3HdbslkKo79UboXck2CnE,1707
|
|
2
|
+
wesktop/asgi.py,sha256=QqfVeR04Lk9_4Og0isBWO1Z027EBo3fZ3X71IDS0OVo,14453
|
|
3
|
+
wesktop/desktop.py,sha256=NaviiqbLXlup07KBJwNTDoCcXYJcDaWQWyPlcVRErIk,1044
|
|
4
|
+
wesktop/entries.py,sha256=CRMLQgIXRDckv3la_BzQtwW_4Iv5BihSic09kYQZbn8,8521
|
|
5
|
+
wesktop/server.py,sha256=nUPt-l8_ngld0Kq_JBUFRs2Oyc-oo3w9lrmsxCI08VA,5314
|
|
6
|
+
wesktop/sse.py,sha256=1_bOj-_k0o4BTGMOeQ1qTZgXVcXIgxMYZ6KSeyr60hk,4290
|
|
7
|
+
wesktop-0.1.1.dist-info/METADATA,sha256=EEf1RTk_mM6Z-9W76g_Npsl02_jg7yUL0iLjgn6gRgg,380
|
|
8
|
+
wesktop-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
wesktop-0.1.1.dist-info/licenses/LICENSE,sha256=6ViJKrwd1dmR_KbVKOaV0zXyV3PTc9Lgvl9KlQfY-NU,1062
|
|
10
|
+
wesktop-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 smm-h
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|