Files
exteraGramPlugins 787965ebbf Initial commit
2026-10-09 12:25:06 +00:00

220 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Client for the exteraGram on-device plugin dev server.
Speaks the wire protocol at 127.0.0.1:42690 (after `adb forward tcp:42690 tcp:42690`):
consecutive UTF-8 JSON objects, no length framing. Requests are
{"@": <command>, "#": <id>, ...}; responses echo "#".
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = REPO_ROOT / "plugin.json"
CONNECT_ATTEMPTS = 5
CONNECT_RETRY_DELAY = 1.0
SOCKET_TIMEOUT = 1.0
RESPONSE_TIMEOUT = 30.0
UNREACHABLE_MESSAGE = (
"dev server unreachable — run scripts/adb-connect.sh, and enable exteraGram "
"Preferences > Plugins > plugin engine + developer mode"
)
def default_plugin_id() -> str | None:
try:
data = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data.get("id")
class DevClient:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self._sock: socket.socket | None = None
self._buf = b""
self._decoder = json.JSONDecoder()
self._next_id = 1
def connect(self) -> None:
last_exc = None
for attempt in range(CONNECT_ATTEMPTS):
try:
sock = socket.create_connection((self.host, self.port), timeout=SOCKET_TIMEOUT)
sock.settimeout(SOCKET_TIMEOUT)
self._sock = sock
return
except OSError as exc:
last_exc = exc
if attempt < CONNECT_ATTEMPTS - 1:
time.sleep(CONNECT_RETRY_DELAY)
print(UNREACHABLE_MESSAGE, file=sys.stderr)
raise SystemExit(1) from last_exc
def close(self) -> None:
if self._sock is not None:
self._sock.close()
self._sock = None
def _read_one(self, deadline: float) -> dict:
while True:
self._buf = self._buf.lstrip()
if self._buf:
try:
text = self._buf.decode("utf-8")
except UnicodeDecodeError:
text = None
if text:
try:
obj, idx = self._decoder.raw_decode(text)
consumed = len(text[:idx].encode("utf-8"))
self._buf = self._buf[consumed:]
return obj
except json.JSONDecodeError:
pass
if time.monotonic() >= deadline:
print("error: timed out waiting for dev server response", file=sys.stderr)
raise SystemExit(1)
try:
chunk = self._sock.recv(4096)
except socket.timeout:
continue
except OSError as exc:
print(f"error: connection lost: {exc}", file=sys.stderr)
raise SystemExit(1)
if not chunk:
print("error: dev server closed the connection", file=sys.stderr)
raise SystemExit(1)
self._buf += chunk
def request(self, command: str, **fields) -> dict:
req_id = self._next_id
self._next_id += 1
payload = {"@": command, "#": req_id, **fields}
self._sock.sendall(json.dumps(payload).encode("utf-8"))
deadline = time.monotonic() + RESPONSE_TIMEOUT
while True:
resp = self._read_one(deadline)
if resp.get("#") == req_id:
if resp.get("error") or resp.get("errors"):
print(json.dumps(resp, indent=2), file=sys.stderr)
raise SystemExit(1)
return resp
# response to a different (stale) request id: keep draining
def cmd_ping(client: DevClient, args) -> int:
resp = client.request("ping")
print(json.dumps(resp, indent=2))
return 0
def cmd_plugins(client: DevClient, args) -> int:
resp = client.request("get_plugins")
print(json.dumps(resp, indent=2))
return 0
def cmd_deploy(client: DevClient, args) -> int:
plugin_id = args.id or default_plugin_id()
if not plugin_id:
print("error: could not determine plugin id (pass --id or fix plugin.json)", file=sys.stderr)
return 1
try:
content = Path(args.file).read_text(encoding="utf-8")
except OSError as exc:
print(f"error: cannot read {args.file}: {exc}", file=sys.stderr)
return 1
client.request("write_plugin", plugin_id=plugin_id, content=content)
time.sleep(0.3)
if args.enable:
client.request("enable_plugin", plugin_id=plugin_id)
if not args.no_reload:
client.request("reload_plugin", plugin_id=plugin_id)
print(f"deployed {args.file} as {plugin_id}")
return 0
def cmd_simple_id(command: str):
def handler(client: DevClient, args) -> int:
client.request(command, plugin_id=args.id)
print(f"{command} {args.id}: ok")
return 0
return handler
def cmd_debugger(client: DevClient, args) -> int:
if args.action == "start":
client.request(
"start_debugger",
host=args.debug_host,
port=args.debug_port,
platform=args.platform,
)
print(f"debugger start requested ({args.platform} -> {args.debug_host}:{args.debug_port})")
else:
client.request("stop_debugger", platform=args.platform)
print(f"debugger stop requested ({args.platform})")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="exteraGram plugin dev server client")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=42690)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("ping").set_defaults(func=cmd_ping)
sub.add_parser("plugins").set_defaults(func=cmd_plugins)
p_deploy = sub.add_parser("deploy")
p_deploy.add_argument("file")
p_deploy.add_argument("--id")
p_deploy.add_argument("--enable", action="store_true")
p_deploy.add_argument("--no-reload", action="store_true")
p_deploy.set_defaults(func=cmd_deploy)
for name, command in (
("enable", "enable_plugin"),
("disable", "disable_plugin"),
("reload", "reload_plugin"),
("remove", "remove_plugin"),
):
p = sub.add_parser(name)
p.add_argument("id")
p.set_defaults(func=cmd_simple_id(command))
p_debugger = sub.add_parser("debugger")
p_debugger.add_argument("action", choices=["start", "stop"])
p_debugger.add_argument("--platform", choices=["vscode", "pycharm"], default="vscode")
p_debugger.add_argument("--debug-host", default="127.0.0.1")
p_debugger.add_argument("--debug-port", type=int, default=5678)
p_debugger.set_defaults(func=cmd_debugger)
return parser
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
client = DevClient(args.host, args.port)
client.connect()
try:
return args.func(client, args)
finally:
client.close()
if __name__ == "__main__":
sys.exit(main())