inscript-lang 2.12.0__tar.gz → 2.13.0__tar.gz
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.
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/PKG-INFO +1 -1
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/export_pipeline.py +112 -1
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript.py +1 -1
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/PKG-INFO +1 -1
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_game.py +40 -0
- inscript_lang-2.13.0/studio_bridge.py +585 -0
- inscript_lang-2.12.0/studio_bridge.py +0 -239
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/README.md +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/analyzer.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/ast_nodes.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/compiler.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/environment.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/errors.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/hot_reload.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_dap.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_fmt.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/SOURCES.txt +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/dependency_links.txt +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/entry_points.txt +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/requires.txt +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_lang.egg-info/top_level.txt +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_studio_api.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/inscript_test.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/interpreter.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/lexer.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/parser.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/pygame_backend.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/pyproject.toml +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/repl.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/scene_tree.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/setup.cfg +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/setup.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_assets.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_extended.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_extended_2.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/stdlib_values.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/studio_readiness.py +0 -0
- {inscript_lang-2.12.0 → inscript_lang-2.13.0}/vm.py +0 -0
|
@@ -42,7 +42,7 @@ MANIFEST_FILE = "inscript.toml"
|
|
|
42
42
|
ENTRY_DEFAULT = os.path.join("src", "main.ins")
|
|
43
43
|
BUILD_MANIFEST = "build.toml"
|
|
44
44
|
|
|
45
|
-
VALID_TARGETS = ("desktop", "web", "android", "ibc")
|
|
45
|
+
VALID_TARGETS = ("desktop", "web", "android", "ios", "ibc")
|
|
46
46
|
|
|
47
47
|
# Pyodide CDN base used in web builds
|
|
48
48
|
PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.27.0/full/"
|
|
@@ -645,3 +645,114 @@ def run_build(target: str, project_dir: str = ".") -> int:
|
|
|
645
645
|
for e in r.errors:
|
|
646
646
|
print(f"[build] {e}", file=sys.stderr)
|
|
647
647
|
return 0 if r.success else 1
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
651
|
+
# v2.13.0: Target: iOS
|
|
652
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
def build_ios(project_dir: str = ".") -> BuildResult:
|
|
655
|
+
"""
|
|
656
|
+
Build an iOS app via BeeWare Briefcase.
|
|
657
|
+
Requires: macOS + Xcode + pip install briefcase
|
|
658
|
+
|
|
659
|
+
Output layout:
|
|
660
|
+
build/ios/
|
|
661
|
+
pyproject.toml — Briefcase config
|
|
662
|
+
app.py — InScript iOS entry point
|
|
663
|
+
build.toml — build manifest
|
|
664
|
+
"""
|
|
665
|
+
import time, subprocess
|
|
666
|
+
t0 = time.perf_counter()
|
|
667
|
+
errors: List[str] = []
|
|
668
|
+
artifacts: List[str] = []
|
|
669
|
+
|
|
670
|
+
manifest = _read_manifest(project_dir)
|
|
671
|
+
pkg = manifest.get("package", {})
|
|
672
|
+
version = pkg.get("version", "0.0.0")
|
|
673
|
+
name = pkg.get("name", "mygame")
|
|
674
|
+
entry = pkg.get("entry", ENTRY_DEFAULT)
|
|
675
|
+
bundle = pkg.get("bundle", f"dev.inscript.{name}")
|
|
676
|
+
|
|
677
|
+
out_dir = os.path.join(project_dir, BUILD_DIR, "ios")
|
|
678
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
679
|
+
|
|
680
|
+
bp_path = os.path.join(out_dir, "pyproject.toml")
|
|
681
|
+
with open(bp_path, "w") as f:
|
|
682
|
+
f.write(textwrap.dedent(f"""\
|
|
683
|
+
[tool.briefcase]
|
|
684
|
+
project_name = "{name}"
|
|
685
|
+
bundle = "{bundle}"
|
|
686
|
+
version = "{version}"
|
|
687
|
+
description = "Built with InScript"
|
|
688
|
+
license = "MIT"
|
|
689
|
+
|
|
690
|
+
[tool.briefcase.app.{name}]
|
|
691
|
+
formal_name = "{name}"
|
|
692
|
+
description = "Built with InScript"
|
|
693
|
+
sources = ["."]
|
|
694
|
+
requires = ["inscript-lang"]
|
|
695
|
+
|
|
696
|
+
[tool.briefcase.app.{name}.iOS]
|
|
697
|
+
requires = ["toga-iOS"]
|
|
698
|
+
"""))
|
|
699
|
+
artifacts.append(bp_path)
|
|
700
|
+
|
|
701
|
+
app_py = os.path.join(out_dir, "app.py")
|
|
702
|
+
with open(app_py, "w") as f:
|
|
703
|
+
f.write(textwrap.dedent(f"""\
|
|
704
|
+
# InScript iOS entry — generated by inscript build --target ios
|
|
705
|
+
import sys
|
|
706
|
+
try:
|
|
707
|
+
import inscript
|
|
708
|
+
inscript.main(["{entry}"])
|
|
709
|
+
except Exception as e:
|
|
710
|
+
print(f"InScript launch error: {{e}}", file=sys.stderr)
|
|
711
|
+
"""))
|
|
712
|
+
artifacts.append(app_py)
|
|
713
|
+
|
|
714
|
+
for sub in ("src", "assets", "scenes"):
|
|
715
|
+
s = os.path.join(project_dir, sub)
|
|
716
|
+
d = os.path.join(out_dir, sub)
|
|
717
|
+
if os.path.isdir(s):
|
|
718
|
+
if os.path.exists(d): shutil.rmtree(d)
|
|
719
|
+
shutil.copytree(s, d)
|
|
720
|
+
|
|
721
|
+
from inscript import VERSION as _IV
|
|
722
|
+
bm = _write_build_manifest(out_dir, {
|
|
723
|
+
"target": "ios", "version": version, "entry": entry,
|
|
724
|
+
"inscript_version": _IV, "bundle": bundle,
|
|
725
|
+
"artifacts": {"pyproject": "pyproject.toml", "app": "app.py"},
|
|
726
|
+
"asset_hashes": {},
|
|
727
|
+
})
|
|
728
|
+
artifacts.append(bm)
|
|
729
|
+
|
|
730
|
+
# Check platform and briefcase
|
|
731
|
+
if sys.platform != "darwin":
|
|
732
|
+
msg = "iOS builds require macOS. Current platform: " + sys.platform
|
|
733
|
+
errors.append(msg)
|
|
734
|
+
print(f"[build:ios] ⚠ {msg}")
|
|
735
|
+
elapsed = (time.perf_counter() - t0) * 1000
|
|
736
|
+
return BuildResult("ios", False, out_dir, artifacts, errors, elapsed)
|
|
737
|
+
|
|
738
|
+
briefcase_ok = shutil.which("briefcase") is not None
|
|
739
|
+
if not briefcase_ok:
|
|
740
|
+
msg = ("BeeWare Briefcase not installed. "
|
|
741
|
+
"Install: pip install briefcase "
|
|
742
|
+
"Then: cd build/ios && briefcase create iOS && briefcase build iOS")
|
|
743
|
+
errors.append(msg)
|
|
744
|
+
print(f"[build:ios] ⚠ {msg}")
|
|
745
|
+
elapsed = (time.perf_counter() - t0) * 1000
|
|
746
|
+
return BuildResult("ios", False, out_dir, artifacts, errors, elapsed)
|
|
747
|
+
|
|
748
|
+
try:
|
|
749
|
+
subprocess.run([sys.executable, "-m", "briefcase", "create", "iOS"],
|
|
750
|
+
cwd=out_dir, check=True)
|
|
751
|
+
subprocess.run([sys.executable, "-m", "briefcase", "build", "iOS"],
|
|
752
|
+
cwd=out_dir, check=True)
|
|
753
|
+
print(f"[build:ios] ✅ iOS app built → {out_dir}")
|
|
754
|
+
except subprocess.CalledProcessError as e:
|
|
755
|
+
errors.append(f"briefcase failed: {e}")
|
|
756
|
+
|
|
757
|
+
elapsed = (time.perf_counter() - t0) * 1000
|
|
758
|
+
return BuildResult("ios", len(errors) == 0, out_dir, artifacts, errors, elapsed)
|
|
@@ -1121,6 +1121,36 @@ class _InputManager:
|
|
|
1121
1121
|
self._key_state = {} # key -> (pressed_this_frame, held, released_this_frame)
|
|
1122
1122
|
self._mouse_x = 0.0; self._mouse_y = 0.0
|
|
1123
1123
|
self._mouse_buttons = {}
|
|
1124
|
+
# v2.13.0: headless emulation — inject key/mouse state without pygame
|
|
1125
|
+
self._emulated_keys: dict = {} # key_name -> bool (held)
|
|
1126
|
+
self._emulated_mouse: tuple = (0.0, 0.0)
|
|
1127
|
+
self._emulated_buttons: dict = {}
|
|
1128
|
+
self._headless = False # set True when pygame unavailable
|
|
1129
|
+
|
|
1130
|
+
def _try_pygame(self) -> bool:
|
|
1131
|
+
try:
|
|
1132
|
+
import pygame
|
|
1133
|
+
return pygame.get_init()
|
|
1134
|
+
except Exception:
|
|
1135
|
+
return False
|
|
1136
|
+
|
|
1137
|
+
# v2.13.0: headless injection API
|
|
1138
|
+
def emulate_key(self, key: str, held: bool = True):
|
|
1139
|
+
"""Inject a key state for headless/Studio preview mode."""
|
|
1140
|
+
self._emulated_keys[key.lower()] = bool(held)
|
|
1141
|
+
self._headless = True
|
|
1142
|
+
|
|
1143
|
+
def emulate_mouse(self, x: float, y: float, buttons: dict | None = None):
|
|
1144
|
+
"""Inject mouse position and button state."""
|
|
1145
|
+
self._emulated_mouse = (float(x), float(y))
|
|
1146
|
+
self._emulated_buttons = buttons or {}
|
|
1147
|
+
self._headless = True
|
|
1148
|
+
|
|
1149
|
+
def clear_emulation(self):
|
|
1150
|
+
"""Clear all emulated input state."""
|
|
1151
|
+
self._emulated_keys.clear()
|
|
1152
|
+
self._emulated_buttons.clear()
|
|
1153
|
+
self._headless = False
|
|
1124
1154
|
|
|
1125
1155
|
def map(self, action, keys=None, axes=None, gamepad=None, gamepad_axis=None):
|
|
1126
1156
|
self._actions[action] = {
|
|
@@ -1129,6 +1159,9 @@ class _InputManager:
|
|
|
1129
1159
|
}
|
|
1130
1160
|
|
|
1131
1161
|
def _is_key_down(self, key):
|
|
1162
|
+
# v2.13.0: headless emulation takes priority over pygame
|
|
1163
|
+
if self._headless or self._emulated_keys:
|
|
1164
|
+
return self._emulated_keys.get(key.lower(), False)
|
|
1132
1165
|
try:
|
|
1133
1166
|
import pygame
|
|
1134
1167
|
kmap = {
|
|
@@ -1169,12 +1202,19 @@ class _InputManager:
|
|
|
1169
1202
|
return max(-1.0, min(1.0, value))
|
|
1170
1203
|
|
|
1171
1204
|
def mouse_pos(self):
|
|
1205
|
+
# v2.13.0: headless emulation
|
|
1206
|
+
if self._headless or self._emulated_mouse != (0.0, 0.0):
|
|
1207
|
+
x, y = self._emulated_mouse
|
|
1208
|
+
return {"x": float(x), "y": float(y)}
|
|
1172
1209
|
try:
|
|
1173
1210
|
import pygame; x, y = pygame.mouse.get_pos(); return {"x": float(x), "y": float(y)}
|
|
1174
1211
|
except Exception:
|
|
1175
1212
|
return {"x": self._mouse_x, "y": self._mouse_y}
|
|
1176
1213
|
|
|
1177
1214
|
def mouse_pressed(self, button=0):
|
|
1215
|
+
# v2.13.0: headless emulation
|
|
1216
|
+
if self._headless or self._emulated_buttons:
|
|
1217
|
+
return self._emulated_buttons.get(int(button), False)
|
|
1178
1218
|
try:
|
|
1179
1219
|
import pygame; return bool(pygame.mouse.get_pressed()[int(button)])
|
|
1180
1220
|
except Exception:
|
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
studio_bridge.py — InScript v2.12.0 Electron Bridge
|
|
4
|
+
================================================================
|
|
5
|
+
A lightweight JSON-RPC HTTP server that the InScript Studio Electron
|
|
6
|
+
shell calls to drive the runtime.
|
|
7
|
+
|
|
8
|
+
Start from CLI:
|
|
9
|
+
inscript --studio-bridge [--bridge-port 8765]
|
|
10
|
+
|
|
11
|
+
Or from Python:
|
|
12
|
+
from studio_bridge import StudioBridge
|
|
13
|
+
bridge = StudioBridge(port=8765)
|
|
14
|
+
bridge.start() # non-blocking daemon thread
|
|
15
|
+
bridge.stop()
|
|
16
|
+
|
|
17
|
+
JSON-RPC protocol (HTTP POST to /rpc):
|
|
18
|
+
Request: { "method": "run", "id": 1, "params": {"file": "src/main.ins"} }
|
|
19
|
+
Response: { "result": "ok", "id": 1 }
|
|
20
|
+
Error: { "error": "...", "id": 1 }
|
|
21
|
+
|
|
22
|
+
Methods
|
|
23
|
+
───────
|
|
24
|
+
run — execute a .ins file; returns {"status": "ok"|"error", "output": [...]}
|
|
25
|
+
stop — stop a running interpreter
|
|
26
|
+
hot_reload — trigger hot reload on a file
|
|
27
|
+
scene_list — return all scene/node declarations in project
|
|
28
|
+
inspect — full project introspection (scenes, nodes, fns, assets)
|
|
29
|
+
eval — evaluate an InScript expression in live env
|
|
30
|
+
ping — health check; returns {"pong": true}
|
|
31
|
+
version — returns {"version": "2.12.0"}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
import json, threading, sys, os, io
|
|
36
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
37
|
+
from typing import Any, Dict, Optional
|
|
38
|
+
|
|
39
|
+
DEFAULT_PORT = 8765
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _RpcHandler(BaseHTTPRequestHandler):
|
|
43
|
+
"""HTTP handler — all requests are POST to /rpc."""
|
|
44
|
+
|
|
45
|
+
def log_message(self, fmt, *args):
|
|
46
|
+
pass # suppress default access log
|
|
47
|
+
|
|
48
|
+
def do_POST(self):
|
|
49
|
+
if self.path != "/rpc":
|
|
50
|
+
self._reply(404, {"error": "Not found — POST to /rpc"})
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
54
|
+
body = self.rfile.read(length)
|
|
55
|
+
request = json.loads(body)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
self._reply(400, {"error": f"Bad JSON: {e}", "id": None})
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
req_id = request.get("id")
|
|
61
|
+
method = request.get("method", "")
|
|
62
|
+
params = request.get("params", {})
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
result = self.server._bridge._dispatch(method, params)
|
|
66
|
+
self._reply(200, {"result": result, "id": req_id})
|
|
67
|
+
except Exception as e:
|
|
68
|
+
self._reply(200, {"error": str(e), "id": req_id})
|
|
69
|
+
|
|
70
|
+
def _reply(self, status: int, data: dict):
|
|
71
|
+
body = json.dumps(data, default=str).encode()
|
|
72
|
+
self.send_response(status)
|
|
73
|
+
self.send_header("Content-Type", "application/json")
|
|
74
|
+
self.send_header("Content-Length", str(len(body)))
|
|
75
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
76
|
+
self.end_headers()
|
|
77
|
+
self.wfile.write(body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StudioBridge:
|
|
81
|
+
"""
|
|
82
|
+
Lightweight JSON-RPC bridge between the InScript runtime and
|
|
83
|
+
the Electron Studio shell.
|
|
84
|
+
"""
|
|
85
|
+
def __init__(self, port: int = DEFAULT_PORT):
|
|
86
|
+
self._port = port
|
|
87
|
+
self._server: Optional[HTTPServer] = None
|
|
88
|
+
self._thread: Optional[threading.Thread] = None
|
|
89
|
+
self._interp = None # live Interpreter (created on first run)
|
|
90
|
+
self._lock = threading.Lock()
|
|
91
|
+
self._running = False
|
|
92
|
+
|
|
93
|
+
# ── lifecycle ──────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def start(self) -> "StudioBridge":
|
|
96
|
+
"""Start the HTTP server in a daemon thread. Non-blocking."""
|
|
97
|
+
self._server = HTTPServer(("localhost", self._port), _RpcHandler)
|
|
98
|
+
self._server._bridge = self
|
|
99
|
+
self._running = True
|
|
100
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
101
|
+
self._thread.start()
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def stop(self):
|
|
105
|
+
"""Shut down the HTTP server."""
|
|
106
|
+
self._running = False
|
|
107
|
+
if self._server:
|
|
108
|
+
self._server.shutdown()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def port(self) -> int:
|
|
112
|
+
return self._port
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def url(self) -> str:
|
|
116
|
+
return f"http://localhost:{self._port}/rpc"
|
|
117
|
+
|
|
118
|
+
# ── RPC dispatch ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _dispatch(self, method: str, params: dict) -> Any:
|
|
121
|
+
handlers = {
|
|
122
|
+
"ping": self._rpc_ping,
|
|
123
|
+
"version": self._rpc_version,
|
|
124
|
+
"run": self._rpc_run,
|
|
125
|
+
"stop": self._rpc_stop,
|
|
126
|
+
"hot_reload": self._rpc_hot_reload,
|
|
127
|
+
"scene_list": self._rpc_scene_list,
|
|
128
|
+
"inspect": self._rpc_inspect,
|
|
129
|
+
"eval": self._rpc_eval,
|
|
130
|
+
}
|
|
131
|
+
handler = handlers.get(method)
|
|
132
|
+
if handler is None:
|
|
133
|
+
raise ValueError(f"Unknown method '{method}'. Available: {sorted(handlers)}")
|
|
134
|
+
return handler(params)
|
|
135
|
+
|
|
136
|
+
# ── RPC handlers ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _rpc_ping(self, _params: dict) -> dict:
|
|
139
|
+
return {"pong": True, "port": self._port}
|
|
140
|
+
|
|
141
|
+
def _rpc_version(self, _params: dict) -> dict:
|
|
142
|
+
from inscript import VERSION
|
|
143
|
+
return {"version": VERSION, "bridge": "1.0"}
|
|
144
|
+
|
|
145
|
+
def _rpc_run(self, params: dict) -> dict:
|
|
146
|
+
"""Run a .ins file and return captured output + errors."""
|
|
147
|
+
file_path = params.get("file", "")
|
|
148
|
+
if not file_path:
|
|
149
|
+
raise ValueError("'file' parameter required")
|
|
150
|
+
if not os.path.isfile(file_path):
|
|
151
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
152
|
+
|
|
153
|
+
with open(file_path, encoding="utf-8") as f:
|
|
154
|
+
source = f.read()
|
|
155
|
+
|
|
156
|
+
output_lines: list = []
|
|
157
|
+
errors: list = []
|
|
158
|
+
|
|
159
|
+
# Redirect stdout
|
|
160
|
+
old_stdout = sys.stdout
|
|
161
|
+
sys.stdout = io.StringIO()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
from interpreter import Interpreter
|
|
165
|
+
from errors import InScriptError
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._interp = Interpreter()
|
|
168
|
+
self._interp.execute(source)
|
|
169
|
+
output_lines = sys.stdout.getvalue().splitlines()
|
|
170
|
+
except Exception as e:
|
|
171
|
+
from errors import InScriptError
|
|
172
|
+
if isinstance(e, InScriptError):
|
|
173
|
+
errors.append(e.to_dict())
|
|
174
|
+
else:
|
|
175
|
+
errors.append({"type": "error", "code": "E0000",
|
|
176
|
+
"class": type(e).__name__, "message": str(e),
|
|
177
|
+
"line": 0, "col": 0})
|
|
178
|
+
output_lines = sys.stdout.getvalue().splitlines()
|
|
179
|
+
finally:
|
|
180
|
+
sys.stdout = old_stdout
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"status": "error" if errors else "ok",
|
|
184
|
+
"output": output_lines,
|
|
185
|
+
"errors": errors,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def _rpc_stop(self, _params: dict) -> dict:
|
|
189
|
+
with self._lock:
|
|
190
|
+
self._interp = None
|
|
191
|
+
return {"stopped": True}
|
|
192
|
+
|
|
193
|
+
def _rpc_hot_reload(self, params: dict) -> dict:
|
|
194
|
+
"""Trigger a hot reload on a running file."""
|
|
195
|
+
file_path = params.get("file", "")
|
|
196
|
+
if not file_path:
|
|
197
|
+
raise ValueError("'file' parameter required")
|
|
198
|
+
with self._lock:
|
|
199
|
+
if self._interp is None:
|
|
200
|
+
raise RuntimeError("No running interpreter — call 'run' first")
|
|
201
|
+
from hot_reload import HotReloader
|
|
202
|
+
reloader = HotReloader(file_path, self._interp)
|
|
203
|
+
result = reloader.tick()
|
|
204
|
+
return {
|
|
205
|
+
"changed": result.changed,
|
|
206
|
+
"success": result.success,
|
|
207
|
+
"patched": result.patched,
|
|
208
|
+
"restored": len(result.restored),
|
|
209
|
+
"errors": result.errors,
|
|
210
|
+
"elapsed_ms": result.elapsed_ms,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def _rpc_scene_list(self, params: dict) -> dict:
|
|
214
|
+
"""Return all scene and node declarations in a project directory."""
|
|
215
|
+
project_dir = params.get("dir", ".")
|
|
216
|
+
from inscript_studio_api import project_inspect
|
|
217
|
+
data = project_inspect(project_dir)
|
|
218
|
+
return {"scenes": data["scenes"], "nodes": data["nodes"]}
|
|
219
|
+
|
|
220
|
+
def _rpc_inspect(self, params: dict) -> dict:
|
|
221
|
+
"""Full project introspection."""
|
|
222
|
+
project_dir = params.get("dir", ".")
|
|
223
|
+
from inscript_studio_api import project_inspect
|
|
224
|
+
return project_inspect(project_dir)
|
|
225
|
+
|
|
226
|
+
def _rpc_eval(self, params: dict) -> dict:
|
|
227
|
+
"""Evaluate an InScript expression in the live interpreter environment."""
|
|
228
|
+
expr = params.get("expr", "")
|
|
229
|
+
if not expr:
|
|
230
|
+
raise ValueError("'expr' parameter required")
|
|
231
|
+
with self._lock:
|
|
232
|
+
if self._interp is None:
|
|
233
|
+
raise RuntimeError("No running interpreter — call 'run' first")
|
|
234
|
+
result = self._interp.execute(expr)
|
|
235
|
+
return {"result": result, "type": type(result).__name__}
|
|
236
|
+
|
|
237
|
+
def __repr__(self):
|
|
238
|
+
status = "running" if self._running else "stopped"
|
|
239
|
+
return f"<StudioBridge port={self._port} {status}>"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
243
|
+
# v2.13.0 — StudioBridge v2 additions
|
|
244
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
245
|
+
|
|
246
|
+
import subprocess as _subprocess
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _GameProcess:
|
|
250
|
+
"""
|
|
251
|
+
Manages a single game process launched by `start_game`.
|
|
252
|
+
The game runs in a subprocess; the bridge stays responsive.
|
|
253
|
+
"""
|
|
254
|
+
def __init__(self, file_path: str, python_exe: str = None):
|
|
255
|
+
self.file_path = file_path
|
|
256
|
+
self._python = python_exe or sys.executable
|
|
257
|
+
self._proc: Optional[_subprocess.Popen] = None
|
|
258
|
+
self._output: list = [] # captured stdout lines
|
|
259
|
+
self._errors: list = []
|
|
260
|
+
self._thread: Optional[threading.Thread] = None
|
|
261
|
+
self._running = False
|
|
262
|
+
|
|
263
|
+
def start(self) -> bool:
|
|
264
|
+
if self._proc is not None and self._proc.poll() is None:
|
|
265
|
+
return False # already running
|
|
266
|
+
inscript_path = os.path.join(os.path.dirname(__file__), "inscript.py")
|
|
267
|
+
self._proc = _subprocess.Popen(
|
|
268
|
+
[self._python, inscript_path, self.file_path],
|
|
269
|
+
stdout=_subprocess.PIPE,
|
|
270
|
+
stderr=_subprocess.STDOUT,
|
|
271
|
+
text=True,
|
|
272
|
+
bufsize=1,
|
|
273
|
+
)
|
|
274
|
+
self._running = True
|
|
275
|
+
self._output = []
|
|
276
|
+
self._errors = []
|
|
277
|
+
self._thread = threading.Thread(target=self._read_output, daemon=True)
|
|
278
|
+
self._thread.start()
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
def stop(self) -> bool:
|
|
282
|
+
if self._proc is None:
|
|
283
|
+
return False
|
|
284
|
+
self._running = False
|
|
285
|
+
try:
|
|
286
|
+
self._proc.terminate()
|
|
287
|
+
self._proc.wait(timeout=2)
|
|
288
|
+
except Exception:
|
|
289
|
+
try: self._proc.kill()
|
|
290
|
+
except Exception: pass
|
|
291
|
+
self._proc = None
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
def is_running(self) -> bool:
|
|
295
|
+
return self._proc is not None and self._proc.poll() is None
|
|
296
|
+
|
|
297
|
+
def get_output(self, since: int = 0) -> list:
|
|
298
|
+
return self._output[since:]
|
|
299
|
+
|
|
300
|
+
def _read_output(self):
|
|
301
|
+
try:
|
|
302
|
+
for line in self._proc.stdout:
|
|
303
|
+
self._output.append(line.rstrip("\n"))
|
|
304
|
+
except Exception:
|
|
305
|
+
pass
|
|
306
|
+
self._running = False
|
|
307
|
+
|
|
308
|
+
def __repr__(self):
|
|
309
|
+
status = "running" if self.is_running() else "stopped"
|
|
310
|
+
return f"<GameProcess '{os.path.basename(self.file_path)}' {status}>"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _extend_bridge(bridge_cls):
|
|
314
|
+
"""
|
|
315
|
+
Monkey-patch v2.13.0 methods onto StudioBridge.
|
|
316
|
+
Called once at module load.
|
|
317
|
+
"""
|
|
318
|
+
# ── Game process management ───────────────────────────────────────────────
|
|
319
|
+
bridge_cls._game_process = None
|
|
320
|
+
|
|
321
|
+
def _rpc_start_game(self, params: dict) -> dict:
|
|
322
|
+
"""
|
|
323
|
+
Launch a .ins game as a subprocess (non-blocking).
|
|
324
|
+
Bridge stays responsive during game execution.
|
|
325
|
+
"""
|
|
326
|
+
file_path = params.get("file", "")
|
|
327
|
+
if not file_path or not os.path.isfile(file_path):
|
|
328
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
329
|
+
with self._lock:
|
|
330
|
+
if self._game_process and self._game_process.is_running():
|
|
331
|
+
self._game_process.stop()
|
|
332
|
+
self._game_process = _GameProcess(file_path)
|
|
333
|
+
started = self._game_process.start()
|
|
334
|
+
return {"started": started, "file": file_path,
|
|
335
|
+
"pid": self._game_process._proc.pid if started else None}
|
|
336
|
+
|
|
337
|
+
def _rpc_stop_game(self, params: dict) -> dict:
|
|
338
|
+
with self._lock:
|
|
339
|
+
if self._game_process is None:
|
|
340
|
+
return {"stopped": False, "reason": "no game running"}
|
|
341
|
+
stopped = self._game_process.stop()
|
|
342
|
+
self._game_process = None
|
|
343
|
+
return {"stopped": stopped}
|
|
344
|
+
|
|
345
|
+
def _rpc_game_status(self, params: dict) -> dict:
|
|
346
|
+
with self._lock:
|
|
347
|
+
if self._game_process is None:
|
|
348
|
+
return {"running": False, "output_lines": 0}
|
|
349
|
+
since = int(params.get("since", 0))
|
|
350
|
+
return {
|
|
351
|
+
"running": self._game_process.is_running(),
|
|
352
|
+
"output": self._game_process.get_output(since),
|
|
353
|
+
"output_lines": len(self._game_process._output),
|
|
354
|
+
"file": self._game_process.file_path,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
# ── Live scene editing ────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
def _rpc_set_node_prop(self, params: dict) -> dict:
|
|
360
|
+
"""
|
|
361
|
+
Set a property on a live NodeInstance.
|
|
362
|
+
Requires a running interpreter (from `run` RPC, not `start_game`).
|
|
363
|
+
"""
|
|
364
|
+
node_name = params.get("node", "")
|
|
365
|
+
prop = params.get("prop", "")
|
|
366
|
+
value = params.get("value")
|
|
367
|
+
if not node_name or not prop:
|
|
368
|
+
raise ValueError("'node' and 'prop' are required")
|
|
369
|
+
with self._lock:
|
|
370
|
+
if self._interp is None:
|
|
371
|
+
raise RuntimeError("No live interpreter — use 'run' first")
|
|
372
|
+
from scene_tree import SceneManager, NodeInstance
|
|
373
|
+
# Try to find node in global env first
|
|
374
|
+
try:
|
|
375
|
+
obj = self._interp._globals._store.get(node_name)
|
|
376
|
+
except Exception:
|
|
377
|
+
obj = None
|
|
378
|
+
if isinstance(obj, NodeInstance):
|
|
379
|
+
obj[prop] = value
|
|
380
|
+
return {"node": node_name, "prop": prop, "value": value, "ok": True}
|
|
381
|
+
# Fall back: set in global env
|
|
382
|
+
try:
|
|
383
|
+
self._interp._globals.set(prop, value)
|
|
384
|
+
return {"prop": prop, "value": value, "ok": True, "global": True}
|
|
385
|
+
except Exception as e:
|
|
386
|
+
raise RuntimeError(f"Cannot set {node_name}.{prop}: {e}")
|
|
387
|
+
|
|
388
|
+
def _rpc_add_node(self, params: dict) -> dict:
|
|
389
|
+
"""
|
|
390
|
+
Instantiate a NodeBlueprint and add it to the live scene.
|
|
391
|
+
"""
|
|
392
|
+
blueprint_name = params.get("blueprint", "")
|
|
393
|
+
node_name = params.get("name", blueprint_name)
|
|
394
|
+
parent_name = params.get("parent", None)
|
|
395
|
+
if not blueprint_name:
|
|
396
|
+
raise ValueError("'blueprint' required")
|
|
397
|
+
with self._lock:
|
|
398
|
+
if self._interp is None:
|
|
399
|
+
raise RuntimeError("No live interpreter")
|
|
400
|
+
from scene_tree import NodeBlueprint, NodeInstance
|
|
401
|
+
bp = self._interp._globals._store.get(blueprint_name)
|
|
402
|
+
if not isinstance(bp, NodeBlueprint):
|
|
403
|
+
raise KeyError(f"No NodeBlueprint '{blueprint_name}' in env")
|
|
404
|
+
inst = bp.instantiate(node_name)
|
|
405
|
+
if parent_name:
|
|
406
|
+
parent = self._interp._globals._store.get(parent_name)
|
|
407
|
+
if isinstance(parent, NodeInstance):
|
|
408
|
+
parent.add_child(inst)
|
|
409
|
+
self._interp._globals.define(node_name, inst)
|
|
410
|
+
return {"added": node_name, "blueprint": blueprint_name, "ok": True}
|
|
411
|
+
|
|
412
|
+
def _rpc_remove_node(self, params: dict) -> dict:
|
|
413
|
+
"""Remove a NodeInstance from its parent in the live scene."""
|
|
414
|
+
node_name = params.get("node", "")
|
|
415
|
+
if not node_name:
|
|
416
|
+
raise ValueError("'node' required")
|
|
417
|
+
with self._lock:
|
|
418
|
+
if self._interp is None:
|
|
419
|
+
raise RuntimeError("No live interpreter")
|
|
420
|
+
from scene_tree import NodeInstance
|
|
421
|
+
inst = self._interp._globals._store.get(node_name)
|
|
422
|
+
if not isinstance(inst, NodeInstance):
|
|
423
|
+
raise KeyError(f"No NodeInstance '{node_name}' in env")
|
|
424
|
+
if inst._parent:
|
|
425
|
+
inst._parent.remove_child(inst)
|
|
426
|
+
return {"removed": node_name, "ok": True}
|
|
427
|
+
|
|
428
|
+
def _rpc_get_live_scene(self, params: dict) -> dict:
|
|
429
|
+
"""
|
|
430
|
+
Introspect the live interpreter's scene state.
|
|
431
|
+
Returns all NodeInstance names, their props, and parent relationships.
|
|
432
|
+
"""
|
|
433
|
+
with self._lock:
|
|
434
|
+
if self._interp is None:
|
|
435
|
+
return {"nodes": [], "count": 0, "reason": "no live interpreter"}
|
|
436
|
+
from scene_tree import NodeInstance
|
|
437
|
+
nodes = []
|
|
438
|
+
for name, val in self._interp._globals._store.items():
|
|
439
|
+
if isinstance(val, NodeInstance):
|
|
440
|
+
nodes.append({
|
|
441
|
+
"name": name,
|
|
442
|
+
"blueprint": val.blueprint.name,
|
|
443
|
+
"parent": val._parent.name if val._parent else None,
|
|
444
|
+
"children": [c.name for c in val.get_children()],
|
|
445
|
+
"props": {k: str(v) for k, v in val._props.items()
|
|
446
|
+
if not callable(v)},
|
|
447
|
+
})
|
|
448
|
+
return {"nodes": nodes, "count": len(nodes)}
|
|
449
|
+
|
|
450
|
+
# ── DAP debugger wiring ───────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
def _rpc_set_breakpoints(self, params: dict) -> dict:
|
|
453
|
+
"""Set breakpoints for the next debug run."""
|
|
454
|
+
lines = [int(l) for l in params.get("lines", [])]
|
|
455
|
+
file_path = params.get("file", "")
|
|
456
|
+
with self._lock:
|
|
457
|
+
if not hasattr(self, '_debug_interp') or self._debug_interp is None:
|
|
458
|
+
# Lazy-create debug interpreter on first breakpoint set
|
|
459
|
+
self._debug_interp = None
|
|
460
|
+
self._debug_bp_lines = lines
|
|
461
|
+
self._debug_bp_file = file_path
|
|
462
|
+
else:
|
|
463
|
+
self._debug_interp.set_breakpoints(lines)
|
|
464
|
+
return {"breakpoints": lines, "file": file_path}
|
|
465
|
+
|
|
466
|
+
def _rpc_debug_run(self, params: dict) -> dict:
|
|
467
|
+
"""Run a file with debug hooks enabled."""
|
|
468
|
+
file_path = params.get("file", "")
|
|
469
|
+
if not file_path or not os.path.isfile(file_path):
|
|
470
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
471
|
+
from inscript_dap import DebugInterpreter
|
|
472
|
+
with open(file_path, encoding="utf-8") as f:
|
|
473
|
+
source = f.read()
|
|
474
|
+
with self._lock:
|
|
475
|
+
self._debug_interp = DebugInterpreter(source, file_path)
|
|
476
|
+
bp_lines = getattr(self, '_debug_bp_lines', [])
|
|
477
|
+
self._debug_interp.set_breakpoints(bp_lines)
|
|
478
|
+
self._debug_interp.launch()
|
|
479
|
+
return {"debug_started": True, "file": file_path}
|
|
480
|
+
|
|
481
|
+
def _rpc_debug_continue(self, _params: dict) -> dict:
|
|
482
|
+
with self._lock:
|
|
483
|
+
di = getattr(self, '_debug_interp', None)
|
|
484
|
+
if di is None: raise RuntimeError("No debug session")
|
|
485
|
+
di.do_continue()
|
|
486
|
+
return {"continued": True}
|
|
487
|
+
|
|
488
|
+
def _rpc_debug_step(self, params: dict) -> dict:
|
|
489
|
+
mode = params.get("mode", "over") # over | into | out
|
|
490
|
+
with self._lock:
|
|
491
|
+
di = getattr(self, '_debug_interp', None)
|
|
492
|
+
if di is None: raise RuntimeError("No debug session")
|
|
493
|
+
if mode == "into": di.do_step_into()
|
|
494
|
+
elif mode == "out": di.do_step_out()
|
|
495
|
+
else: di.do_step_over()
|
|
496
|
+
return {"stepped": mode}
|
|
497
|
+
|
|
498
|
+
def _rpc_debug_vars(self, _params: dict) -> dict:
|
|
499
|
+
with self._lock:
|
|
500
|
+
di = getattr(self, '_debug_interp', None)
|
|
501
|
+
if di is None: raise RuntimeError("No debug session")
|
|
502
|
+
return {
|
|
503
|
+
"locals": {v["name"]: v["value"] for v in di.get_variables("locals")},
|
|
504
|
+
"globals": {v["name"]: v["value"] for v in di.get_variables("globals")},
|
|
505
|
+
"line": di._current_line,
|
|
506
|
+
"frames": di.get_stack_frames(),
|
|
507
|
+
"paused": di._stopped,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
def _rpc_debug_stop(self, _params: dict) -> dict:
|
|
511
|
+
with self._lock:
|
|
512
|
+
di = getattr(self, '_debug_interp', None)
|
|
513
|
+
if di:
|
|
514
|
+
try: di.do_continue() # unblock any waiting thread
|
|
515
|
+
except Exception: pass
|
|
516
|
+
self._debug_interp = None
|
|
517
|
+
return {"stopped": True}
|
|
518
|
+
|
|
519
|
+
# ── Input emulation ───────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
def _rpc_emulate_input(self, params: dict) -> dict:
|
|
522
|
+
"""
|
|
523
|
+
Inject headless input state so Studio preview can simulate key/mouse events
|
|
524
|
+
without a pygame window.
|
|
525
|
+
"""
|
|
526
|
+
from stdlib_game import _INPUT_MANAGER_INSTANCE
|
|
527
|
+
mgr = _INPUT_MANAGER_INSTANCE()
|
|
528
|
+
if mgr is None:
|
|
529
|
+
return {"ok": False, "reason": "input manager not initialised"}
|
|
530
|
+
keys = params.get("keys", {}) # {"space": true, "left": false}
|
|
531
|
+
mouse = params.get("mouse", None) # {"x": 100, "y": 200}
|
|
532
|
+
buttons = params.get("buttons", {}) # {0: true}
|
|
533
|
+
for k, v in keys.items():
|
|
534
|
+
mgr.emulate_key(k, bool(v))
|
|
535
|
+
if mouse:
|
|
536
|
+
mgr.emulate_mouse(float(mouse.get("x", 0)),
|
|
537
|
+
float(mouse.get("y", 0)),
|
|
538
|
+
{int(k): bool(v) for k, v in buttons.items()})
|
|
539
|
+
return {"ok": True, "emulated_keys": list(keys.keys())}
|
|
540
|
+
|
|
541
|
+
# ── Patch dispatch table ──────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
_NEW_METHODS = {
|
|
544
|
+
"start_game": _rpc_start_game,
|
|
545
|
+
"stop_game": _rpc_stop_game,
|
|
546
|
+
"game_status": _rpc_game_status,
|
|
547
|
+
"set_node_prop": _rpc_set_node_prop,
|
|
548
|
+
"add_node": _rpc_add_node,
|
|
549
|
+
"remove_node": _rpc_remove_node,
|
|
550
|
+
"get_live_scene": _rpc_get_live_scene,
|
|
551
|
+
"set_breakpoints": _rpc_set_breakpoints,
|
|
552
|
+
"debug_run": _rpc_debug_run,
|
|
553
|
+
"debug_continue": _rpc_debug_continue,
|
|
554
|
+
"debug_step": _rpc_debug_step,
|
|
555
|
+
"debug_vars": _rpc_debug_vars,
|
|
556
|
+
"debug_stop": _rpc_debug_stop,
|
|
557
|
+
"emulate_input": _rpc_emulate_input,
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
# Patch _dispatch to include new methods
|
|
561
|
+
_orig_dispatch = bridge_cls._dispatch
|
|
562
|
+
|
|
563
|
+
def _new_dispatch(self, method, params):
|
|
564
|
+
if method in _NEW_METHODS:
|
|
565
|
+
return _NEW_METHODS[method](self, params)
|
|
566
|
+
return _orig_dispatch(self, method, params)
|
|
567
|
+
|
|
568
|
+
bridge_cls._dispatch = _new_dispatch
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# Apply patches
|
|
572
|
+
_extend_bridge(StudioBridge)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
576
|
+
# Input manager instance getter (for emulation)
|
|
577
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
def _INPUT_MANAGER_INSTANCE():
|
|
580
|
+
"""Return the global _InputManager if initialised, else None."""
|
|
581
|
+
try:
|
|
582
|
+
from stdlib_game import _INPUT_MANAGER_INSTANCE as _imi
|
|
583
|
+
return _imi
|
|
584
|
+
except ImportError:
|
|
585
|
+
return None
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
studio_bridge.py — InScript v2.12.0 Electron Bridge
|
|
4
|
-
================================================================
|
|
5
|
-
A lightweight JSON-RPC HTTP server that the InScript Studio Electron
|
|
6
|
-
shell calls to drive the runtime.
|
|
7
|
-
|
|
8
|
-
Start from CLI:
|
|
9
|
-
inscript --studio-bridge [--bridge-port 8765]
|
|
10
|
-
|
|
11
|
-
Or from Python:
|
|
12
|
-
from studio_bridge import StudioBridge
|
|
13
|
-
bridge = StudioBridge(port=8765)
|
|
14
|
-
bridge.start() # non-blocking daemon thread
|
|
15
|
-
bridge.stop()
|
|
16
|
-
|
|
17
|
-
JSON-RPC protocol (HTTP POST to /rpc):
|
|
18
|
-
Request: { "method": "run", "id": 1, "params": {"file": "src/main.ins"} }
|
|
19
|
-
Response: { "result": "ok", "id": 1 }
|
|
20
|
-
Error: { "error": "...", "id": 1 }
|
|
21
|
-
|
|
22
|
-
Methods
|
|
23
|
-
───────
|
|
24
|
-
run — execute a .ins file; returns {"status": "ok"|"error", "output": [...]}
|
|
25
|
-
stop — stop a running interpreter
|
|
26
|
-
hot_reload — trigger hot reload on a file
|
|
27
|
-
scene_list — return all scene/node declarations in project
|
|
28
|
-
inspect — full project introspection (scenes, nodes, fns, assets)
|
|
29
|
-
eval — evaluate an InScript expression in live env
|
|
30
|
-
ping — health check; returns {"pong": true}
|
|
31
|
-
version — returns {"version": "2.12.0"}
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
from __future__ import annotations
|
|
35
|
-
import json, threading, sys, os, io
|
|
36
|
-
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
37
|
-
from typing import Any, Dict, Optional
|
|
38
|
-
|
|
39
|
-
DEFAULT_PORT = 8765
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class _RpcHandler(BaseHTTPRequestHandler):
|
|
43
|
-
"""HTTP handler — all requests are POST to /rpc."""
|
|
44
|
-
|
|
45
|
-
def log_message(self, fmt, *args):
|
|
46
|
-
pass # suppress default access log
|
|
47
|
-
|
|
48
|
-
def do_POST(self):
|
|
49
|
-
if self.path != "/rpc":
|
|
50
|
-
self._reply(404, {"error": "Not found — POST to /rpc"})
|
|
51
|
-
return
|
|
52
|
-
try:
|
|
53
|
-
length = int(self.headers.get("Content-Length", 0))
|
|
54
|
-
body = self.rfile.read(length)
|
|
55
|
-
request = json.loads(body)
|
|
56
|
-
except Exception as e:
|
|
57
|
-
self._reply(400, {"error": f"Bad JSON: {e}", "id": None})
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
req_id = request.get("id")
|
|
61
|
-
method = request.get("method", "")
|
|
62
|
-
params = request.get("params", {})
|
|
63
|
-
|
|
64
|
-
try:
|
|
65
|
-
result = self.server._bridge._dispatch(method, params)
|
|
66
|
-
self._reply(200, {"result": result, "id": req_id})
|
|
67
|
-
except Exception as e:
|
|
68
|
-
self._reply(200, {"error": str(e), "id": req_id})
|
|
69
|
-
|
|
70
|
-
def _reply(self, status: int, data: dict):
|
|
71
|
-
body = json.dumps(data, default=str).encode()
|
|
72
|
-
self.send_response(status)
|
|
73
|
-
self.send_header("Content-Type", "application/json")
|
|
74
|
-
self.send_header("Content-Length", str(len(body)))
|
|
75
|
-
self.send_header("Access-Control-Allow-Origin", "*")
|
|
76
|
-
self.end_headers()
|
|
77
|
-
self.wfile.write(body)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class StudioBridge:
|
|
81
|
-
"""
|
|
82
|
-
Lightweight JSON-RPC bridge between the InScript runtime and
|
|
83
|
-
the Electron Studio shell.
|
|
84
|
-
"""
|
|
85
|
-
def __init__(self, port: int = DEFAULT_PORT):
|
|
86
|
-
self._port = port
|
|
87
|
-
self._server: Optional[HTTPServer] = None
|
|
88
|
-
self._thread: Optional[threading.Thread] = None
|
|
89
|
-
self._interp = None # live Interpreter (created on first run)
|
|
90
|
-
self._lock = threading.Lock()
|
|
91
|
-
self._running = False
|
|
92
|
-
|
|
93
|
-
# ── lifecycle ──────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
def start(self) -> "StudioBridge":
|
|
96
|
-
"""Start the HTTP server in a daemon thread. Non-blocking."""
|
|
97
|
-
self._server = HTTPServer(("localhost", self._port), _RpcHandler)
|
|
98
|
-
self._server._bridge = self
|
|
99
|
-
self._running = True
|
|
100
|
-
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
101
|
-
self._thread.start()
|
|
102
|
-
return self
|
|
103
|
-
|
|
104
|
-
def stop(self):
|
|
105
|
-
"""Shut down the HTTP server."""
|
|
106
|
-
self._running = False
|
|
107
|
-
if self._server:
|
|
108
|
-
self._server.shutdown()
|
|
109
|
-
|
|
110
|
-
@property
|
|
111
|
-
def port(self) -> int:
|
|
112
|
-
return self._port
|
|
113
|
-
|
|
114
|
-
@property
|
|
115
|
-
def url(self) -> str:
|
|
116
|
-
return f"http://localhost:{self._port}/rpc"
|
|
117
|
-
|
|
118
|
-
# ── RPC dispatch ──────────────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
def _dispatch(self, method: str, params: dict) -> Any:
|
|
121
|
-
handlers = {
|
|
122
|
-
"ping": self._rpc_ping,
|
|
123
|
-
"version": self._rpc_version,
|
|
124
|
-
"run": self._rpc_run,
|
|
125
|
-
"stop": self._rpc_stop,
|
|
126
|
-
"hot_reload": self._rpc_hot_reload,
|
|
127
|
-
"scene_list": self._rpc_scene_list,
|
|
128
|
-
"inspect": self._rpc_inspect,
|
|
129
|
-
"eval": self._rpc_eval,
|
|
130
|
-
}
|
|
131
|
-
handler = handlers.get(method)
|
|
132
|
-
if handler is None:
|
|
133
|
-
raise ValueError(f"Unknown method '{method}'. Available: {sorted(handlers)}")
|
|
134
|
-
return handler(params)
|
|
135
|
-
|
|
136
|
-
# ── RPC handlers ──────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
def _rpc_ping(self, _params: dict) -> dict:
|
|
139
|
-
return {"pong": True, "port": self._port}
|
|
140
|
-
|
|
141
|
-
def _rpc_version(self, _params: dict) -> dict:
|
|
142
|
-
from inscript import VERSION
|
|
143
|
-
return {"version": VERSION, "bridge": "1.0"}
|
|
144
|
-
|
|
145
|
-
def _rpc_run(self, params: dict) -> dict:
|
|
146
|
-
"""Run a .ins file and return captured output + errors."""
|
|
147
|
-
file_path = params.get("file", "")
|
|
148
|
-
if not file_path:
|
|
149
|
-
raise ValueError("'file' parameter required")
|
|
150
|
-
if not os.path.isfile(file_path):
|
|
151
|
-
raise FileNotFoundError(f"File not found: {file_path}")
|
|
152
|
-
|
|
153
|
-
with open(file_path, encoding="utf-8") as f:
|
|
154
|
-
source = f.read()
|
|
155
|
-
|
|
156
|
-
output_lines: list = []
|
|
157
|
-
errors: list = []
|
|
158
|
-
|
|
159
|
-
# Redirect stdout
|
|
160
|
-
old_stdout = sys.stdout
|
|
161
|
-
sys.stdout = io.StringIO()
|
|
162
|
-
|
|
163
|
-
try:
|
|
164
|
-
from interpreter import Interpreter
|
|
165
|
-
from errors import InScriptError
|
|
166
|
-
with self._lock:
|
|
167
|
-
self._interp = Interpreter()
|
|
168
|
-
self._interp.execute(source)
|
|
169
|
-
output_lines = sys.stdout.getvalue().splitlines()
|
|
170
|
-
except Exception as e:
|
|
171
|
-
from errors import InScriptError
|
|
172
|
-
if isinstance(e, InScriptError):
|
|
173
|
-
errors.append(e.to_dict())
|
|
174
|
-
else:
|
|
175
|
-
errors.append({"type": "error", "code": "E0000",
|
|
176
|
-
"class": type(e).__name__, "message": str(e),
|
|
177
|
-
"line": 0, "col": 0})
|
|
178
|
-
output_lines = sys.stdout.getvalue().splitlines()
|
|
179
|
-
finally:
|
|
180
|
-
sys.stdout = old_stdout
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
"status": "error" if errors else "ok",
|
|
184
|
-
"output": output_lines,
|
|
185
|
-
"errors": errors,
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
def _rpc_stop(self, _params: dict) -> dict:
|
|
189
|
-
with self._lock:
|
|
190
|
-
self._interp = None
|
|
191
|
-
return {"stopped": True}
|
|
192
|
-
|
|
193
|
-
def _rpc_hot_reload(self, params: dict) -> dict:
|
|
194
|
-
"""Trigger a hot reload on a running file."""
|
|
195
|
-
file_path = params.get("file", "")
|
|
196
|
-
if not file_path:
|
|
197
|
-
raise ValueError("'file' parameter required")
|
|
198
|
-
with self._lock:
|
|
199
|
-
if self._interp is None:
|
|
200
|
-
raise RuntimeError("No running interpreter — call 'run' first")
|
|
201
|
-
from hot_reload import HotReloader
|
|
202
|
-
reloader = HotReloader(file_path, self._interp)
|
|
203
|
-
result = reloader.tick()
|
|
204
|
-
return {
|
|
205
|
-
"changed": result.changed,
|
|
206
|
-
"success": result.success,
|
|
207
|
-
"patched": result.patched,
|
|
208
|
-
"restored": len(result.restored),
|
|
209
|
-
"errors": result.errors,
|
|
210
|
-
"elapsed_ms": result.elapsed_ms,
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
def _rpc_scene_list(self, params: dict) -> dict:
|
|
214
|
-
"""Return all scene and node declarations in a project directory."""
|
|
215
|
-
project_dir = params.get("dir", ".")
|
|
216
|
-
from inscript_studio_api import project_inspect
|
|
217
|
-
data = project_inspect(project_dir)
|
|
218
|
-
return {"scenes": data["scenes"], "nodes": data["nodes"]}
|
|
219
|
-
|
|
220
|
-
def _rpc_inspect(self, params: dict) -> dict:
|
|
221
|
-
"""Full project introspection."""
|
|
222
|
-
project_dir = params.get("dir", ".")
|
|
223
|
-
from inscript_studio_api import project_inspect
|
|
224
|
-
return project_inspect(project_dir)
|
|
225
|
-
|
|
226
|
-
def _rpc_eval(self, params: dict) -> dict:
|
|
227
|
-
"""Evaluate an InScript expression in the live interpreter environment."""
|
|
228
|
-
expr = params.get("expr", "")
|
|
229
|
-
if not expr:
|
|
230
|
-
raise ValueError("'expr' parameter required")
|
|
231
|
-
with self._lock:
|
|
232
|
-
if self._interp is None:
|
|
233
|
-
raise RuntimeError("No running interpreter — call 'run' first")
|
|
234
|
-
result = self._interp.execute(expr)
|
|
235
|
-
return {"result": result, "type": type(result).__name__}
|
|
236
|
-
|
|
237
|
-
def __repr__(self):
|
|
238
|
-
status = "running" if self._running else "stopped"
|
|
239
|
-
return f"<StudioBridge port={self._port} {status}>"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|