270 lines
9.8 KiB
Python
270 lines
9.8 KiB
Python
|
|
"""TinqsMDBridge -- Marvelous Designer python plug-in (v2, main-loop design).
|
||
|
|
|
||
|
|
Runs a localhost-only TCP socket server inside Marvelous Designer and
|
||
|
|
executes Python source sent to it, giving external tooling (Claude Code)
|
||
|
|
live control of MD's scripting API (pattern_api / import_api / export_api /
|
||
|
|
fabric_api / utility_api / ApiTypes).
|
||
|
|
|
||
|
|
WHY v2 BLOCKS THE UI -- verified 2026-07-30 on MD 2026 Personal (embedded
|
||
|
|
Python 3.11.8): MD's embedded interpreter does NOT schedule Python background
|
||
|
|
threads while MD is idle. A daemon-thread server binds and listens (the OS
|
||
|
|
accepts connections into the backlog) but accept()/recv() never run, so every
|
||
|
|
request times out. No Python Qt binding ships with MD either, so the iClone
|
||
|
|
QTimer trick is unavailable. The only thread Python code reliably runs on is
|
||
|
|
the one the Plug-in Manager calls into -- so the server IS the click:
|
||
|
|
|
||
|
|
- Clicking Plugin > TinqsMDBridge enters a single-threaded serve loop on
|
||
|
|
the main thread. MD's UI freezes ("Not Responding" is NORMAL) while the
|
||
|
|
bridge session is active. All api calls run on the main thread -- the
|
||
|
|
safest place for them.
|
||
|
|
- The loop exits (returning MD to interactive use) when a client sends the
|
||
|
|
literal code "__STOP__" (python tools/md_bridge.py --stop), or after
|
||
|
|
IDLE_TIMEOUT_S with no requests (default 300, env
|
||
|
|
TINQS_MD_BRIDGE_IDLE overrides) -- the escape hatch, since a frozen UI
|
||
|
|
can't be clicked. Each click is a fresh session; the socket is fully
|
||
|
|
closed on exit.
|
||
|
|
|
||
|
|
Install (one-time, manual -- MD has no auto-load plugin folder):
|
||
|
|
1. In MD: Plugin tab > Plug-in Manager > + ADD
|
||
|
|
2. Select this file, name it "TinqsMDBridge", click OK
|
||
|
|
3. Click Plugin > TinqsMDBridge to START A BRIDGE SESSION (UI freezes)
|
||
|
|
4. Verify from a terminal: python tools/md_bridge.py --ping
|
||
|
|
5. End the session: python tools/md_bridge.py --stop
|
||
|
|
NOTE: if MD copies the .py on registration rather than referencing it in
|
||
|
|
place (check --ping's "source" field), edits require remove + re-ADD.
|
||
|
|
|
||
|
|
Wire protocol (identical to the iClone TinqsBridge): newline-delimited JSON
|
||
|
|
over TCP, one request/response pair per connection.
|
||
|
|
|
||
|
|
request: {"id": <int>, "code": "<python source>"}
|
||
|
|
success: {"id": <int>, "ok": true, "result": <json|null>, "stdout": "<str>"}
|
||
|
|
failure: {"id": <int>, "ok": false, "error": "<traceback str>", "stdout": "<str>"}
|
||
|
|
|
||
|
|
Execution semantics (same `result` convention as the iClone bridge):
|
||
|
|
- exec(code, ns, ns) against ONE persistent namespace. It survives across
|
||
|
|
requests AND across serve sessions (module-level dict; the Plug-in
|
||
|
|
Manager re-execs this file per click but the namespace is re-seeded,
|
||
|
|
fresh api module refs, stale user state discarded -- keep long-lived
|
||
|
|
state on your own side).
|
||
|
|
- Setting `result` in submitted code makes it the response result
|
||
|
|
(JSON-encoded, repr() fallback). Cleared before every exec.
|
||
|
|
- stdout/stderr captured and returned; exceptions come back as ok:false
|
||
|
|
with a traceback and the session keeps serving.
|
||
|
|
|
||
|
|
Log file: %TEMP%/tinqs_md_bridge.log
|
||
|
|
"""
|
||
|
|
|
||
|
|
import contextlib
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import socket
|
||
|
|
import time
|
||
|
|
import traceback
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
VERSION = 2
|
||
|
|
DEFAULT_PORT = 18900
|
||
|
|
HOST = "127.0.0.1"
|
||
|
|
ACCEPT_POLL_TIMEOUT_S = 1.0
|
||
|
|
CONN_READ_TIMEOUT_S = 30.0
|
||
|
|
DEFAULT_IDLE_TIMEOUT_S = 14400.0 # 4 h -- one click lasts a work session;
|
||
|
|
# --stop from any terminal ends it anytime
|
||
|
|
STOP_SENTINEL = "__STOP__"
|
||
|
|
|
||
|
|
LOG_PATH = os.path.join(
|
||
|
|
os.environ.get("TEMP", os.environ.get("TMP", ".")),
|
||
|
|
"tinqs_md_bridge.log",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _log(msg):
|
||
|
|
"""Append a timestamped line to the log file. Must never raise."""
|
||
|
|
try:
|
||
|
|
line = "[{}] {}\n".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg)
|
||
|
|
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
||
|
|
f.write(line)
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _env_float(name, default):
|
||
|
|
raw = os.environ.get(name)
|
||
|
|
if raw:
|
||
|
|
try:
|
||
|
|
return float(raw)
|
||
|
|
except ValueError:
|
||
|
|
_log("WARN: {}={!r} not a number, using {}".format(name, raw, default))
|
||
|
|
return default
|
||
|
|
|
||
|
|
|
||
|
|
def _import_md_api():
|
||
|
|
"""Import whatever MD api modules exist in this MD build; report misses."""
|
||
|
|
ns = {}
|
||
|
|
missing = []
|
||
|
|
for name in ("import_api", "export_api", "pattern_api", "fabric_api",
|
||
|
|
"utility_api", "ApiTypes"):
|
||
|
|
try:
|
||
|
|
ns[name] = __import__(name)
|
||
|
|
except Exception:
|
||
|
|
missing.append(name)
|
||
|
|
return ns, missing
|
||
|
|
|
||
|
|
|
||
|
|
def _execute(ns, code):
|
||
|
|
ns["result"] = None
|
||
|
|
out = io.StringIO()
|
||
|
|
try:
|
||
|
|
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(out):
|
||
|
|
exec(code, ns, ns)
|
||
|
|
result = ns.get("result")
|
||
|
|
try:
|
||
|
|
json.dumps(result)
|
||
|
|
except TypeError:
|
||
|
|
result = repr(result)
|
||
|
|
return {"ok": True, "result": result, "stdout": out.getvalue()}
|
||
|
|
except Exception:
|
||
|
|
return {"ok": False, "error": traceback.format_exc(), "stdout": out.getvalue()}
|
||
|
|
|
||
|
|
|
||
|
|
def _read_request(conn):
|
||
|
|
"""Read one newline-terminated JSON request. Returns dict or None."""
|
||
|
|
conn.settimeout(CONN_READ_TIMEOUT_S)
|
||
|
|
buf = b""
|
||
|
|
while not buf.endswith(b"\n"):
|
||
|
|
chunk = conn.recv(4096)
|
||
|
|
if not chunk:
|
||
|
|
break
|
||
|
|
buf += chunk
|
||
|
|
if not buf.strip():
|
||
|
|
return None
|
||
|
|
return json.loads(buf.decode("utf-8"))
|
||
|
|
|
||
|
|
|
||
|
|
def _respond(conn, resp):
|
||
|
|
conn.sendall((json.dumps(resp) + "\n").encode("utf-8"))
|
||
|
|
|
||
|
|
|
||
|
|
def _reclaim_stale_socket():
|
||
|
|
"""v1 of this plugin parked a never-serving socket on builtins. Close it
|
||
|
|
so our bind succeeds without an MD restart."""
|
||
|
|
import builtins
|
||
|
|
old = getattr(builtins, "_tinqs_md_bridge_singleton", None)
|
||
|
|
if old is not None:
|
||
|
|
try:
|
||
|
|
if getattr(old, "server_socket", None) is not None:
|
||
|
|
old.server_socket.close()
|
||
|
|
_log("closed stale v1 socket")
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
setattr(builtins, "_tinqs_md_bridge_singleton", None)
|
||
|
|
|
||
|
|
|
||
|
|
def _serve():
|
||
|
|
import sys
|
||
|
|
port = int(_env_float("TINQS_MD_BRIDGE_PORT", DEFAULT_PORT))
|
||
|
|
idle_timeout = _env_float("TINQS_MD_BRIDGE_IDLE", DEFAULT_IDLE_TIMEOUT_S)
|
||
|
|
|
||
|
|
_log("=" * 60)
|
||
|
|
_log("TinqsMDBridge v{} session starting, port {}, idle timeout {}s".format(
|
||
|
|
VERSION, port, idle_timeout))
|
||
|
|
|
||
|
|
_reclaim_stale_socket()
|
||
|
|
|
||
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
|
|
try:
|
||
|
|
sock.bind((HOST, port))
|
||
|
|
except OSError:
|
||
|
|
_log("BIND FAILED on {}:{}:\n{}".format(HOST, port, traceback.format_exc()))
|
||
|
|
print("TinqsMDBridge: port {} busy -- another session active? see {}".format(
|
||
|
|
port, LOG_PATH))
|
||
|
|
return
|
||
|
|
sock.listen(5)
|
||
|
|
sock.settimeout(ACCEPT_POLL_TIMEOUT_S)
|
||
|
|
|
||
|
|
api_ns, missing = _import_md_api()
|
||
|
|
try:
|
||
|
|
source = os.path.abspath(__file__)
|
||
|
|
except NameError:
|
||
|
|
source = "<unknown -- __file__ not set by Plug-in Manager>"
|
||
|
|
|
||
|
|
ns = dict(api_ns)
|
||
|
|
ns["BRIDGE"] = {
|
||
|
|
"version": VERSION,
|
||
|
|
"mode": "mainloop",
|
||
|
|
"port": port,
|
||
|
|
"idle_timeout_s": idle_timeout,
|
||
|
|
"source": source,
|
||
|
|
"api_modules": sorted(api_ns.keys()),
|
||
|
|
"api_missing": missing,
|
||
|
|
"python": sys.version,
|
||
|
|
}
|
||
|
|
|
||
|
|
_log("serving on {}:{} api={} missing={} python={}".format(
|
||
|
|
HOST, port, sorted(api_ns.keys()), missing, sys.version.split()[0]))
|
||
|
|
print("TinqsMDBridge v{}: serving on {}:{}".format(VERSION, HOST, port))
|
||
|
|
print("MD's UI is frozen while the bridge session runs -- this is normal.")
|
||
|
|
print("End the session with: python tools/md_bridge.py --stop")
|
||
|
|
print("(auto-ends after {:.0f}s idle)".format(idle_timeout))
|
||
|
|
|
||
|
|
served = 0
|
||
|
|
deadline = time.monotonic() + idle_timeout
|
||
|
|
try:
|
||
|
|
while True:
|
||
|
|
if time.monotonic() > deadline:
|
||
|
|
_log("idle timeout after {} requests -- session over".format(served))
|
||
|
|
print("TinqsMDBridge: idle timeout, session over (served {})".format(served))
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
conn, _addr = sock.accept()
|
||
|
|
except socket.timeout:
|
||
|
|
continue
|
||
|
|
except OSError:
|
||
|
|
_log("server socket died:\n{}".format(traceback.format_exc()))
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
try:
|
||
|
|
req = _read_request(conn)
|
||
|
|
except Exception:
|
||
|
|
_respond(conn, {"id": None, "ok": False,
|
||
|
|
"error": "invalid JSON request", "stdout": ""})
|
||
|
|
continue
|
||
|
|
if req is None:
|
||
|
|
continue
|
||
|
|
code = req.get("code", "")
|
||
|
|
if code.strip() == STOP_SENTINEL:
|
||
|
|
_respond(conn, {"id": req.get("id"), "ok": True,
|
||
|
|
"result": "stopped", "stdout": ""})
|
||
|
|
_log("stop command -- session over, served {}".format(served))
|
||
|
|
print("TinqsMDBridge: stopped by client (served {})".format(served))
|
||
|
|
return
|
||
|
|
resp = _execute(ns, code)
|
||
|
|
resp["id"] = req.get("id")
|
||
|
|
served += 1
|
||
|
|
deadline = time.monotonic() + idle_timeout
|
||
|
|
if not resp["ok"]:
|
||
|
|
last = resp["error"].strip().splitlines()[-1] if resp["error"] else "?"
|
||
|
|
_log("request id={} ok=false: {}".format(req.get("id"), last))
|
||
|
|
_respond(conn, resp)
|
||
|
|
except Exception:
|
||
|
|
_log("connection error:\n{}".format(traceback.format_exc()))
|
||
|
|
finally:
|
||
|
|
try:
|
||
|
|
conn.close()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
finally:
|
||
|
|
try:
|
||
|
|
sock.close()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
_log("socket closed, session ended")
|
||
|
|
|
||
|
|
|
||
|
|
try:
|
||
|
|
_serve()
|
||
|
|
except Exception:
|
||
|
|
_log("session CRASHED:\n{}".format(traceback.format_exc()))
|
||
|
|
print("TinqsMDBridge: crashed -- see {}".format(LOG_PATH))
|