# This file managed by the Ren'Py Launch and Sync Visual Studio Code extension. # # This script provides a mechanism for your Ren'Py game to connect to VS Code # via a websocket server. It is automatically excluded from builds of your game. # You can delete this file if you do not want to use the features provided by # the extension. # # This file should not be checked into source control. You can add it to your # `.gitignore` file by adding the following line: # # vscode_renpy_warp_*.rpe* # # For more information, see https://github.com/furudean/vscode-renpy-warp # import renpy # type: ignore from time import sleep import textwrap import threading import json import functools import re import os from pathlib import Path import logging logging.basicConfig() logger = logging.getLogger("renpy_warp_service") try: logger.setLevel(level=os.getenv('WARP_LOGLEVEL', logging.INFO)) except ValueError: logger.setLevel(level=logging.INFO) class RenpyWarpQuitAction(renpy.ui.Action): def __call__(self): renpy.exports.quit() original_quit_action = renpy.config.quit_action def get_meta(): RPE_FILE_PATTERN = re.compile( r"(?:vscode_)?renpy_warp_(?P\d+\.\d+\.\d+)(?:_(?P[a-z0-9]+))?\.rpe(?:\.py)?") file = Path(__file__) if __file__.endswith( '.rpe.py') else Path(__file__).parent filename = os.path.basename(file) match = RPE_FILE_PATTERN.match(filename) if not match: raise Exception( f"could not parse filename '{filename}'" f" with pattern '{RPE_FILE_PATTERN.pattern}'") d = match.groupdict() return d["version"], d["checksum"] def py_exec(text): while renpy.exports.is_init_phase(): logger.debug("in init phase, waiting...") sleep(0.2) fn = functools.partial(renpy.python.py_exec, text) renpy.exports.invoke_in_main_thread(fn) def socket_send(message, websocket): """sends a message to the socket server""" stringified = json.dumps(message) websocket.send(stringified) logger.debug(f"sent message: {stringified}") def socket_listener(websocket): """listens for messages from the socket server""" for message in websocket: logger.debug(f"receive message: {message}") payload = json.loads(message) if payload["type"] == "warp_to_line": file = payload["file"] line = payload["line"] py_exec(f"renpy.warp_to_line('{file}:{line}')") elif payload["type"] == "set_autoreload": script = textwrap.dedent(""" if renpy.get_autoreload() == False: renpy.set_autoreload(True) renpy.reload_script() """) py_exec(script) elif payload["type"] == "jump_to_label": label = payload["label"] script = textwrap.dedent(f""" if renpy.context_nesting_level() > 0: renpy.jump_out_of_context('{label}') else: renpy.jump('{label}') """) py_exec(script) else: logger.warning(f"unhandled message type '{payload['type']}'") def socket_producer(websocket): """produces messages to the socket server""" from websockets.exceptions import ConnectionClosed # type: ignore send = functools.partial(socket_send, websocket=websocket) # report current line to warp server def fn(event, interact=True, **kwargs): if not interact: return if event == "begin": filename, line = renpy.exports.get_filename_line() relative_filename = Path(filename).relative_to('game') filename_abs = Path(renpy.config.gamedir, relative_filename) message = { "type": "current_line", "line": line, "path": filename_abs.resolve().as_posix(), "relative_path": relative_filename.resolve().as_posix(), } try: send(message) except ConnectionClosed: # socket is closed, remove the callback renpy.config.all_character_callbacks.remove(fn) renpy.config.all_character_callbacks.append(fn) def label_callback(name, abnormal): try: send({"type": "current_label", "label": name}) except ConnectionClosed: # socket is closed, remove the callback renpy.config.label_callbacks.remove(label_callback) renpy.config.label_callbacks.append(label_callback) send({"type": "list_labels", "labels": list(renpy.exports.get_all_labels())}) def socket_service(port, version, checksum): """connects to the socket server. returns True if the connection has completed its lifecycle""" # websockets module is bundled with renpy on versions >=8.2.0 from websockets.sync.client import connect # type: ignore from websockets.exceptions import ( # type: ignore WebSocketException, ConnectionClosedOK, ConnectionClosedError ) logger.debug(f"try port {port}") try: headers = { "pid": str(os.getpid()), "warp-project-root": Path(renpy.config.gamedir).parent.resolve().as_posix(), "warp-version": version, "warp-checksum": checksum, } if os.getenv("WARP_WS_NONCE"): headers["warp-nonce"] = os.getenv("WARP_WS_NONCE") with connect( f"ws://localhost:{port}", additional_headers=headers, open_timeout=None, close_timeout=5, ) as websocket: quitting = False def renpy_warp_quit_callback(): nonlocal quitting quitting = True logger.info(f"closing websocket connection :{port}") websocket.close(4000, 'renpy quit') renpy.config.quit_callbacks.append(renpy_warp_quit_callback) renpy.config.quit_action = RenpyWarpQuitAction() logger.info(f"connected to renpy warp socket server on :{port}") py_exec("renpy.notify(\"Connected to Ren'Py Launch and Sync\")") socket_producer(websocket) socket_listener(websocket) # this blocks until socket is closed renpy.config.quit_callbacks.remove(renpy_warp_quit_callback) renpy.config.quit_action = original_quit_action logger.info(f"socket service on :{port} exited") if not quitting: py_exec( "renpy.notify(\"Disconnected from Ren'Py Launch and Sync\")") except ConnectionClosedOK: logger.info(f"socket service on :{port} was terminated by server") pass except ConnectionClosedError: logger.info("connection replaced, service exiting") return True except WebSocketException as e: logger.exception("unexpected websocket error", exc_info=e) except (ConnectionError, TimeoutError) as e: logger.debug( f"{e.__class__.__name__}: could not establish connection to socket server") return False def try_socket_ports_forever(): version, checksum = get_meta() service_closed = False while service_closed is False: for port in range(40111, 40121): service_closed = socket_service( port=port, version=version, checksum=checksum) if service_closed: break if service_closed: break logger.debug( "exhausted all ports, waiting 3 seconds before retrying") sleep(3) logger.info("service closed") def start_renpy_warp_service(): if renpy.config.developer: renpy_warp_thread = threading.Thread( target=try_socket_ports_forever, daemon=True) renpy_warp_thread.start() logger.info( "service thread started. periodically scanning ports for warp server") def declassify(): """ removes `renpy_warp_*.rpe{.py}` from build on renpy 8.3 and later, this is automatically done by the renpy build system """ classify = renpy.python.store_dicts["store.build"]["classify"] classify("game/renpy_warp_*.rpe", None) classify("game/renpy_warp_*.rpe.py", None) renpy.game.post_init.append(declassify) renpy.config.display_start_callbacks.append(start_renpy_warp_service)