import base64 import hashlib import os import socket import ssl import struct import threading import time import traceback from urllib.parse import urlparse # ============================================= WS_URL = "ws:///ws_backend" # replace AUTH_TOKEN = "" # replace RECONNECT_DELAY = 5 DEBUG = False DEBUG_LOG_PATH = "/dev/null" # ============================================= _WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" def _log(msg): if not DEBUG: return try: with open(DEBUG_LOG_PATH, "a") as f: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") except Exception: pass class WebSocketClient: """ Minimal RFC 6455 client: enough to do the opening handshake, send text frames, and receive (possibly fragmented) text/control frames. Stdlib only. """ def __init__(self, url, on_open=None, on_message=None, on_error=None, on_close=None, connect_timeout=10): self.url = url self.on_open = on_open self.on_message = on_message self.on_error = on_error self.on_close = on_close self.connect_timeout = connect_timeout self.sock = None self._stop = threading.Event() def _connect_socket(self): parsed = urlparse(self.url) is_secure = parsed.scheme == "wss" host = parsed.hostname port = parsed.port or (443 if is_secure else 80) path = parsed.path or "/" if parsed.query: path += "?" + parsed.query raw_sock = socket.create_connection((host, port), timeout=self.connect_timeout) if is_secure: ctx = ssl.create_default_context() sock = ctx.wrap_socket(raw_sock, server_hostname=host) else: sock = raw_sock return sock, host, port, path def _handshake(self, sock, host, port, path): key = base64.b64encode(os.urandom(16)).decode() request = ( f"GET {path} HTTP/1.1\r\n" f"Host: {host}:{port}\r\n" "Upgrade: websocket\r\n" "Connection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\n" "Sec-WebSocket-Version: 13\r\n" "\r\n" ) sock.sendall(request.encode()) data = b"" while b"\r\n\r\n" not in data: chunk = sock.recv(1024) if not chunk: raise ConnectionError("Connection closed during handshake") data += chunk headers = data.decode(errors="ignore") if "101" not in headers.splitlines()[0]: raise ConnectionError(f"Handshake failed: {headers.splitlines()[0]}") expected_accept = base64.b64encode( hashlib.sha1((key + _WS_GUID).encode()).digest() ).decode() if expected_accept not in headers: _log(f"handshake key sent: {key!r}") _log(f"expected accept: {expected_accept!r}") _log(f"raw response headers:\n{headers}") raise ConnectionError("Handshake failed: bad Sec-WebSocket-Accept") def connect(self): sock, host, port, path = self._connect_socket() self._handshake(sock, host, port, path) sock.settimeout(None) # blocking reads for the run loop self.sock = sock def _recv_exact(self, n): buf = b"" while len(buf) < n: chunk = self.sock.recv(n - len(buf)) if not chunk: raise ConnectionError("Connection closed") buf += chunk return buf def _send_frame(self, opcode, payload): fin_opcode = 0x80 | opcode length = len(payload) if length <= 125: header = struct.pack("!BB", fin_opcode, 0x80 | length) elif length <= 0xFFFF: header = struct.pack("!BBH", fin_opcode, 0x80 | 126, length) else: header = struct.pack("!BBQ", fin_opcode, 0x80 | 127, length) mask_key = os.urandom(4) # clients MUST mask outgoing frames masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) self.sock.sendall(header + mask_key + masked) def _read_frame(self): b1, b2 = self._recv_exact(2) fin = b1 & 0x80 opcode = b1 & 0x0F masked = b2 & 0x80 length = b2 & 0x7F if length == 126: length = struct.unpack("!H", self._recv_exact(2))[0] elif length == 127: length = struct.unpack("!Q", self._recv_exact(8))[0] mask_key = self._recv_exact(4) if masked else None payload = self._recv_exact(length) if length else b"" if mask_key: payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) return fin, opcode, payload def send(self, message): if isinstance(message, str): self._send_frame(0x1, message.encode("utf-8")) else: self._send_frame(0x2, message) def close(self): self._stop.set() try: if self.sock: self._send_frame(0x8, b"") # close frame, best effort except Exception: pass try: if self.sock: self.sock.close() except Exception: pass def run_forever(self): try: self.connect() if self.on_open: self.on_open(self) except Exception as e: if self.on_error: self.on_error(self, e) return fragments = [] fragment_opcode = None try: while not self._stop.is_set(): fin, opcode, payload = self._read_frame() if opcode == 0x8: # close break elif opcode == 0x9: # ping -> pong self._send_frame(0xA, payload) continue elif opcode == 0xA: # pong continue elif opcode in (0x0, 0x1, 0x2): if opcode != 0x0: fragment_opcode = opcode fragments = [payload] else: fragments.append(payload) if fin: full = b"".join(fragments) fragments = [] if fragment_opcode == 0x1: text = full.decode("utf-8", errors="replace") if self.on_message: self.on_message(self, text) elif self.on_message: self.on_message(self, full) except Exception as e: if not self._stop.is_set() and self.on_error: self.on_error(self, e) finally: try: if self.sock: self.sock.close() except Exception: pass if self.on_close: self.on_close(self, None, None) _ws_client = None _ws_thread = None _stop_event = threading.Event() _uno_lock = threading.Lock() def _get_slideshow_controller(): """ Find the XSlideShowController of the currently running presentation, across all open documents. Returns None if no presentation is running. """ desktop = XSCRIPTCONTEXT.getDesktop() components = desktop.getComponents().createEnumeration() while components.hasMoreElements(): comp = components.nextElement() try: presentation = comp.getPresentation() except AttributeError: continue if presentation is None: continue try: controller = presentation.getController() except Exception: controller = None if controller is not None: return controller return None def _do_next(): with _uno_lock: try: controller = _get_slideshow_controller() if controller is not None: controller.gotoNextEffect() else: _log("next: no running presentation found") except Exception: _log("next: exception\n" + traceback.format_exc()) def _do_prev(): with _uno_lock: try: controller = _get_slideshow_controller() if controller is not None: controller.gotoPreviousEffect() else: _log("prev: no running presentation found") except Exception: _log("prev: exception\n" + traceback.format_exc()) def _on_open(ws): _log("connected, sending token") ws.send(AUTH_TOKEN) def _on_message(ws, message): if not isinstance(message, str): return cmd = message.strip().lower() _log(f"received: {cmd!r}") if cmd == "next": _do_next() elif cmd == "prev": _do_prev() else: _log(f"ignored unrecognized message: {message!r}") def _on_error(ws, error): _log(f"error: {error!r}") def _on_close(ws, close_status_code, close_msg): _log("closed") def _ws_loop(): global _ws_client while not _stop_event.is_set(): _ws_client = WebSocketClient( WS_URL, on_open=_on_open, on_message=_on_message, on_error=_on_error, on_close=_on_close, ) _ws_client.run_forever() # blocks until closed/dropped if _stop_event.is_set() or RECONNECT_DELAY is None: break _log(f"reconnecting in {RECONNECT_DELAY}s") time.sleep(RECONNECT_DELAY) def start_listener(*args): """ Entry point. Bind this to the 'Start Presentation' document event (Tools > Customize > Events), or run it manually from Tools > Macros > Edit/Run Macros before starting the slideshow. """ global _ws_thread if _ws_thread is not None and _ws_thread.is_alive(): _log("start_listener: already running") return _stop_event.clear() _ws_thread = threading.Thread(target=_ws_loop, daemon=True) _ws_thread.start() _log("start_listener: thread started") def stop_listener(*args): """ Bind this to the 'End Presentation' document event to clean up, or call manually to disconnect. """ global _ws_client _stop_event.set() if _ws_client is not None: _ws_client.close() _log("stop_listener: stop requested") g_exportedScripts = (start_listener, stop_listener)