- First-run safety notice (photosensitivity, clinical use, privacy); glow rate capped at 2.5Hz below the photosensitive trigger band - Patient data: initials/code hint, Export All / Wipe All buttons - Relay sessions get crypto-random room ids instead of guessable room-1 - Fix: Pan Sync off now actually stops pink-noise panning - Fix: slider drags no longer audibly restart the noise source - Fix: switching pink->discrete preset no longer leaves noise running - Fix: Speed Boost no longer compounds into stored speed across reloads - Fix: patient/preset names HTML-escaped in lists - Fix: patientViewport now relays over LAN (old server dropped it) - server.py rewritten stdlib-only: static site + WS relay in one process, no pip/venv; setup.sh/bat deleted, start scripts are now 3 lines - MIT LICENSE added; README rewritten with safety-first quick start Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
247 lines
7.6 KiB
Python
247 lines
7.6 KiB
Python
"""backnforth server — static site + WebSocket relay, Python stdlib only.
|
|
|
|
python3 server.py [--no-browser]
|
|
|
|
Serves the therapist page on http://<ip>:8000/ and the session relay on
|
|
ws://<ip>:8787. No pip installs, no virtualenv — any Python 3.7+ works,
|
|
including old machines that will never see a package index.
|
|
"""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import struct
|
|
import sys
|
|
import threading
|
|
import webbrowser
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
HTTP_PORT = 8000
|
|
WS_PORT = 8787
|
|
_WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
|
|
SESSIONS = {} # session id -> set of live WSHandler
|
|
LOCK = threading.Lock()
|
|
_SERVERS = []
|
|
|
|
|
|
def local_ip():
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.connect(('8.8.8.8', 80))
|
|
ip = s.getsockname()[0]
|
|
s.close()
|
|
return ip
|
|
except OSError:
|
|
try:
|
|
return socket.gethostbyname(socket.gethostname())
|
|
except OSError:
|
|
return '127.0.0.1'
|
|
|
|
|
|
def shutdown_all():
|
|
for srv in _SERVERS:
|
|
try:
|
|
srv.shutdown()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class WSHandler(socketserver.BaseRequestHandler):
|
|
"""Minimal RFC6455 server: unfragmented text frames, ping/pong, close.
|
|
|
|
ponytail: no fragmentation/binary support — the state payloads are a few
|
|
hundred bytes of JSON and browsers never fragment at that size.
|
|
"""
|
|
|
|
def setup(self):
|
|
self.session_id = None
|
|
self.send_lock = threading.Lock()
|
|
|
|
def handle(self):
|
|
if not self._handshake():
|
|
return
|
|
try:
|
|
while True:
|
|
msg = self._read_message()
|
|
if msg is None:
|
|
break
|
|
self._dispatch(msg)
|
|
except (ConnectionError, OSError):
|
|
pass
|
|
|
|
def finish(self):
|
|
if self.session_id:
|
|
with LOCK:
|
|
SESSIONS.get(self.session_id, set()).discard(self)
|
|
|
|
# ---- relay protocol (same JSON messages as the old websockets server) ----
|
|
|
|
def _dispatch(self, text):
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError:
|
|
return
|
|
t = data.get('type')
|
|
if t == 'join':
|
|
self.session_id = data.get('sessionId') or 'default'
|
|
with LOCK:
|
|
SESSIONS.setdefault(self.session_id, set()).add(self)
|
|
self._send_json({'type': 'joined', 'sessionId': self.session_id})
|
|
elif t in ('state', 'patientViewport'):
|
|
sid = data.get('sessionId') or self.session_id or 'default'
|
|
out = json.dumps({'type': t, 'payload': data.get('payload')})
|
|
with LOCK:
|
|
peers = list(SESSIONS.get(sid, ()))
|
|
for peer in peers:
|
|
if peer is not self:
|
|
try:
|
|
peer._send_text(out)
|
|
except Exception:
|
|
pass
|
|
elif t in ('whoami', 'detect_ip'):
|
|
self._send_json({'type': 'detect_ip', 'ip': local_ip()})
|
|
elif t == 'ping':
|
|
self._send_json({'type': 'pong'})
|
|
elif t == 'shutdown':
|
|
if self.client_address[0] in ('127.0.0.1', '::1', local_ip()):
|
|
self._send_json({'type': 'shutdown_ack'})
|
|
threading.Thread(target=shutdown_all, daemon=True).start()
|
|
|
|
# ---- websocket plumbing ----
|
|
|
|
def _handshake(self):
|
|
self.request.settimeout(10)
|
|
data = b''
|
|
while b'\r\n\r\n' not in data:
|
|
chunk = self.request.recv(4096)
|
|
if not chunk or len(data) > 65536:
|
|
return False
|
|
data += chunk
|
|
headers = {}
|
|
for line in data.split(b'\r\n')[1:]:
|
|
if b':' in line:
|
|
k, v = line.split(b':', 1)
|
|
headers[k.strip().lower()] = v.strip()
|
|
key = headers.get(b'sec-websocket-key')
|
|
if not key:
|
|
return False
|
|
accept = base64.b64encode(hashlib.sha1(key + _WS_GUID).digest()).decode()
|
|
self.request.sendall((
|
|
'HTTP/1.1 101 Switching Protocols\r\n'
|
|
'Upgrade: websocket\r\n'
|
|
'Connection: Upgrade\r\n'
|
|
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
|
|
).encode())
|
|
self.request.settimeout(None)
|
|
return True
|
|
|
|
def _recv_exact(self, n):
|
|
buf = b''
|
|
while len(buf) < n:
|
|
chunk = self.request.recv(n - len(buf))
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
return buf
|
|
|
|
def _read_message(self):
|
|
while True:
|
|
head = self._recv_exact(2)
|
|
if head is None:
|
|
return None
|
|
b1, b2 = head
|
|
opcode = b1 & 0x0F
|
|
length = b2 & 0x7F
|
|
if length == 126:
|
|
ext = self._recv_exact(2)
|
|
if ext is None:
|
|
return None
|
|
length = struct.unpack('>H', ext)[0]
|
|
elif length == 127:
|
|
ext = self._recv_exact(8)
|
|
if ext is None:
|
|
return None
|
|
length = struct.unpack('>Q', ext)[0]
|
|
if length > 1_000_000:
|
|
return None
|
|
mask = self._recv_exact(4) if (b2 & 0x80) else b'\x00\x00\x00\x00'
|
|
if mask is None:
|
|
return None
|
|
payload = self._recv_exact(length) if length else b''
|
|
if payload is None:
|
|
return None
|
|
payload = bytes(c ^ mask[i % 4] for i, c in enumerate(payload))
|
|
if opcode == 0x1: # text
|
|
try:
|
|
return payload.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
return None
|
|
elif opcode == 0x9: # ping -> pong
|
|
self._send_frame(0xA, payload)
|
|
elif opcode == 0x8: # close
|
|
try:
|
|
self._send_frame(0x8, b'')
|
|
except Exception:
|
|
pass
|
|
return None
|
|
# anything else (binary/continuation): skip
|
|
|
|
def _send_frame(self, opcode, payload):
|
|
n = len(payload)
|
|
head = bytes([0x80 | opcode])
|
|
if n < 126:
|
|
head += bytes([n])
|
|
elif n < 65536:
|
|
head += bytes([126]) + struct.pack('>H', n)
|
|
else:
|
|
head += bytes([127]) + struct.pack('>Q', n)
|
|
with self.send_lock:
|
|
self.request.sendall(head + payload)
|
|
|
|
def _send_text(self, text):
|
|
self._send_frame(0x1, text.encode('utf-8'))
|
|
|
|
def _send_json(self, obj):
|
|
self._send_text(json.dumps(obj))
|
|
|
|
|
|
class _QuietHTTP(SimpleHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
|
|
def main():
|
|
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
|
ThreadingHTTPServer.allow_reuse_address = True
|
|
httpd = ThreadingHTTPServer(('0.0.0.0', HTTP_PORT), _QuietHTTP)
|
|
wsd = socketserver.ThreadingTCPServer(('0.0.0.0', WS_PORT), WSHandler)
|
|
httpd.daemon_threads = True
|
|
wsd.daemon_threads = True
|
|
_SERVERS.extend([httpd, wsd])
|
|
|
|
ip = local_ip()
|
|
print('backnforth running:')
|
|
print(' Therapist page: http://%s:%d/' % (ip, HTTP_PORT))
|
|
print(' Session relay: ws://%s:%d' % (ip, WS_PORT))
|
|
print('Press Ctrl+C to stop.')
|
|
|
|
threading.Thread(target=wsd.serve_forever, daemon=True).start()
|
|
if '--no-browser' not in sys.argv:
|
|
try:
|
|
webbrowser.open('http://%s:%d/' % (ip, HTTP_PORT))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print('\nStopped.')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|