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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.