py2max 0.2.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.
- py2max/__init__.py +67 -0
- py2max/__main__.py +6 -0
- py2max/cli.py +1251 -0
- py2max/core/__init__.py +39 -0
- py2max/core/abstract.py +146 -0
- py2max/core/box.py +231 -0
- py2max/core/common.py +19 -0
- py2max/core/patcher.py +1658 -0
- py2max/core/patchline.py +68 -0
- py2max/exceptions.py +385 -0
- py2max/export/__init__.py +20 -0
- py2max/export/converters.py +345 -0
- py2max/export/svg.py +393 -0
- py2max/layout/__init__.py +26 -0
- py2max/layout/base.py +463 -0
- py2max/layout/flow.py +405 -0
- py2max/layout/grid.py +374 -0
- py2max/layout/matrix.py +628 -0
- py2max/log.py +338 -0
- py2max/maxref/__init__.py +78 -0
- py2max/maxref/category.py +163 -0
- py2max/maxref/db.py +1082 -0
- py2max/maxref/legacy.py +324 -0
- py2max/maxref/parser.py +703 -0
- py2max/py.typed +0 -0
- py2max/server/__init__.py +54 -0
- py2max/server/client.py +295 -0
- py2max/server/inline.py +312 -0
- py2max/server/repl.py +561 -0
- py2max/server/rpc.py +240 -0
- py2max/server/websocket.py +997 -0
- py2max/static/cola.min.js +4 -0
- py2max/static/d3.v7.min.js +2 -0
- py2max/static/dagre-bundle.js +328 -0
- py2max/static/elk.bundled.js +6663 -0
- py2max/static/index.html +168 -0
- py2max/static/interactive.html +589 -0
- py2max/static/interactive.js +2111 -0
- py2max/static/live-preview.js +324 -0
- py2max/static/svg.min.js +13 -0
- py2max/static/svg.min.js.map +1 -0
- py2max/transformers.py +168 -0
- py2max/utils.py +83 -0
- py2max-0.2.1.dist-info/METADATA +390 -0
- py2max-0.2.1.dist-info/RECORD +48 -0
- py2max-0.2.1.dist-info/WHEEL +4 -0
- py2max-0.2.1.dist-info/entry_points.txt +3 -0
- py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
"""Interactive WebSocket server for py2max with bidirectional communication.
|
|
2
|
+
|
|
3
|
+
This module provides a WebSocket-based server for real-time interactive
|
|
4
|
+
editing of Max patches in the browser.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Bidirectional WebSocket communication
|
|
8
|
+
- Real-time updates (Python → Browser)
|
|
9
|
+
- Interactive editing (Browser → Python)
|
|
10
|
+
- Drag-and-drop object repositioning
|
|
11
|
+
- Connection drawing
|
|
12
|
+
- Object creation from browser
|
|
13
|
+
- Token-based authentication for WebSocket connections
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> from py2max import Patcher
|
|
17
|
+
>>> p = Patcher('demo.maxpat')
|
|
18
|
+
>>> await p.serve_interactive() # Opens browser with interactive editor
|
|
19
|
+
>>> # Edit in browser - changes sync back to Python!
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import http.server
|
|
26
|
+
import json
|
|
27
|
+
import secrets
|
|
28
|
+
import threading
|
|
29
|
+
import webbrowser
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING, Any, Optional, Set
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
import websockets
|
|
35
|
+
from websockets.asyncio.server import Server, ServerConnection, serve
|
|
36
|
+
except ImportError:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"websockets package required for interactive server. "
|
|
39
|
+
"Install with: pip install websockets"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from ..core import Patcher
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Message validation schemas
|
|
47
|
+
# Each schema defines required fields and their expected types
|
|
48
|
+
# Values can be a single type or a tuple of types for isinstance() checks
|
|
49
|
+
MESSAGE_SCHEMAS: dict[str, dict[str, type | tuple[type, ...]]] = {
|
|
50
|
+
"update_position": {"box_id": str, "x": (int, float), "y": (int, float)},
|
|
51
|
+
"create_object": {"text": str}, # x, y optional
|
|
52
|
+
"create_connection": {
|
|
53
|
+
"src_id": str,
|
|
54
|
+
"dst_id": str,
|
|
55
|
+
}, # src_outlet, dst_inlet optional
|
|
56
|
+
"delete_object": {"box_id": str},
|
|
57
|
+
"delete_connection": {"src_id": str, "dst_id": str},
|
|
58
|
+
"save": {}, # No required fields
|
|
59
|
+
"save_as": {"filepath": str},
|
|
60
|
+
"navigate_to_subpatcher": {"box_id": str},
|
|
61
|
+
"navigate_to_parent": {},
|
|
62
|
+
"navigate_to_root": {},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Maximum lengths for string fields to prevent abuse
|
|
66
|
+
MAX_STRING_LENGTHS: dict[str, int] = {
|
|
67
|
+
"box_id": 256,
|
|
68
|
+
"src_id": 256,
|
|
69
|
+
"dst_id": 256,
|
|
70
|
+
"text": 10000, # Max object text length
|
|
71
|
+
"filepath": 4096, # Max filepath length
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Coordinate bounds
|
|
75
|
+
COORDINATE_BOUNDS = {"min": -100000, "max": 100000}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ValidationError(Exception):
|
|
79
|
+
"""Raised when message validation fails."""
|
|
80
|
+
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_message(data: dict) -> tuple[bool, Optional[str]]:
|
|
85
|
+
"""Validate an incoming WebSocket message.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
data: The parsed message data
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Tuple of (is_valid, error_message)
|
|
92
|
+
"""
|
|
93
|
+
message_type = data.get("type")
|
|
94
|
+
|
|
95
|
+
# Check message type exists and is known
|
|
96
|
+
if not message_type:
|
|
97
|
+
return False, "Missing 'type' field"
|
|
98
|
+
|
|
99
|
+
if not isinstance(message_type, str):
|
|
100
|
+
return False, "'type' must be a string"
|
|
101
|
+
|
|
102
|
+
if message_type not in MESSAGE_SCHEMAS:
|
|
103
|
+
return False, f"Unknown message type: {message_type}"
|
|
104
|
+
|
|
105
|
+
schema = MESSAGE_SCHEMAS[message_type]
|
|
106
|
+
|
|
107
|
+
# Check required fields
|
|
108
|
+
for field, expected_type in schema.items():
|
|
109
|
+
if field not in data:
|
|
110
|
+
return False, f"Missing required field: {field}"
|
|
111
|
+
|
|
112
|
+
value = data[field]
|
|
113
|
+
|
|
114
|
+
# Check type (expected_type can be a single type or tuple of types)
|
|
115
|
+
if isinstance(expected_type, tuple):
|
|
116
|
+
if not isinstance(value, expected_type):
|
|
117
|
+
type_names = " or ".join(t.__name__ for t in expected_type)
|
|
118
|
+
return False, f"Field '{field}' must be {type_names}"
|
|
119
|
+
else:
|
|
120
|
+
if not isinstance(value, expected_type):
|
|
121
|
+
return False, f"Field '{field}' must be {expected_type.__name__}"
|
|
122
|
+
|
|
123
|
+
# String length validation
|
|
124
|
+
if isinstance(value, str) and field in MAX_STRING_LENGTHS:
|
|
125
|
+
if len(value) > MAX_STRING_LENGTHS[field]:
|
|
126
|
+
return (
|
|
127
|
+
False,
|
|
128
|
+
f"Field '{field}' exceeds max length ({MAX_STRING_LENGTHS[field]})",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Check for null bytes or control characters (except newline, tab)
|
|
132
|
+
if any(ord(c) < 32 and c not in "\n\t\r" for c in value):
|
|
133
|
+
return False, f"Field '{field}' contains invalid control characters"
|
|
134
|
+
|
|
135
|
+
# Coordinate bounds validation
|
|
136
|
+
if field in ("x", "y") and isinstance(value, (int, float)):
|
|
137
|
+
if value < COORDINATE_BOUNDS["min"] or value > COORDINATE_BOUNDS["max"]:
|
|
138
|
+
return (
|
|
139
|
+
False,
|
|
140
|
+
f"Field '{field}' out of bounds ({COORDINATE_BOUNDS['min']} to {COORDINATE_BOUNDS['max']})",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Validate optional fields if present
|
|
144
|
+
if message_type == "update_position":
|
|
145
|
+
# x and y are required and already validated above
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
if message_type == "create_object":
|
|
149
|
+
# Validate optional x, y if present
|
|
150
|
+
for field in ("x", "y"):
|
|
151
|
+
if field in data:
|
|
152
|
+
value = data[field]
|
|
153
|
+
if not isinstance(value, (int, float)):
|
|
154
|
+
return False, f"Optional field '{field}' must be a number"
|
|
155
|
+
if value < COORDINATE_BOUNDS["min"] or value > COORDINATE_BOUNDS["max"]:
|
|
156
|
+
return False, f"Optional field '{field}' out of bounds"
|
|
157
|
+
|
|
158
|
+
if message_type == "create_connection":
|
|
159
|
+
# Validate optional outlet/inlet indices
|
|
160
|
+
for field in ("src_outlet", "dst_inlet"):
|
|
161
|
+
if field in data:
|
|
162
|
+
value = data[field]
|
|
163
|
+
if not isinstance(value, int):
|
|
164
|
+
return False, f"Optional field '{field}' must be an integer"
|
|
165
|
+
if value < 0 or value > 255: # Max 256 inlets/outlets
|
|
166
|
+
return False, f"Optional field '{field}' out of range (0-255)"
|
|
167
|
+
|
|
168
|
+
if message_type == "delete_connection":
|
|
169
|
+
for field in ("src_outlet", "dst_inlet"):
|
|
170
|
+
if field in data:
|
|
171
|
+
value = data[field]
|
|
172
|
+
if not isinstance(value, int):
|
|
173
|
+
return False, f"Optional field '{field}' must be an integer"
|
|
174
|
+
if value < 0 or value > 255:
|
|
175
|
+
return False, f"Optional field '{field}' out of range (0-255)"
|
|
176
|
+
|
|
177
|
+
return True, None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_patcher_state_json(patcher: Optional["Patcher"]) -> dict:
|
|
181
|
+
"""Convert patcher to JSON state for browser.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
patcher: The patcher to convert
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Dictionary with boxes and lines data
|
|
188
|
+
"""
|
|
189
|
+
if not patcher:
|
|
190
|
+
return {
|
|
191
|
+
"type": "update",
|
|
192
|
+
"boxes": [],
|
|
193
|
+
"lines": [],
|
|
194
|
+
"patcher_path": [],
|
|
195
|
+
"patcher_title": "Untitled",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
boxes = []
|
|
199
|
+
for box in patcher._boxes:
|
|
200
|
+
box_data = {
|
|
201
|
+
"id": getattr(box, "id", ""),
|
|
202
|
+
"text": getattr(box, "text", ""),
|
|
203
|
+
"maxclass": getattr(box, "maxclass", "newobj"),
|
|
204
|
+
"patching_rect": {"x": 0, "y": 0, "w": 100, "h": 22},
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Check if box has a subpatcher
|
|
208
|
+
has_patcher = hasattr(box, "subpatcher") and box.subpatcher is not None
|
|
209
|
+
box_data["has_subpatcher"] = has_patcher
|
|
210
|
+
|
|
211
|
+
# Get patching_rect
|
|
212
|
+
rect = getattr(box, "patching_rect", None)
|
|
213
|
+
if rect:
|
|
214
|
+
if hasattr(rect, "x"):
|
|
215
|
+
box_data["patching_rect"] = {
|
|
216
|
+
"x": rect.x,
|
|
217
|
+
"y": rect.y,
|
|
218
|
+
"w": rect.w,
|
|
219
|
+
"h": rect.h,
|
|
220
|
+
}
|
|
221
|
+
elif isinstance(rect, (list, tuple)) and len(rect) >= 4:
|
|
222
|
+
box_data["patching_rect"] = {
|
|
223
|
+
"x": rect[0],
|
|
224
|
+
"y": rect[1],
|
|
225
|
+
"w": rect[2],
|
|
226
|
+
"h": rect[3],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Get inlet/outlet counts
|
|
230
|
+
inlet_count = 0
|
|
231
|
+
outlet_count = 0
|
|
232
|
+
|
|
233
|
+
# Try get_inlet_count() method first
|
|
234
|
+
if hasattr(box, "get_inlet_count"):
|
|
235
|
+
try:
|
|
236
|
+
inlet_count = box.get_inlet_count()
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
# If get_inlet_count() returned None, try numinlets attribute (from loaded files)
|
|
241
|
+
if inlet_count is None and hasattr(box, "numinlets"):
|
|
242
|
+
inlet_count = getattr(box, "numinlets", 0)
|
|
243
|
+
|
|
244
|
+
box_data["inlet_count"] = inlet_count or 0
|
|
245
|
+
|
|
246
|
+
# Try get_outlet_count() method first
|
|
247
|
+
if hasattr(box, "get_outlet_count"):
|
|
248
|
+
try:
|
|
249
|
+
outlet_count = box.get_outlet_count()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
# If get_outlet_count() returned None, try numoutlets attribute (from loaded files)
|
|
254
|
+
if outlet_count is None and hasattr(box, "numoutlets"):
|
|
255
|
+
outlet_count = getattr(box, "numoutlets", 0)
|
|
256
|
+
|
|
257
|
+
box_data["outlet_count"] = outlet_count or 0
|
|
258
|
+
|
|
259
|
+
boxes.append(box_data)
|
|
260
|
+
|
|
261
|
+
lines = []
|
|
262
|
+
for line in patcher._lines:
|
|
263
|
+
line_data = {
|
|
264
|
+
"src": getattr(line, "src", ""),
|
|
265
|
+
"dst": getattr(line, "dst", ""),
|
|
266
|
+
"src_outlet": 0,
|
|
267
|
+
"dst_inlet": 0,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
source = getattr(line, "source", None)
|
|
271
|
+
if source and len(source) > 1:
|
|
272
|
+
line_data["src_outlet"] = source[1]
|
|
273
|
+
|
|
274
|
+
destination = getattr(line, "destination", None)
|
|
275
|
+
if destination and len(destination) > 1:
|
|
276
|
+
line_data["dst_inlet"] = destination[1]
|
|
277
|
+
|
|
278
|
+
lines.append(line_data)
|
|
279
|
+
|
|
280
|
+
# Build patcher path (breadcrumb trail)
|
|
281
|
+
patcher_path: list[str] = []
|
|
282
|
+
current: Any = patcher
|
|
283
|
+
while current:
|
|
284
|
+
title = getattr(current, "title", None) or "Main"
|
|
285
|
+
patcher_path.insert(0, title)
|
|
286
|
+
current = getattr(current, "_parent", None)
|
|
287
|
+
|
|
288
|
+
# Get current patcher title
|
|
289
|
+
patcher_title = getattr(patcher, "title", None) or getattr(
|
|
290
|
+
patcher, "_path", "Untitled"
|
|
291
|
+
)
|
|
292
|
+
if patcher_title and hasattr(patcher_title, "name"):
|
|
293
|
+
patcher_title = patcher_title.name
|
|
294
|
+
|
|
295
|
+
# Get filepath for save/save-as logic
|
|
296
|
+
filepath = None
|
|
297
|
+
if hasattr(patcher, "filepath") and patcher.filepath:
|
|
298
|
+
filepath = str(patcher.filepath)
|
|
299
|
+
elif hasattr(patcher, "_path") and patcher._path:
|
|
300
|
+
filepath = str(patcher._path)
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
"type": "update",
|
|
304
|
+
"boxes": boxes,
|
|
305
|
+
"lines": lines,
|
|
306
|
+
"patcher_path": patcher_path,
|
|
307
|
+
"patcher_title": str(patcher_title),
|
|
308
|
+
"filepath": filepath,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class InteractiveHTTPHandler(http.server.SimpleHTTPRequestHandler):
|
|
313
|
+
"""HTTP handler for serving static files with token injection."""
|
|
314
|
+
|
|
315
|
+
# Class variable to store the session token
|
|
316
|
+
session_token: Optional[str] = None
|
|
317
|
+
|
|
318
|
+
def __init__(self, *args, **kwargs):
|
|
319
|
+
# Static files are in py2max/static/, not py2max/server/static/
|
|
320
|
+
static_dir = Path(__file__).parent.parent / "static"
|
|
321
|
+
super().__init__(*args, directory=str(static_dir), **kwargs)
|
|
322
|
+
|
|
323
|
+
def do_GET(self):
|
|
324
|
+
"""Handle GET requests."""
|
|
325
|
+
if self.path == "/" or self.path == "/index.html":
|
|
326
|
+
self.serve_interactive_html()
|
|
327
|
+
else:
|
|
328
|
+
super().do_GET()
|
|
329
|
+
|
|
330
|
+
def serve_interactive_html(self):
|
|
331
|
+
"""Serve the interactive editor HTML with injected session token."""
|
|
332
|
+
# Static files are in py2max/static/, not py2max/server/static/
|
|
333
|
+
html_file = Path(__file__).parent.parent / "static" / "interactive.html"
|
|
334
|
+
if html_file.exists():
|
|
335
|
+
html_content = html_file.read_text(encoding="utf-8")
|
|
336
|
+
|
|
337
|
+
# Inject session token into HTML
|
|
338
|
+
if self.session_token:
|
|
339
|
+
token_script = f'<script>window.PY2MAX_SESSION_TOKEN = "{self.session_token}";</script>'
|
|
340
|
+
# Inject before </head> or at the beginning of <body>
|
|
341
|
+
if "</head>" in html_content:
|
|
342
|
+
html_content = html_content.replace(
|
|
343
|
+
"</head>", f"{token_script}\n</head>"
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
html_content = token_script + html_content
|
|
347
|
+
|
|
348
|
+
self.send_response(200)
|
|
349
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
350
|
+
# Add security headers
|
|
351
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
352
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
353
|
+
self.send_header("X-XSS-Protection", "1; mode=block")
|
|
354
|
+
self.end_headers()
|
|
355
|
+
self.wfile.write(html_content.encode("utf-8"))
|
|
356
|
+
else:
|
|
357
|
+
self.send_error(404, "interactive.html not found")
|
|
358
|
+
|
|
359
|
+
def log_message(self, format, *args):
|
|
360
|
+
"""Suppress logging."""
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class InteractiveWebSocketHandler:
|
|
365
|
+
"""WebSocket handler for interactive patcher editing with authentication."""
|
|
366
|
+
|
|
367
|
+
def __init__(self, patcher: Optional["Patcher"], auto_save: bool = False):
|
|
368
|
+
self.root_patcher = patcher # Keep reference to root patcher
|
|
369
|
+
self.patcher = patcher # Current patcher being viewed
|
|
370
|
+
self.clients: Set[ServerConnection] = set()
|
|
371
|
+
self._lock = asyncio.Lock()
|
|
372
|
+
self._save_task: Optional[asyncio.Task[Any]] = None # Track pending save task
|
|
373
|
+
self.auto_save = auto_save # Auto-save configuration
|
|
374
|
+
# Generate a secure session token
|
|
375
|
+
self.session_token = secrets.token_urlsafe(32)
|
|
376
|
+
print(f"WebSocket session token: {self.session_token}")
|
|
377
|
+
|
|
378
|
+
def verify_token(self, token: str) -> bool:
|
|
379
|
+
"""Verify authentication token using constant-time comparison.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
token: The token to verify
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
True if token is valid, False otherwise
|
|
386
|
+
"""
|
|
387
|
+
if not token or not self.session_token:
|
|
388
|
+
return False
|
|
389
|
+
# Use constant-time comparison to prevent timing attacks
|
|
390
|
+
return secrets.compare_digest(token, self.session_token)
|
|
391
|
+
|
|
392
|
+
async def register(self, websocket: ServerConnection):
|
|
393
|
+
"""Register a new client connection."""
|
|
394
|
+
async with self._lock:
|
|
395
|
+
self.clients.add(websocket)
|
|
396
|
+
print(f"Client connected. Total clients: {len(self.clients)}")
|
|
397
|
+
|
|
398
|
+
async def unregister(self, websocket: ServerConnection):
|
|
399
|
+
"""Unregister a client connection."""
|
|
400
|
+
async with self._lock:
|
|
401
|
+
self.clients.discard(websocket)
|
|
402
|
+
print(f"Client disconnected. Total clients: {len(self.clients)}")
|
|
403
|
+
|
|
404
|
+
async def broadcast(self, message: dict):
|
|
405
|
+
"""Broadcast message to all connected clients."""
|
|
406
|
+
if not self.clients:
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
message_str = json.dumps(message)
|
|
410
|
+
async with self._lock:
|
|
411
|
+
# Send to all clients concurrently
|
|
412
|
+
await asyncio.gather(
|
|
413
|
+
*[client.send(message_str) for client in self.clients],
|
|
414
|
+
return_exceptions=True,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
async def handle_client(self, websocket: ServerConnection):
|
|
418
|
+
"""Handle a client WebSocket connection with authentication."""
|
|
419
|
+
try:
|
|
420
|
+
# First message must be authentication token
|
|
421
|
+
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
|
|
422
|
+
auth_str = (
|
|
423
|
+
auth_message.decode("utf-8")
|
|
424
|
+
if isinstance(auth_message, bytes)
|
|
425
|
+
else auth_message
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
auth_data = json.loads(auth_str)
|
|
430
|
+
if auth_data.get("type") != "auth" or not self.verify_token(
|
|
431
|
+
auth_data.get("token", "")
|
|
432
|
+
):
|
|
433
|
+
await websocket.send(
|
|
434
|
+
json.dumps(
|
|
435
|
+
{"type": "error", "message": "Authentication failed"}
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
await websocket.close(1008, "Unauthorized")
|
|
439
|
+
print("Client authentication failed")
|
|
440
|
+
return
|
|
441
|
+
except (json.JSONDecodeError, KeyError):
|
|
442
|
+
await websocket.send(
|
|
443
|
+
json.dumps(
|
|
444
|
+
{"type": "error", "message": "Invalid authentication message"}
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
await websocket.close(1008, "Invalid auth format")
|
|
448
|
+
print("Client sent invalid authentication message")
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
# Authentication successful
|
|
452
|
+
await self.register(websocket)
|
|
453
|
+
await websocket.send(json.dumps({"type": "auth_success"}))
|
|
454
|
+
print("Client authenticated successfully")
|
|
455
|
+
|
|
456
|
+
# Send initial state
|
|
457
|
+
if self.patcher:
|
|
458
|
+
state = get_patcher_state_json(self.patcher)
|
|
459
|
+
await websocket.send(json.dumps(state))
|
|
460
|
+
|
|
461
|
+
# Listen for messages from client
|
|
462
|
+
async for message in websocket:
|
|
463
|
+
msg_str = (
|
|
464
|
+
message.decode("utf-8") if isinstance(message, bytes) else message
|
|
465
|
+
)
|
|
466
|
+
await self.handle_message(websocket, msg_str)
|
|
467
|
+
|
|
468
|
+
except asyncio.TimeoutError:
|
|
469
|
+
print("Client authentication timeout")
|
|
470
|
+
await websocket.close(1008, "Authentication timeout")
|
|
471
|
+
except websockets.exceptions.ConnectionClosed:
|
|
472
|
+
pass
|
|
473
|
+
finally:
|
|
474
|
+
await self.unregister(websocket)
|
|
475
|
+
|
|
476
|
+
async def handle_message(self, websocket: ServerConnection, message: str):
|
|
477
|
+
"""Handle incoming message from client."""
|
|
478
|
+
try:
|
|
479
|
+
data = json.loads(message)
|
|
480
|
+
|
|
481
|
+
# Validate message before processing
|
|
482
|
+
is_valid, error_msg = validate_message(data)
|
|
483
|
+
if not is_valid:
|
|
484
|
+
print(f"Message validation failed: {error_msg}")
|
|
485
|
+
await websocket.send(
|
|
486
|
+
json.dumps({"type": "error", "message": f"Validation error: {error_msg}"})
|
|
487
|
+
)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
message_type = data.get("type")
|
|
491
|
+
|
|
492
|
+
if message_type == "update_position":
|
|
493
|
+
await self.handle_update_position(data)
|
|
494
|
+
elif message_type == "create_object":
|
|
495
|
+
await self.handle_create_object(data)
|
|
496
|
+
elif message_type == "create_connection":
|
|
497
|
+
await self.handle_create_connection(data)
|
|
498
|
+
elif message_type == "delete_object":
|
|
499
|
+
await self.handle_delete_object(data)
|
|
500
|
+
elif message_type == "delete_connection":
|
|
501
|
+
await self.handle_delete_connection(data)
|
|
502
|
+
elif message_type == "save":
|
|
503
|
+
await self.handle_save()
|
|
504
|
+
elif message_type == "save_as":
|
|
505
|
+
await self.handle_save_as(data)
|
|
506
|
+
elif message_type == "navigate_to_subpatcher":
|
|
507
|
+
await self.handle_navigate_to_subpatcher(data)
|
|
508
|
+
elif message_type == "navigate_to_parent":
|
|
509
|
+
await self.handle_navigate_to_parent()
|
|
510
|
+
elif message_type == "navigate_to_root":
|
|
511
|
+
await self.handle_navigate_to_root()
|
|
512
|
+
|
|
513
|
+
except json.JSONDecodeError as e:
|
|
514
|
+
print(f"Invalid JSON: {e}")
|
|
515
|
+
await websocket.send(
|
|
516
|
+
json.dumps({"type": "error", "message": "Invalid JSON format"})
|
|
517
|
+
)
|
|
518
|
+
except Exception as e:
|
|
519
|
+
print(f"Error handling message: {e}")
|
|
520
|
+
await websocket.send(
|
|
521
|
+
json.dumps({"type": "error", "message": f"Server error: {str(e)}"})
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
async def handle_update_position(self, data: dict):
|
|
525
|
+
"""Handle object position update from browser."""
|
|
526
|
+
if not self.patcher:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
box_id = data.get("box_id")
|
|
530
|
+
x = data.get("x")
|
|
531
|
+
y = data.get("y")
|
|
532
|
+
|
|
533
|
+
# Find box and update position
|
|
534
|
+
for box in self.patcher._boxes:
|
|
535
|
+
if box.id == box_id:
|
|
536
|
+
# Update position
|
|
537
|
+
if hasattr(box, "patching_rect"):
|
|
538
|
+
rect = box.patching_rect
|
|
539
|
+
if hasattr(rect, "x"):
|
|
540
|
+
# Rect is a NamedTuple (immutable), create new one
|
|
541
|
+
from ..core.common import Rect
|
|
542
|
+
|
|
543
|
+
box.patching_rect = Rect(
|
|
544
|
+
float(x) if x is not None else 0.0,
|
|
545
|
+
float(y) if y is not None else 0.0,
|
|
546
|
+
rect.w,
|
|
547
|
+
rect.h,
|
|
548
|
+
)
|
|
549
|
+
elif isinstance(rect, list):
|
|
550
|
+
rect[0] = x
|
|
551
|
+
rect[1] = y
|
|
552
|
+
|
|
553
|
+
# Send delta update instead of full state (performance optimization)
|
|
554
|
+
await self.broadcast(
|
|
555
|
+
{
|
|
556
|
+
"type": "position_update",
|
|
557
|
+
"box_id": box_id,
|
|
558
|
+
"x": float(x) if x is not None else 0.0,
|
|
559
|
+
"y": float(y) if y is not None else 0.0,
|
|
560
|
+
}
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# Schedule debounced save
|
|
564
|
+
await self.schedule_save()
|
|
565
|
+
break
|
|
566
|
+
|
|
567
|
+
async def schedule_save(self):
|
|
568
|
+
"""Schedule a debounced save after 2 seconds of no updates (if auto-save enabled)."""
|
|
569
|
+
if not self.auto_save:
|
|
570
|
+
return # Auto-save disabled
|
|
571
|
+
|
|
572
|
+
# Cancel previous save task if exists
|
|
573
|
+
if self._save_task and not self._save_task.done():
|
|
574
|
+
self._save_task.cancel()
|
|
575
|
+
|
|
576
|
+
# Schedule new save task
|
|
577
|
+
self._save_task = asyncio.create_task(self._debounced_save())
|
|
578
|
+
|
|
579
|
+
async def _debounced_save(self):
|
|
580
|
+
"""Save patch after delay (debounced)."""
|
|
581
|
+
try:
|
|
582
|
+
await asyncio.sleep(2.0) # Wait 2 seconds
|
|
583
|
+
if (
|
|
584
|
+
self.patcher
|
|
585
|
+
and hasattr(self.patcher, "filepath")
|
|
586
|
+
and self.patcher.filepath
|
|
587
|
+
):
|
|
588
|
+
self.patcher.save()
|
|
589
|
+
print(f"Auto-saved: {self.patcher.filepath}")
|
|
590
|
+
except asyncio.CancelledError:
|
|
591
|
+
# Task was cancelled (new position update came in)
|
|
592
|
+
pass
|
|
593
|
+
except Exception as e:
|
|
594
|
+
print(f"Error during auto-save: {e}")
|
|
595
|
+
|
|
596
|
+
async def handle_create_object(self, data: dict):
|
|
597
|
+
"""Handle object creation from browser."""
|
|
598
|
+
if not self.patcher:
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
text = data.get("text", "newobj")
|
|
602
|
+
x = data.get("x", 100)
|
|
603
|
+
y = data.get("y", 100)
|
|
604
|
+
|
|
605
|
+
# Create new object
|
|
606
|
+
box = self.patcher.add_textbox(text)
|
|
607
|
+
|
|
608
|
+
# Set position
|
|
609
|
+
if hasattr(box, "patching_rect"):
|
|
610
|
+
rect = box.patching_rect
|
|
611
|
+
if hasattr(rect, "x"):
|
|
612
|
+
# Rect is a NamedTuple (immutable), create new one
|
|
613
|
+
from ..core.common import Rect
|
|
614
|
+
|
|
615
|
+
box.patching_rect = Rect(x, y, rect.w, rect.h)
|
|
616
|
+
elif isinstance(rect, list):
|
|
617
|
+
rect[0] = x
|
|
618
|
+
rect[1] = y
|
|
619
|
+
|
|
620
|
+
# Broadcast update to all clients
|
|
621
|
+
state = get_patcher_state_json(self.patcher)
|
|
622
|
+
await self.broadcast(state)
|
|
623
|
+
|
|
624
|
+
# Schedule auto-save
|
|
625
|
+
await self.schedule_save()
|
|
626
|
+
|
|
627
|
+
async def handle_create_connection(self, data: dict):
|
|
628
|
+
"""Handle connection creation from browser."""
|
|
629
|
+
if not self.patcher:
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
src_id = data.get("src_id")
|
|
633
|
+
dst_id = data.get("dst_id")
|
|
634
|
+
src_outlet = data.get("src_outlet", 0)
|
|
635
|
+
dst_inlet = data.get("dst_inlet", 0)
|
|
636
|
+
|
|
637
|
+
# Find source and destination boxes
|
|
638
|
+
src_box = None
|
|
639
|
+
dst_box = None
|
|
640
|
+
|
|
641
|
+
for box in self.patcher._boxes:
|
|
642
|
+
if box.id == src_id:
|
|
643
|
+
src_box = box
|
|
644
|
+
if box.id == dst_id:
|
|
645
|
+
dst_box = box
|
|
646
|
+
|
|
647
|
+
if src_box and dst_box:
|
|
648
|
+
# Create connection
|
|
649
|
+
self.patcher.add_line(src_box, dst_box, outlet=src_outlet, inlet=dst_inlet) # type: ignore[arg-type]
|
|
650
|
+
|
|
651
|
+
# Broadcast update to all clients
|
|
652
|
+
state = get_patcher_state_json(self.patcher)
|
|
653
|
+
await self.broadcast(state)
|
|
654
|
+
|
|
655
|
+
# Schedule auto-save
|
|
656
|
+
await self.schedule_save()
|
|
657
|
+
|
|
658
|
+
async def handle_delete_object(self, data: dict):
|
|
659
|
+
"""Handle object deletion from browser."""
|
|
660
|
+
if not self.patcher:
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
box_id = data.get("box_id")
|
|
664
|
+
|
|
665
|
+
# Find and remove box
|
|
666
|
+
for i, box in enumerate(self.patcher._boxes):
|
|
667
|
+
if box.id == box_id:
|
|
668
|
+
self.patcher._boxes.pop(i)
|
|
669
|
+
|
|
670
|
+
# Also remove any connected lines
|
|
671
|
+
self.patcher._lines = [
|
|
672
|
+
line
|
|
673
|
+
for line in self.patcher._lines
|
|
674
|
+
if line.src != box_id and line.dst != box_id
|
|
675
|
+
]
|
|
676
|
+
|
|
677
|
+
# Broadcast update to all clients
|
|
678
|
+
state = get_patcher_state_json(self.patcher)
|
|
679
|
+
await self.broadcast(state)
|
|
680
|
+
|
|
681
|
+
# Schedule auto-save
|
|
682
|
+
await self.schedule_save()
|
|
683
|
+
break
|
|
684
|
+
|
|
685
|
+
async def handle_delete_connection(self, data: dict):
|
|
686
|
+
"""Handle connection deletion from browser."""
|
|
687
|
+
if not self.patcher:
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
src_id = data.get("src_id")
|
|
691
|
+
dst_id = data.get("dst_id")
|
|
692
|
+
src_outlet = data.get("src_outlet", 0)
|
|
693
|
+
dst_inlet = data.get("dst_inlet", 0)
|
|
694
|
+
|
|
695
|
+
# Find and remove matching line
|
|
696
|
+
for i, line in enumerate(self.patcher._lines):
|
|
697
|
+
source = getattr(line, "source", [None, 0])
|
|
698
|
+
destination = getattr(line, "destination", [None, 0])
|
|
699
|
+
if (
|
|
700
|
+
line.src == src_id
|
|
701
|
+
and line.dst == dst_id
|
|
702
|
+
and source[1] == src_outlet
|
|
703
|
+
and destination[1] == dst_inlet
|
|
704
|
+
):
|
|
705
|
+
self.patcher._lines.pop(i)
|
|
706
|
+
|
|
707
|
+
# Broadcast update to all clients
|
|
708
|
+
state = get_patcher_state_json(self.patcher)
|
|
709
|
+
await self.broadcast(state)
|
|
710
|
+
|
|
711
|
+
# Schedule auto-save
|
|
712
|
+
await self.schedule_save()
|
|
713
|
+
break
|
|
714
|
+
|
|
715
|
+
async def handle_save(self):
|
|
716
|
+
"""Handle manual save request from browser."""
|
|
717
|
+
if not self.root_patcher: # Save root patcher
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
if hasattr(self.root_patcher, "filepath") and self.root_patcher.filepath:
|
|
722
|
+
self.root_patcher.save()
|
|
723
|
+
print(f"Saved: {self.root_patcher.filepath}")
|
|
724
|
+
|
|
725
|
+
# Notify clients that save completed
|
|
726
|
+
await self.broadcast(
|
|
727
|
+
{
|
|
728
|
+
"type": "save_complete",
|
|
729
|
+
"filepath": str(self.root_patcher.filepath),
|
|
730
|
+
}
|
|
731
|
+
)
|
|
732
|
+
else:
|
|
733
|
+
# No filepath set - request filename from client
|
|
734
|
+
print("No filepath set, requesting filename from client")
|
|
735
|
+
await self.broadcast(
|
|
736
|
+
{"type": "save_as_required", "message": "Please enter a filename"}
|
|
737
|
+
)
|
|
738
|
+
except Exception as e:
|
|
739
|
+
print(f"Error saving: {e}")
|
|
740
|
+
await self.broadcast({"type": "save_error", "message": str(e)})
|
|
741
|
+
|
|
742
|
+
async def handle_save_as(self, data: dict):
|
|
743
|
+
"""Handle save-as request with specified filepath."""
|
|
744
|
+
if not self.root_patcher:
|
|
745
|
+
return
|
|
746
|
+
|
|
747
|
+
filepath = data.get("filepath", "")
|
|
748
|
+
if not filepath:
|
|
749
|
+
await self.broadcast(
|
|
750
|
+
{"type": "save_error", "message": "No filepath provided"}
|
|
751
|
+
)
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
try:
|
|
755
|
+
# Ensure .maxpat extension
|
|
756
|
+
if not filepath.endswith((".maxpat", ".maxhelp", ".rbnopat")):
|
|
757
|
+
filepath = filepath + ".maxpat"
|
|
758
|
+
|
|
759
|
+
# Set the filepath on the patcher
|
|
760
|
+
from pathlib import Path
|
|
761
|
+
|
|
762
|
+
self.root_patcher._path = Path(filepath)
|
|
763
|
+
|
|
764
|
+
# Save the patcher
|
|
765
|
+
self.root_patcher.save()
|
|
766
|
+
print(f"Saved as: {filepath}")
|
|
767
|
+
|
|
768
|
+
# Notify clients that save completed
|
|
769
|
+
await self.broadcast(
|
|
770
|
+
{
|
|
771
|
+
"type": "save_complete",
|
|
772
|
+
"filepath": filepath,
|
|
773
|
+
}
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Also update the state to include the new filepath
|
|
777
|
+
state = get_patcher_state_json(self.patcher)
|
|
778
|
+
state["filepath"] = filepath
|
|
779
|
+
await self.broadcast(state)
|
|
780
|
+
|
|
781
|
+
except Exception as e:
|
|
782
|
+
print(f"Error saving: {e}")
|
|
783
|
+
await self.broadcast({"type": "save_error", "message": str(e)})
|
|
784
|
+
|
|
785
|
+
async def handle_navigate_to_subpatcher(self, data: dict):
|
|
786
|
+
"""Handle navigation to a subpatcher."""
|
|
787
|
+
if not self.patcher:
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
box_id = data.get("box_id")
|
|
791
|
+
if not box_id:
|
|
792
|
+
return
|
|
793
|
+
|
|
794
|
+
# Find box with matching ID
|
|
795
|
+
for box in self.patcher._boxes:
|
|
796
|
+
if box.id == box_id:
|
|
797
|
+
# Check if box has a subpatcher
|
|
798
|
+
if hasattr(box, "subpatcher") and box.subpatcher is not None:
|
|
799
|
+
# Navigate to subpatcher
|
|
800
|
+
self.patcher = box.subpatcher
|
|
801
|
+
box_text = getattr(box, "text", box.id)
|
|
802
|
+
print(f"Navigated to subpatcher: {box_text}")
|
|
803
|
+
|
|
804
|
+
# Send updated state to all clients
|
|
805
|
+
state = get_patcher_state_json(self.patcher)
|
|
806
|
+
await self.broadcast(state)
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
async def handle_navigate_to_parent(self):
|
|
810
|
+
"""Handle navigation to parent patcher."""
|
|
811
|
+
if not self.patcher:
|
|
812
|
+
return
|
|
813
|
+
|
|
814
|
+
# Get parent patcher
|
|
815
|
+
parent = getattr(self.patcher, "_parent", None)
|
|
816
|
+
if parent:
|
|
817
|
+
self.patcher = parent
|
|
818
|
+
print("Navigated to parent patcher")
|
|
819
|
+
|
|
820
|
+
# Send updated state to all clients
|
|
821
|
+
state = get_patcher_state_json(self.patcher)
|
|
822
|
+
await self.broadcast(state)
|
|
823
|
+
else:
|
|
824
|
+
print("Already at root patcher")
|
|
825
|
+
|
|
826
|
+
async def handle_navigate_to_root(self):
|
|
827
|
+
"""Handle navigation to root patcher."""
|
|
828
|
+
if not self.root_patcher:
|
|
829
|
+
return
|
|
830
|
+
|
|
831
|
+
self.patcher = self.root_patcher
|
|
832
|
+
print("Navigated to root patcher")
|
|
833
|
+
|
|
834
|
+
# Send updated state to all clients
|
|
835
|
+
state = get_patcher_state_json(self.patcher)
|
|
836
|
+
await self.broadcast(state)
|
|
837
|
+
|
|
838
|
+
async def notify_update(self):
|
|
839
|
+
"""Notify all clients of a patcher update."""
|
|
840
|
+
if self.patcher:
|
|
841
|
+
state = get_patcher_state_json(self.patcher)
|
|
842
|
+
await self.broadcast(state)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
class InteractivePatcherServer:
|
|
846
|
+
"""WebSocket server for interactive patcher editing.
|
|
847
|
+
|
|
848
|
+
Can be used as a context manager for automatic cleanup:
|
|
849
|
+
|
|
850
|
+
>>> async with p.serve_interactive() as server:
|
|
851
|
+
... await asyncio.sleep(10) # Server runs
|
|
852
|
+
# Server automatically stopped
|
|
853
|
+
"""
|
|
854
|
+
|
|
855
|
+
def __init__(
|
|
856
|
+
self,
|
|
857
|
+
patcher: "Patcher",
|
|
858
|
+
port: int = 8000,
|
|
859
|
+
auto_open: bool = True,
|
|
860
|
+
auto_save: bool = False,
|
|
861
|
+
):
|
|
862
|
+
"""Initialize the interactive server.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
patcher: Patcher instance to serve
|
|
866
|
+
port: HTTP/WebSocket server port (default: 8000)
|
|
867
|
+
auto_open: Automatically open browser (default: True)
|
|
868
|
+
auto_save: Automatically save changes after 2 seconds (default: False)
|
|
869
|
+
"""
|
|
870
|
+
self.patcher = patcher
|
|
871
|
+
self.port = port
|
|
872
|
+
self.ws_port = port + 1 # WebSocket on different port
|
|
873
|
+
self.auto_open = auto_open
|
|
874
|
+
self.handler = InteractiveWebSocketHandler(patcher, auto_save=auto_save)
|
|
875
|
+
self.ws_server: Optional[Server] = None
|
|
876
|
+
self.http_server: Optional[http.server.HTTPServer] = None
|
|
877
|
+
self.http_thread: Optional[threading.Thread] = None
|
|
878
|
+
self._running = False
|
|
879
|
+
|
|
880
|
+
async def start(self):
|
|
881
|
+
"""Start the HTTP and WebSocket servers.
|
|
882
|
+
|
|
883
|
+
Returns:
|
|
884
|
+
self: For method chaining
|
|
885
|
+
"""
|
|
886
|
+
if self._running:
|
|
887
|
+
print("Server already running")
|
|
888
|
+
return self
|
|
889
|
+
|
|
890
|
+
# Set the session token in the HTTP handler class
|
|
891
|
+
InteractiveHTTPHandler.session_token = self.handler.session_token
|
|
892
|
+
|
|
893
|
+
# Start HTTP server in background thread
|
|
894
|
+
self.http_server = http.server.HTTPServer(
|
|
895
|
+
("localhost", self.port), InteractiveHTTPHandler
|
|
896
|
+
)
|
|
897
|
+
self.http_thread = threading.Thread(
|
|
898
|
+
target=self.http_server.serve_forever, daemon=True
|
|
899
|
+
)
|
|
900
|
+
self.http_thread.start()
|
|
901
|
+
|
|
902
|
+
# Start WebSocket server
|
|
903
|
+
self.ws_server = await serve(
|
|
904
|
+
self.handler.handle_client, "localhost", self.ws_port
|
|
905
|
+
)
|
|
906
|
+
self._running = True
|
|
907
|
+
|
|
908
|
+
url = f"http://localhost:{self.port}"
|
|
909
|
+
print(f"Interactive server started: {url}")
|
|
910
|
+
print(f"WebSocket endpoint: ws://localhost:{self.ws_port}/ws")
|
|
911
|
+
|
|
912
|
+
# Open browser
|
|
913
|
+
if self.auto_open:
|
|
914
|
+
await asyncio.sleep(0.5) # Give server time to start
|
|
915
|
+
webbrowser.open(url)
|
|
916
|
+
|
|
917
|
+
return self
|
|
918
|
+
|
|
919
|
+
async def stop(self):
|
|
920
|
+
"""Stop the servers."""
|
|
921
|
+
await self.shutdown()
|
|
922
|
+
|
|
923
|
+
async def shutdown(self):
|
|
924
|
+
"""Shutdown the servers gracefully."""
|
|
925
|
+
if not self._running:
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
# Stop WebSocket server
|
|
929
|
+
if self.ws_server:
|
|
930
|
+
self.ws_server.close()
|
|
931
|
+
await self.ws_server.wait_closed()
|
|
932
|
+
|
|
933
|
+
# Stop HTTP server
|
|
934
|
+
if self.http_server:
|
|
935
|
+
self.http_server.shutdown()
|
|
936
|
+
self.http_server.server_close()
|
|
937
|
+
|
|
938
|
+
self._running = False
|
|
939
|
+
print("Interactive server stopped")
|
|
940
|
+
|
|
941
|
+
# Clear clients
|
|
942
|
+
self.handler.clients.clear()
|
|
943
|
+
|
|
944
|
+
async def __aenter__(self):
|
|
945
|
+
"""Async context manager entry."""
|
|
946
|
+
await self.start()
|
|
947
|
+
return self
|
|
948
|
+
|
|
949
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
950
|
+
"""Async context manager exit."""
|
|
951
|
+
await self.shutdown()
|
|
952
|
+
return False
|
|
953
|
+
|
|
954
|
+
async def notify_update(self):
|
|
955
|
+
"""Notify all connected clients of an update."""
|
|
956
|
+
await self.handler.notify_update()
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
async def serve_interactive(
|
|
960
|
+
patcher: "Patcher",
|
|
961
|
+
port: int = 8000,
|
|
962
|
+
auto_open: bool = True,
|
|
963
|
+
auto_save: bool = False,
|
|
964
|
+
) -> InteractivePatcherServer:
|
|
965
|
+
"""Start an interactive WebSocket server for a patcher.
|
|
966
|
+
|
|
967
|
+
The server can be used as an async context manager:
|
|
968
|
+
|
|
969
|
+
>>> async with serve_interactive(p) as server:
|
|
970
|
+
... await asyncio.sleep(10)
|
|
971
|
+
# Server automatically stopped
|
|
972
|
+
|
|
973
|
+
Or managed manually:
|
|
974
|
+
|
|
975
|
+
>>> server = await serve_interactive(p)
|
|
976
|
+
>>> # ... interact ...
|
|
977
|
+
>>> await server.shutdown()
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
patcher: Patcher instance to serve
|
|
981
|
+
port: WebSocket server port (default: 8000)
|
|
982
|
+
auto_open: Automatically open browser (default: True)
|
|
983
|
+
auto_save: Automatically save changes after 2 seconds (default: False)
|
|
984
|
+
|
|
985
|
+
Returns:
|
|
986
|
+
InteractivePatcherServer instance
|
|
987
|
+
"""
|
|
988
|
+
server = InteractivePatcherServer(patcher, port, auto_open, auto_save)
|
|
989
|
+
await server.start()
|
|
990
|
+
return server
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
__all__ = [
|
|
994
|
+
"InteractiveWebSocketHandler",
|
|
995
|
+
"InteractivePatcherServer",
|
|
996
|
+
"serve_interactive",
|
|
997
|
+
]
|