pi-sdk-web 0.3.12 → 0.4.0
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.
- package/dist/pi-bin/pii +208 -0
- package/dist/pi-bin/pii-cli.js +49 -0
- package/dist/pi-bin/server/rpc_client.py +361 -0
- package/dist/pi-bin/server/server.py +451 -0
- package/dist/pi-bin/server/websocket.py +204 -0
- package/dist/server.js +10 -0
- package/dist/static/app.js +31 -8
- package/dist/static/index.html +1 -1
- package/dist/static/style.css +2 -2
- package/dist/ui-context.js +18 -14
- package/package.json +4 -3
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pi-web server
|
|
3
|
+
|
|
4
|
+
Bridges a browser (via WebSocket) to a Pi RPC subprocess.
|
|
5
|
+
|
|
6
|
+
Run standalone:
|
|
7
|
+
python3 server.py <session_id> <cwd> [--port <port>]
|
|
8
|
+
|
|
9
|
+
The pii script will eventually invoke this server after resolving the session
|
|
10
|
+
name to a session id + cwd.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import socket
|
|
20
|
+
import threading
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
26
|
+
|
|
27
|
+
from rpc_client import RpcClient, RpcCommands
|
|
28
|
+
from websocket import WebSocketConnection, WebSocketServer
|
|
29
|
+
|
|
30
|
+
DEFAULT_PORT = 4080
|
|
31
|
+
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Peripheral:
|
|
35
|
+
"""Holds the Pi RPC client and manages WebSocket clients."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, session_id: str, cwd: str):
|
|
38
|
+
self.session_id = session_id
|
|
39
|
+
self.cwd = cwd
|
|
40
|
+
self.client = RpcClient(session_id, cwd, on_event=self._on_rpc_event, on_exit=self._on_rpc_exit)
|
|
41
|
+
self.commands = RpcCommands(self.client)
|
|
42
|
+
self._clients: set[WebSocketConnection] = set()
|
|
43
|
+
self._clients_lock = threading.Lock()
|
|
44
|
+
self._exit_error: Optional[str] = None
|
|
45
|
+
self._version = self._get_pi_version()
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Lifecycle
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def start(self) -> None:
|
|
52
|
+
self.client.start()
|
|
53
|
+
self.client.wait_ready()
|
|
54
|
+
|
|
55
|
+
def stop(self) -> None:
|
|
56
|
+
self.client.stop()
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# WebSocket client management
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def add_client(self, conn: WebSocketConnection) -> None:
|
|
63
|
+
with self._clients_lock:
|
|
64
|
+
self._clients.add(conn)
|
|
65
|
+
# Send initial state to the new client
|
|
66
|
+
self._send_initial_state(conn)
|
|
67
|
+
|
|
68
|
+
def remove_client(self, conn: WebSocketConnection) -> None:
|
|
69
|
+
with self._clients_lock:
|
|
70
|
+
self._clients.discard(conn)
|
|
71
|
+
|
|
72
|
+
def _broadcast(self, obj: object) -> None:
|
|
73
|
+
message = json.dumps(obj, ensure_ascii=False)
|
|
74
|
+
dead: list[WebSocketConnection] = []
|
|
75
|
+
with self._clients_lock:
|
|
76
|
+
for conn in list(self._clients):
|
|
77
|
+
try:
|
|
78
|
+
conn.send_text(message)
|
|
79
|
+
except Exception:
|
|
80
|
+
dead.append(conn)
|
|
81
|
+
for conn in dead:
|
|
82
|
+
self.remove_client(conn)
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# RPC event -> broadcast
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# Events after which footer stats should be refreshed (TUI does this too)
|
|
89
|
+
_STATS_REFRESH_EVENTS = {
|
|
90
|
+
"agent_settled",
|
|
91
|
+
"turn_end",
|
|
92
|
+
"tool_execution_end",
|
|
93
|
+
"compaction_end",
|
|
94
|
+
"entry_appended",
|
|
95
|
+
"session_info_changed",
|
|
96
|
+
"thinking_level_changed",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def _on_rpc_event(self, data: dict) -> None:
|
|
100
|
+
self._broadcast(data)
|
|
101
|
+
if data.get("type") in self._STATS_REFRESH_EVENTS:
|
|
102
|
+
self._schedule_stats_refresh()
|
|
103
|
+
|
|
104
|
+
def _schedule_stats_refresh(self) -> None:
|
|
105
|
+
"""Fetch and broadcast latest session stats in a background thread.
|
|
106
|
+
|
|
107
|
+
This must not run in the RPC read-loop thread, because get_session_stats
|
|
108
|
+
is a synchronous command that would deadlock if issued from there.
|
|
109
|
+
"""
|
|
110
|
+
def run() -> None:
|
|
111
|
+
try:
|
|
112
|
+
stats = self.commands.get_session_stats().get("data", {})
|
|
113
|
+
self._broadcast({"type": "stats", "data": stats})
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
threading.Thread(target=run, daemon=True).start()
|
|
117
|
+
|
|
118
|
+
def _on_rpc_exit(self, code: int | None) -> None:
|
|
119
|
+
logging.warning("Pi RPC process exited with code=%s stderr=%s", code, self.client._stderr_lines[-5:])
|
|
120
|
+
self._exit_error = f"Pi RPC process exited (code={code})"
|
|
121
|
+
self._broadcast({"type": "pi_error", "error": self._exit_error})
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Initial state for new clients
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _send_initial_state(self, conn: WebSocketConnection) -> None:
|
|
128
|
+
"""Send history + state to a newly connected browser client."""
|
|
129
|
+
try:
|
|
130
|
+
conn.send_json({"type": "state", "data": self._build_state()})
|
|
131
|
+
self._send_history(conn)
|
|
132
|
+
except RuntimeError as e:
|
|
133
|
+
conn.send_json({"type": "error", "error": str(e)})
|
|
134
|
+
|
|
135
|
+
def _build_state(self) -> dict:
|
|
136
|
+
"""Build the full session state dict (model/thinking/stats/version/...)."""
|
|
137
|
+
state = self.commands.get_state()
|
|
138
|
+
data = state.get("data", {})
|
|
139
|
+
data["cwd"] = self._format_cwd_for_footer(self.cwd)
|
|
140
|
+
data["gitBranch"] = self._get_git_branch()
|
|
141
|
+
data["sessionStats"] = self.commands.get_session_stats().get("data", {})
|
|
142
|
+
data["version"] = self._version
|
|
143
|
+
data["commands"] = self.commands.get_commands()
|
|
144
|
+
return data
|
|
145
|
+
|
|
146
|
+
def _broadcast_state(self) -> None:
|
|
147
|
+
"""Broadcast the full state after mutations (model/thinking/session name)."""
|
|
148
|
+
try:
|
|
149
|
+
self._broadcast({"type": "state", "data": self._build_state()})
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def _get_pi_version(self) -> str:
|
|
154
|
+
"""Get the installed pi version once."""
|
|
155
|
+
try:
|
|
156
|
+
import subprocess
|
|
157
|
+
|
|
158
|
+
result = subprocess.run(
|
|
159
|
+
["pi", "--version"],
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
timeout=5,
|
|
163
|
+
)
|
|
164
|
+
if result.returncode == 0:
|
|
165
|
+
return result.stdout.strip()
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
def _format_cwd_for_footer(self, cwd: str) -> str:
|
|
171
|
+
"""Show home directory as ~ like the TUI footer."""
|
|
172
|
+
home = os.path.expanduser("~")
|
|
173
|
+
try:
|
|
174
|
+
rel = os.path.relpath(cwd, home)
|
|
175
|
+
if rel == ".":
|
|
176
|
+
return "~"
|
|
177
|
+
if not rel.startswith(".."):
|
|
178
|
+
return f"~/{rel}"
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
return cwd
|
|
182
|
+
|
|
183
|
+
def _get_git_branch(self) -> str | None:
|
|
184
|
+
"""Return the current git branch of the session cwd, if any."""
|
|
185
|
+
try:
|
|
186
|
+
import subprocess
|
|
187
|
+
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
["git", "branch", "--show-current"],
|
|
190
|
+
cwd=self.cwd,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
timeout=3,
|
|
194
|
+
)
|
|
195
|
+
if result.returncode == 0:
|
|
196
|
+
branch = result.stdout.strip()
|
|
197
|
+
return branch or None
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def _send_history(self, conn: WebSocketConnection) -> None:
|
|
203
|
+
try:
|
|
204
|
+
entries = self.commands.get_entries()
|
|
205
|
+
conn.send_json({"type": "history", "data": entries})
|
|
206
|
+
except RuntimeError as e:
|
|
207
|
+
conn.send_json({"type": "error", "error": str(e)})
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Handle incoming WebSocket messages (browser -> Pi)
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def handle_client_message(self, conn: WebSocketConnection, raw: str) -> None:
|
|
214
|
+
logging.debug("WS message: %s", raw[:200])
|
|
215
|
+
try:
|
|
216
|
+
data = json.loads(raw)
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
conn.send_json({"type": "error", "error": "Invalid JSON"})
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
cmd_type = data.get("type")
|
|
222
|
+
if not cmd_type:
|
|
223
|
+
conn.send_json({"type": "error", "error": "Missing 'type'"})
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
self._dispatch_command(cmd_type, data)
|
|
228
|
+
except RuntimeError as e:
|
|
229
|
+
conn.send_json({"type": "error", "error": str(e)})
|
|
230
|
+
|
|
231
|
+
def _dispatch_command(self, cmd_type: str, data: dict) -> None:
|
|
232
|
+
if cmd_type == "prompt":
|
|
233
|
+
message = data.get("message", "")
|
|
234
|
+
if not message:
|
|
235
|
+
raise RuntimeError("Missing 'message'")
|
|
236
|
+
self.commands.prompt(message)
|
|
237
|
+
elif cmd_type == "abort":
|
|
238
|
+
self.commands.abort()
|
|
239
|
+
elif cmd_type == "get_state":
|
|
240
|
+
pass # broadcast via client init; ignore
|
|
241
|
+
elif cmd_type == "get_stats":
|
|
242
|
+
stats = self.commands.get_session_stats().get("data", {})
|
|
243
|
+
self._broadcast({"type": "stats", "data": stats})
|
|
244
|
+
elif cmd_type == "bash":
|
|
245
|
+
command = data.get("command", "")
|
|
246
|
+
if not command:
|
|
247
|
+
raise RuntimeError("Missing 'command'")
|
|
248
|
+
result = self.commands.bash(command)
|
|
249
|
+
self._broadcast({"type": "bash_result", "command": command, "data": result})
|
|
250
|
+
elif cmd_type == "cycle_model":
|
|
251
|
+
self.commands.cycle_model()
|
|
252
|
+
self._broadcast_state()
|
|
253
|
+
elif cmd_type == "set_model":
|
|
254
|
+
provider = data.get("provider")
|
|
255
|
+
model_id = data.get("modelId")
|
|
256
|
+
if not provider or not model_id:
|
|
257
|
+
raise RuntimeError("Missing 'provider' or 'modelId'")
|
|
258
|
+
self.commands.set_model(provider, model_id)
|
|
259
|
+
self._broadcast_state()
|
|
260
|
+
elif cmd_type == "get_available_models":
|
|
261
|
+
models = self.commands.get_available_models()
|
|
262
|
+
self._broadcast({"type": "models", "data": models})
|
|
263
|
+
elif cmd_type == "cycle_thinking_level":
|
|
264
|
+
self.commands.cycle_thinking_level()
|
|
265
|
+
self._broadcast_state()
|
|
266
|
+
elif cmd_type == "set_thinking_level":
|
|
267
|
+
level = data.get("level")
|
|
268
|
+
if not level:
|
|
269
|
+
raise RuntimeError("Missing 'level'")
|
|
270
|
+
self.commands.set_thinking_level(level)
|
|
271
|
+
self._broadcast_state()
|
|
272
|
+
elif cmd_type == "get_available_thinking_levels":
|
|
273
|
+
levels = self.commands.get_available_thinking_levels()
|
|
274
|
+
self._broadcast({"type": "thinking_levels", "data": levels})
|
|
275
|
+
elif cmd_type == "compact":
|
|
276
|
+
custom_instructions = data.get("customInstructions")
|
|
277
|
+
self.commands.compact(custom_instructions)
|
|
278
|
+
elif cmd_type == "set_session_name":
|
|
279
|
+
name = data.get("name", "")
|
|
280
|
+
if not name:
|
|
281
|
+
raise RuntimeError("Missing 'name'")
|
|
282
|
+
self.commands.set_session_name(name)
|
|
283
|
+
self._broadcast_state()
|
|
284
|
+
elif cmd_type == "extension_ui_response":
|
|
285
|
+
response_id = data.get("id")
|
|
286
|
+
if not response_id:
|
|
287
|
+
raise RuntimeError("Missing 'id'")
|
|
288
|
+
# Pass through all remaining fields (value/confirmed/cancelled)
|
|
289
|
+
extra = {k: v for k, v in data.items() if k not in ("type", "id")}
|
|
290
|
+
self.commands.extension_ui_response(response_id, **extra)
|
|
291
|
+
else:
|
|
292
|
+
raise RuntimeError(f"Unsupported command: {cmd_type}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class ServerContext:
|
|
296
|
+
"""Shared state accessible from HTTP request handlers."""
|
|
297
|
+
|
|
298
|
+
def __init__(self, peripheral: Peripheral):
|
|
299
|
+
self.peripheral = peripheral
|
|
300
|
+
self.ws = WebSocketServer(self._on_ws_connection)
|
|
301
|
+
|
|
302
|
+
def _on_ws_connection(self, conn: WebSocketConnection, request_headers: dict) -> None:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class HTTPHandler(BaseHTTPRequestHandler):
|
|
307
|
+
"""Serves static files and upgrades WebSocket connections."""
|
|
308
|
+
|
|
309
|
+
context: ServerContext = None # type: ignore
|
|
310
|
+
|
|
311
|
+
def log_message(self, format: str, *args) -> None:
|
|
312
|
+
# Quiet by default
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
def finish(self) -> None:
|
|
316
|
+
# For WebSocket upgrades we hand the raw socket to a WebSocketConnection
|
|
317
|
+
# and must NOT let BaseHTTPRequestHandler close it.
|
|
318
|
+
if getattr(self, "_ws_upgraded", False):
|
|
319
|
+
return
|
|
320
|
+
super().finish()
|
|
321
|
+
|
|
322
|
+
def _send_file(self, path: Path, content_type: str) -> None:
|
|
323
|
+
try:
|
|
324
|
+
data = path.read_bytes()
|
|
325
|
+
self.send_response(200)
|
|
326
|
+
self.send_header("Content-Type", content_type)
|
|
327
|
+
self.send_header("Content-Length", str(len(data)))
|
|
328
|
+
self.end_headers()
|
|
329
|
+
self.wfile.write(data)
|
|
330
|
+
except OSError:
|
|
331
|
+
self.send_error(404)
|
|
332
|
+
|
|
333
|
+
def do_GET(self) -> None: # noqa: N802
|
|
334
|
+
# WebSocket upgrade
|
|
335
|
+
if self.headers.get("Upgrade", "").lower() == "websocket":
|
|
336
|
+
self._handle_ws_upgrade()
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
path = self.path.split("?")[0]
|
|
340
|
+
if path == "/":
|
|
341
|
+
path = "/index.html"
|
|
342
|
+
|
|
343
|
+
# Resolve static path safely
|
|
344
|
+
rel = path.lstrip("/")
|
|
345
|
+
file_path = (STATIC_DIR / rel).resolve()
|
|
346
|
+
if not str(file_path).startswith(str(STATIC_DIR.resolve())):
|
|
347
|
+
self.send_error(403)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
if not file_path.exists():
|
|
351
|
+
self.send_error(404)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
ext = file_path.suffix.lower()
|
|
355
|
+
content_type = {
|
|
356
|
+
".html": "text/html; charset=utf-8",
|
|
357
|
+
".css": "text/css; charset=utf-8",
|
|
358
|
+
".js": "application/javascript; charset=utf-8",
|
|
359
|
+
".json": "application/json; charset=utf-8",
|
|
360
|
+
".svg": "image/svg+xml",
|
|
361
|
+
".png": "image/png",
|
|
362
|
+
".jpg": "image/jpeg",
|
|
363
|
+
".ico": "image/x-icon",
|
|
364
|
+
}.get(ext, "application/octet-stream")
|
|
365
|
+
self._send_file(file_path, content_type)
|
|
366
|
+
|
|
367
|
+
def _handle_ws_upgrade(self) -> None:
|
|
368
|
+
ctx = self.server.ws_context
|
|
369
|
+
key = self.headers.get("Sec-WebSocket-Key")
|
|
370
|
+
|
|
371
|
+
import base64
|
|
372
|
+
import hashlib
|
|
373
|
+
|
|
374
|
+
accept = base64.b64encode(
|
|
375
|
+
hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()
|
|
376
|
+
).decode()
|
|
377
|
+
|
|
378
|
+
self.send_response(101)
|
|
379
|
+
self.send_header("Upgrade", "websocket")
|
|
380
|
+
self.send_header("Connection", "Upgrade")
|
|
381
|
+
self.send_header("Sec-WebSocket-Accept", accept)
|
|
382
|
+
self.end_headers()
|
|
383
|
+
|
|
384
|
+
# Take over the socket. We detach the underlying fd so that when the
|
|
385
|
+
# HTTP handler/server finishes it cannot close the live WebSocket.
|
|
386
|
+
fd = self.connection.detach()
|
|
387
|
+
sock = socket.socket(fileno=fd)
|
|
388
|
+
self._ws_upgraded = True
|
|
389
|
+
self.close_connection = True
|
|
390
|
+
|
|
391
|
+
peripheral = ctx.peripheral
|
|
392
|
+
|
|
393
|
+
def on_message(msg: str) -> None:
|
|
394
|
+
peripheral.handle_client_message(conn, msg)
|
|
395
|
+
|
|
396
|
+
def on_close() -> None:
|
|
397
|
+
peripheral.remove_client(conn)
|
|
398
|
+
|
|
399
|
+
conn = WebSocketConnection(sock, on_message, on_close)
|
|
400
|
+
ctx.clients.append(conn)
|
|
401
|
+
peripheral.add_client(conn)
|
|
402
|
+
conn.start_reading()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class PiWebHTTPServer(ThreadingHTTPServer):
|
|
406
|
+
daemon_threads = True
|
|
407
|
+
|
|
408
|
+
def __init__(self, addr: tuple, handler_cls, peripheral: Peripheral):
|
|
409
|
+
super().__init__(addr, handler_cls)
|
|
410
|
+
self.ws_context = ServerContext(peripheral)
|
|
411
|
+
self.ws_context.clients = []
|
|
412
|
+
handler_cls.context = self.ws_context
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def main() -> None:
|
|
416
|
+
parser = argparse.ArgumentParser(description="pi-web server")
|
|
417
|
+
parser.add_argument("session_id", help="Pi session id")
|
|
418
|
+
parser.add_argument("cwd", help="Session working directory")
|
|
419
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind (default: 4080)")
|
|
420
|
+
args = parser.parse_args()
|
|
421
|
+
|
|
422
|
+
if not (1 <= args.port <= 65535):
|
|
423
|
+
print(f"Invalid port: {args.port}", file=os.sys.stderr)
|
|
424
|
+
os.sys.exit(1)
|
|
425
|
+
|
|
426
|
+
if not os.path.isdir(args.cwd):
|
|
427
|
+
print(f"Working directory not found: {args.cwd}", file=os.sys.stderr)
|
|
428
|
+
os.sys.exit(1)
|
|
429
|
+
|
|
430
|
+
peripheral = Peripheral(args.session_id, args.cwd)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
httpd = PiWebHTTPServer(("127.0.0.1", args.port), HTTPHandler, peripheral)
|
|
434
|
+
except OSError as e:
|
|
435
|
+
print(f"Failed to bind 127.0.0.1:{args.port}: {e}", file=os.sys.stderr)
|
|
436
|
+
os.sys.exit(1)
|
|
437
|
+
|
|
438
|
+
peripheral.start()
|
|
439
|
+
print(f"server at http://127.0.0.1:{args.port}/", flush=True)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
httpd.serve_forever()
|
|
443
|
+
except KeyboardInterrupt:
|
|
444
|
+
pass
|
|
445
|
+
finally:
|
|
446
|
+
peripheral.stop()
|
|
447
|
+
httpd.server_close()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple WebSocket server implementation using only the Python standard library.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- HTTP handshake (RFC 6455)
|
|
6
|
+
- Text and binary frames
|
|
7
|
+
- Ping/Pong keepalive
|
|
8
|
+
- Fragmented message reassembly
|
|
9
|
+
- Client close handling
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import socket
|
|
17
|
+
import struct
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
22
|
+
|
|
23
|
+
OP_CONT = 0x0
|
|
24
|
+
OP_TEXT = 0x1
|
|
25
|
+
OP_BINARY = 0x2
|
|
26
|
+
OP_CLOSE = 0x8
|
|
27
|
+
OP_PING = 0x9
|
|
28
|
+
OP_PONG = 0xA
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WebSocketError(Exception):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebSocketConnection:
|
|
36
|
+
"""A single WebSocket client connection."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
sock: socket.socket,
|
|
41
|
+
on_message: Callable[[str], None],
|
|
42
|
+
on_close: Callable[[], None],
|
|
43
|
+
) -> None:
|
|
44
|
+
self.sock = sock
|
|
45
|
+
self.on_message = on_message
|
|
46
|
+
self.on_close = on_close
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._closed = False
|
|
49
|
+
self._reader_thread: Optional[threading.Thread] = None
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Reading
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def start_reading(self) -> None:
|
|
56
|
+
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
57
|
+
self._reader_thread.start()
|
|
58
|
+
|
|
59
|
+
def _read_exact(self, n: int) -> bytes:
|
|
60
|
+
buf = b""
|
|
61
|
+
while len(buf) < n:
|
|
62
|
+
chunk = self.sock.recv(n - len(buf))
|
|
63
|
+
if not chunk:
|
|
64
|
+
raise WebSocketError("connection closed")
|
|
65
|
+
buf += chunk
|
|
66
|
+
return buf
|
|
67
|
+
|
|
68
|
+
def _read_loop(self) -> None:
|
|
69
|
+
try:
|
|
70
|
+
while not self._closed:
|
|
71
|
+
self._read_frame()
|
|
72
|
+
except (WebSocketError, socket.error, OSError):
|
|
73
|
+
pass
|
|
74
|
+
finally:
|
|
75
|
+
self.close()
|
|
76
|
+
if self.on_close:
|
|
77
|
+
try:
|
|
78
|
+
self.on_close()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def _read_frame(self) -> None:
|
|
83
|
+
header = self._read_exact(2)
|
|
84
|
+
fin = (header[0] >> 7) & 0x01
|
|
85
|
+
opcode = header[0] & 0x0F
|
|
86
|
+
masked = (header[1] >> 7) & 0x01
|
|
87
|
+
length = header[1] & 0x7F
|
|
88
|
+
|
|
89
|
+
if length == 126:
|
|
90
|
+
length = struct.unpack(">H", self._read_exact(2))[0]
|
|
91
|
+
elif length == 127:
|
|
92
|
+
length = struct.unpack(">Q", self._read_exact(8))[0]
|
|
93
|
+
|
|
94
|
+
mask_key = self._read_exact(4) if masked else None
|
|
95
|
+
payload = self._read_exact(length)
|
|
96
|
+
if mask_key:
|
|
97
|
+
payload = bytes(
|
|
98
|
+
b ^ mask_key[i % 4] for i, b in enumerate(payload)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if opcode == OP_TEXT:
|
|
102
|
+
self.on_message(payload.decode("utf-8", errors="replace"))
|
|
103
|
+
elif opcode == OP_BINARY:
|
|
104
|
+
self.on_message(payload.decode("utf-8", errors="replace"))
|
|
105
|
+
elif opcode == OP_PING:
|
|
106
|
+
self._send_frame(OP_PONG, payload)
|
|
107
|
+
elif opcode == OP_PONG:
|
|
108
|
+
pass
|
|
109
|
+
elif opcode == OP_CLOSE:
|
|
110
|
+
self.close()
|
|
111
|
+
elif opcode == OP_CONT:
|
|
112
|
+
# For simplicity, treat continuation frames as pass-through text.
|
|
113
|
+
self.on_message(payload.decode("utf-8", errors="replace"))
|
|
114
|
+
else:
|
|
115
|
+
raise WebSocketError(f"unsupported opcode {opcode}")
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Writing
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _send_frame(self, opcode: int, payload: bytes) -> None:
|
|
122
|
+
if self._closed:
|
|
123
|
+
return
|
|
124
|
+
with self._lock:
|
|
125
|
+
header = bytearray()
|
|
126
|
+
header.append(0x80 | opcode)
|
|
127
|
+
length = len(payload)
|
|
128
|
+
if length < 126:
|
|
129
|
+
header.append(length)
|
|
130
|
+
elif length < 65536:
|
|
131
|
+
header.append(126)
|
|
132
|
+
header.extend(struct.pack(">H", length))
|
|
133
|
+
else:
|
|
134
|
+
header.append(127)
|
|
135
|
+
header.extend(struct.pack(">Q", length))
|
|
136
|
+
try:
|
|
137
|
+
self.sock.sendall(bytes(header) + payload)
|
|
138
|
+
except (socket.error, OSError):
|
|
139
|
+
self.close()
|
|
140
|
+
|
|
141
|
+
def send_text(self, message: str) -> None:
|
|
142
|
+
self._send_frame(OP_TEXT, message.encode("utf-8"))
|
|
143
|
+
|
|
144
|
+
def send_json(self, obj: object) -> None:
|
|
145
|
+
import json
|
|
146
|
+
|
|
147
|
+
self.send_text(json.dumps(obj, ensure_ascii=False))
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Close
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def close(self) -> None:
|
|
154
|
+
if self._closed:
|
|
155
|
+
return
|
|
156
|
+
self._closed = True
|
|
157
|
+
try:
|
|
158
|
+
self.sock.close()
|
|
159
|
+
except OSError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class WebSocketServer:
|
|
164
|
+
"""Minimal WebSocket server that handles one handshake per incoming connection."""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
handle_connection: Callable[[WebSocketConnection, dict], None],
|
|
169
|
+
) -> None:
|
|
170
|
+
self.handle_connection = handle_connection
|
|
171
|
+
|
|
172
|
+
def upgrade(self, sock: socket.socket, request: bytes) -> Optional[WebSocketConnection]:
|
|
173
|
+
"""Perform the WebSocket handshake and return a connection, or None on failure."""
|
|
174
|
+
try:
|
|
175
|
+
key = self._extract_key(request)
|
|
176
|
+
if not key:
|
|
177
|
+
sock.close()
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
accept = base64.b64encode(
|
|
181
|
+
hashlib.sha1((key + MAGIC).encode()).digest()
|
|
182
|
+
).decode()
|
|
183
|
+
|
|
184
|
+
response = (
|
|
185
|
+
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
186
|
+
"Upgrade: websocket\r\n"
|
|
187
|
+
"Connection: Upgrade\r\n"
|
|
188
|
+
f"Sec-WebSocket-Accept: {accept}\r\n"
|
|
189
|
+
"\r\n"
|
|
190
|
+
)
|
|
191
|
+
sock.sendall(response.encode())
|
|
192
|
+
except (socket.error, OSError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
conn = WebSocketConnection(sock, lambda msg: None, lambda: None)
|
|
196
|
+
return conn
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _extract_key(request: bytes) -> Optional[str]:
|
|
200
|
+
text = request.decode("utf-8", errors="replace")
|
|
201
|
+
for line in text.split("\r\n"):
|
|
202
|
+
if line.lower().startswith("sec-websocket-key:"):
|
|
203
|
+
return line.split(":", 1)[1].strip()
|
|
204
|
+
return None
|
package/dist/server.js
CHANGED
|
@@ -418,6 +418,16 @@ export class PiWebServer {
|
|
|
418
418
|
// Used by the sidebar Tools refresh button
|
|
419
419
|
this.broadcastState();
|
|
420
420
|
break;
|
|
421
|
+
case "set_theme": {
|
|
422
|
+
// Browser theme switch (dark/light): swap the extension ANSI theme.
|
|
423
|
+
// CSS switches instantly on the client; the server theme only
|
|
424
|
+
// affects extension setStatus/setWidget generated after this point
|
|
425
|
+
// (same as TUI - status text is fixed at setStatus time).
|
|
426
|
+
const name = data.name === "light" ? "light" : "dark";
|
|
427
|
+
this.uiContext.setWebTheme(name);
|
|
428
|
+
this.broadcast({ type: "theme_set", data: { name } });
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
421
431
|
case "bash": {
|
|
422
432
|
const command = typeof data.command === "string" ? data.command : "";
|
|
423
433
|
if (!command)
|