diff --git a/examples/fts-search.py b/examples/fts-search.py new file mode 100755 index 0000000..5c3a255 --- /dev/null +++ b/examples/fts-search.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""SQLite FTS5 full-text search over een directory. Stdlib only, 3.6+. + +Run: + python3 fts-search.py index ./docs + python3 fts-search.py search "deploy checklist" + python3 fts-search.py serve --port 8083 + +Spec (dit bestand is de spec): + + CLI: + index [--db] walk + (her)indexeer, incrementeel op mtime+size + search BM25-ranking, snippets met -highlights + serve [--port] HTTP UI + JSON API op /api/search + + JSON API: + /api/search?q=&limit=&offset= 200 {"total","items":[{path,title, + snippet,rank,mtime,size}]} + /api/stats 200 {"docs","bytes","indexed_at"} + + Ranking: SQLite bm25(fts), snippets via snippet() met -markering. + Query-taal: woorden worden AND-gecombineerd als frasen ("a" AND "b"), + veilig ge-escaped, nooit rauwe MATCH-syntax van de gebruiker. + + Indexeer-regels: hidden files/dirs overgeslagen, binaire bestanden + overgeslagen (extensie-lijst + NUL-byte sniff), alleen .txt/.md/.rst/ + .py/.js/.html/.css/.json/.toml/.yaml/.xml/.log/.csv en extensieloze + tekstbestanden. + +Caps: MAX_FILE 1 MiB, MAX_FILES 10000, MAX_QUERY 200 chars, MAX_LIMIT 50, +MAX_BODY-index 200 KiB per bestand. +""" + +import argparse +import json +import os +import socketserver +import sqlite3 +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +TEXT_EXTS = frozenset([ + ".txt", ".md", ".markdown", ".rst", ".py", ".js", ".ts", ".html", + ".htm", ".css", ".json", ".toml", ".yaml", ".yml", ".xml", ".log", + ".csv", ".tsv", ".ini", ".cfg", ".sh", ".sql", ".java", ".c", ".h", + ".go", ".rs", "", +]) +MAX_FILE = 1024 * 1024 +MAX_FILES = 10000 +MAX_INDEX_CHARS = 200 * 1024 + + +class SearchError(Exception): + pass + + +class SearchEngine(object): + """SQLite + FTS5 index. Een verbinding, RLock, WAL.""" + + SCHEMA = ("CREATE TABLE IF NOT EXISTS docs (" + "path TEXT PRIMARY KEY," + "title TEXT NOT NULL," + "body TEXT NOT NULL," + "mtime REAL NOT NULL," + "size INTEGER NOT NULL," + "indexed_at TEXT NOT NULL)") + + def __init__(self, db_path): + self.db_path = db_path + self._lock = threading.RLock() + self.db = sqlite3.connect(db_path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.execute("PRAGMA journal_mode=WAL") + self.db.execute("PRAGMA busy_timeout=5000") + self.db.execute("PRAGMA synchronous=NORMAL") + with self.db: + self.db.execute(self.SCHEMA) + try: + self.db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS fts " + "USING fts5(path, title, body, " + "tokenize='porter unicode61')") + except sqlite3.OperationalError: + raise SearchError( + "deze SQLite heeft geen FTS5 (nodig voor ranking). " + "Python >= 3.9 bundelt het vrijwel altijd.") + self.db.execute( + "CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs " + "BEGIN INSERT INTO fts(path, title, body) " + "VALUES (new.path, new.title, new.body); END") + self.db.execute( + "CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs " + "BEGIN DELETE FROM fts WHERE path=old.path; END") + self.db.execute( + "CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs " + "BEGIN DELETE FROM fts WHERE path=old.path; " + "INSERT INTO fts(path, title, body) " + "VALUES (new.path, new.title, new.body); END") + + @staticmethod + def match_query(query): + """Bouw veilige MATCH-query: frasen met AND, alles ge-escaped.""" + words = [w for w in query.split() if w.strip("\"'")] + terms = [] + for word in words[:10]: + clean = word.strip("\"'()*:^").replace('"', "") + if clean: + terms.append('"%s"' % clean) + return " AND ".join(terms) + + def search(self, query, limit=20, offset=0): + query = (query or "")[:200].strip() + if not query: + return 0, [] + match = self.match_query(query) + if not match: + return 0, [] + limit = min(max(limit, 1), 50) + offset = max(offset, 0) + with self._lock: + try: + total = self.db.execute( + "SELECT COUNT(*) AS n FROM fts WHERE fts MATCH ?", + (match,)).fetchone()["n"] + rows = self.db.execute( + "SELECT d.path, d.title, d.mtime, d.size," + " bm25(fts) AS rank," + " snippet(fts, 2, '', '', ' ... ', 32) AS snippet" + " FROM fts JOIN docs d ON d.path=fts.path" + " WHERE fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?", + (match, limit, offset)).fetchall() + except sqlite3.OperationalError: + return 0, [] + return total, [{"path": r["path"], "title": r["title"], + "mtime": r["mtime"], "size": r["size"], + "rank": r["rank"], "snippet": r["snippet"]} + for r in rows] + + def stats(self): + with self._lock: + row = self.db.execute( + "SELECT COUNT(*) AS docs, COALESCE(SUM(size),0) AS bytes," + " MAX(indexed_at) AS indexed_at FROM docs").fetchone() + return {"docs": row["docs"], "bytes": row["bytes"], + "indexed_at": row["indexed_at"]} + + def upsert(self, path, title, body, mtime, size): + with self._lock: + now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + self.db.execute( + "INSERT OR REPLACE INTO docs" + " (path, title, body, mtime, size, indexed_at)" + " VALUES (?, ?, ?, ?, ?, ?)", + (path, title, body[:MAX_INDEX_CHARS], mtime, size, now)) + + def remove(self, path): + with self._lock: + self.db.execute("DELETE FROM docs WHERE path=?", (path,)) + + def known(self): + with self._lock: + return {r["path"]: (r["mtime"], r["size"]) + for r in self.db.execute("SELECT path, mtime, size" + " FROM docs").fetchall()} + + def commit(self): + with self._lock: + self.db.commit() + + +class DocIndexer(object): + """Walk + filter + incrementele indexering. Puur, testbaar.""" + + def __init__(self, engine): + self.engine = engine + + @staticmethod + def is_indexable(name, full): + base = os.path.basename(name) + if base.startswith("."): + return False + _, ext = os.path.splitext(base) + if ext.lower() not in TEXT_EXTS: + return False + try: + if os.path.getsize(full) > MAX_FILE: + return False + except OSError: + return False + return True + + @staticmethod + def read_text(full): + try: + with open(full, "rb") as fh: + raw = fh.read(MAX_FILE + 1) + except OSError: + return None + if b"\x00" in raw[:8192]: + return None + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + try: + return raw.decode("latin-1") + except UnicodeDecodeError: + return None + + def index_dir(self, root): + root = os.path.abspath(root) + known = self.engine.known() + seen = set() + added = updated = skipped = 0 + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + dirnames.sort() + for name in sorted(filenames): + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, root) + if len(seen) >= MAX_FILES: + skipped += 1 + continue + if not self.is_indexable(name, full): + skipped += 1 + continue + try: + st = os.stat(full) + except OSError: + skipped += 1 + continue + seen.add(rel) + prev = known.get(rel) + if prev == (st.st_mtime, st.st_size): + continue + text = self.read_text(full) + if text is None: + skipped += 1 + continue + title = os.path.basename(rel) + self.engine.upsert(rel, title, text, st.st_mtime, st.st_size) + if prev is None: + added += 1 + else: + updated += 1 + removed = 0 + for rel in known: + if rel not in seen: + self.engine.remove(rel) + removed += 1 + self.engine.commit() + return {"added": added, "updated": updated, "removed": removed, + "skipped": skipped, "total": len(seen)} + + +class SearchHandler(BaseHTTPRequestHandler): + server_version = "TaiSearch/1.0" + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + pass + + def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if self.command != "HEAD" and body: + self.wfile.write(body) + + def _json(self, code, obj): + self._send(code, json.dumps(obj), "application/json") + + def do_GET(self): + try: + parsed = urlparse(self.path) + if parsed.path == "/": + self._send(200, SEARCH_HTML, "text/html; charset=utf-8") + return + if parsed.path == "/api/stats": + self._json(200, self.server.engine.stats()) + return + if parsed.path == "/api/search": + qs = parse_qs(parsed.query) + try: + limit = int(qs.get("limit", ["20"])[0]) + offset = int(qs.get("offset", ["0"])[0]) + except ValueError: + limit, offset = 20, 0 + total, items = self.server.engine.search( + qs.get("q", [""])[0], limit=limit, offset=offset) + self._json(200, {"total": total, "limit": limit, + "offset": offset, "items": items}) + return + self._send(404, "not found\n") + except BrokenPipeError: + pass + + +SEARCH_HTML = """ + + +Zoeken +
+

Zoeken

+
+
+

    +
    +
    +
    +""" + + +class SearchServer(socketserver.ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def default_db(): + home = os.environ.get("TAI_HOME") or os.path.expanduser("~/.tai") + try: + os.makedirs(home, exist_ok=True) + return os.path.join(home, "fts-example.db") + except OSError: + return os.path.abspath("./fts-example.db") + + +def cmd_index(args): + if not os.path.isdir(args.dir): + raise SearchError("geen directory: %s" % args.dir) + engine = SearchEngine(args.db) + result = DocIndexer(engine).index_dir(args.dir) + print("index: %(added)d nieuw, %(updated)d bijgewerkt, " + "%(removed)d weg, %(skipped)d overgeslagen " + "(totaal %(total)d) -> %(db)s" % dict(result, db=args.db)) + + +def cmd_search(args): + if not os.path.exists(args.db): + raise SearchError("geen index (%s). Run eerst: index " % args.db) + engine = SearchEngine(args.db) + total, items = engine.search(args.query, limit=args.limit, + offset=args.offset) + print("%d resultaat(en) voor %r:" % (total, args.query)) + for item in items: + clean = item["snippet"].replace("", "").replace("", "") + print(" [%.2f] %s\n %s" % (item["rank"], item["path"], clean)) + + +def cmd_serve(args): + if not os.path.exists(args.db): + raise SearchError("geen index (%s). Run eerst: index " % args.db) + engine = SearchEngine(args.db) + stats = engine.stats() + server = SearchServer((args.host, args.port), SearchHandler) + server.engine = engine + print("search: http://%s:%d/ (%d docs)" % (args.host, args.port, + stats["docs"])) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="SQLite FTS5 full-text search over een directory.") + parser.add_argument("--db", default=None, help="sqlite index file") + sub = parser.add_subparsers(dest="cmd") # 3.6: geen required-kwarg + sub.required = True + p_index = sub.add_parser("index", help="directory indexeren") + p_index.add_argument("dir") + p_search = sub.add_parser("search", help="zoeken op CLI") + p_search.add_argument("query") + p_search.add_argument("--limit", type=int, default=20) + p_search.add_argument("--offset", type=int, default=0) + p_serve = sub.add_parser("serve", help="HTTP UI + JSON API starten") + p_serve.add_argument("--host", default="127.0.0.1") + p_serve.add_argument("--port", type=int, default=8083) + args = parser.parse_args(argv) + if args.db is None: + args.db = default_db() + try: + if args.cmd == "index": + cmd_index(args) + elif args.cmd == "search": + cmd_search(args) + elif args.cmd == "serve": + cmd_serve(args) + except SearchError as exc: + parser.error(str(exc)) + + +if __name__ == "__main__": + main() + diff --git a/examples/reverse-proxy.py b/examples/reverse-proxy.py new file mode 100755 index 0000000..44665a3 --- /dev/null +++ b/examples/reverse-proxy.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Round-robin reverse proxy met healthchecks. Stdlib only, single file, 3.6+. + +Run: + python3 reverse-proxy.py --backends http://127.0.0.1:9001,http://127.0.0.1:9002 + python3 reverse-proxy.py --backends http://127.0.0.1:9001 --port 8080 --health-path /healthz + +Spec (dit bestand is de spec): + + Gedrag: + - Round-robin over gezonde backends (alleen http, geen https-targets). + - Actieve healthcheck: GET {health-path} elke --health-interval s; + 2xx/3xx is gezond. Ongezonde backend tijdelijk uit rotatie. + - Passieve healthcheck: connect/read-fout markeert backend direct down + en de request failovert naar de volgende gezonde backend. + - Retries: maximaal N pogingen (N = aantal backends), nooit een body + tweemaal sturen bij POST/PUT tenzij klein genoeg om te bufferen. + - Hop-header hygiene: Connection, Keep-Alive, Proxy-*, TE, Trailer, + Transfer-Encoding en Upgrade worden nooit doorgestuurd. + - X-Forwarded-For (client-IP appended), X-Forwarded-Proto, X-Forwarded-Host + en Via worden gezet. + - GET /__proxy__/status: JSON met backend-staten (alleen dit pad is + van de proxy zelf; de rest wordt 1-op-1 doorgezet). + +Caps: MAX_BODY 10 MiB, TIMEOUT 10 s (connect) / 30 s (read), +HEALTH_INTERVAL 10 s, HEALTH_TIMEOUT 3 s, MAX_BACKENDS 32. +""" + +import argparse +import json +import socketserver +import threading +import time +from http.client import HTTPConnection, HTTPException +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse + +HOP_HEADERS = frozenset([ + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", +]) +MAX_BODY = 10 * 1024 * 1024 +CONNECT_TIMEOUT = 10 +READ_TIMEOUT = 30 + + +class Backend(object): + """Een upstream + zijn gezondheidsstaat.""" + + def __init__(self, url): + parsed = urlparse(url.strip()) + if parsed.scheme != "http" or not parsed.hostname: + raise ValueError("backend must be http://host[:port][/prefix]: %s" + % url) + self.name = url.strip() + self.host = parsed.hostname + self.port = parsed.port or 80 + self.prefix = parsed.path.rstrip("/") + self._lock = threading.RLock() + self.healthy = True + self.last_check = 0.0 + self.last_error = "" + self.requests = 0 + self.failures = 0 + + def mark(self, healthy, error=""): + with self._lock: + self.healthy = healthy + self.last_check = time.time() + self.last_error = error[:200] if error else "" + if healthy: + self.requests += 1 + else: + self.failures += 1 + + def status(self): + with self._lock: + return {"name": self.name, "healthy": self.healthy, + "last_check": self.last_check, + "last_error": self.last_error, + "requests": self.requests, "failures": self.failures} + + +class BackendPool(object): + """Round-robin over gezonde backends. Thread-safe.""" + + def __init__(self, backends): + self.backends = backends + self._lock = threading.RLock() + self._next = 0 + + def pick_order(self): + """Volgorde voor deze request: rotatie-start, gezonde eerst.""" + with self._lock: + backends = list(self.backends) + start = self._next % len(backends) + self._next += 1 + ordered = backends[start:] + backends[:start] + healthy = [b for b in ordered if b.healthy] + return healthy + [b for b in ordered if not b.healthy] + + def status(self): + return {"backends": [b.status() for b in self.backends]} + + +class HealthChecker(threading.Thread): + """Achtergrondlus: pollt elke interval het health-path per backend.""" + + daemon = True + + def __init__(self, pool, path="/", interval=10, timeout=3): + super(HealthChecker, self).__init__(name="healthchecker") + self.pool = pool + self.path = path or "/" + self.interval = max(1, interval) + self.timeout = timeout + self._stop = threading.Event() + + def stop(self): + self._stop.set() + + def run(self): + while not self._stop.wait(self.interval): + for backend in self.pool.backends: + self.check_once(backend) + + def check_once(self, backend): + try: + conn = HTTPConnection(backend.host, backend.port, + timeout=self.timeout) + try: + conn.request("GET", backend.prefix + self.path, + headers={"Connection": "close", + "Host": backend.host}) + resp = conn.getresponse() + resp.read(65536) + ok = 200 <= resp.status < 400 + backend.mark(True if ok else False, + "" if ok else "health %d" % resp.status) + finally: + conn.close() + except Exception as exc: + backend.mark(False, "%s: %s" % (type(exc).__name__, exc)) + + +class ProxyHandler(BaseHTTPRequestHandler): + server_version = "TaiProxy/1.0" + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + pass + + def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if self.command != "HEAD" and body: + self.wfile.write(body) + + def _forward_headers(self): + client_ip = self.client_address[0] + fwd = self.headers.get("X-Forwarded-For", "") + out = {} + for key, value in self.headers.items(): + if key.lower() in HOP_HEADERS: + continue + out[key] = value + out["X-Forwarded-For"] = (fwd + ", " + client_ip) if fwd else client_ip + out["X-Forwarded-Proto"] = "http" + out["X-Forwarded-Host"] = self.headers.get("Host", "") + via = self.headers.get("Via", "") + out["Via"] = (via + ", " if via else "") + "1.1 TaiProxy" + out["Connection"] = "close" + return out + + def _read_body(self): + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + return None + if length < 0 or length > MAX_BODY: + return None + if length == 0: + return b"" + return self.rfile.read(length) + + def _try_backend(self, backend, path, body, headers): + target = backend.prefix + path + conn = HTTPConnection(backend.host, backend.port, + timeout=CONNECT_TIMEOUT) + try: + out_headers = dict(headers) + out_headers["Host"] = backend.host + ( + "" if backend.port == 80 else ":%d" % backend.port) + conn.request(self.command, target, body=body, + headers=out_headers) + conn.sock.settimeout(READ_TIMEOUT) + resp = conn.getresponse() + self.send_response(resp.status, resp.reason) + for key, value in resp.getheaders(): + if key.lower() in HOP_HEADERS: + continue + self.send_header(key, value) + self.send_header("Via", "1.1 TaiProxy") + self.end_headers() + if self.command != "HEAD": + while True: + chunk = resp.read(65536) + if not chunk: + break + self.wfile.write(chunk) + backend.mark(True) + return True + finally: + try: + conn.close() + except Exception: + pass + + def _proxy(self): + pool = self.server.pool # type: BackendPool + if self.path == "/__proxy__/status" and self.command == "GET": + self._send(200, json.dumps(pool.status()), "application/json") + return + body = self._read_body() + if body is None: + self._send(413, "body too large or bad length (max 10 MiB)") + return + headers = self._forward_headers() + errors = [] + for backend in pool.pick_order(): + try: + if self._try_backend(backend, self.path, body, headers): + return + except (HTTPException, OSError) as exc: + backend.mark(False, "%s: %s" % (type(exc).__name__, exc)) + errors.append("%s: %s" % (backend.name, exc)) + continue + except BrokenPipeError: + return + self._send(502, "no healthy backend (%s)" % "; ".join(errors)) + + def _dispatch(self): + try: + self._proxy() + except BrokenPipeError: + pass + except Exception as exc: + try: + self._send(500, "proxy error: %s" % exc) + except (BrokenPipeError, OSError): + pass + + do_GET = _dispatch + do_HEAD = _dispatch + do_POST = _dispatch + do_PUT = _dispatch + do_PATCH = _dispatch + do_DELETE = _dispatch + do_OPTIONS = _dispatch + + +class ProxyServer(socketserver.ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Round-robin reverse proxy met healthchecks.") + parser.add_argument("--backends", required=True, + help="comma-separated http://host:port[/prefix]") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--health-path", default="/", + help="pad voor healthchecks (default /)") + parser.add_argument("--health-interval", type=int, default=10, + help="seconden tussen healthchecks (default 10)") + args = parser.parse_args(argv) + urls = [u for u in args.backends.split(",") if u.strip()] + if not urls or len(urls) > 32: + parser.error("need 1..32 backends") + try: + backends = [Backend(u) for u in urls] + except ValueError as exc: + parser.error(str(exc)) + pool = BackendPool(backends) + server = ProxyServer((args.host, args.port), ProxyHandler) + server.pool = pool + checker = HealthChecker(pool, path=args.health_path, + interval=args.health_interval) + for backend in backends: + checker.check_once(backend) + checker.start() + print("proxy: http://%s:%d/ -> %s" % (args.host, args.port, + ", ".join(urls))) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + checker.stop() + + +if __name__ == "__main__": + main() + diff --git a/examples/sse-chat.py b/examples/sse-chat.py new file mode 100755 index 0000000..8e46154 --- /dev/null +++ b/examples/sse-chat.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""Multi-room chat met Server-Sent Events. Stdlib only, single file, 3.6+. + +Run: + python3 sse-chat.py --port 8082 + +Open http://127.0.0.1:8082/ in twee browsers en chat. Geen JS-deps: +alleen EventSource + fetch. + +Spec (dit bestand is de spec): + + Endpoint Method Statusen Beschrijving + /api/rooms GET 200 Kamers + aantallen + /api/rooms POST 201/400 Kamer aanmaken {"room"} + /api/rooms/{room}/messages GET 200/404 History (?since=&limit=) + /api/rooms/{room}/messages POST 201/400/404 Bericht {"nick","text"} + /events?room={room} GET 200/404 SSE-stream (id/event/retry) + / GET 200 Embedded UI + /healthz GET 200 {"ok":true,...} + + SSE-events: "message" (JSON-bericht), "hello" (laatste id bij connect). + Reconnect: Last-Event-ID-header of ?since= wordt gerespecteerd, gemiste + berichten worden nagezonden. Heartbeat: ": ping" elke 15 s. + + Kamernamen: ^[a-z0-9-]{1,32}$. Standaardkamers: lobby, random, tech. + +Caps: MAX_ROOMS 50, MAX_HISTORY 200/room, MAX_NICK 32, MAX_TEXT 2000, +MAX_BODY 8 KiB, HEARTBEAT 15 s. +""" + +import argparse +import json +import re +import socketserver +import threading +import time +from collections import deque +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +try: + from queue import Queue, Empty +except ImportError: # Python 2 fallback, nooit gebruikt maar goedkoop + from Queue import Queue, Empty + +ROOM_RE = re.compile(r"^[a-z0-9-]{1,32}$") + + +class ChatError(Exception): + def __init__(self, status, message): + super(ChatError, self).__init__(message) + self.status = status + self.message = message + + +class ChatStore(object): + """Kamers, history en SSE-abonnees. Alles achter een RLock.""" + + MAX_ROOMS = 50 + MAX_HISTORY = 200 + MAX_SUBSCRIBERS = 500 + DEFAULT_ROOMS = ("lobby", "random", "tech") + + def __init__(self): + self._lock = threading.RLock() + self._rooms = {} + self._subs = {} + for room in self.DEFAULT_ROOMS: + self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY), + "next_id": 1} + + @staticmethod + def _now(): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + def rooms(self): + with self._lock: + return [{"room": name, "messages": len(info["messages"])} + for name, info in sorted(self._rooms.items())] + + def create_room(self, room): + if not isinstance(room, str) or not ROOM_RE.match(room): + raise ChatError(400, "bad room (want [a-z0-9-]{1,32})") + with self._lock: + if room in self._rooms: + raise ChatError(409, "room exists") + if len(self._rooms) >= self.MAX_ROOMS: + raise ChatError(507, "too many rooms") + self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY), + "next_id": 1} + return {"room": room, "messages": 0} + + def _require(self, room): + if room not in self._rooms: + raise ChatError(404, "no such room") + return self._rooms[room] + + def history(self, room, since=0, limit=100): + with self._lock: + info = self._require(room) + items = [m for m in info["messages"] if m["id"] > since] + return items[-limit:] + + def post(self, room, nick, text): + if not isinstance(nick, str) or not 1 <= len(nick.strip()) <= 32: + raise ChatError(400, "nick must be 1..32 chars") + if not isinstance(text, str) or not 1 <= len(text.strip()) <= 2000: + raise ChatError(400, "text must be 1..2000 chars") + with self._lock: + info = self._require(room) + msg = {"id": info["next_id"], "room": room, + "nick": nick.strip()[:32], "text": text.strip()[:2000], + "ts": self._now()} + info["next_id"] += 1 + info["messages"].append(msg) + subs = list(self._subs.get(room, [])) + for queue in subs: + try: + queue.put_nowait(msg) + except Exception: + pass + return msg + + def subscribe(self, room): + with self._lock: + self._require(room) + total = sum(len(v) for v in self._subs.values()) + if total >= self.MAX_SUBSCRIBERS: + raise ChatError(503, "too many subscribers") + queue = Queue(maxsize=200) + self._subs.setdefault(room, []).append(queue) + return queue + + def unsubscribe(self, room, queue): + with self._lock: + subs = self._subs.get(room, []) + if queue in subs: + subs.remove(queue) + + +class ChatHandler(BaseHTTPRequestHandler): + server_version = "TaiChat/1.0" + protocol_version = "HTTP/1.1" + MAX_BODY = 8 * 1024 + HEARTBEAT = 15 + + def log_message(self, fmt, *args): + pass + + def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if self.command != "HEAD" and body: + self.wfile.write(body) + + def _json(self, code, obj): + self._send(code, json.dumps(obj), "application/json") + + def _read_json(self): + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + raise ChatError(400, "bad Content-Length") + if length < 0 or length > self.MAX_BODY: + raise ChatError(413, "body too large") + raw = self.rfile.read(length) if length else b"" + try: + data = json.loads((raw or b"{}").decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise ChatError(400, "body is not valid JSON") + if not isinstance(data, dict): + raise ChatError(400, "body must be a JSON object") + return data + + def _events(self, room, since): + store = self.server.chat # type: ChatStore + try: + queue = store.subscribe(room) + except ChatError as exc: + self._json(exc.status, {"error": exc.message}) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + try: + self._sse("hello", {"room": room, "since": since}) + for msg in store.history(room, since=since, limit=200): + self._sse("message", msg, msg["id"]) + while True: + try: + msg = queue.get(timeout=self.HEARTBEAT) + except Empty: + self.wfile.write(b": ping\n\n") + self.wfile.flush() + continue + if msg["id"] <= since: + continue + since = msg["id"] + self._sse("message", msg, msg["id"]) + except (BrokenPipeError, ConnectionResetError, OSError): + pass + finally: + store.unsubscribe(room, queue) + + def _sse(self, event, obj, msg_id=None): + lines = ["event: %s" % event] + if msg_id is not None: + lines.append("id: %d" % msg_id) + lines.append("retry: 3000") + for chunk in json.dumps(obj).split("\n"): + lines.append("data: %s" % chunk) + self.wfile.write(("\n".join(lines) + "\n\n").encode("utf-8")) + self.wfile.flush() + + def _handle(self): + store = self.server.chat # type: ChatStore + parsed = urlparse(self.path) + parts = [p for p in parsed.path.split("/") if p] + method = self.command + if not parts: + if method != "GET": + raise ChatError(405, "method not allowed") + self._send(200, CHAT_HTML, "text/html; charset=utf-8") + return + if parts == ["healthz"]: + rooms = store.rooms() + self._json(200, {"ok": True, "rooms": len(rooms), + "messages": sum(r["messages"] for r in rooms)}) + return + if parts == ["events"] and method == "GET": + qs = parse_qs(parsed.query) + room = qs.get("room", ["lobby"])[0] + since = qs.get("since", ["0"])[0] + last_id = self.headers.get("Last-Event-ID", "").strip() + if last_id.isdigit(): + since = last_id + try: + since = max(0, int(since)) + except ValueError: + raise ChatError(400, "bad since") + self._events(room, since) + return + if parts[0] != "api" or len(parts) < 2 or parts[1] != "rooms": + raise ChatError(404, "unknown endpoint") + if len(parts) == 2: + if method == "GET": + self._json(200, {"rooms": store.rooms()}) + elif method == "POST": + data = self._read_json() + self._json(201, store.create_room(data.get("room", ""))) + else: + raise ChatError(405, "method not allowed") + return + room = parts[2] + if len(parts) == 4 and parts[3] == "messages": + if method == "GET": + qs = parse_qs(parsed.query) + try: + since = max(0, int(qs.get("since", ["0"])[0])) + limit = min(max(int(qs.get("limit", ["100"])[0]), 1), 200) + except ValueError: + raise ChatError(400, "bad since/limit") + self._json(200, {"room": room, "messages": store.history( + room, since=since, limit=limit)}) + elif method == "POST": + data = self._read_json() + self._json(201, store.post(room, data.get("nick", ""), + data.get("text", ""))) + else: + raise ChatError(405, "method not allowed") + return + raise ChatError(404, "unknown endpoint") + + def _dispatch(self): + try: + self._handle() + except ChatError as exc: + self._json(exc.status, {"error": exc.message}) + except BrokenPipeError: + pass + + do_GET = _dispatch + do_HEAD = _dispatch + do_POST = _dispatch + + +CHAT_HTML = """ + + +Chat + + +
    +

    #lobby

    verbinden...
    +
    +
      +
      +
      + +""" + + +class ChatServer(socketserver.ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Multi-room SSE chat (stdlib only).") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8082) + args = parser.parse_args(argv) + server = ChatServer((args.host, args.port), ChatHandler) + server.chat = ChatStore() + print("chat: http://%s:%d/" % (args.host, args.port)) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() + diff --git a/examples/todo-rest-api.py b/examples/todo-rest-api.py new file mode 100755 index 0000000..4cca260 --- /dev/null +++ b/examples/todo-rest-api.py @@ -0,0 +1,501 @@ +#!/usr/bin/env python3 +"""Todo REST API + SQLite + embedded UI. Stdlib only, single file, 3.6+. + +Run: + python3 todo-rest-api.py --port 8081 + python3 todo-rest-api.py --port 8081 --db ./todos.db + +Open http://127.0.0.1:8081/ voor de UI, of praat JSON tegen /api/todos. + +Spec (dit bestand is de spec): + + Endpoint Method Statusen Beschrijving + /api/todos GET 200 Lijst (filter/paging/sort) + /api/todos POST 201/400/413 Aanmaken, JSON body + /api/todos/{id} GET 200/404 Een todo + /api/todos/{id} PUT 200/400/404 Vervangen (volledig) + /api/todos/{id} PATCH 200/400/404 Deels bijwerken + /api/todos/{id} DELETE 204/404 Verwijderen + /api/todos/{id}/toggle POST 200/404 done-flag omklappen + / GET 200 Embedded UI (geen deps) + /healthz GET 200 {"ok": true, "todos": N} + + Query params (lijst): status=open|done|all (default all), q=zoektekst, + limit=1..100 (default 50), offset>=0 (default 0), + sort=created|updated|priority|title (default updated), + order=asc|desc (default desc). + + Todo-object: {"id": int, "title": str, "notes": str, "priority": 1..5, + "done": bool, "due": "YYYY-MM-DD"|null, + "created_at": iso, "updated_at": iso}. + +Caps: MAX_TITLE 200, MAX_NOTES 5000, MAX_BODY 64 KiB, MAX_LIMIT 100, +MAX_TODOS 100000. Concurrency: een SQLite-verbinding + RLock, WAL-mode, +busy_timeout 5s. +""" + +import argparse +import json +import os +import re +import socketserver +import sqlite3 +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +DUE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +class TodoError(Exception): + def __init__(self, status, message): + super(TodoError, self).__init__(message) + self.status = status + self.message = message + + +class TodoValidator(object): + """Enige plek die beslist wat een geldige todo is.""" + + MAX_TITLE = 200 + MAX_NOTES = 5000 + + @classmethod + def clean(cls, data, partial=False): + if not isinstance(data, dict): + raise TodoError(400, "body must be a JSON object") + out = {} + if "title" in data or not partial: + title = data.get("title", "") + if not isinstance(title, str) or not title.strip(): + raise TodoError(400, "title is required (1..200 chars)") + title = title.strip() + if len(title) > cls.MAX_TITLE: + raise TodoError(400, "title too long (max 200)") + out["title"] = title + if "notes" in data: + notes = data["notes"] + if notes is None: + notes = "" + if not isinstance(notes, str): + raise TodoError(400, "notes must be a string") + if len(notes) > cls.MAX_NOTES: + raise TodoError(400, "notes too long (max 5000)") + out["notes"] = notes + if "priority" in data: + prio = data["priority"] + if not isinstance(prio, int) or not 1 <= prio <= 5: + raise TodoError(400, "priority must be int 1..5") + out["priority"] = prio + if "done" in data: + done = data["done"] + if not isinstance(done, bool): + raise TodoError(400, "done must be boolean") + out["done"] = done + if "due" in data: + due = data["due"] + if due is not None and (not isinstance(due, str) + or not DUE_RE.match(due)): + raise TodoError(400, "due must be YYYY-MM-DD or null") + out["due"] = due + return out + + +class TodoStore(object): + """SQLite-opslag. Thread-safe via een RLock rond een verbinding.""" + + SCHEMA = ("CREATE TABLE IF NOT EXISTS todos (" + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "title TEXT NOT NULL," + "notes TEXT NOT NULL DEFAULT ''," + "priority INTEGER NOT NULL DEFAULT 3," + "done INTEGER NOT NULL DEFAULT 0," + "due TEXT," + "created_at TEXT NOT NULL," + "updated_at TEXT NOT NULL)") + INDEXES = ("CREATE INDEX IF NOT EXISTS idx_todos_done ON todos(done)", + "CREATE INDEX IF NOT EXISTS idx_todos_updated " + "ON todos(updated_at)", + "CREATE INDEX IF NOT EXISTS idx_todos_priority " + "ON todos(priority)") + + SORTS = {"created": "created_at", "updated": "updated_at", + "priority": "priority", "title": "title"} + MAX_TODOS = 100000 + + def __init__(self, path): + self._lock = threading.RLock() + self.db = sqlite3.connect(path, check_same_thread=False) + self.db.row_factory = sqlite3.Row + self.db.execute("PRAGMA journal_mode=WAL") + self.db.execute("PRAGMA busy_timeout=5000") + self.db.execute("PRAGMA synchronous=NORMAL") + with self.db: + self.db.execute(self.SCHEMA) + for index in self.INDEXES: + self.db.execute(index) + + @staticmethod + def _now(): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + @staticmethod + def _row_to_dict(row): + return {"id": row["id"], "title": row["title"], + "notes": row["notes"], "priority": row["priority"], + "done": bool(row["done"]), "due": row["due"], + "created_at": row["created_at"], + "updated_at": row["updated_at"]} + + def count(self): + with self._lock: + row = self.db.execute("SELECT COUNT(*) AS n FROM todos").fetchone() + return row["n"] + + def create(self, fields): + with self._lock: + if self.count() >= self.MAX_TODOS: + raise TodoError(507, "todo store full") + now = self._now() + cur = self.db.execute( + "INSERT INTO todos (title, notes, priority, done, due," + " created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + (fields["title"], fields.get("notes", ""), + fields.get("priority", 3), int(fields.get("done", False)), + fields.get("due"), now, now)) + self.db.commit() + return self.get(cur.lastrowid) + + def get(self, todo_id): + with self._lock: + row = self.db.execute("SELECT * FROM todos WHERE id=?", + (todo_id,)).fetchone() + if row is None: + raise TodoError(404, "no such todo") + return self._row_to_dict(row) + + def list(self, status="all", q="", limit=50, offset=0, + sort="updated", order="desc"): + column = self.SORTS.get(sort, "updated_at") + direction = "ASC" if order == "asc" else "DESC" + where = [] + params = [] + if status == "open": + where.append("done=0") + elif status == "done": + where.append("done=1") + if q: + where.append("(title LIKE ? OR notes LIKE ?)") + params.extend(["%" + q + "%", "%" + q + "%"]) + sql = "SELECT * FROM todos" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY %s %s, id %s LIMIT ? OFFSET ?" % (column, direction, + direction) + params.extend([limit, offset]) + count_sql = "SELECT COUNT(*) AS n FROM todos" + if where: + count_sql += " WHERE " + " AND ".join(where) + with self._lock: + total = self.db.execute(count_sql, params[:-2]).fetchone()["n"] + rows = self.db.execute(sql, params).fetchall() + return total, [self._row_to_dict(r) for r in rows] + + def update(self, todo_id, fields): + with self._lock: + current = self.get(todo_id) + merged = {"title": current["title"], "notes": current["notes"], + "priority": current["priority"], "done": current["done"], + "due": current["due"]} + merged.update(fields) + now = self._now() + self.db.execute( + "UPDATE todos SET title=?, notes=?, priority=?, done=?," + " due=?, updated_at=? WHERE id=?", + (merged["title"], merged["notes"], merged["priority"], + int(merged["done"]), merged["due"], now, todo_id)) + self.db.commit() + return self.get(todo_id) + + def delete(self, todo_id): + with self._lock: + cur = self.db.execute("DELETE FROM todos WHERE id=?", (todo_id,)) + self.db.commit() + if cur.rowcount == 0: + raise TodoError(404, "no such todo") + + def toggle(self, todo_id): + with self._lock: + current = self.get(todo_id) + return self.update(todo_id, {"done": not current["done"]}) + + +class TodoHandler(BaseHTTPRequestHandler): + """Een instantie per request. Deelt de store via server.store.""" + + server_version = "TaiTodo/1.0" + protocol_version = "HTTP/1.1" + MAX_BODY = 64 * 1024 + + def log_message(self, fmt, *args): + pass + + # -- generieke hulpjes ------------------------------------------------ + + def _send(self, code, body=b"", ctype="text/plain; charset=utf-8", + extra=None): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for key, value in (extra or {}).items(): + self.send_header(key, value) + self.end_headers() + if self.command != "HEAD" and body: + self.wfile.write(body) + + def _json(self, code, obj): + self._send(code, json.dumps(obj), "application/json") + + def _read_json(self): + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + raise TodoError(400, "bad Content-Length") + if length < 0 or length > self.MAX_BODY: + raise TodoError(413, "body too large (max 64 KiB)") + raw = self.rfile.read(length) if length else b"" + if not raw: + raise TodoError(400, "empty body, want JSON object") + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + raise TodoError(400, "body is not valid JSON") + + def _route(self): + """Splits pad in (resource, id, action). Gooit TodoError bij 404.""" + parsed = urlparse(self.path) + parts = [p for p in parsed.path.split("/") if p] + if not parts: + return ("ui", None, None, parsed) + if parts == ["healthz"]: + return ("healthz", None, None, parsed) + if parts[0] != "api" or len(parts) < 2 or parts[1] != "todos": + raise TodoError(404, "unknown endpoint") + if len(parts) == 2: + return ("todos", None, None, parsed) + try: + todo_id = int(parts[2]) + if todo_id <= 0: + raise ValueError + except ValueError: + raise TodoError(404, "bad todo id") + if len(parts) == 3: + return ("todos", todo_id, None, parsed) + if len(parts) == 4 and parts[3] == "toggle": + return ("todos", todo_id, "toggle", parsed) + raise TodoError(404, "unknown endpoint") + + def _handle(self): + store = self.server.store # type: TodoStore + resource, todo_id, action, parsed = self._route() + method = self.command + if resource == "ui": + if method not in ("GET", "HEAD"): + raise TodoError(405, "method not allowed") + self._send(200, UI_HTML, "text/html; charset=utf-8") + return + if resource == "healthz": + if method not in ("GET", "HEAD"): + raise TodoError(405, "method not allowed") + self._json(200, {"ok": True, "todos": store.count()}) + return + if todo_id is None: + if method == "GET": + qs = parse_qs(parsed.query) + status = qs.get("status", ["all"])[0] + if status not in ("open", "done", "all"): + raise TodoError(400, "status must be open|done|all") + try: + limit = int(qs.get("limit", ["50"])[0]) + offset = int(qs.get("offset", ["0"])[0]) + except ValueError: + raise TodoError(400, "limit/offset must be ints") + limit = min(max(limit, 1), 100) + offset = max(offset, 0) + total, items = store.list( + status=status, q=qs.get("q", [""])[0][:200], + limit=limit, offset=offset, + sort=qs.get("sort", ["updated"])[0], + order=qs.get("order", ["desc"])[0]) + self._json(200, {"total": total, "limit": limit, + "offset": offset, "items": items}) + elif method == "POST": + todo = store.create(TodoValidator.clean(self._read_json())) + self._json(201, todo) + else: + raise TodoError(405, "method not allowed") + return + if action == "toggle": + if method != "POST": + raise TodoError(405, "method not allowed") + self._json(200, store.toggle(todo_id)) + return + if method == "GET": + self._json(200, store.get(todo_id)) + elif method == "PUT": + self._json(200, store.update( + todo_id, TodoValidator.clean(self._read_json()))) + elif method == "PATCH": + self._json(200, store.update( + todo_id, TodoValidator.clean(self._read_json(), partial=True))) + elif method == "DELETE": + store.delete(todo_id) + self._send(204, b"") + else: + raise TodoError(405, "method not allowed") + + def _dispatch(self): + try: + self._handle() + except TodoError as exc: + self._json(exc.status, {"error": exc.message}) + except BrokenPipeError: + pass + + do_GET = _dispatch + do_HEAD = _dispatch + do_POST = _dispatch + do_PUT = _dispatch + do_PATCH = _dispatch + do_DELETE = _dispatch + + +UI_HTML = """ + + +Todo +
      +

      Todo

      +
      +
      + +
      + + + +
      +
        +
        +
        +
        +""" + + +class TodoServer(socketserver.ThreadingMixIn, HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Todo REST API + SQLite + embedded UI (stdlib only).") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8081) + parser.add_argument("--db", default=":memory:", + help="sqlite file (default :memory:)") + args = parser.parse_args(argv) + if args.db != ":memory:": + parent = os.path.dirname(os.path.abspath(args.db)) + if not os.path.isdir(parent): + parser.error("db directory does not exist: %s" % parent) + server = TodoServer((args.host, args.port), TodoHandler) + server.store = TodoStore(args.db) + print("todo: http://%s:%d/ (db=%s)" % (args.host, args.port, args.db)) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() + diff --git a/examples/webdav-server.py b/examples/webdav-server.py new file mode 100755 index 0000000..5a24c8c --- /dev/null +++ b/examples/webdav-server.py @@ -0,0 +1,1051 @@ +#!/usr/bin/env python3 +"""Complete WebDAV Class 1 server. Stdlib only, single file, Python 3.6+. + +Run: + python3 webdav-server.py --root ./share --port 8080 + python3 webdav-server.py --root ./share --port 8080 --user alice --password s3cret + +Works with: cadaver, Nautilus/GNOME Files (dav://), rclone (webdav remote), +Windows Explorer (Map network drive), curl. + +Spec (this file is the spec, DAV class 1): + + Method Statusen Beschrijving + OPTIONS 200 DAV: 1, Allow-lijst, MS-Author-Via + GET 200/206/304/404 File download, Range, ETag/If-* + HEAD als GET zonder body Headers only + PUT 201/204/409/423 File aanmaken/overschrijven + DELETE 204/404/423 File/dir verwijderen (recursief) + MKCOL 201/405/409/415 Directory aanmaken + PROPFIND 207 Metadata als multistatus XML + PROPPATCH 207 Dead properties set/remove + COPY 201/204/412/409 File/dir kopieren (Depth, Overwrite) + MOVE 201/204/412/409 File/dir verplaatsen + LOCK 200/423 Exclusive write lock, opaquelocktoken + UNLOCK 204/400/409 Lock opheffen via Lock-Token + +Caps (harde plafonds, zie DavConfig): + MAX_XML_BODY 1 MiB PROPFIND/PROPPATCH/LOCK request body + MAX_UPLOAD 200 MiB PUT body (instelbaar via --max-upload-mb) + MAX_PROPFIND 2000 max entries per PROPFIND (infinity afgekapt) + LOCK_TIMEOUT 1 uur default, max 1 dag via Timeout-header + +Veiligheid: alle paden gejailed onder --root (symlink-escape geeft 403), +optionele Basic-auth met constant-time vergelijking. +""" + +import argparse +import base64 +import hashlib +import hmac +import json +import mimetypes +import os +import posixpath +import shutil +import socketserver +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import unquote, urlparse +from xml.etree import ElementTree as ET + + +DAV_NS = "DAV:" +ET.register_namespace("D", DAV_NS) + + +def _d(tag): + return "{%s}%s" % (DAV_NS, tag) + + +def _http_date(ts): + from email.utils import formatdate + return formatdate(ts, usegmt=True) + + +class DavConfig(object): + """Alle knoppen van de server op een plek. Defaults zijn de spec.""" + + MAX_XML_BODY = 1024 * 1024 + MAX_PROPFIND = 2000 + LOCK_DEFAULT_TIMEOUT = 3600 + LOCK_MAX_TIMEOUT = 86400 + + def __init__(self, root, user=None, password=None, max_upload_mb=200, + readonly=False): + self.root = os.path.realpath(os.path.abspath(root)) + self.user = user + self.password = password + self.max_upload = max_upload_mb * 1024 * 1024 + self.readonly = readonly + + @property + def auth_enabled(self): + return bool(self.user) and self.password is not None + + +class DavStore(object): + """Filesystem-laag. Enige plek die paden naar schijf vertaalt.""" + + def __init__(self, config): + self.config = config + self._lock = threading.RLock() + + def resolve(self, uri_path): + """Vertaal URI-pad naar schijfpad. None bij jail-escape.""" + path = unquote(urlparse(uri_path).path) + path = posixpath.normpath(path) + if path.startswith("/"): + path = path[1:] + full = os.path.realpath(os.path.join(self.config.root, path)) + root = self.config.root + if full != root and not full.startswith(root + os.sep): + return None + return full + + def uri_of(self, full, is_dir): + rel = os.path.relpath(full, self.config.root) + if rel == ".": + return "/" + parts = rel.split(os.sep) + from urllib.parse import quote + uri = "/" + "/".join(quote(p, safe="") for p in parts) + if is_dir and not uri.endswith("/"): + uri += "/" + return uri + + @staticmethod + def etag(full): + try: + st = os.stat(full) + except OSError: + return None + h = hashlib.sha1() + h.update(("%d:%d" % (int(st.st_mtime), st.st_size)).encode("ascii")) + return '"%s"' % h.hexdigest()[:16] + + @staticmethod + def content_type(full): + guess = mimetypes.guess_type(full)[0] + return guess or "application/octet-stream" + + +class DavLocks(object): + """In-memory exclusive write locks. Thread-safe.""" + + def __init__(self): + self._lock = threading.RLock() + self._by_token = {} + self._by_path = {} + + def _prune(self): + now = time.time() + dead = [t for t, e in self._by_token.items() if e["expires"] < now] + for t in dead: + self._release_token(t) + + def _release_token(self, token): + entry = self._by_token.pop(token, None) + if entry is not None: + paths = self._by_path.get(entry["path"], []) + if token in paths: + paths.remove(token) + + def create(self, path, depth, owner, timeout): + with self._lock: + self._prune() + token = "opaquelocktoken:%s" % uuid.uuid4() + entry = {"token": token, "path": path, "depth": depth, + "owner": owner or "", + "expires": time.time() + timeout} + self._by_token[token] = entry + self._by_path.setdefault(path, []).append(token) + return entry + + def release(self, token): + with self._lock: + if token not in self._by_token: + return False + self._release_token(token) + return True + + def refresh(self, token, timeout): + with self._lock: + self._prune() + entry = self._by_token.get(token) + if entry is None: + return None + entry["expires"] = time.time() + timeout + return entry + + def locked(self, path, token=None): + """True als path onder een lock valt die NIET via token is vrijgegeven.""" + with self._lock: + self._prune() + if token and token in self._by_token: + entry = self._by_token[token] + if self._covers(entry, path): + return False + for entry in self._by_token.values(): + if entry["token"] == token: + continue + if self._covers(entry, path): + return True + return False + + @staticmethod + def _covers(entry, path): + if path == entry["path"]: + return True + if entry["depth"] == "infinity" and path.startswith(entry["path"] + os.sep): + return True + return False + + def for_path(self, path): + with self._lock: + self._prune() + return [e for e in self._by_token.values() if e["path"] == path] + + +class DavProps(object): + """Dead properties. JSON-persistent onder root, thread-safe.""" + + FILENAME = ".davprops.json" + + def __init__(self, config): + self._lock = threading.RLock() + self._file = os.path.join(config.root, self.FILENAME) + self._data = {} + self._load() + + def _load(self): + try: + with open(self._file, "r", encoding="utf-8") as fh: + self._data = json.load(fh) + except (OSError, ValueError): + self._data = {} + + def _save(self): + tmp = self._file + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(self._data, fh) + os.replace(tmp, self._file) + except OSError: + try: + os.unlink(tmp) + except OSError: + pass + + def get_all(self, uri): + with self._lock: + return dict(self._data.get(uri, {})) + + def patch(self, uri, set_props, remove_props): + with self._lock: + cur = self._data.setdefault(uri, {}) + for qname, value in set_props.items(): + cur[qname] = value + for qname in remove_props: + cur.pop(qname, None) + if not cur and uri in self._data: + del self._data[uri] + self._save() + + +class DavXml(object): + """Bouw- en parse-hulpjes voor PROPFIND/PROPPATCH/LOCK bodies.""" + + ALLPROPS = ("creationdate", "displayname", "getcontentlength", + "getcontenttype", "getlastmodified", "getetag", + "resourcetype", "supportedlock", "lockdiscovery") + + @staticmethod + def parse_body(handler, limit): + try: + length = int(handler.headers.get("Content-Length", "0") or "0") + except ValueError: + return None, 400 + if length < 0 or length > limit: + return None, 413 + if length == 0: + return None, 0 + try: + raw = handler.rfile.read(length) + except OSError: + return None, 400 + try: + return ET.fromstring(raw), 0 + except ET.ParseError: + return None, 400 + + @staticmethod + def propfind_mode(root): + if root is None: + return "allprop", [] + if root.find(_d("allprop")) is not None: + return "allprop", [] + if root.find(_d("propname")) is not None: + return "propname", [] + prop = root.find(_d("prop")) + if prop is None: + return "allprop", [] + return "named", list(prop) + + @staticmethod + def lockinfo(root): + scope = "exclusive" + owner = "" + if root is not None: + if root.find(".//%s" % _d("shared")) is not None: + scope = "shared" + owner_el = root.find(".//%s" % _d("owner")) + if owner_el is not None: + owner = "".join(owner_el.itertext()).strip()[:500] + return scope, owner + + @staticmethod + def proppatch(root): + to_set, to_remove = {}, [] + if root is None: + return to_set, to_remove + for op in root: + kind = op.tag.replace("{%s}" % DAV_NS, "") + prop = op.find(_d("prop")) + if prop is None: + continue + for child in prop: + if kind == "set": + inner = b"".join( + ET.tostring(g) for g in child) if len(child) else b"" + text = (child.text or "").strip() + if inner: + value = inner.decode("utf-8", "replace")[:4000] + else: + value = text[:4000] + to_set[child.tag] = value + elif kind == "remove": + to_remove.append(child.tag) + return to_set, to_remove + + +class DavHandler(BaseHTTPRequestHandler): + """Een DavHandler-instantie per request. Deelt store/locks/props via server.""" + + server_version = "TaiWebDAV/1.0" + protocol_version = "HTTP/1.1" + + @property + def dav(self): + return self.server.dav + + def log_message(self, fmt, *args): + pass + + # -- generieke hulpjes ------------------------------------------------ + + def _send(self, code, body=b"", ctype="text/plain; charset=utf-8", + extra=None): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("DAV", "1") + self.send_header("MS-Author-Via", "DAV") + for key, value in (extra or {}).items(): + self.send_header(key, value) + self.end_headers() + if self.command != "HEAD" and body: + self.wfile.write(body) + + def _error(self, code, message): + self._send(code, message + "\n") + + def _check_auth(self): + cfg = self.dav["config"] + if not cfg.auth_enabled: + return True + auth = self.headers.get("Authorization", "") + expect = "Basic " + base64.b64encode( + ("%s:%s" % (cfg.user, cfg.password)).encode("utf-8")).decode("ascii") + if hmac.compare_digest(auth, expect): + return True + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="webdav"') + self.send_header("Content-Length", "13") + self.send_header("DAV", "1") + self.end_headers() + self.wfile.write(b"unauthorized\n") + return False + + def _if_token(self): + """Haal lock-token uit If-header: ().""" + header = self.headers.get("If", "") + start = header.find("(<") + if start == -1: + start = header.find("<") + if start == -1: + return None + end = header.find(">", start) + else: + end = header.find(">)", start) + if end == -1: + return None + return header[start + 2 if header[start:start + 2] == "(<" else start + 1:end] + + # -- OPTIONS / GET / HEAD --------------------------------------------- + + def do_OPTIONS(self): + if not self._check_auth(): + return + self._send(200, "", extra={ + "Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, PROPFIND, " + "PROPPATCH, COPY, MOVE, LOCK, UNLOCK", + }) + + def _serve_file(self, full, uri): + store = self.dav["store"] # type: DavStore + try: + size = os.path.getsize(full) + mtime = os.path.getmtime(full) + except OSError: + self._error(404, "not found") + return + etag = store.etag(full) + if self.headers.get("If-None-Match") == etag: + self._send(304, "", extra={"ETag": etag}) + return + ctype = store.content_type(full) + range_header = self.headers.get("Range", "") + start, end = 0, size - 1 + partial = False + if range_header.startswith("bytes="): + try: + spec = range_header[len("bytes="):].strip() + if spec.startswith("-"): + suffix = int(spec[1:]) + start = max(0, size - suffix) + else: + first, _, last = spec.partition("-") + start = int(first) + if last.strip(): + end = min(int(last), size - 1) + if 0 <= start <= end < size: + partial = True + else: + self._send(416, "range not satisfiable", extra={ + "Content-Range": "bytes */%d" % size}) + return + except ValueError: + pass + length = end - start + 1 + self.send_response(206 if partial else 200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(length)) + self.send_header("ETag", etag) + self.send_header("Last-Modified", _http_date(mtime)) + self.send_header("Accept-Ranges", "bytes") + self.send_header("DAV", "1") + if partial: + self.send_header("Content-Range", + "bytes %d-%d/%d" % (start, end, size)) + self.end_headers() + if self.command == "HEAD": + return + try: + with open(full, "rb") as fh: + fh.seek(start) + remaining = length + while remaining > 0: + chunk = fh.read(min(65536, remaining)) + if not chunk: + break + self.wfile.write(chunk) + remaining -= len(chunk) + except OSError: + pass + + def _serve_dir_listing(self, full, uri): + try: + names = sorted(os.listdir(full)) + except OSError: + self._error(404, "not found") + return + rows = [] + for name in names: + if name == DavProps.FILENAME: + continue + child = os.path.join(full, name) + is_dir = os.path.isdir(child) + href = self.dav["store"].uri_of(child, is_dir) + label = name + ("/" if is_dir else "") + try: + size = "" if is_dir else str(os.path.getsize(child)) + except OSError: + size = "?" + rows.append('%s%s' + % (href, label, size)) + html = ("" + "" + "Index of %s" + "" + "

        Index of %s

        " + "%s
        ") % (uri, uri, "".join(rows)) + self._send(200, html, "text/html; charset=utf-8", + extra={"ETag": self.dav["store"].etag(full) or ""}) + + def do_GET(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + if os.path.isdir(full): + uri = store.uri_of(full, True) + if not self.path.endswith("/"): + self.send_response(301) + self.send_header("Location", uri) + self.send_header("Content-Length", "0") + self.send_header("DAV", "1") + self.end_headers() + return + self._serve_dir_listing(full, uri) + elif os.path.exists(full): + self._serve_file(full, store.uri_of(full, False)) + else: + self._error(404, "not found") + + def do_HEAD(self): + self.do_GET() + + # -- PUT / DELETE / MKCOL --------------------------------------------- + + def _mutate_guards(self, full, token=None): + cfg = self.dav["config"] # type: DavConfig + if cfg.readonly: + self._error(403, "read-only server") + return False + if self.dav["locks"].locked(full, token): + self._error(423, "locked") + return False + return True + + def do_PUT(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + cfg = self.dav["config"] # type: DavConfig + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + if os.path.isdir(full): + self._error(405, "is a collection") + return + token = self._if_token() + if not self._mutate_guards(full, token): + return + parent = os.path.dirname(full) + if not os.path.isdir(parent): + self._error(409, "parent missing") + return + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + self._error(400, "bad Content-Length") + return + if length < 0 or length > cfg.max_upload: + self._error(413, "upload too large (max %d bytes)" % cfg.max_upload) + return + existed = os.path.exists(full) + tmp = full + ".davtmp-%d" % os.getpid() + try: + remaining = length + with open(tmp, "wb") as fh: + while remaining > 0: + chunk = self.rfile.read(min(65536, remaining)) + if not chunk: + break + fh.write(chunk) + remaining -= len(chunk) + os.replace(tmp, full) + except OSError: + try: + os.unlink(tmp) + except OSError: + pass + self._error(507, "insufficient storage") + return + self._send(204 if existed else 201, "", + extra={"ETag": store.etag(full) or ""}) + + def do_DELETE(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + if full == self.dav["config"].root: + self._error(403, "refusing to delete root") + return + if not os.path.exists(full): + self._error(404, "not found") + return + if not self._mutate_guards(full, self._if_token()): + return + try: + if os.path.isdir(full) and not os.path.islink(full): + shutil.rmtree(full) + else: + os.unlink(full) + except OSError: + self._error(507, "cannot delete") + return + self._send(204, "") + + def do_MKCOL(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + length = 0 + if length: + self._error(415, "MKCOL takes no body") + return + if os.path.exists(full): + self._error(405, "already exists") + return + if not self._mutate_guards(full, self._if_token()): + return + parent = os.path.dirname(full) + if not os.path.isdir(parent): + self._error(409, "parent missing") + return + try: + os.mkdir(full) + except OSError: + self._error(507, "cannot create collection") + return + self._send(201, "") + + # -- PROPFIND / PROPPATCH --------------------------------------------- + + def _live_props(self, full, uri, is_dir): + store = self.dav["store"] # type: DavStore + try: + st = os.stat(full) + except OSError: + return {} + name = posixpath.basename(uri.rstrip("/")) or "/" + props = { + _d("creationdate"): time.strftime("%Y-%m-%dT%H:%M:%SZ", + time.gmtime(st.st_ctime)), + _d("displayname"): name, + _d("getlastmodified"): _http_date(st.st_mtime), + _d("getetag"): store.etag(full) or "", + _d("supportedlock"): "" + "" + "", + } + if is_dir: + rt = ET.Element(_d("resourcetype")) + ET.SubElement(rt, _d("collection")) + props[_d("resourcetype")] = rt + else: + props[_d("resourcetype")] = ET.Element(_d("resourcetype")) + props[_d("getcontentlength")] = str(st.st_size) + props[_d("getcontenttype")] = store.content_type(full) + locks = self.dav["locks"].for_path(full) + if locks: + discs = [] + for entry in locks: + discs.append( + "" + "" + "%s" + "%s" + "" % (entry["depth"], entry["token"])) + props[_d("lockdiscovery")] = "".join(discs) + else: + props[_d("lockdiscovery")] = ET.Element(_d("lockdiscovery")) + return props + + def _prop_response(self, full, uri, mode, wanted): + is_dir = os.path.isdir(full) + live = self._live_props(full, uri, is_dir) + dead = self.dav["props"].get_all(uri) + resp = ET.Element(_d("response")) + href = ET.SubElement(resp, _d("href")) + href.text = uri + if mode == "propname": + propstat = ET.SubElement(resp, _d("propstat")) + prop = ET.SubElement(propstat, _d("prop")) + for qname in list(live) + list(dead): + ET.SubElement(prop, qname) + status = ET.SubElement(propstat, _d("status")) + status.text = "HTTP/1.1 200 OK" + return resp + if mode == "allprop": + names = [q for q in DavXml.ALLPROPS] + wanted_tags = [_d(n) for n in names] + list(dead) + else: + wanted_tags = [child.tag for child in wanted] + found = ET.Element(_d("prop")) + missing = ET.Element(_d("prop")) + for qname in wanted_tags: + if qname in live: + el = ET.SubElement(found, qname) + value = live[qname] + if isinstance(value, ET.Element): + for child in list(value): + el.append(child) + elif value.startswith("<"): + try: + el.append(ET.fromstring( + "%s" % value)) + except ET.ParseError: + el.text = value + else: + el.text = value + elif qname in dead: + el = ET.SubElement(found, qname) + el.text = dead[qname] + else: + ET.SubElement(missing, qname) + if len(found): + propstat = ET.SubElement(resp, _d("propstat")) + propstat.append(found) + status = ET.SubElement(propstat, _d("status")) + status.text = "HTTP/1.1 200 OK" + if len(missing): + propstat = ET.SubElement(resp, _d("propstat")) + propstat.append(missing) + status = ET.SubElement(propstat, _d("status")) + status.text = "HTTP/1.1 404 Not Found" + return resp + + def do_PROPFIND(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + if not os.path.exists(full): + self._error(404, "not found") + return + root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY) + if err: + self._error(err, "bad xml body") + return + mode, wanted = DavXml.propfind_mode(root) + depth = self.headers.get("Depth", "infinity").strip().lower() + if depth not in ("0", "1", "infinity"): + self._error(400, "bad Depth (want 0, 1, infinity)") + return + targets = [(full, store.uri_of(full, os.path.isdir(full)))] + if depth in ("1", "infinity") and os.path.isdir(full): + try: + names = sorted(os.listdir(full)) + except OSError: + self._error(404, "not found") + return + for name in names: + if name == DavProps.FILENAME: + continue + child = os.path.join(full, name) + targets.append( + (child, store.uri_of(child, os.path.isdir(child)))) + if len(targets) >= DavConfig.MAX_PROPFIND: + break + if depth == "infinity": + extra = [] + for base, _ in list(targets[1:]): + if not os.path.isdir(base): + continue + for dirpath, dirnames, filenames in os.walk(base): + for name in sorted(dirnames) + sorted(filenames): + if name == DavProps.FILENAME: + continue + child = os.path.join(dirpath, name) + extra.append( + (child, store.uri_of( + child, os.path.isdir(child)))) + if len(targets) + len(extra) >= DavConfig.MAX_PROPFIND: + break + if len(targets) + len(extra) >= DavConfig.MAX_PROPFIND: + break + targets.extend(extra[:DavConfig.MAX_PROPFIND - len(targets)]) + multi = ET.Element(_d("multistatus")) + for target_full, uri in targets: + multi.append(self._prop_response(target_full, uri, mode, wanted)) + body = (b'\n' + ET.tostring( + multi, encoding="utf-8")) + self._send(207, body, "application/xml; charset=utf-8") + + def do_PROPPATCH(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + if not os.path.exists(full): + self._error(404, "not found") + return + if not self._mutate_guards(full, self._if_token()): + return + root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY) + if err or root is None: + self._error(err or 400, "bad xml body") + return + to_set, to_remove = DavXml.proppatch(root) + uri = store.uri_of(full, os.path.isdir(full)) + self.dav["props"].patch(uri, to_set, to_remove) + multi = ET.Element(_d("multistatus")) + resp = ET.SubElement(multi, _d("response")) + href = ET.SubElement(resp, _d("href")) + href.text = uri + propstat = ET.SubElement(resp, _d("propstat")) + prop = ET.SubElement(propstat, _d("prop")) + for qname in list(to_set) + to_remove: + ET.SubElement(prop, qname) + status = ET.SubElement(propstat, _d("status")) + status.text = "HTTP/1.1 200 OK" + body = (b'\n' + ET.tostring( + multi, encoding="utf-8")) + self._send(207, body, "application/xml; charset=utf-8") + + # -- COPY / MOVE ------------------------------------------------------- + + def _destination(self): + dest = self.headers.get("Destination", "") + if not dest: + return None + parsed = urlparse(dest) + return self.dav["store"].resolve(parsed.path or "/") + + def _copy_move(self, is_move): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + src = store.resolve(self.path) + dst = self._destination() + if src is None or dst is None: + self._error(403, "outside root") + return + if not os.path.exists(src): + self._error(404, "source not found") + return + if src == dst or dst.startswith(src + os.sep): + self._error(502, "bad gateway: overlapping paths") + return + if not self._mutate_guards(src, self._if_token()): + return + overwrite = self.headers.get("Overwrite", "T").strip().upper() != "F" + existed = os.path.exists(dst) + if existed and not overwrite: + self._error(412, "destination exists") + return + parent = os.path.dirname(dst) + if not os.path.isdir(parent): + self._error(409, "destination parent missing") + return + try: + if existed: + if os.path.isdir(dst) and not os.path.islink(dst): + shutil.rmtree(dst) + else: + os.unlink(dst) + if os.path.isdir(src) and not os.path.islink(src): + if not is_move and self.headers.get( + "Depth", "infinity").strip() == "0": + os.mkdir(dst) + else: + shutil.copytree(src, dst, symlinks=True) + else: + shutil.copy2(src, dst) + if is_move: + if os.path.isdir(src) and not os.path.islink(src): + shutil.rmtree(src) + else: + os.unlink(src) + except OSError: + self._error(507, "insufficient storage") + return + self._send(204 if existed else 201, "") + + def do_COPY(self): + self._copy_move(False) + + def do_MOVE(self): + self._copy_move(True) + + # -- LOCK / UNLOCK ----------------------------------------------------- + + @staticmethod + def _parse_timeout(header): + if not header: + return DavConfig.LOCK_DEFAULT_TIMEOUT + header = header.strip().lower() + if header.startswith("second-"): + try: + value = int(header[len("second-"):]) + except ValueError: + return DavConfig.LOCK_DEFAULT_TIMEOUT + return min(max(1, value), DavConfig.LOCK_MAX_TIMEOUT) + return DavConfig.LOCK_DEFAULT_TIMEOUT + + def do_LOCK(self): + if not self._check_auth(): + return + store = self.dav["store"] # type: DavStore + full = store.resolve(self.path) + if full is None: + self._error(403, "outside root") + return + if self.dav["config"].readonly: + self._error(403, "read-only server") + return + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + length = 0 + if length == 0: + token = self._if_token() + if not token: + self._error(400, "lock refresh needs If-header with token") + return + entry = self.dav["locks"].refresh( + token, self._parse_timeout(self.headers.get("Timeout"))) + if entry is None: + self._error(409, "unknown lock token") + return + self._send_lock_response(entry) + return + if not os.path.exists(full): + parent = os.path.dirname(full) + if not os.path.isdir(parent): + self._error(409, "parent missing") + return + try: + open(full, "wb").close() + except OSError: + self._error(507, "insufficient storage") + return + if self.dav["locks"].locked(full): + self._error(423, "locked") + return + root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY) + if err: + self._error(err, "bad xml body") + return + scope, owner = DavXml.lockinfo(root) + if scope != "exclusive": + self._error(422, "only exclusive locks supported") + return + depth = self.headers.get("Depth", "infinity").strip().lower() + if depth not in ("0", "infinity"): + depth = "infinity" + entry = self.dav["locks"].create( + full, depth, owner, + self._parse_timeout(self.headers.get("Timeout"))) + self._send_lock_response(entry) + + def _send_lock_response(self, entry): + prop = ET.Element(_d("prop")) + disco = ET.SubElement(prop, _d("lockdiscovery")) + active = ET.SubElement(disco, _d("activelock")) + ET.SubElement(ET.SubElement(active, _d("locktype")), _d("write")) + ET.SubElement(ET.SubElement(active, _d("lockscope")), _d("exclusive")) + depth = ET.SubElement(active, _d("depth")) + depth.text = entry["depth"] + owner = ET.SubElement(active, _d("owner")) + owner.text = entry["owner"] + token = ET.SubElement(active, _d("locktoken")) + href = ET.SubElement(token, _d("href")) + href.text = entry["token"] + body = (b'\n' + ET.tostring( + prop, encoding="utf-8")) + self._send(200, body, "application/xml; charset=utf-8", + extra={"Lock-Token": "<%s>" % entry["token"]}) + + def do_UNLOCK(self): + if not self._check_auth(): + return + header = self.headers.get("Lock-Token", "").strip() + if header.startswith("<") and header.endswith(">"): + header = header[1:-1] + if not header or not self.dav["locks"].release(header): + self._error(409, "unknown lock token") + return + self._send(204, "") + + +class DavServer(socketserver.ThreadingMixIn, HTTPServer): + """Thread-per-request HTTP server (3.6-safe ThreadingHTTPServer).""" + + daemon_threads = True + allow_reuse_address = True + + +def build_server(host, port, config): + server = DavServer((host, port), DavHandler) + server.dav = {"config": config, + "store": DavStore(config), + "locks": DavLocks(), + "props": DavProps(config)} + return server + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Complete WebDAV Class 1 server (stdlib only).") + parser.add_argument("--root", default="./share", + help="directory to share (created if missing)") + parser.add_argument("--host", default="127.0.0.1", + help="bind host (default 127.0.0.1)") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--user", default=None, help="Basic-auth user") + parser.add_argument("--password", default=None, help="Basic-auth password") + parser.add_argument("--max-upload-mb", type=int, default=200) + parser.add_argument("--readonly", action="store_true", + help="refuse all mutations") + args = parser.parse_args(argv) + try: + os.makedirs(args.root, exist_ok=True) + except OSError as exc: + parser.error("cannot create --root: %s" % exc) + config = DavConfig(args.root, user=args.user, password=args.password, + max_upload_mb=args.max_upload_mb, + readonly=args.readonly) + server = build_server(args.host, args.port, config) + print("webdav: serving %s on http://%s:%d/ (DAV class 1)%s" % ( + config.root, args.host, args.port, + " [auth: %s]" % args.user if args.user else "")) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() + diff --git a/mplex.py b/mplex.py new file mode 100644 index 0000000..afb3265 --- /dev/null +++ b/mplex.py @@ -0,0 +1,6848 @@ +#!/usr/bin/env python3 +# retoor +import argparse +import asyncio +import base64 +import codecs +import collections +import contextlib +import difflib +import getpass +import glob +import hashlib +import importlib.util +import hmac +import html +import io +import itertools +import json +import operator +import os +import queue +import random +import re +import shutil +import shlex +import signal +import socket +import sqlite3 +import ssl +import struct +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +import weakref +import zipfile +from datetime import datetime, timedelta, timezone + +try: + import readline +except ImportError: + readline = None + +try: + import fcntl +except ImportError: + fcntl = None + +try: + import pty + import select + import termios + import tty +except ImportError: + pty = select = termios = tty = None + +PLEXER_DIR_NAME = "plexer" +PLEXER_PANE_ENV = "TAI_PLEXER_PANE" +PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET" +PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE" +PLEXER_RECV_MAX = 1 << 20 + + +def plexer_dir(home): + path = os.path.join(home, PLEXER_DIR_NAME) + os.makedirs(path, exist_ok=True) + try: + os.chmod(path, 0o700) + except OSError: + pass + return path + + +class PlexerBackend: + def run(self): + raise NotImplementedError + + +# --------------------------------------------------------------------------- # +# Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns +# every pty, window layout and scrollback; clients attach over a Unix socket +# and receive finished frames. Key bindings, command names and option names +# follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`), +# and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md. +# --------------------------------------------------------------------------- # + +MPLEX_SOCKET_NAME = "server.sock" +MPLEX_LOCK_NAME = "server.lock" +MPLEX_LOG_NAME = "server.log" +MPLEX_CONFIG_NAME = "mplex.toml" +MPLEX_MAGIC = b"\x01" +MPLEX_FRAME_MAX = 1 << 22 +MPLEX_CLIENT_OUT_MAX = 1 << 22 +MPLEX_PANE_IN_MAX = 1 << 22 +MPLEX_MAX_SESSIONS = 64 +MPLEX_MAX_WINDOWS = 256 +MPLEX_MAX_PANES = 256 +MPLEX_MAX_CLIENTS = 32 +MPLEX_MESSAGES_MAX = 200 +MPLEX_HISTORY_CAP = 200000 +MPLEX_BUFFERS_CAP = 100 +MPLEX_PROMPT_HISTORY_CAP = 100 +MPLEX_COMMAND_DEPTH_MAX = 16 +MPLEX_SHELL_TIMEOUT = 5.0 +MPLEX_SHELL_OUTPUT_MAX = 65536 +MPLEX_PASTE_MAX = 1 << 22 +MPLEX_RENDER_INTERVAL = 0.012 +MPLEX_REAP_KILL_AFTER = 2.0 +MPLEX_IDLE_EXIT = 15.0 +MPLEX_LOOP_ERRORS_MAX = 50 +MPLEX_LOG_MAX = 1 << 24 +MPLEX_CONFIG_MAX = 1 << 20 +MPLEX_UNSUPPORTED = ("display-menu", "display-popup", "customize-mode", "lock-server", "lock-session", "lock-client", "pipe-pane", "join-pane", "move-pane", "link-window", "unlink-window", "respawn-pane", "respawn-window", "resize-window", "set-hook", "show-hooks", "wait-for", "server-access", "choose-tree-mouse") + +# (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings. +MPLEX_DEFAULT_KEYS = ( + ('copy-mode', 'Escape', 'send-keys -X cancel', False, ''), + ('copy-mode', 'Space', 'send-keys -X page-down', False, ''), + ('copy-mode', ',', 'send-keys -X jump-reverse', False, ''), + ('copy-mode', ';', 'send-keys -X jump-again', False, ''), + ('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), + ('copy-mode', 'N', 'send-keys -X search-reverse', False, ''), + ('copy-mode', 'P', 'send-keys -X toggle-position', False, ''), + ('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''), + ('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), + ('copy-mode', 'X', 'send-keys -X set-mark', False, ''), + ('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), + ('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), + ('copy-mode', 'n', 'send-keys -X search-again', False, ''), + ('copy-mode', 'q', 'send-keys -X cancel', False, ''), + ('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''), + ('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), + ('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''), + ('copy-mode', 'End', 'send-keys -X end-of-line', False, ''), + ('copy-mode', 'NPage', 'send-keys -X page-down', False, ''), + ('copy-mode', 'PPage', 'send-keys -X page-up', False, ''), + ('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''), + ('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''), + ('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''), + ('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''), + ('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode', 'M-<', 'send-keys -X history-top', False, ''), + ('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''), + ('copy-mode', 'M-R', 'send-keys -X top-line', False, ''), + ('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''), + ('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''), + ('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''), + ('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''), + ('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''), + ('copy-mode', 'M-v', 'send-keys -X page-up', False, ''), + ('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''), + ('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''), + ('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''), + ('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''), + ('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''), + ('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''), + ('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''), + ('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''), + ('copy-mode', 'C-c', 'send-keys -X cancel', False, ''), + ('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''), + ('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''), + ('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''), + ('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), + ('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''), + ('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''), + ('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''), + ('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''), + ('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''), + ('copy-mode', 'C-v', 'send-keys -X page-down', False, ''), + ('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''), + ('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''), + ('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''), + ('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''), + ('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''), + ('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''), + ('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''), + ('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''), + ('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''), + ('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''), + ('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''), + ('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''), + ('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''), + ('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), + ('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), + ('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''), + ('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''), + ('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''), + ('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''), + ('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), + ('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''), + ('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), + ('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''), + ('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''), + ('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''), + ('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''), + ('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''), + ('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''), + ('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''), + ('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''), + ('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), + ('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''), + ('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''), + ('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''), + ('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''), + ('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''), + ('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''), + ('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), + ('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''), + ('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''), + ('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''), + ('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''), + ('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''), + ('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''), + ('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''), + ('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''), + ('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), + ('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''), + ('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''), + ('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''), + ('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''), + ('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''), + ('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''), + ('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''), + ('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''), + ('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''), + ('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''), + ('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''), + ('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''), + ('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''), + ('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''), + ('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''), + ('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''), + ('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''), + ('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''), + ('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''), + ('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''), + ('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''), + ('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''), + ('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''), + ('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''), + ('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''), + ('prefix', 'Space', 'next-layout', False, 'Select next layout'), + ('prefix', '!', 'break-pane', False, 'Break pane to a new window'), + ('prefix', '"', 'split-window', False, 'Split window vertically'), + ('prefix', '#', 'list-buffers', False, 'List all paste buffers'), + ('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'), + ('prefix', '%', 'split-window -h', False, 'Split window horizontally'), + ('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'), + ('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'), + ('prefix', '(', 'switch-client -p', False, 'Switch to previous client'), + ('prefix', ')', 'switch-client -n', False, 'Switch to next client'), + ('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'), + ('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'), + ('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'), + ('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'), + ('prefix', '0', 'select-window -t :=0', False, 'Select window 0'), + ('prefix', '1', 'select-window -t :=1', False, 'Select window 1'), + ('prefix', '2', 'select-window -t :=2', False, 'Select window 2'), + ('prefix', '3', 'select-window -t :=3', False, 'Select window 3'), + ('prefix', '4', 'select-window -t :=4', False, 'Select window 4'), + ('prefix', '5', 'select-window -t :=5', False, 'Select window 5'), + ('prefix', '6', 'select-window -t :=6', False, 'Select window 6'), + ('prefix', '7', 'select-window -t :=7', False, 'Select window 7'), + ('prefix', '8', 'select-window -t :=8', False, 'Select window 8'), + ('prefix', '9', 'select-window -t :=9', False, 'Select window 9'), + ('prefix', ':', 'command-prompt', False, 'Prompt for a command'), + ('prefix', ';', 'last-pane', False, 'Move to the previously active pane'), + ('prefix', '<', 'display-menu', False, 'Display window menu'), + ('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'), + ('prefix', '>', 'display-menu', False, 'Display pane menu'), + ('prefix', '?', 'list-keys -N', False, 'List key bindings'), + ('prefix', 'C', 'customize-mode -Z', False, 'Customize options'), + ('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'), + ('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'), + ('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'), + ('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'), + ('prefix', '[', 'copy-mode', False, 'Enter copy mode'), + ('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'), + ('prefix', 'c', 'new-window', False, 'Create a new window'), + ('prefix', 'd', 'detach-client', False, 'Detach the current client'), + ('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'), + ('prefix', 'i', 'display-message', False, 'Display window information'), + ('prefix', 'l', 'last-window', False, 'Select the previously current window'), + ('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'), + ('prefix', 'n', 'next-window', False, 'Select the next window'), + ('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'), + ('prefix', 'p', 'previous-window', False, 'Select the previous window'), + ('prefix', 'q', 'display-panes', False, 'Display pane numbers'), + ('prefix', 'r', 'refresh-client', False, 'Redraw the current client'), + ('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'), + ('prefix', 't', 'clock-mode', False, 'Show a clock'), + ('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'), + ('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'), + ('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'), + ('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'), + ('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'), + ('prefix', '~', 'show-messages', False, 'Show messages'), + ('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'), + ('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'), + ('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'), + ('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'), + ('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'), + ('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'), + ('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'), + ('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'), + ('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'), + ('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'), + ('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'), + ('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'), + ('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'), + ('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'), + ('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'), + ('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'), + ('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'), + ('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'), + ('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'), + ('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'), + ('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'), + ('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'), + ('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'), + ('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'), + ('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'), + ('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'), + ('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'), + ('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'), + ('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'), + ('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'), + ('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'), +) + +MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled") + + +class MplexError(Exception): + """A user-facing failure: the text is shown verbatim as the command's error.""" + + +class MplexKeys: + """Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what + a terminal sends, and byte encoding for send-keys.""" + + ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC", + "Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"} + NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace", + "Escape", "Space") + tuple("F%d" % n for n in range(1, 13)) + FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End", + "pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab", + "enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"} + CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"} + CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24} + SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"} + CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F} + + @classmethod + def canonical(cls, spec): + if spec == " ": + return "Space" + text = str(spec).strip() + if not text: + raise MplexError("empty key name") + mods = set() + while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": + mods.add(text[0]) + text = text[2:] + if len(text) == 2 and text[0] == "^": + mods.add("C") + text = text[1] + text = cls.ALIASES.get(text, text) + if len(text) == 1: + if text == " ": + text = "Space" + elif "C" in mods and text.isalpha(): + text = text.lower() + elif "S" in mods and text.isalpha(): + text = text.upper() + mods.discard("S") + elif text not in cls.NAMED: + raise MplexError("unknown key: %s" % spec) + if "C" in mods: + if text == "i": + mods.discard("C") + text = "Tab" + elif text == "m": + mods.discard("C") + text = "Enter" + elif text == "[": + mods.discard("C") + text = "Escape" + return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text + + @classmethod + def name_of_char(cls, raw): + """Canonical name for one decoded character (no escape prefix).""" + if len(raw) == 1: + byte = raw[0] + if byte == 0x00: + return "C-Space" + if byte == 0x09: + return "Tab" + if byte == 0x0D: + return "Enter" + if byte == 0x1B: + return "Escape" + if byte == 0x20: + return "Space" + if byte == 0x7F: + return "BSpace" + if 0x01 <= byte <= 0x1A: + return "C-" + chr(byte + 0x60) + if 0x1C <= byte <= 0x1F: + return "C-" + "\\]^_"[byte - 0x1C] + return chr(byte) + return raw.decode("utf-8", "replace") + + @classmethod + def split(cls, data, final=False): + """bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys + (they are still forwarded). A lone ESC waits until final=True.""" + tokens = [] + buf = bytes(data) + while buf: + if buf[0] != 0x1B: + length = 1 + lead = buf[0] + if lead >= 0xF0: + length = 4 + elif lead >= 0xE0: + length = 3 + elif lead >= 0xC0: + length = 2 + if length > len(buf): + if not final: + break + length = len(buf) + raw = buf[:length] + tokens.append((cls.name_of_char(raw), raw)) + buf = buf[length:] + continue + if len(buf) == 1: + if not final: + break + tokens.append(("Escape", b"\x1b")) + buf = b"" + break + event, rest = tui_parse_one(buf, final) + if event == "need_more": + if not final: + break + tokens.append(("Escape", b"\x1b")) + buf = buf[1:] + continue + if event == "escape": + tokens.append(("Escape", b"\x1b")) + buf = buf[1:] + continue + consumed = len(buf) - len(rest) + if consumed <= 0: + consumed = 1 + raw = buf[:consumed] + buf = buf[consumed:] + tokens.append((cls._name_of_event(event, raw), raw)) + return tokens, buf + + @classmethod + def _name_of_event(cls, event, raw): + if not isinstance(event, TuiKey): + return "" + if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"): + inner = cls.name_of_char(raw[1:]) + try: + return cls.canonical("M-" + inner) + except MplexError: + return "" + key = event.key + base = key.split("+")[-1] if not key.endswith("+") else "+" + if base in cls.FROM_TUI: + base = cls.FROM_TUI[base] + elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit(): + base = "F" + base[1:] + else: + return "" + prefix = "" + mods = set(getattr(event, "modifiers", None) or []) + if "ctrl" in mods: + prefix += "C-" + if "alt" in mods or "meta" in mods: + prefix += "M-" + if "shift" in mods: + prefix += "S-" + try: + return cls.canonical(prefix + base) + except MplexError: + return "" + + @classmethod + def to_bytes(cls, name, app_cursor=False): + """Bytes a terminal would send for a canonical key name.""" + mods = "" + text = name + while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": + mods += text[0] + text = text[2:] + if len(text) == 1 or text == "Space": + char = " " if text == "Space" else text + data = char.encode("utf-8") + if "C" in mods: + if char == " ": + data = b"\x00" + elif char.isalpha() and char.isascii(): + data = bytes([ord(char.upper()) & 0x1F]) + elif char in cls.CTRL_PUNCT: + data = bytes([cls.CTRL_PUNCT[char]]) + return (b"\x1b" if "M" in mods else b"") + data + simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"} + if text in simple and not ("S" in mods and text not in ("BTab",)): + return (b"\x1b" if "M" in mods else b"") + simple[text] + mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0) + if text in cls.CSI_FINAL: + if mod_code > 1: + return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii") + return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii") + if text in cls.CSI_TILDE: + if mod_code > 1: + return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii") + return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii") + if text in cls.SS3_FUNC: + if mod_code > 1: + return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii") + return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii") + raise MplexError("unknown key: %s" % name) + + @classmethod + def app_cursor_form(cls, raw): + """Plain CSI cursor keys become SS3 when the pane asked for application cursor mode.""" + if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"): + return b"\x1bO" + raw[2:3] + return raw + + +class MplexArgs: + """getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value.""" + + def __init__(self, flags, positional): + self.flags = flags + self.positional = positional + + def has(self, flag): + return flag in self.flags + + def get(self, flag, default=None): + values = self.flags.get(flag) + if not values: + return default + return values[-1] + + def all(self, flag): + return list(self.flags.get(flag) or []) + + def int(self, flag, default=None): + value = self.get(flag) + if value is None or value is True: + return default + try: + return int(value) + except ValueError: + raise MplexError("-%s expects a number: %s" % (flag, value)) + + @classmethod + def parse(cls, name, spec, words, min_args=0, max_args=None): + takes = set() + known = set() + index = 0 + while index < len(spec): + known.add(spec[index]) + if index + 1 < len(spec) and spec[index + 1] == ":": + takes.add(spec[index]) + index += 1 + index += 1 + flags = {} + positional = [] + pos = 0 + while pos < len(words): + word = words[pos] + if word == "--": + positional.extend(words[pos + 1:]) + break + if len(word) < 2 or word[0] != "-": + positional.extend(words[pos:]) + break + inner = 1 + while inner < len(word): + letter = word[inner] + if letter not in known: + raise MplexError("command %s: unknown flag -%s" % (name, letter)) + if letter in takes: + if inner + 1 < len(word): + value = word[inner + 1:] + else: + pos += 1 + if pos >= len(words): + raise MplexError("command %s: -%s expects an argument" % (name, letter)) + value = words[pos] + flags.setdefault(letter, []).append(value) + break + flags.setdefault(letter, []).append(True) + inner += 1 + pos += 1 + if len(positional) < min_args or (max_args is not None and len(positional) > max_args): + raise MplexError("command %s: wrong number of arguments" % name) + return cls(flags, positional) + + +class MplexCommandParser: + """Splits tmux-syntax command text into commands of words: quotes, backslash, + {braces} as one argument, ';' and newlines between commands.""" + + @staticmethod + def parse(text): + commands = [] + words = [] + cur = [] + has = False + i = 0 + n = len(text) + + def end_word(): + nonlocal cur, has + if has: + words.append("".join(cur)) + cur = [] + has = False + + def end_command(): + nonlocal words + end_word() + if words: + commands.append(words) + words = [] + + while i < n: + c = text[i] + if c in " \t\r": + end_word() + i += 1 + elif c == "\n" or c == ";": + end_command() + i += 1 + elif c == "\\": + if i + 1 < n: + if text[i + 1] == "\n": + i += 2 + continue + cur.append(text[i + 1]) + has = True + i += 2 + elif c == "'": + j = text.find("'", i + 1) + if j < 0: + raise MplexError("unterminated single quote") + cur.append(text[i + 1:j]) + has = True + i = j + 1 + elif c == '"': + j = i + 1 + piece = [] + while j < n and text[j] != '"': + if text[j] == "\\" and j + 1 < n: + nxt = text[j + 1] + piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt)) + j += 2 + else: + piece.append(text[j]) + j += 1 + if j >= n: + raise MplexError("unterminated double quote") + cur.append("".join(piece)) + has = True + i = j + 1 + elif c == "{" and not has: + depth = 0 + j = i + quote = "" + while j < n: + ch = text[j] + if quote: + if ch == "\\" and quote == '"': + j += 1 + elif ch == quote: + quote = "" + elif ch in "'\"": + quote = ch + elif ch == "\\": + j += 1 + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + break + j += 1 + if depth != 0 or j >= n: + raise MplexError("unterminated { block") + cur.append(text[i + 1:j].strip()) + has = True + i = j + 1 + else: + cur.append(c) + has = True + i += 1 + end_command() + return commands + + @staticmethod + def quote(word): + word = str(word) + if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word): + return word + return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"' + + +class MplexStyle: + """tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell + format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes.""" + + NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white") + ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64, + "reverse": 8, "hidden": 128, "strikethrough": 16} + + @classmethod + def color(cls, text, background): + text = text.strip().lower() + if text in ("default", "terminal", ""): + return None + base = 40 if background else 30 + if text in cls.NAMES: + return str(base + cls.NAMES.index(text)) + if text.startswith("bright") and text[6:] in cls.NAMES: + return str(base + 60 + cls.NAMES.index(text[6:])) + if text.startswith("colour") or text.startswith("color"): + digits = text[6:] if text.startswith("colour") else text[5:] + if digits.isdigit() and int(digits) < 256: + return "%d;5;%d" % (48 if background else 38, int(digits)) + if text.startswith("#") and len(text) == 7: + try: + red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16) + except ValueError: + raise MplexError("bad colour: %s" % text) + return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue) + raise MplexError("bad colour: %s" % text) + + @classmethod + def parse(cls, text, base=(None, None, 0), reset=None): + """Apply a style string on top of base; "default" returns to reset (base when omitted).""" + fg, bg, attr = base + reset = base if reset is None else reset + for item in re.split(r"[,\s]+", str(text).strip()): + if not item: + continue + low = item.lower() + if low == "default": + fg, bg, attr = reset + elif low in ("none", "noattr"): + attr = 0 + elif low.startswith("fg="): + fg = cls.color(item[3:], False) + elif low.startswith("bg="): + bg = cls.color(item[3:], True) + elif low in cls.ATTRS: + attr |= cls.ATTRS[low] + elif low.startswith("no") and low[2:] in cls.ATTRS: + attr &= ~cls.ATTRS[low[2:]] + elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")): + continue + else: + raise MplexError("bad style: %s" % item) + return (fg, bg, attr) + + @staticmethod + def sgr(fg, bg, attr): + codes = ["0"] + for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")): + if attr & bit: + codes.append(code) + if fg: + codes.append(fg) + if bg: + codes.append(bg) + return "\x1b[" + ";".join(codes) + "m" + + +class MplexOptions: + """Typed option store with a parent chain: window, session, then global.""" + + WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~" + DEFS = { + "prefix": ("key", "C-b"), "prefix2": ("key", "None"), + "base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0), + "escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500), + "history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000), + "mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""), + "status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"), + "status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"), + "status-left": ("str", "[#{session_name}] "), + "status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"), + "status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40), + "status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"), + "message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"), + "mode-style": ("style", "noattr,bg=yellow,fg=black"), + "copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"), + "copy-mode-mark-style": ("style", "bg=red,fg=black"), + "display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000), + "display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"), + "window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), + "window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), + "window-status-separator": ("str", " "), + "window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"), + "pane-border-style": ("style", "default"), + "pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"), + "pane-border-lines": ("choice:single|double|heavy|simple", "single"), + "clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"), + "main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24), + "renumber-windows": ("bool", False), "automatic-rename": ("bool", True), + "automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"), + "allow-set-title": ("bool", True), + "default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"), + "detach-on-destroy": ("bool", True), "wrap-search": ("bool", True), + "word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False), + "synchronize-panes": ("bool", False), "copy-command": ("str", ""), + "buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50), + "set-clipboard": ("choice:on|external|off", "off"), + } + + def __init__(self, parent=None): + self.parent = parent + self.values = {} + + @classmethod + def check(cls, name, raw): + if name not in cls.DEFS: + raise MplexError("unknown or unsupported option: %s" % name) + kind = cls.DEFS[name][0] + text = str(raw) + if kind == "str": + return text + if kind == "bool": + low = text.strip().lower() + if low in ("on", "yes", "true", "1"): + return True + if low in ("off", "no", "false", "0"): + return False + raise MplexError("value is invalid: %s" % text) + if kind == "key": + return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text) + if kind == "colour": + MplexStyle.color(text, False) + return text + if kind == "style": + if "#{" not in text: + MplexStyle.parse(text) + return text + if kind.startswith("int:"): + _tag, low, high = kind.split(":") + try: + value = int(text) + except ValueError: + raise MplexError("value is invalid: %s" % text) + if value < int(low) or value > int(high): + raise MplexError("value out of range %s..%s: %s" % (low, high, text)) + return value + if kind.startswith("choice:"): + choices = kind.split(":", 1)[1].split("|") + if text not in choices: + raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text)) + return text + raise MplexError("bad option kind for %s" % name) + + def get(self, name): + node = self + while node is not None: + if name in node.values: + return node.values[name] + node = node.parent + default = self.DEFS[name][1] + if name in ("mode-keys", "status-keys"): + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" + return "vi" if "vi" in editor else "emacs" + return default + + def is_set(self, name): + return name in self.values + + def set(self, name, raw, append=False): + value = self.check(name, raw) + if append and isinstance(value, str) and name in self.values: + value = self.values[name] + value + self.values[name] = value + return value + + def toggle(self, name): + if self.DEFS.get(name, ("",))[0] != "bool": + raise MplexError("only boolean options can be toggled: %s" % name) + self.values[name] = not self.get(name) + + def unset(self, name): + if name not in self.DEFS: + raise MplexError("unknown or unsupported option: %s" % name) + self.values.pop(name, None) + + @staticmethod + def show(value): + if value is True: + return "on" + if value is False: + return "off" + return str(value) + + +class MplexBinding: + __slots__ = ("command", "repeat", "note") + + def __init__(self, command, repeat=False, note=""): + self.command = command + self.repeat = repeat + self.note = note + + +class MplexConfig: + """Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load: + each bad entry is skipped, counted in errors, and shown to the first client.""" + + ERRORS_MAX = 50 + TOP_KEYS = ("options", "keys") + + def __init__(self): + self.options = MplexOptions() + self.tables = {} + self.errors = [] + self.path = "" + self.skipped = 0 + for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS: + self.bind(table, MplexKeys.canonical(key), command, repeat, note) + + def error(self, text): + self.skipped += 1 + if len(self.errors) < self.ERRORS_MAX: + self.errors.append(text) + + def bind(self, table, key, command, repeat=False, note=""): + self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note) + + def unbind(self, table, key): + return self.tables.get(table, {}).pop(key, None) is not None + + def lookup(self, table, key): + return self.tables.get(table, {}).get(key) + + @staticmethod + def default_path(home): + return os.path.join(home, MPLEX_CONFIG_NAME) + + @classmethod + def load(cls, path): + config = cls() + config.path = path + config.apply_file(path) + return config + + def apply_file(self, path): + try: + if os.path.getsize(path) > MPLEX_CONFIG_MAX: + self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX)) + return + with open(path, "rb") as handle: + raw = handle.read() + except FileNotFoundError: + return + except OSError as exc: + self.error("%s: cannot read: %s" % (path, short_error(exc))) + return + try: + import tomllib + except ImportError: + self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path) + return + try: + data = tomllib.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + self.error("%s: not valid TOML: %s" % (path, exc)) + return + self.apply_data(data) + + def apply_data(self, data): + for key in data: + if key not in self.TOP_KEYS: + self.error("unknown top-level key [%s] (use options, keys)" % key) + options = data.get("options", {}) + if not isinstance(options, dict): + self.error("[options] must be a table") + options = {} + for name, value in options.items(): + try: + if isinstance(value, bool): + value = "on" if value else "off" + elif not isinstance(value, (str, int)): + raise MplexError("value must be a string, number or boolean") + self.options.set(name, value) + except MplexError as exc: + self.error("options.%s: %s" % (name, exc)) + keys = data.get("keys", {}) + if not isinstance(keys, dict): + self.error("[keys] must be a table") + return + clear = keys.get("clear", []) + if not isinstance(clear, list): + self.error("keys.clear must be a list of table names") + clear = [] + for table in clear: + self.tables[str(table)] = {} + for table, entries in keys.items(): + if table == "clear": + continue + if not isinstance(entries, dict): + self.error("keys.%s must be a table of key = command" % table) + continue + for key, spec in entries.items(): + self._apply_binding(table, key, spec) + + def _apply_binding(self, table, key, spec): + where = "keys.%s.%s" % (table, key) + try: + name = MplexKeys.canonical(key) + if spec is False or spec == "": + self.unbind(table, name) + return + note = "" + repeat = False + if isinstance(spec, dict): + unknown = [item for item in spec if item not in ("command", "repeat", "note")] + if unknown: + raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0]) + command = spec.get("command") + repeat = bool(spec.get("repeat", False)) + note = str(spec.get("note", "")) + else: + command = spec + if not isinstance(command, str) or not command.strip(): + raise MplexError("command must be a non-empty string, or false to unbind") + for words in MplexCommandParser.parse(command): + MplexCommands.resolve(words[0]) + self.bind(table, name, command, repeat, note) + except MplexError as exc: + self.error("%s: %s" % (where, exc)) + + +class MplexFormat: + """tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as + =N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through.""" + + SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index", + "S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"} + DEPTH_MAX = 12 + + @staticmethod + def match_close(text, start, opener, closer): + """Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1.""" + depth = 1 + pos = start + 1 + while pos < len(text): + ch = text[pos] + if ch == "#" and pos + 1 < len(text): + if text[pos + 1] == opener: + depth += 1 + pos += 2 + continue + if ch == closer: + depth -= 1 + if depth == 0: + return pos + pos += 1 + return -1 + + @classmethod + def split_commas(cls, text): + parts = [] + cur = [] + depth = 0 + pos = 0 + while pos < len(text): + ch = text[pos] + if ch == "#" and pos + 1 < len(text): + nxt = text[pos + 1] + if nxt == "," and depth == 0: + cur.append("#,") + pos += 2 + continue + if nxt in "{(": + depth += 1 + cur.append(text[pos:pos + 2]) + pos += 2 + continue + cur.append(text[pos:pos + 2]) + pos += 2 + continue + if ch == "}" or ch == ")": + depth = max(0, depth - 1) + if ch == "," and depth == 0: + parts.append("".join(cur)) + cur = [] + else: + cur.append(ch) + pos += 1 + parts.append("".join(cur)) + return parts + + @classmethod + def expand(cls, text, resolve, depth=0): + if depth > cls.DEPTH_MAX or not text: + return "" + out = [] + pos = 0 + n = len(text) + while pos < n: + ch = text[pos] + if ch != "#" or pos + 1 >= n: + out.append(ch) + pos += 1 + continue + nxt = text[pos + 1] + if nxt == "#": + out.append("#") + pos += 2 + elif nxt == "{": + end = cls.match_close(text, pos + 1, "{", "}") + if end < 0: + out.append(ch) + pos += 1 + else: + out.append(cls.evaluate(text[pos + 2:end], resolve, depth)) + pos = end + 1 + elif nxt == "(": + end = cls.match_close(text, pos + 1, "(", ")") + if end < 0: + out.append(ch) + pos += 1 + else: + out.append(resolve("(" + text[pos + 2:end]) or "") + pos = end + 1 + elif nxt == "[": + end = text.find("]", pos + 2) + if end < 0: + out.append(ch) + pos += 1 + else: + out.append(text[pos:end + 1]) + pos = end + 1 + elif nxt in cls.SHORT: + out.append(resolve(cls.SHORT[nxt]) or "") + pos += 2 + elif nxt in ",}": + out.append(nxt) + pos += 2 + else: + out.append(ch) + pos += 1 + return "".join(out) + + @staticmethod + def truthy(value): + return value not in ("", "0", None) + + @classmethod + def value_of(cls, text, resolve, depth): + """A variable name or a nested format: its text value.""" + if "#" in text: + return cls.expand(text, resolve, depth + 1) + value = resolve(text) + return "" if value is None else str(value) + + @classmethod + def evaluate(cls, inner, resolve, depth): + if inner.startswith("?"): + parts = cls.split_commas(inner[1:]) + index = 0 + while index + 1 < len(parts): + cond = cls.value_of(parts[index], resolve, depth) + if cls.truthy(cond): + return cls.expand(parts[index + 1], resolve, depth + 1) + index += 2 + if index < len(parts): + return cls.expand(parts[index], resolve, depth + 1) + return "" + colon = cls.modifier_colon(inner) + if colon < 0: + value = resolve(inner) + return "" if value is None else str(value) + mods = inner[:colon] + rest = inner[colon + 1:] + return cls.apply_modifiers(mods, rest, resolve, depth) + + @staticmethod + def modifier_colon(inner): + """Index of the ':' that ends a modifier list, or -1 when inner is a plain name.""" + colon = inner.find(":") + if colon <= 0: + return -1 + head = inner[:colon] + if "#" in head or "{" in head or " " in head: + return -1 + return colon + + @classmethod + def apply_modifiers(cls, mods, rest, resolve, depth): + specs = [item for item in mods.split(";") if item] + text = None + for spec in specs: + if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"): + parts = cls.split_commas(rest) + left = cls.expand(parts[0], resolve, depth + 1) if parts else "" + right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" + return cls.compare(spec, left, right) + if spec in ("m", "m/r", "m/ri", "m/i"): + parts = cls.split_commas(rest) + pattern = cls.expand(parts[0], resolve, depth + 1) if parts else "" + subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" + flags = re.IGNORECASE if spec.endswith("i") else 0 + if spec.startswith("m/r"): + try: + return "1" if re.search(pattern, subject, flags) else "0" + except re.error: + return "0" + import fnmatch + return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0" + if spec == "l": + return rest + if text is None: + text = cls.value_of(rest, resolve, depth) + text = cls.apply_one(spec, text, resolve, depth) + return text or "" + + @classmethod + def apply_one(cls, spec, text, resolve, depth): + if spec == "E" or spec == "T": + return cls.expand(text, resolve, depth + 1) + if spec == "q": + return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text) + if spec == "t": + try: + return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text))) + except (ValueError, OverflowError, OSError): + return text + if spec == "n": + return str(len(text)) + if spec == "b": + return os.path.basename(text) + if spec == "d": + return os.path.dirname(text) + if spec == "!": + return "0" if cls.truthy(text) else "1" + if spec == "!!": + return "1" if cls.truthy(text) else "0" + if spec.startswith("="): + body = spec[1:] + marker = "" + if body.startswith("/"): + _blank, count, marker = (body.split("/", 2) + ["", ""])[:3] + body = count + try: + limit = int(body) + except ValueError: + return text + if limit >= 0 and len(text) > limit: + return text[:limit] + marker + if limit < 0 and len(text) > -limit: + return marker + text[limit:] + return text + if spec.startswith("s/"): + fields = spec.split("/") + if len(fields) >= 3: + try: + return re.sub(fields[1], fields[2], text) + except re.error: + return text + return text + + @staticmethod + def compare(op, left, right): + if op.startswith("e|"): + kind = op.split("|")[1] if "|" in op else "" + try: + a, b = float(left), float(right) + except ValueError: + return "0" + table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0, + "==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b} + if kind not in table: + return "0" + value = table[kind] + if isinstance(value, bool): + return "1" if value else "0" + return str(int(value)) if float(value).is_integer() else str(value) + if op == "==": + return "1" if left == right else "0" + if op == "!=": + return "1" if left != right else "0" + if op == "&&": + return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0" + if op == "||": + return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0" + try: + a, b = float(left), float(right) + except ValueError: + a, b = left, right + return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0" + + @staticmethod + def with_time(text, stamp=None): + if "%" not in text: + return text + try: + return time.strftime(text, time.localtime(stamp)) + except (ValueError, OverflowError): + return text + + @staticmethod + def strip_styles(text): + return re.sub(r"#\[[^\]]*\]", "", text) + + +class MplexLayoutNode: + __slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse") + + def __init__(self, kind, pane=None, w=1, h=1): + self.reverse = False + self.kind = kind + self.children = [] + self.parent = None + self.pane = pane + self.x = 0 + self.y = 0 + self.w = w + self.h = h + + def leaves(self): + if self.kind == "pane": + return [self] + found = [] + for child in self.children: + found.extend(child.leaves()) + return found + + def adopt(self, child): + child.parent = self + self.children.append(child) + return child + + +class MplexLayout: + """Split tree: a node is a pane or a container laid out side by side ("lr") or + stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are + rescaled proportionally when the window changes size.""" + + @staticmethod + def size_of(node, kind): + return node.w if kind == "lr" else node.h + + @staticmethod + def set_size(node, kind, value): + if kind == "lr": + node.w = value + else: + node.h = value + + @classmethod + def assign(cls, node, x, y, w, h): + node.x, node.y, node.w, node.h = x, y, w, h + if node.kind == "pane": + return + kids = node.children + count = len(kids) + kind = node.kind + total = (w if kind == "lr" else h) - (count - 1) + if total < count: + sizes = [1] * count + else: + sizes = [max(1, cls.size_of(kid, kind)) for kid in kids] + weight = sum(sizes) or count + if weight != total: + sizes = [max(1, int(round(size * total / weight))) for size in sizes] + diff = total - sum(sizes) + while diff != 0: + moved = False + for index in (range(count) if diff > 0 else range(count - 1, -1, -1)): + if diff > 0: + sizes[index] += 1 + diff -= 1 + moved = True + elif sizes[index] > 1: + sizes[index] -= 1 + diff += 1 + moved = True + if diff == 0: + break + if not moved: + break + order = list(zip(kids, sizes)) + if node.reverse: + order.reverse() + pos = x if kind == "lr" else y + for kid, size in order: + if kind == "lr": + cls.assign(kid, pos, y, size, h) + else: + cls.assign(kid, x, pos, w, size) + pos += size + 1 + + @classmethod + def split(cls, root, leaf, kind, before, new_pane, size=None): + """Insert new_pane beside leaf. Returns (root, new_leaf).""" + total = cls.size_of(leaf, kind) + if total < 3: + raise MplexError("no space for new pane") + avail = total - 1 + if size is None: + new_size = avail - avail // 2 if before else avail // 2 + else: + new_size = max(1, min(avail - 1, size)) + old_size = avail - new_size + fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h) + parent = leaf.parent + if parent is not None and parent.kind == kind: + index = parent.children.index(leaf) + parent.children.insert(index if before else index + 1, fresh) + fresh.parent = parent + else: + box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h) + box.x, box.y = leaf.x, leaf.y + box.parent = parent + if parent is None: + root = box + else: + parent.children[parent.children.index(leaf)] = box + for item in ((fresh, leaf) if before else (leaf, fresh)): + box.adopt(item) + cls.set_size(leaf, kind, old_size) + cls.set_size(fresh, kind, new_size) + return root, fresh + + @classmethod + def remove(cls, root, leaf): + """Drop leaf, hand its space to a neighbour. Returns the new root (None when empty).""" + parent = leaf.parent + if parent is None: + return None + index = parent.children.index(leaf) + parent.children.remove(leaf) + neighbour = parent.children[index - 1] if index > 0 else parent.children[0] + kind = parent.kind + cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1) + if len(parent.children) == 1: + only = parent.children[0] + grand = parent.parent + only.w, only.h = parent.w, parent.h + only.parent = grand + if grand is None: + return only + slot = grand.children.index(parent) + if only.kind == grand.kind: + grand.children[slot:slot + 1] = only.children + for child in only.children: + child.parent = grand + else: + grand.children[slot] = only + return root + + @classmethod + def resize_by(cls, leaf, kind, delta, prefer_after): + """Move the border of leaf's nearest enclosing "kind" container by delta cells.""" + node = leaf + while node.parent is not None and node.parent.kind != kind: + node = node.parent + parent = node.parent + if parent is None: + raise MplexError("no border to resize") + kids = parent.children + index = kids.index(node) + use_after = (index < len(kids) - 1) if prefer_after else (index == 0) + first = index if use_after else index - 1 + a, b = kids[first], kids[first + 1] + size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind) + new_a = max(1, min(size_a + size_b - 1, size_a + delta)) + cls.set_size(a, kind, new_a) + cls.set_size(b, kind, size_a + size_b - new_a) + + @staticmethod + def shares(total, count, spare_first): + """Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last.""" + room = total - (count - 1) + base, extra = divmod(room, count) + sizes = [base] * count + for index in (range(extra) if spare_first else range(count - extra, count)): + sizes[index] += 1 + return sizes + + @classmethod + def resize_to(cls, leaf, kind, size): + """Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before).""" + change = size - cls.size_of(leaf, kind) + node = leaf + while node.parent is not None and node.parent.kind != kind: + node = node.parent + if node.parent is None: + raise MplexError("no border to resize") + has_next = node.parent.children.index(node) < len(node.parent.children) - 1 + cls.resize_by(leaf, kind, change if has_next else -change, True) + + @classmethod + def preset(cls, name, panes, width, height, main_width, main_height): + count = len(panes) + leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes] + mirrored = name.endswith("-mirrored") + base = name[:-len("-mirrored")] if mirrored else name + if count == 1: + leaves[0].w, leaves[0].h = width, height + return leaves[0] + + def box(kind, items, w, h, spare_first=True): + node = MplexLayoutNode(kind, w=w, h=h) + for item in items: + node.adopt(item) + for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)): + if kind == "lr": + item.w, item.h = size, h + else: + item.w, item.h = w, size + return node + + if base == "even-horizontal": + return box("lr", leaves, width, height) + if base == "even-vertical": + return box("tb", leaves, width, height) + if base in ("main-horizontal", "main-vertical"): + horizontal = base == "main-horizontal" + span = height if horizontal else width + main_size = max(1, min(span - 2, main_height if horizontal else main_width)) + main = leaves[0] + rest = leaves[1:] + other_size = span - main_size - 1 + if horizontal: + others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size) + main.w, main.h, others.w, others.h = width, main_size, width, other_size + root = box("tb", [main, others], width, height) + else: + others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height) + main.w, main.h, others.w, others.h = main_size, height, other_size, height + root = box("lr", [main, others], width, height) + if horizontal: + main.h, others.h = main_size, other_size + else: + main.w, others.w = main_size, other_size + root.reverse = mirrored + return root + rows = columns = 1 + while rows * columns < count: + rows += 1 + if rows * columns < count: + columns += 1 + lines = [] + for start in range(0, count, columns): + chunk = leaves[start:start + columns] + lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False)) + if len(lines) == 1: + return lines[0] + root = box("tb", lines, width, height, spare_first=False) + for line in lines: + line.w = width + return root + + @staticmethod + def neighbour(panes, current, direction, width, height): + left, top = current.x, current.y + right, bottom = current.x + current.w, current.y + current.h + + def overlaps(other): + if direction in ("L", "R"): + return other.y < bottom and other.y + other.h > top + return other.x < right and other.x + other.w > left + + wanted = [] + for other in panes: + if other is current or not overlaps(other): + continue + if direction == "L" and other.x + other.w + 1 == left: + wanted.append(other) + elif direction == "R" and other.x == right + 1: + wanted.append(other) + elif direction == "U" and other.y + other.h + 1 == top: + wanted.append(other) + elif direction == "D" and other.y == bottom + 1: + wanted.append(other) + if not wanted: + for other in panes: + if other is current or not overlaps(other): + continue + if direction == "L" and other.x + other.w >= width: + wanted.append(other) + elif direction == "R" and other.x == 0: + wanted.append(other) + elif direction == "U" and other.y + other.h >= height: + wanted.append(other) + elif direction == "D" and other.y == 0: + wanted.append(other) + if not wanted: + return None + return max(wanted, key=lambda item: item.last_active) + + +class MplexPane: + """One pty-backed shell: its screen, scrollback, output queue and mode stack.""" + + def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"): + self.id = ident + self.window = None + self.x = 0 + self.y = 0 + self.w = max(1, cols) + self.h = max(1, rows) + self.screen = TuiVtScreen(self.h, self.w) + self.screen.history = collections.deque(maxlen=max(0, history_limit)) + self.master_fd = None + self.pid = None + self.dead = False + self.exit_status = None + self.pending_in = bytearray() + self.dropped = 0 + self.modes = [] + self.prefix_count = 1 + self.created = time.time() + self.last_active = 0.0 + self.shell_name = "" + self.last_search = None + self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident)) + self._log = None + self._log_size = 0 + self.input_bytes = 0 + + @property + def tag(self): + return "%%%d" % self.id + + @property + def title(self): + return self.screen.title or socket.gethostname() + + def spawn(self, file, argv, cwd, env): + master, slave = os.openpty() + try: + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) + attrs = termios.tcgetattr(slave) + attrs[0] |= getattr(termios, "IUTF8", 0) + termios.tcsetattr(slave, termios.TCSANOW, attrs) + except (OSError, termios.error): + pass + log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + self._log = os.fdopen(log_fd, "ab", buffering=0) + pid = os.fork() + if pid == 0: + try: + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + for fd in (0, 1, 2): + os.dup2(slave, fd) + if slave > 2: + os.close(slave) + os.close(master) + for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM): + signal.signal(sig, signal.SIG_DFL) + try: + os.chdir(cwd) + except OSError: + pass + os.execvpe(file, argv, env) + except BaseException: + pass + finally: + os._exit(127) + os.close(slave) + os.set_blocking(master, False) + self.master_fd = master + self.pid = pid + self.shell_name = os.path.basename(file) + + def place(self, x, y, w, h): + self.x, self.y = x, y + w, h = max(1, w), max(1, h) + if (w, h) == (self.w, self.h): + return + self.w, self.h = w, h + self.screen.resize(h, w) + self.apply_winsize() + for mode in self.modes: + mode.on_resize() + + def apply_winsize(self): + if self.master_fd is None: + return + try: + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) + except OSError: + pass + + def feed(self, data): + history = self.screen.history + before = self.screen.history_pushed + before_len = len(history) if history is not None else 0 + self.screen.feed(data) + pushed = self.screen.history_pushed - before + evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0 + if self._log is not None: + self.log(data) + replies = self.screen.take_replies() + if replies: + self.write(replies) + for mode in self.modes: + mode.on_output(pushed, evicted) + + def log(self, data): + """Append to the session log; at MPLEX_LOG_MAX it rolls over to .1 (one generation kept).""" + try: + if self._log_size + len(data) > MPLEX_LOG_MAX: + self._log.close() + os.replace(self.log_path, self.log_path + ".1") + fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + self._log = os.fdopen(fd, "ab", buffering=0) + self._log_size = 0 + self._log.write(data) + self._log_size += len(data) + except OSError: + pass + + def write(self, data): + if not data: + return + if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX: + self.dropped += len(data) + return + self.pending_in += data + + def drain(self): + if not self.pending_in or self.master_fd is None: + return + try: + written = os.write(self.master_fd, bytes(self.pending_in[:4096])) + del self.pending_in[:written] + except BlockingIOError: + pass + except OSError: + self.pending_in.clear() + + def top_mode(self): + return self.modes[-1] if self.modes else None + + def push_mode(self, mode): + self.modes.append(mode) + return mode + + def pop_mode(self, mode=None): + if mode is None: + if self.modes: + self.modes.pop() + elif mode in self.modes: + self.modes.remove(mode) + + def find_mode(self, cls): + for mode in self.modes: + if isinstance(mode, cls): + return mode + return None + + def foreground_command(self): + try: + group = os.tcgetpgrp(self.master_fd) + with open("/proc/%d/comm" % group, "r") as handle: + return handle.read().strip() or self.shell_name + except (OSError, TypeError, ValueError): + return self.shell_name + + def current_path(self): + try: + return os.readlink("/proc/%d/cwd" % self.pid) + except (OSError, TypeError): + return "" + + def history_size(self): + history = self.screen.history + return len(history) if history is not None else 0 + + def clear_history(self): + if self.screen.history is not None: + self.screen.history.clear() + + def close(self, hard=False): + """Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead.""" + if self.pid is not None: + sig = signal.SIGKILL if hard else signal.SIGHUP + try: + os.killpg(self.pid, sig) + except OSError: + try: + os.kill(self.pid, sig) + except OSError: + pass + if self.master_fd is not None: + try: + os.close(self.master_fd) + except OSError: + pass + self.master_fd = None + if self._log is not None: + try: + self._log.close() + except OSError: + pass + self._log = None + + def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24): + """Last lines of the raw session log, reading only the tail in growing chunks.""" + try: + size = os.path.getsize(self.log_path) + except OSError: + return "" + with open(self.log_path, "rb") as handle: + read_from = size + data = b"" + while True: + read_from = max(0, read_from - chunk_size) + handle.seek(read_from) + data = handle.read(size - read_from) + if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes: + break + return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:]) + + +class MplexWindow: + def __init__(self, ident, name, width, height, parent_options): + self.id = ident + self.name = name + self.root = None + self.active = None + self.last_pane = None + self.zoomed = None + self.width = width + self.height = height + self.layout_index = -1 + self.options = MplexOptions(parent_options) + self.auto_rename = True + self.created = time.time() + self.session = None + + @property + def tag(self): + return "@%d" % self.id + + def panes(self): + return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else [] + + def relayout(self): + if self.root is None: + return + MplexLayout.assign(self.root, 0, 0, self.width, self.height) + for leaf in self.root.leaves(): + leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h) + if self.zoomed is not None and self.zoomed in self.panes(): + self.zoomed.place(0, 0, self.width, self.height) + else: + self.zoomed = None + + def visible_panes(self): + return [self.zoomed] if self.zoomed is not None else self.panes() + + def leaf_of(self, pane): + for leaf in self.root.leaves(): + if leaf.pane is pane: + return leaf + return None + + def set_active(self, pane): + if pane is not self.active: + self.last_pane = self.active + self.active = pane + pane.last_active = time.monotonic() + + +class MplexSession: + def __init__(self, ident, name, parent_options, cwd, env): + self.id = ident + self.name = name + self.windows = {} + self.current = None + self.history = [] + self.created = time.time() + self.options = MplexOptions(parent_options) + self.cwd = cwd + self.env = env + self.last_attached = time.time() + + @property + def tag(self): + return "$%d" % self.id + + def window(self): + return self.windows.get(self.current) + + def indexes(self): + return sorted(self.windows) + + def free_index(self, base): + index = base + while index in self.windows: + index += 1 + return index + + @property + def last(self): + """Index of the window that was current before this one, or None.""" + for window in self.history: + index = self.index_of(window) + if index is not None: + return index + return None + + def select(self, index): + if index not in self.windows: + return False + if index != self.current: + previous = self.window() + target = self.windows[index] + self.history = [item for item in self.history if item is not previous and item is not target] + if previous is not None: + self.history.insert(0, previous) + self.current = index + return True + + def index_of(self, window): + for index, item in self.windows.items(): + if item is window: + return index + return None + + +class MplexCanvas: + """A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that + turns two successive grids into the escape sequences that repaint only what changed.""" + + BLANK = (" ", None, None, 0) + GLYPHS = { + "single": "─│┌┐└┘├┤┬┴┼", + "double": "═║╔╗╚╝╠╣╦╩╬", + "heavy": "━┃┏┓┗┛┣┫┳┻╋", + "simple": "-|+++++++++", + } + + def __init__(self, rows, cols): + self.rows = rows + self.cols = cols + self.cells = [[self.BLANK] * cols for _row in range(rows)] + self.cursor = None + + def put(self, x, y, text, style=(None, None, 0), limit=None): + if y < 0 or y >= self.rows: + return x + fg, bg, attr = style + row = self.cells[y] + for char in text: + if limit is not None and limit <= 0: + break + if 0 <= x < self.cols: + row[x] = (char, fg, bg, attr) + x += 1 + if limit is not None: + limit -= 1 + return x + + def put_cells(self, x, y, cells): + if y < 0 or y >= self.rows: + return + row = self.cells[y] + for cell in cells: + if 0 <= x < self.cols: + row[x] = cell + x += 1 + + def blit(self, x, y, block, width): + for index, source in enumerate(block): + target = y + index + if target < 0 or target >= self.rows: + continue + row = self.cells[target] + take = min(width, len(source), self.cols - x) + if take > 0: + row[x:x + take] = source[:take] + + def fill(self, x, y, w, h, style=(None, None, 0), char=" "): + cell = (char, style[0], style[1], style[2]) + for target in range(max(0, y), min(self.rows, y + h)): + row = self.cells[target] + for col in range(max(0, x), min(self.cols, x + w)): + row[col] = cell + + @classmethod + def border_char(cls, up, down, left, right, lines): + glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"]) + horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs + if (up or down) and not (left or right): + return vert + if (left or right) and not (up or down): + return horiz + if up and down and left and right: + return cross + if up and down and right: + return tee_r + if up and down and left: + return tee_l + if left and right and down: + return tee_d + if left and right and up: + return tee_u + if down and right: + return tl + if down and left: + return tr + if up and right: + return bl + if up and left: + return br + return cross + + def encode(self, previous): + """Escape bytes repainting this grid over previous (a list of row tuples, or None).""" + snapshot = [tuple(row) for row in self.cells] + out = ["\x1b[?25l"] + if previous is None or len(previous) != len(snapshot): + out.append("\x1b[0m\x1b[2J") + previous = None + for index, row in enumerate(snapshot): + if previous is not None and previous[index] == row: + continue + out.append("\x1b[%d;1H" % (index + 1)) + current = None + for char, fg, bg, attr in row: + style = (fg, bg, attr) + if style != current: + out.append(MplexStyle.sgr(fg, bg, attr)) + current = style + out.append(char) + out.append("\x1b[0m") + if self.cursor is not None: + out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1)) + return "".join(out).encode("utf-8", "replace"), snapshot + + +class MplexStatusText: + """Format-expanded text with #[...] style markers turned into styled cells.""" + + @staticmethod + def cells(text, base, reset=None): + reset = base if reset is None else reset + current = base + out = [] + pos = 0 + while pos < len(text): + if text.startswith("#[", pos): + end = text.find("]", pos + 2) + if end > 0: + try: + current = MplexStyle.parse(text[pos + 2:end], current, reset) + except MplexError: + pass + pos = end + 1 + continue + out.append((text[pos], current[0], current[1], current[2])) + pos += 1 + return out + + +class MplexMode: + """Pane mode base: owns the pane's keyboard and its drawing while it is on top.""" + + name = "mode" + + def __init__(self, server, pane): + self.server = server + self.pane = pane + + def render(self, client): + raise NotImplementedError + + def key(self, name, raw, client): + return None + + def on_output(self, pushed, evicted): + return None + + def on_resize(self): + return None + + def close(self): + self.pane.pop_mode(self) + self.server.mark_dirty(self.pane.window) + + +class MplexCopyMode(MplexMode): + """Scrollback browser: movement, search, selection and copy, driven by the + copy-mode / copy-mode-vi key tables through "send-keys -X ".""" + + name = "copy-mode" + + def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True): + super().__init__(server, pane) + self.static_rows = None + if lines is not None: + self.name = "view-mode" + self.static_rows = [[(char, None, None, 0) for char in line] for line in lines] + while len(self.static_rows) < pane.h: + self.static_rows.append([]) + self.oy = 0 + self.cx = 0 if lines is not None else pane.screen.cx + self.cy = 0 if lines is not None else pane.screen.cy + self.anchor = None + self.rect = False + self.line_select = False + self.mark = None + self.jump = None + self.inc_origin = None + self.show_position = show_position + self.exit_at_bottom = exit_at_bottom + self.count = 1 + self.lastcx = 0 + self.lastsx = 0 + # a search string outlives copy mode; re-entering it starts searching upward, as tmux does + self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None + self._line_cache = (-1, "") + if lines is not None: + self.oy = self.hist() + + @property + def rows(self): + return self.pane.h + + @property + def cols(self): + return self.pane.w + + def hist(self): + if self.static_rows is not None: + return max(0, len(self.static_rows) - self.rows) + return self.pane.history_size() + + def total(self): + return self.hist() + self.rows + + def top(self): + return self.hist() - self.oy + + def abs_y(self): + return self.top() + self.cy + + def row(self, ay): + if self.static_rows is not None: + return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else [] + hist = self.hist() + if ay < hist: + return self.pane.screen.history[ay] + grid = self.pane.screen.grid + index = ay - hist + return grid[index] if 0 <= index < len(grid) else [] + + def text(self, ay): + return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip() + + def clamp(self): + self.oy = max(0, min(self.hist(), self.oy)) + self.cy = max(0, min(self.rows - 1, self.cy)) + self.cx = max(0, min(self.cols, self.cx)) + + def goto(self, x, ay): + ay = max(0, min(self.total() - 1, ay)) + top = self.top() + if ay < top: + top = ay + elif ay >= top + self.rows: + top = ay - self.rows + 1 + self.oy = max(0, min(self.hist(), self.hist() - top)) + self.cy = ay - self.top() + self.cx = max(0, min(self.cols, x)) + + def on_output(self, pushed, evicted): + if self.static_rows is not None: + return + if self.oy > 0 and pushed: + self.oy += pushed + if evicted: + if self.anchor: + self.anchor = (self.anchor[0], self.anchor[1] - evicted) + if self.mark: + self.mark = (self.mark[0], self.mark[1] - evicted) + self.clamp() + + def on_resize(self): + self.clamp() + + # ---- rendering ------------------------------------------------------- + + def vi_keys(self): + return self.server.pane_options(self.pane).get("mode-keys") == "vi" + + def selected(self, x, ay): + if self.anchor is None: + return False + ax, ay0 = self.anchor + cx, cy = self.cx, self.abs_y() + extra = 1 if self.vi_keys() else 0 + if self.rect: + return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra + if self.line_select: + return min(ay0, cy) <= ay <= max(ay0, cy) + first, last = sorted(((ay0, ax), (cy, cx))) + return first <= (ay, x) < (last[0], last[1] + extra) + + def pattern(self): + if not self.search: + return None + return self.compile(self.search[0], len(self.search) > 2 and self.search[2]) + + def render(self, client): + opts = self.server.pane_options(self.pane) + mode_style = MplexStyle.parse(opts.get("mode-style")) + match_style = MplexStyle.parse(opts.get("copy-mode-match-style")) + pattern = self.pattern() + top = self.top() + out = [] + for view_row in range(self.rows): + ay = top + view_row + source = self.row(ay) + cells = [] + for col in range(self.cols): + cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK + cells.append(cell) + if pattern is not None: + line = "".join(cell[0] for cell in cells) + for match in pattern.finditer(line): + if match.end() == match.start(): + continue + for col in range(match.start(), match.end()): + cells[col] = self.tint(cells[col], match_style) + if self.anchor is not None: + for col in range(self.cols): + if self.selected(col, ay): + cells[col] = self.tint(cells[col], mode_style) + out.append(cells) + if self.show_position and out: + label = "[%d/%d]" % (self.oy, self.hist()) + start = max(0, self.cols - len(label)) + for index, char in enumerate(label[:self.cols]): + out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2]) + return out, (self.cx, self.cy) + + @staticmethod + def tint(cell, style): + char, fg, bg, attr = cell + return (char, style[0] or fg, style[1] or bg, attr | style[2]) + + # ---- keys ------------------------------------------------------------ + + def key(self, name, raw, client): + table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode" + binding = self.server.config.lookup(table, name) + if binding is not None: + self.server.run_binding(client, binding, self.pane) + return True + + def command(self, name, words, client): + """Run one copy-mode command (send-keys -X name args) count times.""" + method = getattr(self, "c_" + name.replace("-", "_"), None) + if method is None: + raise MplexError("unknown copy mode command: %s" % name) + count = max(1, min(self.pane.prefix_count, 10000)) + self.pane.prefix_count = 1 + self.count = count + self._line_cache = (-1, "") + result = method(words, client) + self.clamp() + self.server.mark_dirty(self.pane.window) + if result == "cancel": + self.close() + + # ---- cursor and scrolling -------------------------------------------- + # The cursor sits between characters like tmux's: it may stand one past the + # last character of a line (x == line length), and vertical moves remember the + # column they started from (lastcx / lastsx), snapping to the line end the way + # tmux does. These rules were checked against tmux 3.6 with a differential fuzz. + + def llen(self, ay): + return len(self.text(ay)) + + def snap(self): + px = self.llen(self.abs_y()) + if (self.cx >= self.lastsx and self.cx != px) or self.cx > px: + self.cx = px + + def c_cancel(self, words, client): + return "cancel" + + def step_up(self, scroll_only): + ox = self.llen(self.abs_y()) + if self.cx != ox: + self.lastcx, self.lastsx = self.cx, ox + self.cx = self.lastcx + if scroll_only or self.cy == 0: + if self.oy < self.hist(): + self.oy += 1 + else: + self.cy -= 1 + self.snap() + + def step_down(self, scroll_only): + ox = self.llen(self.abs_y()) + if self.cx != ox: + self.lastcx, self.lastsx = self.cx, ox + self.cx = self.lastcx + if scroll_only or self.cy == self.rows - 1: + if self.oy > 0: + self.oy -= 1 + else: + self.cy += 1 + self.snap() + + def c_cursor_up(self, words, client): + for _i in range(self.count): + self.step_up(False) + + def c_cursor_down(self, words, client): + for _i in range(self.count): + self.step_down(False) + return self.bottom_exit() + + def c_cursor_down_and_cancel(self, words, client): + for _i in range(self.count): + self.step_down(False) + if self.oy == 0 and self.cy == self.rows - 1: + return "cancel" + + def bottom_exit(self): + return "cancel" if self.exit_at_bottom and self.oy == 0 else None + + def c_cursor_left(self, words, client): + for _i in range(self.count): + ay = self.abs_y() + if self.cx == 0 and ay > 0: + self.goto(self.llen(ay - 1), ay - 1) + elif self.cx > 0: + self.cx -= 1 + + def c_cursor_right(self, words, client): + for _i in range(self.count): + ay = self.abs_y() + if self.cx >= self.llen(ay): + if ay < self.total() - 1: + self.goto(0, ay + 1) + else: + self.cx += 1 + + def c_start_of_line(self, words, client): + self.cx = 0 + + def c_end_of_line(self, words, client): + self.cx = self.llen(self.abs_y()) + + def c_back_to_indentation(self, words, client): + line = self.text(self.abs_y()) + if line: + self.cx = len(line) - len(line.lstrip(" ")) + + def c_top_line(self, words, client): + self.cx, self.cy = 0, 0 + + def c_middle_line(self, words, client): + self.cx, self.cy = 0, (self.rows - 1) // 2 + + def c_bottom_line(self, words, client): + self.cx, self.cy = 0, self.rows - 1 + + def c_history_top(self, words, client): + self.oy, self.cx, self.cy = self.hist(), 0, 0 + + def c_history_bottom(self, words, client): + self.oy, self.cy = 0, self.rows - 1 + self.cx = self.llen(self.abs_y()) + return self.bottom_exit() + + def page_size(self, half): + return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1 + + def remember_column(self): + ox = self.llen(self.abs_y()) + if self.cx != ox: + self.lastcx, self.lastsx = self.cx, ox + self.cx = self.lastcx + + def page_up(self, half): + size = self.page_size(half) + self.remember_column() + if self.oy + size > self.hist(): + self.oy = self.hist() + self.cy = self.cy - size if self.cy > size else 0 + else: + self.oy += size + self.snap() + + def page_down(self, half): + size = self.page_size(half) + self.remember_column() + if self.oy < size: + self.cy = min(self.rows - 1, self.cy + size) + self.oy = 0 + else: + self.oy -= size + self.snap() + + def c_page_up(self, words, client): + for _i in range(self.count): + self.page_up(False) + + def c_page_down(self, words, client): + for _i in range(self.count): + self.page_down(False) + return self.bottom_exit() + + def c_page_down_and_cancel(self, words, client): + for _i in range(self.count): + self.page_down(False) + if self.oy == 0: + return "cancel" + + def c_halfpage_up(self, words, client): + for _i in range(self.count): + self.page_up(True) + + def c_halfpage_down(self, words, client): + for _i in range(self.count): + self.page_down(True) + return self.bottom_exit() + + def c_halfpage_down_and_cancel(self, words, client): + for _i in range(self.count): + self.page_down(True) + if self.oy == 0: + return "cancel" + + def c_scroll_up(self, words, client): + for _i in range(self.count): + self.step_up(True) + + def c_scroll_down(self, words, client): + for _i in range(self.count): + self.step_down(True) + return self.bottom_exit() + + def c_scroll_down_and_cancel(self, words, client): + for _i in range(self.count): + self.step_down(True) + if self.oy == 0: + return "cancel" + + def c_scroll_middle(self, words, client): + mid = (self.rows - 1) // 2 + delta = self.cy - mid + if delta > 0 and self.oy >= delta: + self.oy -= delta + self.cy = mid + elif delta < 0 and self.hist() - self.oy >= -delta: + self.oy += -delta + self.cy = mid + + def c_cursor_centre_vertical(self, words, client): + self.cy = self.rows // 2 + + def c_cursor_centre_horizontal(self, words, client): + return None + + def c_scroll_top(self, words, client): + if self.oy >= self.cy: + self.oy -= self.cy + self.cy = 0 + + def c_scroll_bottom(self, words, client): + need = self.rows - 1 - self.cy + if self.hist() - self.oy >= need: + self.oy += need + self.cy = self.rows - 1 + + def c_goto_line(self, words, client): + try: + line = int(words[0]) + except (IndexError, ValueError): + raise MplexError("goto-line needs a line number") + self.oy = max(0, min(self.hist(), line)) + + def c_refresh_from_pane(self, words, client): + return None + + def c_toggle_position(self, words, client): + self.show_position = not self.show_position + + # ---- words, paragraphs, brackets ------------------------------------- + + def in_ws(self, x, ay): + return self.char_at(x, ay) == " " + + def in_sep(self, x, ay, separators): + return self.char_at(x, ay) in separators + + def separators(self, big): + return "" if big else self.server.pane_options(self.pane).get("word-separators") + + def char_at(self, x, ay): + if self._line_cache[0] != ay: + self._line_cache = (ay, self.text(ay)) + line = self._line_cache[1] + return line[x] if x < len(line) else " " + + def advance_blanks(self, x, ay, xx): + """Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom).""" + last = self.total() - 1 + while x > xx or self.in_ws(x, ay): + if x > xx: + if ay == last: + return x, ay, xx, True + ay += 1 + x = 0 + xx = self.llen(ay) + else: + x += 1 + return x, ay, xx, False + + def run_end(self, x, ay, xx, separators): + """x just past the run of separators (or of word characters) that starts at x.""" + if self.in_sep(x, ay, separators) and not self.in_ws(x, ay): + x += 1 + while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay): + x += 1 + else: + x += 1 + while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)): + x += 1 + return x + + def c_next_word(self, words, client, big=False): + separators = self.separators(big) + x, ay = self.cx, self.abs_y() + for _i in range(self.count): + xx = self.llen(ay) + if x > xx: + # past the line end (reachable after centre-vertical or goto-line): tmux carries on skipping + # the first word of the next line before it looks for a word start + if ay == self.total() - 1: + break + ay += 1 + x = 0 + xx = self.llen(ay) + if not self.in_ws(x, ay): + x = self.run_end(x, ay, xx, separators) + x, ay, xx, _bottom = self.advance_blanks(x, ay, xx) + self.goto(x, ay) + + def c_next_space(self, words, client): + self.c_next_word(words, client, True) + + def c_next_word_end(self, words, client, big=False): + separators = self.separators(big) + vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" + x, ay = self.cx, self.abs_y() + for _i in range(self.count): + xx = self.llen(ay) + if vi: + if x < xx: + x += 1 + elif ay < self.total() - 1: + self.goto(0, ay + 1) + x, ay = 0, ay + 1 + xx = self.llen(ay) + x, ay, xx, bottom = self.advance_blanks(x, ay, xx) + if not bottom: + x = self.run_end(x, ay, xx, separators) + if vi and x > 0: + x -= 1 + if bottom: + break + self.goto(x, ay) + + def c_next_space_end(self, words, client): + self.c_next_word_end(words, client, True) + + def c_previous_word(self, words, client, big=False): + separators = self.separators(big) + x, ay = self.cx, self.abs_y() + for _i in range(self.count): + # one step back, then over whitespace (and empty lines) to a word character + while True: + if x == 0: + if ay == 0: + break + ay -= 1 + x = self.llen(ay) + else: + x -= 1 + if not self.in_ws(x, ay): + break + if self.in_ws(x, ay): + break + letters = not self.in_sep(x, ay, separators) + while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)): + x -= 1 + self.goto(x, ay) + + def c_previous_space(self, words, client): + self.c_previous_word(words, client, True) + + def c_next_paragraph(self, words, client): + ay = self.abs_y() + last = self.total() - 1 + for _i in range(self.count): + while ay < last and not self.llen(ay): + ay += 1 + while ay < last and self.llen(ay): + ay += 1 + self.scroll_to(self.llen(ay), ay) + + def c_previous_paragraph(self, words, client): + ay = self.abs_y() + for _i in range(self.count): + while ay > 0 and not self.llen(ay): + ay -= 1 + while ay > 0 and self.llen(ay): + ay -= 1 + self.scroll_to(0, ay) + + def match_bracket(self, x, ay, forward): + """Position of the bracket matching the one at (x, ay), or None.""" + pairs = {"(": ")", "[": "]", "{": "}"} + reverse = {value: key for key, value in pairs.items()} + opener = self.char_at(x, ay) + closer = pairs.get(opener) if forward else reverse.get(opener) + if closer is None: + return None + depth = 0 + pos = (x, ay) + for _i in range(20000): + char = self.char_at(*pos) + if char == opener: + depth += 1 + elif char == closer: + depth -= 1 + if depth == 0: + return pos + pos = self.step(pos[0], pos[1], forward) + if pos is None: + return None + return None + + def step(self, x, ay, forward): + """Next position, line ends counting as one blank cell. None at the buffer edge.""" + if forward: + if x < self.llen(ay): + return x + 1, ay + return (0, ay + 1) if ay + 1 < self.total() else None + if x > 0: + return x - 1, ay + return (self.llen(ay - 1), ay - 1) if ay > 0 else None + + def c_next_matching_bracket(self, words, client): + ay = self.abs_y() + if not self.llen(ay): + return + here = self.char_at(self.cx, ay) + found = None + if self.vi_keys(): + if here in "([{": + found = self.match_bracket(self.cx, ay, True) + elif here in ")]}": + found = self.match_bracket(self.cx, ay, False) + else: + for x in (self.cx, self.cx + 1): + if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "): + found = self.match_bracket(x, ay, True) + if found is not None: + self.goto(*found) + else: + self.c_next_word_end([], None, True) + + def c_previous_matching_bracket(self, words, client): + ay = self.abs_y() + if not self.llen(ay): + return + here = self.char_at(self.cx, ay) + found = None + if here in ")]}": + found = self.match_bracket(self.cx, ay, False) + elif self.vi_keys(): + if here in "([{" or self.char_at(self.cx + 1, ay) in "([{": + return + elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}": + found = self.match_bracket(self.cx - 1, ay, False) + if found is not None: + self.goto(*found) + else: + self.c_previous_word([], None, True) + + # ---- jump ------------------------------------------------------------ + + def do_jump(self, kind, char): + line = self.text(self.abs_y()) + x = self.cx + for _i in range(self.count): + if kind == "f": + found = line.find(char, x + 1) + elif kind == "t": + found = line.find(char, x + 2) + found = found - 1 if found >= 0 else -1 + elif kind == "F": + found = line.rfind(char, 0, x) + else: + found = line.rfind(char, 0, max(0, x - 1)) + found = found + 1 if found >= 0 else -1 + if found < 0: + return + x = found + self.cx = x + + def set_jump(self, kind, words): + if not words or not words[0]: + raise MplexError("jump needs a character") + self.jump = (kind, words[0][0]) + self.do_jump(*self.jump) + + def c_jump_forward(self, words, client): + self.set_jump("f", words) + + def c_jump_backward(self, words, client): + self.set_jump("F", words) + + def c_jump_to_forward(self, words, client): + self.set_jump("t", words) + + def c_jump_to_backward(self, words, client): + self.set_jump("T", words) + + def c_jump_again(self, words, client): + if self.jump: + self.do_jump(*self.jump) + + def c_jump_reverse(self, words, client): + if self.jump: + opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]] + self.do_jump(opposite, self.jump[1]) + + # ---- selection and copy ---------------------------------------------- + + def c_begin_selection(self, words, client): + self.anchor = (self.cx, self.abs_y()) + self.line_select = False + + def c_stop_selection(self, words, client): + return None + + def c_clear_selection(self, words, client): + self.anchor = None + self.line_select = False + + def c_select_line(self, words, client): + self.anchor = (0, self.abs_y()) + self.line_select = True + self.rect = False + for _i in range(self.count - 1): + self.step_down(False) + self.cx = self.llen(self.abs_y()) + + def c_select_word(self, words, client): + ay = self.abs_y() + separators = self.separators(False) + line = self.text(ay) + if not line: + return + start = min(self.cx, len(line) - 1) + while start > 0 and self.in_ws(start, ay): + start -= 1 + if self.in_ws(start, ay): + return + letters = not self.in_sep(start, ay, separators) + end = start + while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)): + start -= 1 + while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)): + end += 1 + self.anchor = (start, ay) + self.line_select = False + self.cx = end if self.vi_keys() else end + 1 + + def c_rectangle_toggle(self, words, client): + self.rect = not self.rect + + def c_rectangle_on(self, words, client): + self.rect = True + + def c_rectangle_off(self, words, client): + self.rect = False + + def c_other_end(self, words, client): + if self.anchor is None: + return + old = (self.cx, self.abs_y()) + self.goto(*self.anchor) + self.anchor = old + + def c_set_mark(self, words, client): + self.mark = (self.cx, self.abs_y()) + + def c_jump_to_mark(self, words, client): + if self.mark is None: + return + old = (self.cx, self.abs_y()) + self.goto(*self.mark) + self.mark = old + + def selection_text(self): + """The selected text. Emacs keys: the cursor stands between characters, so the end is + exclusive; vi keys: the character under the cursor is included, and so is the line end + (a newline) when the cursor stands on it. Both checked against tmux 3.6.""" + if self.anchor is None: + return "" + vi = self.vi_keys() + ax, ay0 = self.anchor + cx, cy = self.cx, self.abs_y() + extra = 1 if vi else 0 + if self.line_select: + lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)] + return "\n".join(lines) + ("\n" if vi else "") + if self.rect: + left, right = min(ax, cx), max(ax, cx) + extra + return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1)) + (sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx))) + parts = [] + for ay in range(sy, ey + 1): + line = self.text(ay) + first = sx if ay == sy else 0 + last = ex + extra if ay == ey else len(line) + piece = line[first:last] + if ay == ey and vi and last > len(line): + piece += "\n" + parts.append(piece) + return "\n".join(parts) + + def deliver(self, text, words, append=False): + if not text: + return + command = words[0] if words else "" + self.server.store_copy(text, command, append) + + def c_copy_selection(self, words, client): + self.deliver(self.selection_text(), []) + self.c_clear_selection(words, client) + + def c_copy_selection_and_cancel(self, words, client): + self.deliver(self.selection_text(), []) + return "cancel" + + def c_copy_pipe(self, words, client): + self.deliver(self.selection_text(), words) + self.c_clear_selection(words, client) + + def c_copy_pipe_and_cancel(self, words, client): + self.deliver(self.selection_text(), words) + return "cancel" + + def c_append_selection(self, words, client): + self.deliver(self.selection_text(), [], True) + self.c_clear_selection(words, client) + + def c_append_selection_and_cancel(self, words, client): + self.deliver(self.selection_text(), [], True) + return "cancel" + + def copy_span(self, from_start, words, cancel): + """copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines.""" + ay = self.abs_y() + self.anchor = (0 if from_start else self.cx, ay) + self.line_select = False + self.rect = False + for _i in range(self.count - 1): + self.step_down(False) + self.cx = self.llen(self.abs_y()) + self.deliver(self.selection_text(), words) + self.c_clear_selection(words, None) + return "cancel" if cancel else None + + def c_copy_end_of_line(self, words, client): + return self.copy_span(False, [], False) + + def c_copy_end_of_line_and_cancel(self, words, client): + return self.copy_span(False, [], True) + + def c_copy_pipe_end_of_line(self, words, client): + return self.copy_span(False, words, False) + + def c_copy_pipe_end_of_line_and_cancel(self, words, client): + return self.copy_span(False, words, True) + + def c_copy_line(self, words, client): + return self.copy_span(True, [], False) + + def c_copy_line_and_cancel(self, words, client): + return self.copy_span(True, [], True) + + def c_copy_pipe_line(self, words, client): + return self.copy_span(True, words, False) + + def c_copy_pipe_line_and_cancel(self, words, client): + return self.copy_span(True, words, True) + + # ---- search ---------------------------------------------------------- + + def compile(self, text, literal=False): + flags = re.IGNORECASE if text == text.lower() else 0 + if literal: + return re.compile(re.escape(text), flags) + try: + return re.compile(text, flags) + except re.error: + return None + + def find(self, pattern, forward, x, ay, wrap): + """First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line).""" + total = self.total() + + def matches_of(index): + return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()] + + order = range(ay, total) if forward else range(ay, -1, -1) + for index in order: + found = matches_of(index) + if index == ay: + found = [m for m in found if (m.start() >= x if forward else m.start() < x)] + if found: + match = found[0] if forward else found[-1] + return match.start(), match.end(), index + if wrap: + for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)): + found = matches_of(index) + if found: + match = found[0] if forward else found[-1] + return match.start(), match.end(), index + return None + + def scroll_to(self, x, ay): + """Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view.""" + hist, rows = self.hist(), self.rows + self.cx = max(0, min(self.cols, x)) + top = hist - self.oy + if top <= ay < top + rows: + self.cy = ay - top + return + gap = rows // 4 + if ay < rows: + offset, self.cy = 0, ay + elif ay > hist + rows - gap: + offset, self.cy = hist, ay - hist + else: + offset, self.cy = ay + gap - rows, rows - gap + self.oy = max(0, min(hist, hist - offset)) + + def run_search(self, text, forward, literal=False, origin=None): + if not text: + return False + self.search = (text, forward, literal) + if self.static_rows is None: + self.pane.last_search = self.search + pattern = self.compile(text, literal) + if pattern is None: + return False + wrap = bool(self.server.pane_options(self.pane).get("wrap-search")) + vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" + x, ay = origin if origin is not None else (self.cx, self.abs_y()) + if forward and vi and origin is None: + x += 1 + found = self.find(pattern, forward, x, ay, wrap) + if found is None: + return False + start, end, line = found + self.scroll_to(end if forward and not vi else start, line) + return True + + def search_failed(self, client): + """tmux reports a failed search as a status message, not as a command error.""" + self.server.message(client, "Search failed") + + def search_command(self, words, forward, literal=False, client=None): + text = words[0] if words else "" + if not text and self.search: + text = self.search[0] + if not self.run_search(text, forward, literal): + self.search_failed(client) + + def c_search_forward(self, words, client): + self.search_command(words, True, False, client) + + def c_search_backward(self, words, client): + self.search_command(words, False, False, client) + + def c_search_forward_text(self, words, client): + self.search_command(words, True, True, client) + + def c_search_backward_text(self, words, client): + self.search_command(words, False, True, client) + + def c_search_again(self, words, client): + if not self.search: + return + text, forward, literal = self.search + if not self.run_search(text, forward, literal): + self.search_failed(client) + + def c_search_reverse(self, words, client): + if not self.search: + return + text, forward, literal = self.search + if not self.run_search(text, not forward, literal): + self.search_failed(client) + self.search = (text, forward, literal) + + def incremental(self, words, forward): + arg = words[0] if words else "" + if self.inc_origin is None: + self.inc_origin = (self.cx, self.abs_y()) + mark = arg[:1] + if mark == "=": + origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1]) + self.run_search(arg[1:], forward, origin=origin) + elif mark in ("+", "-"): + self.run_search(arg[1:], mark == "+") + else: + self.inc_origin = None + if arg: + self.search = (arg, forward, False) + + def c_search_forward_incremental(self, words, client): + self.incremental(words, True) + + def c_search_backward_incremental(self, words, client): + self.incremental(words, False) + + def search_cancel(self): + if self.inc_origin is not None: + self.goto(*self.inc_origin) + self.inc_origin = None + self.server.mark_dirty(self.pane.window) + + +class MplexPrompt: + """The command line in the status row: emacs-style editing, history, and the + single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants.""" + + def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False, + on_change=None, on_cancel=None, on_step=None, confirm=False): + self.label = label + self.text = text + self.pos = len(text) + self.on_done = on_done + self.history = history if history is not None else [] + self.hist_index = None + self.single = single + self.numeric = numeric + self.key_name = key_name + self.on_change = on_change + self.on_cancel = on_cancel + self.on_step = on_step + self.confirm = confirm + + def view(self): + return self.label + self.text, len(self.label) + self.pos + + def finish(self, server, client, value): + client.prompt = None + if value and not (self.single or self.numeric or self.key_name or self.confirm): + self.history.append(value) + del self.history[:-MPLEX_PROMPT_HISTORY_CAP] + self.on_done(value) + + def cancel(self, client): + client.prompt = None + if self.on_cancel: + self.on_cancel() + + def changed(self): + if self.on_change: + self.on_change(self.text) + + @staticmethod + def printable(name, raw): + if name == "Space": + return " " + if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable(): + return name + return "" + + def key(self, server, client, name, raw): + char = self.printable(name, raw) + if self.confirm: + if char == "y": + self.finish(server, client, "y") + else: + self.cancel(client) + return + if self.key_name: + self.finish(server, client, name) + return + if self.single: + if char: + self.finish(server, client, char) + elif name in ("Escape", "C-c", "C-g"): + self.cancel(client) + return + if self.numeric: + if char.isdigit(): + self.text += char + self.pos = len(self.text) + return + self.finish(server, client, self.text) + client.redispatch(name, raw) + return + text, pos = self.text, self.pos + if name in ("Enter", "C-j"): + self.finish(server, client, text) + return + if name in ("Escape", "C-c", "C-g"): + self.cancel(client) + return + if name in ("C-a", "Home"): + pos = 0 + elif name in ("C-e", "End"): + pos = len(text) + elif name in ("C-b", "Left"): + pos = max(0, pos - 1) + elif name in ("C-f", "Right"): + pos = min(len(text), pos + 1) + elif name == "M-b": + while pos > 0 and text[pos - 1] == " ": + pos -= 1 + while pos > 0 and text[pos - 1] != " ": + pos -= 1 + elif name == "M-f": + while pos < len(text) and text[pos] == " ": + pos += 1 + while pos < len(text) and text[pos] != " ": + pos += 1 + elif name in ("BSpace", "C-h"): + if pos > 0: + text = text[:pos - 1] + text[pos:] + pos -= 1 + elif name in ("DC", "C-d"): + text = text[:pos] + text[pos + 1:] + elif name == "C-k": + text = text[:pos] + elif name == "C-u": + text = text[pos:] + pos = 0 + elif name == "C-w": + start = pos + while start > 0 and text[start - 1] == " ": + start -= 1 + while start > 0 and text[start - 1] != " ": + start -= 1 + text = text[:start] + text[pos:] + pos = start + elif name == "C-y": + piece = server.top_buffer_text() + text = text[:pos] + piece + text[pos:] + pos += len(piece) + elif name == "C-t": + if 0 < pos < len(text): + text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:] + pos += 1 + elif name in ("Up", "C-p", "Down", "C-n"): + if self.history: + if name in ("Up", "C-p"): + self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1) + elif self.hist_index is not None: + self.hist_index += 1 + if self.hist_index is not None and self.hist_index >= len(self.history): + self.hist_index = None + text = "" + elif self.hist_index is not None: + text = self.history[self.hist_index] + pos = len(text) + elif name in ("C-s", "C-r") and self.on_step: + self.on_step(name, text) + return + elif char: + text = text[:pos] + char + text[pos:] + pos += len(char) + else: + return + edited = text != self.text + self.text, self.pos = text, pos + if edited: + self.changed() + + +class MplexFont: + GLYPHS = { + "0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."), + "2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"), + "4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"), + "6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"), + "8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"), + ":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"), + "P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"), + } + HEIGHT = 5 + + @classmethod + def width(cls, text): + return len(text) * 6 - 1 if text else 0 + + @classmethod + def draw(cls, text, bg): + """Five rows of cells for text in big glyphs, lit pixels painted with background bg.""" + rows = [] + lit = (" ", None, bg, 0) + dark = MplexCanvas.BLANK + for line in range(cls.HEIGHT): + row = [] + for index, char in enumerate(text): + glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"]) + row.extend(lit if pixel == "#" else dark for pixel in glyph[line]) + if index != len(text) - 1: + row.append(dark) + rows.append(row) + return rows + + +class MplexClockMode(MplexMode): + name = "clock-mode" + + def render(self, client): + opts = self.server.pane_options(self.pane) + bg = MplexStyle.color(opts.get("clock-mode-colour"), True) + now = time.localtime() + text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now) + grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)] + if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text): + big = MplexFont.draw(text, bg) + top = (self.pane.h - MplexFont.HEIGHT) // 2 + left = (self.pane.w - MplexFont.width(text)) // 2 + for index, row in enumerate(big): + grid[top + index][left:left + len(row)] = row + else: + top = self.pane.h // 2 + left = max(0, (self.pane.w - len(text)) // 2) + for index, char in enumerate(text[:self.pane.w]): + grid[top][left + index] = (char, None, bg, 0) + return grid, None + + def key(self, name, raw, client): + self.close() + return True + + +class MplexPaneNumbers: + """display-panes overlay: a big index on every pane until a key or the timer ends it.""" + + def __init__(self, server, window, until): + self.server = server + self.window = window + self.until = until + + def key(self, name, raw, client): + client.overlay = None + if len(name) == 1 and name.isdigit(): + base = self.server.pane_options(self.window.active).get("pane-base-index") + panes = self.window.panes() + index = int(name) - base + if 0 <= index < len(panes): + self.server.select_pane(self.window, panes[index]) + self.server.mark_dirty(self.window) + + def draw(self, canvas, area_y): + opts = self.window.options + base = opts.get("pane-base-index") + normal = MplexStyle.color(opts.get("display-panes-colour"), True) + active = MplexStyle.color(opts.get("display-panes-active-colour"), True) + for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()): + bg = active if pane is self.window.active else normal + text = str(index + base) + if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text): + big = MplexFont.draw(text, bg) + top = pane.y + (pane.h - MplexFont.HEIGHT) // 2 + left = pane.x + (pane.w - MplexFont.width(text)) // 2 + for offset, row in enumerate(big): + canvas.put_cells(left, area_y + top + offset, row) + else: + canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0)) + size = "%dx%d" % (pane.w, pane.h) + if pane.w > len(size) + 1: + canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0)) + + +class MplexChoice: + __slots__ = ("label", "payload", "children", "expanded", "depth", "tag") + + def __init__(self, label, payload=None, children=None, expanded=False): + self.label = label + self.payload = payload + self.children = children or [] + self.expanded = expanded + self.depth = 0 + self.tag = False + + +class MplexChooser(MplexMode): + """choose-tree / choose-buffer / choose-client: a list you move through, with an + optional live preview of the selected item below it.""" + + name = "tree-mode" + HOT_KEYS = "0123456789" + + def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None): + super().__init__(server, pane) + self.name = kind + self.builder = builder + self.items = builder() + self.on_close = on_close + self.on_choose = on_choose + self.on_delete = on_delete + self.preview = preview + self.show_preview = preview is not None + self.cursor = 0 + self.visible = [] + self.reflatten() + if selected is not None: + for index, item in enumerate(self.visible): + if item.payload == selected: + self.cursor = index + + def reflatten(self): + self.visible = [] + + def walk(nodes, depth): + for node in nodes: + node.depth = depth + self.visible.append(node) + if node.children and node.expanded: + walk(node.children, depth + 1) + + walk(self.items, 0) + self.cursor = max(0, min(len(self.visible) - 1, self.cursor)) + + def selected(self): + return self.visible[self.cursor] if self.visible else None + + def rebuild(self): + opened = set() + + def collect(nodes): + for node in nodes: + if node.expanded: + opened.add(repr(node.payload)) + collect(node.children) + + def restore(nodes): + for node in nodes: + node.expanded = node.expanded or repr(node.payload) in opened + restore(node.children) + + collect(self.items) + self.items = self.builder() + restore(self.items) + self.reflatten() + if not self.visible: + self.close() + + def close(self): + super().close() + if self.on_close is not None: + callback, self.on_close = self.on_close, None + callback() + + def list_height(self): + if self.show_preview and self.pane.h >= 8: + return max(3, self.pane.h // 2) + return self.pane.h + + def render(self, client): + w, h = self.pane.w, self.pane.h + opts = self.server.pane_options(self.pane) + mode_style = MplexStyle.parse(opts.get("mode-style")) + grid = [[MplexCanvas.BLANK] * w for _row in range(h)] + list_h = self.list_height() + top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0 + for row in range(list_h): + index = top + row + if index >= len(self.visible): + break + node = self.visible[index] + hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " " + marker = ("- " if node.expanded else "+ ") if node.children else " " + line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w) + style = mode_style if index == self.cursor else (None, None, 0) + grid[row] = [(char, style[0], style[1], style[2]) for char in line] + if self.show_preview and list_h < h: + border = MplexCanvas.GLYPHS["single"][0] + grid[list_h] = [(border, None, None, 0)] * w + chosen = self.selected() + if chosen is not None: + block = self.preview(chosen, w, h - list_h - 1) or [] + for offset, cells in enumerate(block[:h - list_h - 1]): + grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w] + return grid, None + + def move(self, delta): + self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta)) + + def key(self, name, raw, client): + node = self.selected() + page = max(1, self.list_height() - 1) + if name in ("q", "Escape", "C-c"): + self.close() + elif name in ("Up", "k", "C-p"): + self.move(-1) + elif name in ("Down", "j", "C-n"): + self.move(1) + elif name in ("PPage", "C-b"): + self.move(-page) + elif name in ("NPage", "C-f"): + self.move(page) + elif name in ("Home", "g"): + self.cursor = 0 + elif name in ("End", "G"): + self.cursor = max(0, len(self.visible) - 1) + elif name in ("Right", "l", "+") and node is not None and node.children: + node.expanded = True + self.reflatten() + elif name in ("Left", "h", "-") and node is not None: + if node.children and node.expanded: + node.expanded = False + else: + for index in range(self.cursor - 1, -1, -1): + if self.visible[index].depth < node.depth: + self.cursor = index + break + self.reflatten() + elif name == "t" and node is not None: + node.tag = not node.tag + elif name == "v" and self.preview is not None: + self.show_preview = not self.show_preview + elif name == "x" and node is not None and self.on_delete is not None: + self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client)) + elif name == "Enter" and node is not None: + self.close() + self.on_choose(node, client) + elif len(name) == 1 and name in self.HOT_KEYS: + index = self.HOT_KEYS.index(name) + if index < len(self.visible): + self.cursor = index + self.close() + self.on_choose(self.visible[index], client) + self.server.mark_dirty(self.pane.window) + return True + + def delete(self, node, client): + self.on_delete(node, client) + self.server.refresh_choosers() + + +class MplexRenderer: + """Composes one client's whole screen: panes, borders, status row, prompt, overlays.""" + + def __init__(self, server): + self.server = server + + def block_of(self, pane, client): + mode = pane.top_mode() + if mode is not None: + return mode.render(client) + grid = pane.screen.grid + blank = MplexCanvas.BLANK + block = [[cell if cell is not None else blank for cell in row] for row in grid] + cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None + return block, cursor + + def frame(self, client): + server = self.server + canvas = MplexCanvas(client.rows, client.cols) + session = client.session + window = session.window() + opts = window.options + status_on = bool(opts.get("status")) + on_top = opts.get("status-position") == "top" + area_y = 1 if (status_on and on_top) else 0 + cursor = None + active = window.active + for pane in window.visible_panes(): + block, pane_cursor = self.block_of(pane, client) + canvas.blit(pane.x, area_y + pane.y, block, pane.w) + if pane is active and pane_cursor is not None: + cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1]) + if window.zoomed is None and len(window.panes()) > 1: + self.borders(canvas, window, area_y) + if isinstance(client.overlay, MplexPaneNumbers): + client.overlay.draw(canvas, area_y) + cursor = None + status_row = 0 if on_top else client.rows - 1 + if status_on: + self.status(canvas, client, session, window, status_row) + line_row = status_row if status_on else client.rows - 1 + if client.prompt is not None: + text, pos = client.prompt.view() + style = MplexStyle.parse(opts.get("message-style")) + canvas.fill(0, line_row, client.cols, 1, style) + canvas.put(0, line_row, text, style, client.cols) + cursor = (min(client.cols - 1, pos), line_row) + elif client.message is not None and time.monotonic() < client.message[1]: + style = MplexStyle.parse(opts.get("message-style")) + canvas.fill(0, line_row, client.cols, 1, style) + canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols) + canvas.cursor = cursor + return canvas + + def borders(self, canvas, window, area_y): + W, H = window.width, window.height + panes = window.panes() + cells = set() + for pane in panes: + if pane.x + pane.w < W: + for row in range(pane.y, min(H, pane.y + pane.h + 1)): + cells.add((pane.x + pane.w, row)) + if pane.y + pane.h < H: + for col in range(pane.x, min(W, pane.x + pane.w + 1)): + cells.add((col, pane.y + pane.h)) + active = window.active + hot = set() + if active is not None: + for col in range(active.x - 1, active.x + active.w + 1): + hot.add((col, active.y - 1)) + hot.add((col, active.y + active.h)) + for row in range(active.y - 1, active.y + active.h + 1): + hot.add((active.x - 1, row)) + hot.add((active.x + active.w, row)) + hot &= cells + opts = window.options + lines = opts.get("pane-border-lines") + plain = MplexStyle.parse(opts.get("pane-border-style")) + hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active)) + hot_style = MplexStyle.parse(hot_text, plain) + for (x, y) in cells: + style = hot_style if (x, y) in hot else plain + glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines) + canvas.put(x, area_y + y, glyph, style) + + def status(self, canvas, client, session, window, row): + server = self.server + opts = window.options + base = MplexStyle.parse(opts.get("status-style")) + canvas.fill(0, row, client.cols, 1, base) + resolver = server.resolver_for(session, window, window.active, client) + left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)), + MplexStyle.parse(opts.get("status-left-style"), base), base) + right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)), + MplexStyle.parse(opts.get("status-right-style"), base), base) + left = left[:opts.get("status-left-length")] + right = right[:opts.get("status-right-length")] + if len(left) > client.cols: + left = left[:client.cols] + room_right = max(0, client.cols - len(left)) + right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right] + avail = client.cols - len(left) - len(right) + entries = [] + current_at = 0 + total = 0 + last_index = session.last + separator = opts.get("window-status-separator") + for index in session.indexes(): + item = session.windows[index] + wres = server.resolver_for(session, item, item.active, client) + is_current = index == session.current + fmt = item.options.get("window-status-current-format" if is_current else "window-status-format") + style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style") + style = MplexStyle.parse(item.options.get(style_name), base) + cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style) + if entries: + cells = [(char, base[0], base[1], base[2]) for char in separator] + cells + if is_current: + current_at = total + (len(separator) if entries else 0) + entries.append(cells) + total += len(cells) + listing = [cell for cells in entries for cell in cells] + if len(listing) > avail: + listing = self.fit_list(listing, avail, current_at, base) + justify = opts.get("status-justify") + offset = 0 + if justify in ("centre", "center"): + offset = max(0, (avail - len(listing)) // 2) + elif justify == "right": + offset = max(0, avail - len(listing)) + canvas.put_cells(0, row, left) + canvas.put_cells(len(left) + offset, row, listing) + canvas.put_cells(client.cols - len(right), row, right) + + @staticmethod + def fit_list(cells, avail, current_at, base): + """The slice of the window list around the current window, with < and > markers.""" + if avail <= 0: + return [] + start = max(0, min(current_at - avail // 3, len(cells) - avail)) + piece = cells[start:start + avail] + marker = lambda char: (char, base[0], base[1], base[2] | 1) + if start > 0 and piece: + piece[0] = marker("<") + if start + avail < len(cells) and piece: + piece[-1] = marker(">") + return piece + + +class MplexCtx: + """What a command runs against: the issuing client and its current session, window, pane.""" + + def __init__(self, server, client=None, pane=None, interactive=False): + self.server = server + self.client = client + self.interactive = interactive + self.depth = 0 + self.pane = pane + self.window = pane.window if pane is not None else None + self.session = self.window.session if self.window is not None else None + if client is not None and client.session is not None and pane is None: + self.session = client.session + self.window = self.session.window() + self.pane = self.window.active if self.window is not None else None + elif self.session is None and client is not None and client.env_pane is not None: + found = server.panes.get(client.env_pane) + if found is not None and found.window is not None: + self.pane = found + self.window = found.window + self.session = found.window.session + if self.session is None: + self.session = server.latest_session() + if self.session is not None: + self.window = self.session.window() + self.pane = self.window.active if self.window is not None else None + + +class MplexTargets: + """tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session.""" + + @staticmethod + def session(ctx, spec): + server = ctx.server + if not spec: + if ctx.session is None: + raise MplexError("no current session") + return ctx.session + found = None + if spec.startswith("$") and spec[1:].isdigit(): + found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None) + else: + spec = spec.lstrip("=") + found = next((s for s in server.sessions.values() if s.name == spec), None) + if found is None: + matches = [s for s in server.sessions.values() if s.name.startswith(spec)] + if len(matches) == 1: + found = matches[0] + if found is None: + import fnmatch + matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)] + if len(matches) == 1: + found = matches[0] + if found is None: + raise MplexError("can't find session: %s" % spec) + return found + + @classmethod + def window(cls, ctx, spec): + """Returns (session, index, window).""" + server = ctx.server + spec = spec or "" + if spec.startswith("@") and spec[1:].isdigit(): + window = server.windows.get(int(spec[1:])) + if window is None: + raise MplexError("can't find window: %s" % spec) + return window.session, window.session.index_of(window), window + if ":" in spec: + sess_part, _sep, win_part = spec.partition(":") + session = cls.session(ctx, sess_part) + else: + sess_part, win_part, session = "", spec, ctx.session + if session is None: + raise MplexError("no current session") + index = cls.window_index(session, win_part) + if index is None and ":" not in spec and win_part: + try: + session = cls.session(ctx, win_part) + index = session.current + except MplexError: + index = None + if index is None or index not in session.windows: + raise MplexError("can't find window: %s" % (win_part or spec)) + return session, index, session.windows[index] + + @staticmethod + def window_index(session, text): + indexes = session.indexes() + if not indexes: + return None + text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text + if text == "": + return session.current + if text in ("!", "{last}"): + return session.last + if text in ("^", "{start}"): + return indexes[0] + if text in ("$", "{end}"): + return indexes[-1] + if text == "{next}": + text = "+" + if text == "{previous}": + text = "-" + if text[0] in "+-": + step = int(text[1:]) if text[1:].isdigit() else 1 + here = indexes.index(session.current) if session.current in indexes else 0 + return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)] + if text.isdigit(): + return int(text) + for index in indexes: + if session.windows[index].name == text: + return index + matches = [index for index in indexes if session.windows[index].name.startswith(text)] + if len(matches) == 1: + return matches[0] + import fnmatch + matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)] + return matches[0] if len(matches) == 1 else None + + @classmethod + def pane(cls, ctx, spec): + """Returns (session, window, pane).""" + server = ctx.server + spec = spec or "" + if spec.startswith("%") and spec[1:].isdigit(): + pane = server.panes.get(int(spec[1:])) + if pane is None or pane.window is None: + raise MplexError("can't find pane: %s" % spec) + return pane.window.session, pane.window, pane + if ":" in spec: + sess_part, _sep, rest = spec.partition(":") + if "." in rest: + win_part, _dot, pane_part = rest.rpartition(".") + else: + win_part, pane_part = rest, "" + ctx_for_window = sess_part + ":" + win_part + session, _index, window = cls.window(ctx, ctx_for_window) + elif "." in spec: + win_part, _dot, pane_part = spec.rpartition(".") + session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window) + else: + pane_part = spec + session, window = ctx.session, ctx.window + if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"): + try: + session, _index, window = cls.window(ctx, pane_part) + pane_part = "" + except MplexError: + raise MplexError("can't find pane: %s" % spec) + if window is None: + raise MplexError("no current window") + panes = window.panes() + if not pane_part: + return session, window, window.active + base = window.options.get("pane-base-index") + if pane_part.isdigit(): + index = int(pane_part) - base + if 0 <= index < len(panes): + return session, window, panes[index] + raise MplexError("can't find pane: %s" % pane_part) + here = panes.index(window.active) if window.active in panes else 0 + if pane_part in ("!", "{last}"): + if window.last_pane in panes: + return session, window, window.last_pane + raise MplexError("can't find pane: %s" % pane_part) + if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"): + sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+" + amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1 + return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)] + edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height, + "{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width, + "{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width, + "{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0, + "{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width} + if pane_part in edges: + for pane in panes: + if edges[pane_part](pane): + return session, window, pane + raise MplexError("can't find pane: %s" % pane_part) + + +class MplexClientConn: + """Server-side state of one connection: framing, key table position, prompt, overlay, last frame.""" + + def __init__(self, server, sock, ident): + self.server = server + self.sock = sock + self.id = ident + self.inbuf = bytearray() + self.out = bytearray() + self.rows = 24 + self.cols = 80 + self.session = None + self.attached = False + self.table = "root" + self.repeating = False + self.repeat_until = 0.0 + self.keybuf = b"" + self.esc_deadline = None + self.prompt = None + self.message = None + self.overlay = None + self.prev = None + self.cursor = None + self.dirty = True + self.last_render = 0.0 + self.pid = 0 + self.tty = "" + self.term = "" + self.cwd = os.getcwd() + self.env = {} + self.env_pane = None + self.closing = False + self.created = time.time() + + def send(self, kind, payload=b""): + if len(self.out) > MPLEX_CLIENT_OUT_MAX: + self.prev = None + return False + self.out += kind + struct.pack(">I", len(payload)) + payload + return True + + def frames(self, data): + """Complete (kind, payload) frames in what arrived; None when the stream is corrupt.""" + self.inbuf += data + found = [] + while len(self.inbuf) >= 5: + length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0] + if length > MPLEX_FRAME_MAX: + return None + if len(self.inbuf) < 5 + length: + break + found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length]))) + del self.inbuf[:5 + length] + return found + + def redispatch(self, name, raw): + self.server.handle_key(self, name, raw) + + def flush(self): + if not self.out: + return + try: + sent = self.sock.send(bytes(self.out[:65536])) + del self.out[:sent] + except (BlockingIOError, InterruptedError): + pass + except OSError: + self.out.clear() + self.closing = True + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +class MplexServer: + """Owns every session, window, pane and client. One select loop, no threads.""" + + def __init__(self, home): + self.home = home + self.dir = plexer_dir(home) + self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME) + self.config = MplexConfig.load(MplexConfig.default_path(home)) + self.options = self.config.options + self.sessions = {} + self.windows = {} + self.panes = {} + self.clients = [] + self.conns = [] + self.buffers = [] + self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX) + self.prompt_history = {} + self.timers = [] + self.reaping = [] + self.marked = None + self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0} + self.had_session = False + self.running = False + self.listener = None + self.renderer = MplexRenderer(self) + self.commands = MplexCommands(self) + self.shell_cache = {} + self.started = time.monotonic() + self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") + self.next_tick = 0.0 + self.notice = None + for problem in self.config.errors: + self.log_message("mplex.toml: " + problem) + if self.config.skipped: + self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped + + # ---- bookkeeping ----------------------------------------------------- + + def next_id(self, kind): + value = self.ids[kind] + self.ids[kind] += 1 + return value + + def log_message(self, text): + self.messages.append((time.time(), text)) + + def latest_session(self): + if not self.sessions: + return None + return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id)) + + def attached_clients(self, session=None): + return [c for c in self.clients if c.attached and (session is None or c.session is session)] + + def pane_options(self, pane): + return pane.window.options if pane.window is not None else self.options + + def message(self, client, text, milliseconds=None): + self.log_message(text) + if client is None: + return + if milliseconds is None: + milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time") + client.message = (text, time.monotonic() + milliseconds / 1000.0) + client.dirty = True + self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True)) + + def mark_dirty(self, window=None): + for client in self.clients: + if client.attached and (window is None or client.session.window() is window): + client.dirty = True + + def mark_all_dirty(self): + for client in self.clients: + client.dirty = True + + def add_timer(self, deadline, callback): + if len(self.timers) < 1024: + self.timers.append((deadline, callback)) + + # ---- buffers and copy ------------------------------------------------ + + def add_buffer(self, text, name=None): + if name is None: + name = "buffer%d" % self.next_id("buffer") + self.buffers = [item for item in self.buffers if item["name"] != name] + self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]}) + del self.buffers[self.options.get("buffer-limit"):] + return name + + def top_buffer_text(self): + return self.buffers[0]["data"] if self.buffers else "" + + def find_buffer(self, name): + if not name: + if not self.buffers: + raise MplexError("no buffers") + return self.buffers[0] + for item in self.buffers: + if item["name"] == name: + return item + raise MplexError("unknown buffer: %s" % name) + + def run_shell_input(self, command, text): + try: + done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT) + if done.returncode != 0: + raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200]) + except (OSError, subprocess.SubprocessError) as exc: + raise MplexError("copy command failed: " + short_error(exc)) + + def store_copy(self, text, command="", append=False): + if append and self.buffers: + self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX] + text = self.buffers[0]["data"] + else: + self.add_buffer(text) + pipe = command or self.options.get("copy-command") + if pipe: + self.run_shell_input(pipe, text) + if self.options.get("set-clipboard") != "off": + sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07" + for client in self.attached_clients(): + client.send(b"O", sequence.encode("ascii")) + + # ---- sizing ---------------------------------------------------------- + + def resize_session(self, session): + sizes = [(c.cols, c.rows) for c in self.attached_clients(session)] + if sizes: + session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes)) + elif not hasattr(session, "size"): + session.size = (80, 24) + cols, rows = session.size + for window in session.windows.values(): + window.width = max(1, cols) + window.height = max(1, rows - (1 if window.options.get("status") else 0)) + window.relayout() + self.mark_dirty() + + def resize_all(self): + for session in self.sessions.values(): + self.resize_session(session) + + # ---- model: panes, windows, sessions --------------------------------- + + def default_shell(self, options): + return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh" + + def resolve_term(self, name): + wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"] + roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"] + for term in wanted: + for root in roots: + if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))): + return term + return "xterm" + + def spawn_pane(self, window, width, height, words, cwd, env_extra=None): + if len(self.panes) >= MPLEX_MAX_PANES: + raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES) + session = window.session + options = window.options + pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp) + shell = self.default_shell(options) + if words: + file = shell if len(words) == 1 else words[0] + argv = [shell, "-c", words[0]] if len(words) == 1 else list(words) + elif options.get("default-command"): + file, argv = shell, [shell, "-c", options.get("default-command")] + else: + file, argv = shell, ["-" + os.path.basename(shell)] + env = dict(session.env) + env.pop("LINES", None) + env.pop("COLUMNS", None) + env["TERM"] = self.resolve_term(options.get("default-terminal")) + env[PLEXER_ACTIVE_ENV] = "1" + env[PLEXER_SOCKET_ENV] = self.sock_path + env[PLEXER_PANE_ENV] = str(pane.id) + env.update(env_extra or {}) + pane.window = window + pane.spawn(file, argv, cwd or session.cwd, env) + self.panes[pane.id] = pane + return pane + + def register_window(self, session, window, index, select): + window.session = session + window.options.parent = session.options + session.windows[index] = window + self.windows[window.id] = window + if select or session.current is None: + session.select(index) + + def create_window(self, session, name, cwd, words, env_extra, index, select): + if len(self.windows) >= MPLEX_MAX_WINDOWS: + raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS) + cols, rows = getattr(session, "size", (80, 24)) + window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options) + window.session = session + pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra) + window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height) + window.active = pane + pane.last_active = time.monotonic() + window.auto_rename = not name + if not name: + window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0]) + self.register_window(session, window, index, select) + window.relayout() + self.mark_dirty() + return window + + def create_session(self, name, cwd, env, words, window_name, size, index_hint=None): + if len(self.sessions) >= MPLEX_MAX_SESSIONS: + raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS) + session = MplexSession(self.next_id("session"), name, self.options, cwd, env) + session.size = size + self.sessions[session.id] = session + self.had_session = True + try: + self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True) + except Exception: + self.sessions.pop(session.id, None) + raise + return session + + def auto_session_name(self): + taken = {s.name for s in self.sessions.values()} + number = 0 + while str(number) in taken: + number += 1 + return str(number) + + def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select): + leaf = window.leaf_of(target) + if leaf is None: + raise MplexError("pane is not in this window") + if window.zoomed is not None: + window.zoomed = None + window.relayout() + leaf = window.leaf_of(target) + span = leaf.w if kind == "lr" else leaf.h + if span < 3: + raise MplexError("no space for new pane") + pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra) + try: + window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size) + except MplexError: + self.discard_pane(pane) + raise + window.layout_index = -1 + window.relayout() + if select: + window.set_active(pane) + self.mark_dirty(window) + return pane + + def discard_pane(self, pane): + pane.close() + self.panes.pop(pane.id, None) + if pane.pid is not None: + self.reaping.append((pane.pid, time.monotonic())) + pane.pid = None + + def select_pane(self, window, pane): + if window.zoomed is not None and pane is not window.zoomed: + window.zoomed = None + window.relayout() + window.set_active(pane) + self.mark_dirty(window) + + def kill_pane(self, pane): + window = pane.window + if window is None: + return + if len(window.panes()) == 1: + self.kill_window(window) + return + was_active = window.active is pane + window.root = MplexLayout.remove(window.root, window.leaf_of(pane)) + if window.zoomed is pane: + window.zoomed = None + if self.marked is pane: + self.marked = None + self.discard_pane(pane) + rest = window.panes() + if window.last_pane is pane: + window.last_pane = None + if was_active: + window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active) + window.last_pane = None + window.layout_index = -1 + window.relayout() + self.mark_dirty(window) + + def kill_window(self, window): + session = window.session + index = session.index_of(window) + for pane in window.panes(): + if self.marked is pane: + self.marked = None + self.discard_pane(pane) + self.windows.pop(window.id, None) + if index is not None: + session.windows.pop(index, None) + if not session.windows: + self.kill_session(session) + return + session.history = [item for item in session.history if item is not window] + if session.current == index: + nxt = session.last + if nxt is None: + lower = [i for i in session.indexes() if i < index] + nxt = lower[-1] if lower else session.indexes()[-1] + session.current = nxt + session.history = [item for item in session.history if item is not session.windows[nxt]] + if session.options.get("renumber-windows"): + self.renumber(session) + self.mark_dirty() + + def renumber(self, session): + base = self.options.get("base-index") + current = session.window() + ordered = [session.windows[i] for i in session.indexes()] + session.windows = {base + n: window for n, window in enumerate(ordered)} + session.current = session.index_of(current) if current else None + + def kill_session(self, session): + for window in list(session.windows.values()): + for pane in window.panes(): + if self.marked is pane: + self.marked = None + self.discard_pane(pane) + self.windows.pop(window.id, None) + session.windows.clear() + self.sessions.pop(session.id, None) + for client in list(self.attached_clients(session)): + others = [s for s in self.sessions.values()] + if others and not self.options.get("detach-on-destroy"): + self.attach_client(client, self.latest_session()) + else: + self.detach_client(client, "[exited]", b"X") + self.mark_dirty() + + def attach_client(self, client, session): + old = client.session + client.session = session + session.last_attached = time.time() + client.table = "root" + client.prompt = None + client.overlay = None + client.prev = None + if not client.attached: + client.attached = True + client.send(b"A") + if old is not None and old is not session: + self.resize_session(old) + self.resize_session(session) + client.dirty = True + if self.notice: + self.message(client, self.notice, 4000) + self.notice = None + + def detach_client(self, client, text="", kind=b"D"): + old = client.session + client.send(kind, text.encode("utf-8")) + client.attached = False + client.session = None + client.closing = True + if old is not None and old.id in self.sessions: + self.resize_session(old) + + # ---- formats --------------------------------------------------------- + + def window_flags(self, session, window): + flags = "" + index = session.index_of(window) + if index == session.current: + flags += "*" + elif index == session.last: + flags += "-" + if window.zoomed is not None: + flags += "Z" + if self.marked is not None and self.marked.window is window: + flags += "M" + return flags + + def resolver_for(self, session, window, pane, client=None): + server = self + + def get(name): + if name.startswith("("): + return server.shell_output(name[1:]) + return server.variable(name, session, window, pane, client) + + return get + + def shell_output(self, command): + now = time.monotonic() + cached = self.shell_cache.get(command) + interval = max(1, self.options.get("status-interval")) + if cached and now - cached[0] < interval: + return cached[1] + try: + done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT) + text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0] + except (OSError, subprocess.SubprocessError): + text = "" + if len(self.shell_cache) > 64: + self.shell_cache.clear() + self.shell_cache[command] = (now, text) + return text + + def variable(self, name, session, window, pane, client): + if name in MplexOptions.DEFS: + scope = window.options if window is not None else (session.options if session is not None else self.options) + value = scope.get(name) + if isinstance(value, bool): + return "1" if value else "0" + return str(value) + if name == "host": + return socket.gethostname() + if name == "host_short": + return socket.gethostname().split(".")[0] + if name == "version": + return VERSION + if session is not None: + if name == "session_name": + return session.name + if name == "session_id": + return session.tag + if name == "session_windows": + return str(len(session.windows)) + if name == "session_created": + return str(int(session.created)) + if name == "session_attached": + return str(len(self.attached_clients(session))) + if window is not None and session is not None: + index = session.index_of(window) + if name == "window_index": + return str(index) + if name == "window_name": + return window.name + if name == "window_id": + return window.tag + if name == "window_flags": + return self.window_flags(session, window) + if name == "window_active": + return "1" if index == session.current else "0" + if name == "window_last_flag": + return "1" if index == session.last else "0" + if name == "window_panes": + return str(len(window.panes())) + if name == "window_zoomed_flag": + return "1" if window.zoomed is not None else "0" + if name == "window_width": + return str(window.width) + if name == "window_height": + return str(window.height) + if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"): + return "0" + if name == "window_marked_flag": + return "1" if self.marked is not None and self.marked.window is window else "0" + if pane is not None: + mode = pane.top_mode() + screen = pane.screen + if name == "pane_id": + return pane.tag + if name == "pane_index": + return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0" + if name == "pane_title": + return pane.title + if name == "pane_current_command": + return pane.foreground_command() + if name == "pane_current_path": + return pane.current_path() + if name == "pane_pid": + return str(pane.pid or "") + if name == "pane_width": + return str(pane.w) + if name == "pane_height": + return str(pane.h) + if name == "pane_active": + return "1" if pane.window is not None and pane.window.active is pane else "0" + if name == "pane_last": + return "1" if pane.window is not None and pane.window.last_pane is pane else "0" + if name == "pane_in_mode": + return "1" if mode is not None else "0" + if name == "pane_mode": + return mode.name if mode is not None else "" + if name == "pane_dead": + return "1" if pane.dead else "0" + if name == "pane_dead_status": + return "" if pane.exit_status is None else str(pane.exit_status) + if name == "pane_left": + return str(pane.x) + if name == "pane_top": + return str(pane.y) + if name == "pane_right": + return str(pane.x + pane.w - 1) + if name == "pane_bottom": + return str(pane.y + pane.h - 1) + if name == "pane_synchronized": + return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0" + if name == "pane_marked": + return "1" if self.marked is pane else "0" + if name == "pane_marked_set": + return "1" if self.marked is not None else "0" + if name == "alternate_on": + return "1" if screen.alt_active else "0" + if name == "history_size": + return str(pane.history_size()) + if name == "history_limit": + return str(screen.history.maxlen if screen.history is not None else 0) + if name == "cursor_x": + return str(screen.cx) + if name == "cursor_y": + return str(screen.cy) + if name == "pane_search_string": + return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else "" + if isinstance(mode, MplexCopyMode): + if name == "scroll_position": + return str(mode.oy) + if name == "copy_cursor_x": + return str(mode.cx) + if name == "copy_cursor_y": + return str(mode.cy) + if name == "selection_present": + return "1" if mode.anchor is not None else "0" + if name == "copy_cursor_line": + return mode.text(mode.abs_y()) + if name == "copy_cursor_word": + line = mode.text(mode.abs_y()) + if not line: + return "" + x = min(mode.cx, len(line) - 1) + seps = pane.window.options.get("word-separators") + " " + start = end = x + while start > 0 and line[start - 1] not in seps: + start -= 1 + while end + 1 < len(line) and line[end + 1] not in seps: + end += 1 + return line[start:end + 1] + if name == "scroll_position": + return "" + if client is not None: + if name == "client_width": + return str(client.cols) + if name == "client_height": + return str(client.rows) + if name == "client_session": + return client.session.name if client.session else "" + if name == "client_tty": + return client.tty + if name == "client_termname": + return client.term + if name == "client_pid": + return str(client.pid) + if name == "client_key_table": + return client.table + if name == "client_prefix": + return "1" if client.table == "prefix" else "0" + if name == "client_created": + return str(int(client.created)) + return None + + def expand(self, text, ctx, extra=None): + """Format-expand text for a command context (strftime applied).""" + base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client) + if extra: + def resolve(name): + return extra[name] if name in extra else base(name) + else: + resolve = base + return MplexFormat.with_time(MplexFormat.expand(text, resolve)) + + # ---- key handling ---------------------------------------------------- + + def is_prefix(self, session, name): + opts = session.options + return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None" + + def input_bytes(self, client, data): + client.keybuf = (client.keybuf + data)[-65536:] + self.process_keys(client, False) + + def process_keys(self, client, final): + tokens, rest = MplexKeys.split(client.keybuf, final) + client.keybuf = rest + for name, raw in tokens: + if not client.attached: + break + self.handle_key(client, name, raw) + if client.keybuf and client.attached: + client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0 + else: + client.esc_deadline = None + + def handle_key(self, client, name, raw): + if not client.attached: + return + client.dirty = True + if client.prompt is not None: + client.prompt.key(self, client, name, raw) + return + if client.overlay is not None: + client.overlay.key(name, raw, client) + return + client.message = None + session = client.session + window = session.window() + pane = window.active + if not name: + self.forward(pane, window, raw) + return + table = client.table + if table == "root": + binding = self.config.lookup("root", name) + if binding is not None: + self.run_binding(client, binding, pane) + return + if self.is_prefix(session, name): + client.table = "prefix" + client.repeating = False + return + mode = pane.top_mode() + if mode is not None: + mode.key(name, raw, client) + return + self.forward(pane, window, raw) + return + binding = self.config.lookup(table, name) + if client.repeating and not (binding is not None and binding.repeat): + client.table = "root" + client.repeating = False + self.handle_key(client, name, raw) + return + client.table = "root" + client.repeating = False + if binding is not None: + self.run_binding(client, binding, pane) + if binding.repeat and client.attached and client.table == "root": + client.table = table + client.repeating = True + client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0 + + def forward(self, pane, window, raw): + data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw + targets = window.panes() if window.options.get("synchronize-panes") else [pane] + for target in targets: + if not target.dead: + target.write(data) + target.input_bytes += len(data) + + def run_binding(self, client, binding, pane=None): + self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command) + + def run_checked(self, ctx, text): + try: + output = self.run_line(ctx, text) + if output: + self.show_output(ctx, output) + except MplexError as exc: + self.message(ctx.client, str(exc)) + except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down + import traceback + sys.stderr.write(traceback.format_exc()) + self.message(ctx.client, "internal error: " + short_error(exc)) + + def show_output(self, ctx, text): + pane = ctx.pane + lines = text.rstrip("\n").split("\n") + if pane is None or (len(lines) == 1 and len(lines[0]) < 200): + self.message(ctx.client, lines[0] if lines else "") + return + old = pane.find_mode(MplexCopyMode) + if old is not None and old.static_rows is not None: + old.close() + pane.push_mode(MplexCopyMode(self, pane, lines=lines)) + self.mark_dirty(pane.window) + + def ask_confirm(self, client, text, callback): + def done(value): + callback() + client.prompt = MplexPrompt(text + " ", "", done, confirm=True) + client.dirty = True + + def refresh_choosers(self): + for pane in list(self.panes.values()): + for mode in list(pane.modes): + if isinstance(mode, MplexChooser): + mode.rebuild() + self.mark_dirty() + + # ---- command execution ----------------------------------------------- + + def run_line(self, ctx, text, depth=0): + return self.run_commands(ctx, MplexCommandParser.parse(text), depth) + + def run_commands(self, ctx, commands, depth=0): + if depth > MPLEX_COMMAND_DEPTH_MAX: + raise MplexError("commands nested too deeply") + flat = [] + for words in commands: + piece = [] + for word in words: + if word == ";": + if piece: + flat.append(piece) + piece = [] + else: + piece.append(word) + if piece: + flat.append(piece) + output = [] + for words in flat: + text = self.run_one(ctx, words, depth) + if text: + output.append(text) + return "\n".join(output) + + def run_words(self, ctx, words): + return self.run_commands(ctx, [words], 0) + + def run_one(self, ctx, words, depth): + name = MplexCommands.resolve(words[0]) + aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] + args = MplexArgs.parse(name, spec, words[1:], low, high) + ctx.depth = depth + handler = getattr(self.commands, "cmd_" + name.replace("-", "_")) + return handler(ctx, args) or "" + + # ---- sockets --------------------------------------------------------- + + def open_listener(self): + lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + os.close(lock_fd) + return None + self.lock_fd = lock_fd + try: + os.unlink(self.sock_path) + except OSError: + pass + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self.sock_path) + os.chmod(self.sock_path, 0o600) + listener.listen(16) + listener.setblocking(False) + return listener + + def accept(self): + try: + sock, _addr = self.listener.accept() + except OSError: + return + sock.setblocking(False) + if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2: + sock.close() + return + self.conns.append(MplexPending(sock)) + + def pending_read(self, pending): + try: + data = pending.sock.recv(65536) + except (BlockingIOError, InterruptedError): + return + except OSError: + data = b"" + if not data: + self.drop_pending(pending) + return + pending.buf += data + if pending.buf[:1] == MPLEX_MAGIC: + if len(self.clients) >= MPLEX_MAX_CLIENTS: + self.drop_pending(pending) + return + self.conns.remove(pending) + client = MplexClientConn(self, pending.sock, self.next_id("client")) + self.clients.append(client) + self.client_data(client, bytes(pending.buf[1:])) + return + if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX: + self.control(pending) + + def drop_pending(self, pending): + if pending in self.conns: + self.conns.remove(pending) + try: + pending.sock.close() + except OSError: + pass + + def control(self, pending): + """Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController).""" + try: + request = json.loads(bytes(pending.buf).decode("utf-8", "replace")) + if not isinstance(request, dict): + raise ValueError("request must be a JSON object") + reply = self.dispatch_control(request) + except Exception as exc: # noqa: BLE001 -- one bad request must not end the session + reply = {"ok": False, "error": short_error(exc)} + try: + pending.sock.setblocking(True) + pending.sock.settimeout(2) + pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8")) + except OSError: + pass + self.drop_pending(pending) + + def control_pane(self, request): + ident = request.get("pane") + if ident not in (None, ""): + pane = self.panes.get(int(str(ident).lstrip("%"))) + if pane is None: + raise ValueError("no such pane: %s" % ident) + return pane + session = self.latest_session() + if session is None or session.window() is None: + raise ValueError("no session") + return session.window().active + + def dispatch_control(self, request): + cmd = request.get("cmd") + if cmd == "send": + pane = self.control_pane(request) + text = str(request.get("text") or "") + pane.write(text.encode("utf-8", "replace")) + if request.get("enter"): + pane.write(b"\r") + return {"ok": True, "data": True} + if cmd == "read": + pane = self.control_pane(request) + return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))} + if cmd == "status": + pane = self.control_pane(request) + window = pane.window + return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path, + "pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}} + return {"ok": False, "error": "unknown command %r" % cmd} + + def client_read(self, client): + try: + data = client.sock.recv(65536) + except (BlockingIOError, InterruptedError): + return + except OSError: + data = b"" + if not data: + self.drop_client(client) + return + self.client_data(client, data) + + def client_data(self, client, data): + frames = client.frames(data) + if frames is None: + self.drop_client(client) + return + for kind, payload in frames: + if kind == b"H": + self.on_hello(client, payload) + elif kind == b"I" and client.attached: + self.input_bytes(client, payload) + elif kind == b"R": + try: + size = json.loads(payload.decode("utf-8", "replace")) + client.rows = max(2, min(1000, int(size["rows"]))) + client.cols = max(2, min(2000, int(size["cols"]))) + except (ValueError, KeyError, TypeError): + continue + client.prev = None + if client.attached: + self.resize_session(client.session) + client.dirty = True + + def on_hello(self, client, payload): + try: + info = json.loads(payload.decode("utf-8", "replace")) + if not isinstance(info, dict): + raise ValueError("hello must be an object") + client.rows = max(2, min(1000, int(info.get("rows") or 24))) + client.cols = max(2, min(2000, int(info.get("cols") or 80))) + client.term = str(info.get("term") or "")[:64] + client.tty = str(info.get("tty") or "")[:128] + client.cwd = str(info.get("cwd") or "/")[:4096] + client.pid = int(info.get("pid") or 0) + env = info.get("env") or {} + client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {} + pane = info.get("pane") + client.env_pane = int(pane) if pane not in (None, "") else None + commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item] + except (ValueError, TypeError) as exc: + client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8")) + client.closing = True + return + ctx = MplexCtx(self, client=client) + try: + output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text) + except MplexError as exc: + client.send(b"E", str(exc).encode("utf-8")) + client.closing = True + return + except Exception as exc: # noqa: BLE001 + import traceback + sys.stderr.write(traceback.format_exc()) + client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8")) + client.closing = True + return + if not client.attached: + if output: + client.send(b"M", output.encode("utf-8")) + client.send(b"X", b"") + client.closing = True + + def drop_client(self, client): + if client in self.clients: + self.clients.remove(client) + session = client.session + client.attached = False + client.session = None + client.close() + if session is not None and session.id in self.sessions: + self.resize_session(session) + + # ---- main loop ------------------------------------------------------- + + def serve(self): + self.listener = self.open_listener() + if self.listener is None: + return 1 + signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False)) + signal.signal(signal.SIGHUP, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + self.running = True + try: + self.loop() + finally: + self.shutdown() + return 0 + + def stop(self): + self.running = False + + def loop(self): + """Run until stopped. One failing iteration is logged and survived, because it would otherwise + end every pane; MPLEX_LOOP_ERRORS_MAX failures in a row end the server.""" + failures = 0 + while self.running: + try: + self.iterate() + failures = 0 + except Exception: # noqa: BLE001 -- see docstring + import traceback + sys.stderr.write(traceback.format_exc()) + sys.stderr.flush() + failures += 1 + if failures >= MPLEX_LOOP_ERRORS_MAX: + sys.stderr.write("mplex server: %d failed iterations in a row, exiting\n" % failures) + self.running = False + time.sleep(0.05) + + def iterate(self): + now = time.monotonic() + self.run_deadlines(now) + fd_map = {self.listener.fileno(): ("listen", None)} + for pending in self.conns: + fd_map[pending.sock.fileno()] = ("pending", pending) + for client in self.clients: + fd_map[client.sock.fileno()] = ("client", client) + for pane in self.panes.values(): + if pane.master_fd is not None: + fd_map[pane.master_fd] = ("pane", pane) + writers = {} + for client in self.clients: + if client.out: + writers[client.sock.fileno()] = client + for pane in self.panes.values(): + if pane.pending_in and pane.master_fd is not None: + writers[pane.master_fd] = pane + try: + ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now)) + except InterruptedError: + return + except (OSError, ValueError): + self.sweep_bad_fds() + return + for fd in ready: + kind, obj = fd_map[fd] + if kind == "listen": + self.accept() + elif kind == "pending": + self.pending_read(obj) + elif kind == "client": + self.client_read(obj) + elif kind == "pane": + self.pane_read(obj) + for fd in can_write: + target = writers[fd] + if isinstance(target, MplexPane): + target.drain() + else: + target.flush() + now = time.monotonic() + self.reap_children(now) + self.render_clients(now) + self.close_finished() + if not self.sessions and (self.had_session or now - self.started > MPLEX_IDLE_EXIT) and not self.clients_busy(): + self.running = False + + def clients_busy(self): + return any(client.out for client in self.clients) + + def sweep_bad_fds(self): + for pending in list(self.conns): + if pending.sock.fileno() < 0: + self.conns.remove(pending) + for client in list(self.clients): + if client.sock.fileno() < 0: + self.drop_client(client) + for pane in list(self.panes.values()): + if pane.master_fd is not None: + try: + os.fstat(pane.master_fd) + except OSError: + self.pane_exited(pane) + + def wakeup(self, now): + due = [now + 1.0] + due.extend(deadline for deadline, _cb in self.timers) + for client in self.clients: + if client.esc_deadline: + due.append(client.esc_deadline) + if client.repeating: + due.append(client.repeat_until) + if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4: + due.append(client.last_render + MPLEX_RENDER_INTERVAL) + if client.overlay is not None and hasattr(client.overlay, "until"): + due.append(client.overlay.until) + return max(0.0, min(due) - now) + + def run_deadlines(self, now): + if self.timers: + ripe = [item for item in self.timers if item[0] <= now] + self.timers = [item for item in self.timers if item[0] > now] + for _deadline, callback in ripe: + callback() + for client in list(self.clients): + if client.esc_deadline and now >= client.esc_deadline and client.attached: + client.esc_deadline = None + self.process_keys(client, True) + if client.repeating and now >= client.repeat_until: + client.repeating = False + client.table = "root" + client.dirty = True + overlay = client.overlay + if overlay is not None and hasattr(overlay, "until") and now >= overlay.until: + client.overlay = None + client.dirty = True + if now >= self.next_tick: + self.next_tick = now + 1.0 + self.tick(now) + for pending in list(self.conns): + if now - pending.created > 5.0: + self.drop_pending(pending) + + def tick(self, now): + for window in list(self.windows.values()): + if window.auto_rename and window.active is not None and window.session is not None: + template = window.options.get("automatic-rename-format") + if window.options.get("automatic-rename"): + resolver = self.resolver_for(window.session, window, window.active) + name = MplexFormat.expand(template, resolver) + if name and name != window.name: + window.name = name[:64] + self.mark_all_dirty() + for pane in window.panes(): + if isinstance(pane.top_mode(), MplexClockMode): + self.mark_dirty(window) + interval = self.options.get("status-interval") + if interval and int(now) % interval == 0: + self.mark_all_dirty() + + def pane_read(self, pane): + try: + data = os.read(pane.master_fd, 65536) + except (BlockingIOError, InterruptedError): + return + except OSError: + data = b"" + if not data: + self.pane_exited(pane) + return + pane.feed(data) + self.mark_dirty(pane.window) + + def pane_exited(self, pane): + status = None + if pane.pid is not None: + try: + done, raw = os.waitpid(pane.pid, os.WNOHANG) + if done: + status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw + pane.pid = None + except OSError: + pane.pid = None + if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead: + pane.dead = True + pane.exit_status = status + pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8")) + if pane.master_fd is not None: + try: + os.close(pane.master_fd) + except OSError: + pass + pane.master_fd = None + self.mark_dirty(pane.window) + return + if pane.window is not None and pane.id in self.panes: + self.kill_pane(pane) + + def reap_children(self, now): + keep = [] + for pid, since in self.reaping: + try: + done, _status = os.waitpid(pid, os.WNOHANG) + except OSError: + continue + if done: + continue + if now - since > MPLEX_REAP_KILL_AFTER: + try: + os.killpg(pid, signal.SIGKILL) + except OSError: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + keep.append((pid, since)) + self.reaping = keep + + def render_clients(self, now): + for client in self.clients: + if not client.attached or not client.dirty: + continue + if now - client.last_render < MPLEX_RENDER_INTERVAL: + continue + if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4: + continue + canvas = self.renderer.frame(client) + data, snapshot = canvas.encode(client.prev) + client.last_render = now + client.dirty = False + if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor: + continue + client.prev = snapshot + client.cursor = canvas.cursor + client.send(b"O", data) + + def close_finished(self): + for client in list(self.clients): + if client.closing and not client.out: + self.drop_client(client) + + def shutdown(self): + for client in list(self.clients): + if client.attached: + client.send(b"X", b"[server exited]") + for _try in range(20): + if not client.out: + break + client.flush() + if client.out: + time.sleep(0.005) + client.close() + self.clients = [] + for pane in list(self.panes.values()): + pane.close(hard=True) + if pane.pid is not None: + try: + os.waitpid(pane.pid, os.WNOHANG) + except OSError: + pass + for item in (self.listener,): + try: + item.close() + except (OSError, AttributeError): + pass + try: + os.unlink(self.sock_path) + except OSError: + pass + + @staticmethod + def probe(path): + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(1) + try: + probe.connect(path) + return True + except OSError: + return False + finally: + probe.close() + + @classmethod + def ensure_running(cls, home, wait=5.0): + """Start the server as a detached daemon unless one already answers on its socket.""" + path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) + if cls.probe(path): + return True + pid = os.fork() + if pid == 0: + code = 1 + try: + os.setsid() + if os.fork() > 0: + os._exit(0) + devnull = os.open(os.devnull, os.O_RDWR) + log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + os.dup2(devnull, 0) + os.dup2(log, 1) + os.dup2(log, 2) + os.chdir("/") + code = cls(home).serve() + except BaseException: # noqa: BLE001 + import traceback + traceback.print_exc() + os._exit(code) + os.waitpid(pid, 0) + deadline = time.monotonic() + wait + while time.monotonic() < deadline: + if cls.probe(path): + return True + time.sleep(0.05) + return False + + +class MplexPending: + """A connection that has not yet said whether it speaks frames or one JSON line.""" + + def __init__(self, sock): + self.sock = sock + self.buf = bytearray() + self.created = time.monotonic() + + +# name: (aliases, getopt spec, min positional, max positional) +MPLEX_COMMAND_SPECS = { + "attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0), + "bind-key": (("bind",), "nrT:N:", 1, None), + "break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0), + "capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0), + "choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1), + "choose-client": ((), "NrZF:f:K:O:t:", 0, 1), + "choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1), + "clear-history": (("clearhist",), "Ht:", 0, 0), + "clock-mode": ((), "t:", 0, 0), + "command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1), + "confirm-before": (("confirm",), "byc:p:t:", 1, 1), + "copy-mode": ((), "deHMqSus:t:", 0, 0), + "delete-buffer": (("deleteb",), "b:", 0, 0), + "detach-client": (("detach",), "aPE:s:t:", 0, 0), + "display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1), + "display-panes": (("displayp",), "bNd:t:", 0, 1), + "find-window": (("findw",), "CiNrTZt:", 1, 1), + "has-session": (("has",), "t:", 0, 0), + "if-shell": (("if",), "bFt:", 2, 3), + "kill-pane": (("killp",), "at:", 0, 0), + "kill-server": ((), "", 0, 0), + "kill-session": ((), "aCt:", 0, 0), + "kill-window": (("killw",), "at:", 0, 0), + "last-pane": (("lastp",), "deZt:", 0, 0), + "last-window": (("last",), "t:", 0, 0), + "list-buffers": (("lsb",), "F:f:", 0, 0), + "list-clients": (("lsc",), "F:f:t:", 0, 0), + "list-commands": (("lscm",), "F:", 0, 1), + "list-keys": (("lsk",), "1aNP:T:", 0, 1), + "list-panes": (("lsp",), "asF:f:t:", 0, 0), + "list-sessions": (("ls",), "F:f:", 0, 0), + "list-windows": (("lsw",), "aF:f:t:", 0, 0), + "load-buffer": (("loadb",), "b:t:", 1, 1), + "move-window": (("movew",), "abdkrs:t:", 0, 0), + "new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None), + "new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None), + "next-layout": (("nextl",), "t:", 0, 0), + "next-window": (("next",), "at:", 0, 0), + "paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0), + "previous-layout": (("prevl",), "t:", 0, 0), + "previous-window": (("prev",), "at:", 0, 0), + "refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1), + "rename-session": (("rename",), "t:", 1, 1), + "rename-window": (("renamew",), "t:", 1, 1), + "resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1), + "rotate-window": (("rotatew",), "DUZt:", 0, 0), + "run-shell": (("run",), "bCEc:d:t:", 0, 1), + "save-buffer": (("saveb",), "ab:", 1, 1), + "select-layout": (("selectl",), "Enopt:", 0, 1), + "select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0), + "select-window": (("selectw",), "lnpTt:", 0, 0), + "send-keys": (("send",), "FHKlMRXc:N:t:", 0, None), + "send-prefix": ((), "2t:", 0, 0), + "set-buffer": (("setb",), "awb:n:t:", 0, 1), + "set-option": (("set",), "aFgopqsuUwt:", 1, 2), + "set-window-option": (("setw",), "aFgoqut:", 1, 2), + "show-buffer": (("showb",), "b:", 0, 0), + "show-messages": (("showmsgs",), "JTt:", 0, 0), + "show-options": (("show",), "AgHpqsvwt:", 0, 1), + "show-window-options": (("showw",), "gvt:", 0, 1), + "source-file": (("source",), "Fnqvt:", 1, None), + "split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None), + "start-server": (("start",), "", 0, 0), + "suspend-client": (("suspendc",), "t:", 0, 0), + "swap-pane": (("swapp",), "dDUZs:t:", 0, 0), + "swap-window": (("swapw",), "ds:t:", 0, 0), + "switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0), + "unbind-key": (("unbind",), "anqT:", 0, 1), +} + +MPLEX_DEFAULT_FORMATS = { + "list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}", + "list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}", + "list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}", + "list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]", + "list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"", + "display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)", +} + + +class MplexCommands: + """tmux's command set as methods named cmd_(ctx, args).""" + + def __init__(self, server): + self.s = server + + @staticmethod + def resolve(word): + if word in MPLEX_COMMAND_SPECS: + return word + for name, spec in MPLEX_COMMAND_SPECS.items(): + if word in spec[0]: + return name + if word in MPLEX_UNSUPPORTED: + raise MplexError("command not supported by mplex: %s" % word) + matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5]))) + raise MplexError("unknown command: %s" % word) + + # ---- helpers --------------------------------------------------------- + + def need_client(self, ctx): + if ctx.client is None or not ctx.client.attached: + raise MplexError("no attached client") + return ctx.client + + def pane_target(self, ctx, args, flag="t"): + return MplexTargets.pane(ctx, args.get(flag)) + + def window_target(self, ctx, args, flag="t"): + return MplexTargets.window(ctx, args.get(flag)) + + def session_target(self, ctx, args, flag="t"): + return MplexTargets.session(ctx, args.get(flag)) + + def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None): + base = self.s.resolver_for(session, window, pane, client) + if extra: + def resolve(name): + return extra[name] if name in extra else base(name) + else: + resolve = base + return MplexFormat.with_time(MplexFormat.expand(text, resolve)) + + def listing(self, command, args, rows): + template = args.get("F") or MPLEX_DEFAULT_FORMATS[command] + flt = args.get("f") + lines = [] + for session, window, pane, client, extra in rows: + if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)): + continue + lines.append(self.fmt(template, session, window, pane, client, extra)) + return "\n".join(lines) + + def substitute(self, template, answers): + out = [] + in_quote = False + pos = 0 + while pos < len(template): + char = template[pos] + if char == "\\" and pos + 1 < len(template): + out.append(template[pos:pos + 2]) + pos += 2 + continue + if char == '"': + in_quote = not in_quote + if char == "%" and pos + 1 < len(template): + nxt = template[pos + 1] + index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None) + if index is not None: + value = answers[index] if index < len(answers) else "" + if in_quote: + value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + out.append(value) + pos += 2 + continue + out.append(char) + pos += 1 + return "".join(out) + + def run_later(self, ctx, template, answers): + """Run template after a prompt finished, against the pane the prompt was opened for.""" + pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None + fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True) + fresh.depth = ctx.depth + 1 + self.s.run_checked(fresh, self.substitute(template, answers)) + + # ---- sessions and clients -------------------------------------------- + + def cmd_new_session(self, ctx, args): + s = self.s + name = args.get("s") or s.auto_session_name() + if ":" in name or "." in name: + raise MplexError("bad session name: %s" % name) + existing = next((item for item in s.sessions.values() if item.name == name), None) + if existing is not None and args.has("A"): + return self.attach(ctx, existing, args.has("D")) + if existing is not None: + raise MplexError("duplicate session: %s" % name) + client = ctx.client + cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd())) + env = dict(client.env) if client is not None and client.env else dict(os.environ) + for item in args.all("e"): + key, _eq, value = item.partition("=") + env[key] = value + size = (80, 24 + (1 if s.options.get("status") else 0)) + if args.get("x") or args.get("y"): + size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0)) + elif client is not None and not args.has("d"): + size = (client.cols, client.rows) + session = s.create_session(name, cwd, env, args.positional, args.get("n"), size) + if not args.has("d") and client is not None: + s.attach_client(client, session) + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active) + return "" + + def attach(self, ctx, session, detach_others): + client = self.need_client_for_attach(ctx) + if detach_others: + for other in list(self.s.attached_clients(session)): + if other is not client: + self.s.detach_client(other, "[detached (from session %s)]" % session.name) + self.s.attach_client(client, session) + return "" + + def need_client_for_attach(self, ctx): + if ctx.client is None: + raise MplexError("no client to attach") + return ctx.client + + def cmd_attach_session(self, ctx, args): + if args.get("t"): + session = MplexTargets.session(ctx, args.get("t")) + else: + session = self.s.latest_session() + if session is None: + raise MplexError("no sessions") + return self.attach(ctx, session, args.has("d")) + + def cmd_detach_client(self, ctx, args): + s = self.s + if args.get("s"): + session = MplexTargets.session(ctx, args.get("s")) + targets = list(s.attached_clients(session)) + else: + current = self.find_client(ctx, args.get("t")) + targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current] + for client in targets: + s.detach_client(client, "[detached (from session %s)]" % client.session.name) + return "" + + def find_client(self, ctx, spec): + if not spec: + return self.need_client(ctx) + for client in self.s.attached_clients(): + if spec in (client.tty, str(client.id), str(client.pid)): + return client + raise MplexError("can't find client: %s" % spec) + + def cmd_has_session(self, ctx, args): + MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None)) + return "" + + def cmd_kill_session(self, ctx, args): + target = self.session_target(ctx, args) + if args.has("a"): + for session in [item for item in self.s.sessions.values() if item is not target]: + self.s.kill_session(session) + else: + self.s.kill_session(target) + return "" + + def cmd_kill_server(self, ctx, args): + self.s.stop() + return "" + + def cmd_start_server(self, ctx, args): + return "" + + def cmd_list_sessions(self, ctx, args): + rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)] + return self.listing("list-sessions", args, rows) + + def cmd_list_windows(self, ctx, args): + sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] + rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()] + return self.listing("list-windows", args, rows) + + def cmd_list_panes(self, ctx, args): + s = self.s + if args.has("a") or args.has("s"): + sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] + windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()] + else: + sess, _index, window = self.window_target(ctx, args) + windows = [(sess, window)] + rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()] + return self.listing("list-panes", args, rows) + + def cmd_list_clients(self, ctx, args): + rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()] + return self.listing("list-clients", args, rows) + + def cmd_rename_session(self, ctx, args): + session = self.session_target(ctx, args) + name = args.positional[0] + if not name or ":" in name or "." in name: + raise MplexError("bad session name: %s" % name) + if any(item.name == name and item is not session for item in self.s.sessions.values()): + raise MplexError("duplicate session: %s" % name) + session.name = name + self.s.mark_all_dirty() + return "" + + def cmd_switch_client(self, ctx, args): + client = self.need_client(ctx) + s = self.s + if args.has("T"): + client.table = args.get("T") + client.repeating = False + return "" + ordered = sorted(s.sessions.values(), key=lambda x: x.id) + here = ordered.index(client.session) + window = None + if args.has("n"): + target = ordered[(here + 1) % len(ordered)] + elif args.has("p"): + target = ordered[(here - 1) % len(ordered)] + elif args.has("l"): + target = getattr(client, "last_session", None) + if target is None or target.id not in s.sessions: + raise MplexError("no last session") + elif args.get("t"): + spec = args.get("t") + if spec.startswith("%"): + target, window = MplexTargets.pane(ctx, spec)[:2] + elif ":" in spec or spec.startswith("@"): + target, _index, window = MplexTargets.window(ctx, spec) + else: + target = MplexTargets.session(ctx, spec) + else: + raise MplexError("switch-client needs -n, -p, -l, -t or -T") + previous = client.session + s.attach_client(client, target) + if target is not previous: + client.last_session = previous + if window is not None: + target.select(target.index_of(window)) + return "" + + def cmd_suspend_client(self, ctx, args): + self.need_client(ctx).send(b"Z") + return "" + + def cmd_refresh_client(self, ctx, args): + client = self.need_client(ctx) + client.prev = None + client.dirty = True + return "" + + def cmd_list_commands(self, ctx, args): + lines = [] + for name in sorted(MPLEX_COMMAND_SPECS): + aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] + lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else "")) + return "\n".join(lines) + + # ---- windows --------------------------------------------------------- + + def cmd_new_window(self, ctx, args): + s = self.s + spec = args.get("t") + session = ctx.session + index = None + anchor = None + if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")): + has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None + if not has_window: + spec = spec + ":" + if spec and spec.endswith(":"): + session = MplexTargets.session(ctx, spec[:-1]) + elif spec and self.is_new_index(ctx, spec): + sess_part, _sep, idx_part = spec.rpartition(":") + session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session + index = int(idx_part.lstrip("=")) + elif spec: + session, index, anchor = MplexTargets.window(ctx, spec) + if session is None: + raise MplexError("no current session") + if index is None: + index = session.free_index(s.options.get("base-index")) + current = session.current + if (args.has("a") or args.has("b")) and current is not None: + index = current + 1 if args.has("a") else current + self.make_room(session, index) + elif anchor is not None: + if args.has("a"): + index += 1 + self.make_room(session, index) + elif args.has("b"): + self.make_room(session, index) + elif args.has("k"): + s.kill_window(anchor) + else: + raise MplexError("index in use: %d" % index) + cwd = os.path.expanduser(args.get("c") or session.cwd) + env_extra = dict(item.partition("=")[::2] for item in args.all("e")) + if args.has("S"): + for idx in session.indexes(): + if session.windows[idx].name == args.get("n"): + session.select(idx) + return "" + created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d")) + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active) + return "" + + def is_new_index(self, ctx, spec): + """-t names an index that does not exist yet (the usual new-window -t :N case).""" + if ":" not in spec: + return False + sess_part, _sep, idx_part = spec.rpartition(":") + idx_part = idx_part.lstrip("=") + if not idx_part.isdigit(): + return False + try: + session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session + except MplexError: + return False + return int(idx_part) not in session.windows + + def make_room(self, session, index): + if index not in session.windows: + return + current = session.window() + moved = {} + for idx in session.indexes(): + moved[idx + 1 if idx >= index else idx] = session.windows[idx] + session.windows = moved + session.current = session.index_of(current) if current else None + + def cmd_kill_window(self, ctx, args): + session, index, window = self.window_target(ctx, args) + if args.has("a"): + for other in [session.windows[i] for i in session.indexes() if i != index]: + self.s.kill_window(other) + else: + self.s.kill_window(window) + return "" + + def cmd_select_window(self, ctx, args): + if args.has("n") or args.has("p"): + return self.step_window(ctx, args, 1 if args.has("n") else -1) + if args.has("l"): + return self.cmd_last_window(ctx, args) + session, index, window = self.window_target(ctx, args) + if args.has("T") and index == session.current: + if session.last is None: + raise MplexError("no last window") + session.select(session.last) + self.s.mark_dirty() + return "" + session.select(index) + self.s.mark_dirty() + return "" + + def step_window(self, ctx, args, step): + session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]) + if args.has("a"): + raise MplexError("no %s window" % ("next" if step > 0 else "previous")) + indexes = session.indexes() + here = indexes.index(session.current) + session.select(indexes[(here + step) % len(indexes)]) + self.s.mark_dirty() + return "" + + def cmd_next_window(self, ctx, args): + return self.step_window(ctx, args, 1) + + def cmd_previous_window(self, ctx, args): + return self.step_window(ctx, args, -1) + + def cmd_last_window(self, ctx, args): + session = MplexTargets.session(ctx, args.get("t")) + if session.last not in session.windows: + raise MplexError("no last window") + session.select(session.last) + self.s.mark_dirty() + return "" + + def cmd_rename_window(self, ctx, args): + session, index, window = self.window_target(ctx, args) + if not args.positional[0]: + raise MplexError("empty window name") + window.name = args.positional[0][:64] + window.auto_rename = False + self.s.mark_all_dirty() + return "" + + def parse_window_destination(self, ctx, spec): + if ":" in spec: + sess_part, _sep, idx_part = spec.rpartition(":") + session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session + else: + session, idx_part = ctx.session, spec + idx_part = idx_part.lstrip("=") + if not idx_part.lstrip("-").isdigit(): + raise MplexError("bad window index: %s" % idx_part) + return session, int(idx_part) + + def cmd_move_window(self, ctx, args): + s = self.s + if args.has("r"): + s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0])) + s.mark_all_dirty() + return "" + src_session, src_index, window = MplexTargets.window(ctx, args.get("s")) + if not args.get("t"): + raise MplexError("move-window needs -t") + dst_session, idx = self.parse_window_destination(ctx, args.get("t")) + if args.has("a"): + idx += 1 + self.make_room(dst_session, idx) + elif args.has("b"): + self.make_room(dst_session, idx) + elif idx in dst_session.windows and dst_session.windows[idx] is not window: + if args.has("k"): + s.kill_window(dst_session.windows[idx]) + else: + raise MplexError("index in use: %d" % idx) + if dst_session is src_session and idx == src_index: + return "" + was_current = src_session.current == src_index + src_session.windows.pop(src_index) + src_session.history = [item for item in src_session.history if item is not window] + window.session = dst_session + window.options.parent = dst_session.options + dst_session.windows[idx] = window + if was_current and src_session.windows and src_session is not dst_session: + src_session.current = src_session.indexes()[0] + elif was_current and src_session is dst_session: + src_session.current = idx + if not args.has("d"): + dst_session.select(idx) + if not src_session.windows: + s.kill_session(src_session) + else: + s.resize_session(src_session) + s.resize_session(dst_session) + return "" + + def cmd_swap_window(self, ctx, args): + src_session, src_index, src = MplexTargets.window(ctx, args.get("s")) + dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t")) + was = (src_session.window(), dst_session.window()) + src_session.windows[src_index], dst_session.windows[dst_index] = dst, src + src.session, dst.session = dst_session, src_session + src.options.parent, dst.options.parent = dst_session.options, src_session.options + for session, current in ((src_session, was[0]), (dst_session, was[1])): + if current is not None: + session.current = session.index_of(current) + self.s.resize_session(src_session) + self.s.resize_session(dst_session) + return "" + + def cmd_find_window(self, ctx, args): + s = self.s + text = args.positional[0] + fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "") + fields = fields or "CNT" + if args.has("r"): + flags = re.IGNORECASE if args.has("i") else 0 + try: + matcher = re.compile(text, flags).search + except re.error as exc: + raise MplexError("bad regular expression: %s" % exc) + else: + import fnmatch + pattern = "*%s*" % text + low = pattern.lower() if args.has("i") else pattern + + def matcher(value): + return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low) + + found = [] + for session in sorted(s.sessions.values(), key=lambda x: x.id): + for index in session.indexes(): + window = session.windows[index] + hit = False + if "N" in fields and matcher(window.name): + hit = True + for pane in window.panes(): + if "T" in fields and matcher(pane.title): + hit = True + if "C" in fields and matcher(pane.screen.text()): + hit = True + if hit: + found.append((session, index, window)) + if not found: + raise MplexError("no windows matching: %s" % text) + if len(found) == 1: + session, index, _window = found[0] + client = ctx.client + if client is not None and client.attached and client.session is not session: + s.attach_client(client, session) + session.select(index) + s.mark_dirty() + return "" + items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found] + return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args) + + # ---- panes ----------------------------------------------------------- + + def size_arg(self, text, total): + text = text.strip() + if text.endswith("%"): + return max(1, total * int(text[:-1]) // 100) + return int(text) + + def cmd_split_window(self, ctx, args): + s = self.s + session, window, target = self.pane_target(ctx, args) + kind = "lr" if args.has("h") else "tb" + leaf = window.leaf_of(target) + size = None + if args.get("l"): + try: + size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h) + except ValueError: + raise MplexError("bad size: %s" % args.get("l")) + cwd = os.path.expanduser(args.get("c") or session.cwd) + env_extra = dict(item.partition("=")[::2] for item in args.all("e")) + pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d")) + if args.has("Z") and not args.has("d"): + window.zoomed = pane + window.relayout() + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane) + return "" + + def cmd_kill_pane(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + if args.has("a"): + for other in [item for item in window.panes() if item is not pane]: + self.s.kill_pane(other) + else: + self.s.kill_pane(pane) + return "" + + def cmd_select_pane(self, ctx, args): + s = self.s + if args.has("e") or args.has("d"): + raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex") + if args.has("M"): + s.marked = None + s.mark_all_dirty() + return "" + session, window, pane = self.pane_target(ctx, args) + if args.has("m"): + s.marked = None if s.marked is pane else pane + s.mark_all_dirty() + return "" + if args.get("T") is not None: + pane.screen.title = args.get("T")[:256] + s.mark_all_dirty() + return "" + new = pane + for flag in "UDLR": + if args.has(flag): + found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height) + if found is None: + raise MplexError("no pane in that direction") + new = found + if args.has("l"): + if window.last_pane not in window.panes(): + raise MplexError("no last pane") + new = window.last_pane + keep_zoom = args.has("Z") and window.zoomed is not None + zoomed = window.zoomed + s.select_pane(window, new) + if keep_zoom and zoomed is not None: + window.zoomed = new + window.relayout() + if session.window() is not window: + session.select(session.index_of(window)) + return "" + + def cmd_last_pane(self, ctx, args): + _session, _index, window = MplexTargets.window(ctx, args.get("t")) + if window.last_pane not in window.panes(): + raise MplexError("no last pane") + self.s.select_pane(window, window.last_pane) + return "" + + def cmd_resize_pane(self, ctx, args): + if args.has("M") or args.has("T"): + raise MplexError("resize-pane -M/-T is not supported by mplex") + session, window, pane = self.pane_target(ctx, args) + if args.has("Z"): + if window.zoomed is not None: + window.zoomed = None + elif len(window.panes()) > 1: + window.zoomed = pane + window.relayout() + self.s.mark_dirty(window) + return "" + leaf = window.leaf_of(pane) + amount = 1 + if args.positional: + try: + amount = int(args.positional[0]) + except ValueError: + raise MplexError("bad adjustment: %s" % args.positional[0]) + try: + if args.get("x"): + MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width)) + if args.get("y"): + MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height)) + if args.has("R"): + MplexLayout.resize_by(leaf, "lr", amount, True) + elif args.has("L"): + MplexLayout.resize_by(leaf, "lr", -amount, False) + elif args.has("D"): + MplexLayout.resize_by(leaf, "tb", amount, True) + elif args.has("U"): + MplexLayout.resize_by(leaf, "tb", -amount, False) + except MplexError: + pass + window.layout_index = -1 + window.relayout() + self.s.mark_dirty(window) + return "" + + def cmd_swap_pane(self, ctx, args): + s = self.s + if args.has("U") or args.has("D"): + _s, window, dst = self.pane_target(ctx, args) + panes = window.panes() + here = panes.index(dst) + src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)] + else: + _s, _w, dst = self.pane_target(ctx, args) + if args.get("s"): + _s2, _w2, src = MplexTargets.pane(ctx, args.get("s")) + elif s.marked is not None: + src = s.marked + else: + raise MplexError("no marked pane") + if src is dst: + return "" + wa, wb = src.window, dst.window + leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst) + leaf_a.pane, leaf_b.pane = dst, src + src.window, dst.window = wb, wa + if wa is not wb: + if wa.active is src: + wa.active = dst + if wb.active is dst: + wb.active = src + for window in (wa,) if wa is wb else (wa, wb): + window.relayout() + s.marked = None + s.mark_all_dirty() + return "" + + def cmd_rotate_window(self, ctx, args): + session, index, window = self.window_target(ctx, args) + panes = window.panes() + if len(panes) < 2: + return "" + order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1] + position = panes.index(window.active) + for leaf, pane in zip(window.root.leaves(), order): + leaf.pane = pane + window.active = order[position] + window.relayout() + s = self.s + s.mark_dirty(window) + return "" + + def cmd_break_pane(self, ctx, args): + s = self.s + session, window, pane = MplexTargets.pane(ctx, args.get("s")) + if len(window.panes()) < 2: + raise MplexError("can't break with only one pane") + dst_session = session + index = dst_session.free_index(s.options.get("base-index")) + if args.get("t"): + dst_session, index = self.parse_window_destination(ctx, args.get("t")) + if index in dst_session.windows: + raise MplexError("index in use: %d" % index) + leaf = window.leaf_of(pane) + was_active = window.active is pane + window.root = MplexLayout.remove(window.root, leaf) + if window.zoomed is pane: + window.zoomed = None + rest = window.panes() + if was_active or window.active is pane: + window.active = window.last_pane if window.last_pane in rest else rest[0] + if window.last_pane is pane: + window.last_pane = None + window.relayout() + cols, rows = getattr(dst_session, "size", (80, 24)) + fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options) + fresh.auto_rename = not args.get("n") + fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height) + fresh.active = pane + pane.window = fresh + s.register_window(dst_session, fresh, index, not args.has("d")) + fresh.relayout() + s.mark_all_dirty() + if args.has("P"): + return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane) + return "" + + def apply_layout(self, window, name, index): + panes = window.panes() + opts = window.options + window.zoomed = None + window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height")) + window.layout_index = index + window.relayout() + self.s.mark_dirty(window) + + def cmd_select_layout(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + names = MPLEX_LAYOUT_NAMES + if args.has("E"): + leaf = window.leaf_of(pane) + parent = leaf.parent + if parent is not None: + for child in parent.children: + MplexLayout.set_size(child, parent.kind, 1) + window.relayout() + self.s.mark_dirty(window) + return "" + if args.positional: + wanted = args.positional[0] + matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)] + if len(matches) != 1: + raise MplexError("unknown or ambiguous layout: %s" % wanted) + self.apply_layout(window, matches[0], names.index(matches[0])) + return "" + if args.has("p"): + index = (window.layout_index - 1) % len(names) + elif args.has("o"): + index = window.layout_index if window.layout_index >= 0 else 0 + else: + index = (window.layout_index + 1) % len(names) + self.apply_layout(window, names[index], index) + return "" + + def cmd_next_layout(self, ctx, args): + session, index, window = self.window_target(ctx, args) + self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)) + return "" + + def cmd_previous_layout(self, ctx, args): + session, index, window = self.window_target(ctx, args) + step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES) + self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step) + return "" + + def cmd_clear_history(self, ctx, args): + _s, _w, pane = self.pane_target(ctx, args) + pane.clear_history() + return "" + + def cmd_capture_pane(self, ctx, args): + _s, _w, pane = self.pane_target(ctx, args) + screen = pane.screen + if args.has("a") and not screen.alt_active: + if args.has("q"): + return "" + raise MplexError("no alternate screen") + history = list(screen.history or []) + rows = history + [list(row) for row in screen.grid] + base = len(history) + + def line_of(row): + text = "".join(cell[0] if cell else " " for cell in row) + return text if args.has("N") else text.rstrip() + + def bound(value, default): + if value is None: + return default + if value == "-": + return None + try: + return base + int(value) + except ValueError: + raise MplexError("bad line number: %s" % value) + + start = bound(args.get("S"), base) + end = bound(args.get("E"), base + pane.h - 1) + start = 0 if start is None else max(0, start) + end = len(rows) - 1 if end is None else min(len(rows) - 1, end) + text = "\n".join(line_of(rows[i]) for i in range(start, end + 1)) + if args.has("p"): + return text + self.s.add_buffer(text + "\n", args.get("b")) + return "" + + # ---- keys, prompts, input -------------------------------------------- + + def cmd_send_prefix(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + name = session.options.get("prefix2" if args.has("2") else "prefix") + if name == "None": + raise MplexError("no prefix key set") + self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor)) + return "" + + def cmd_send_keys(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + words = list(args.positional) + count = args.int("N", None) + if args.has("M"): + raise MplexError("send-keys -M (mouse) is not supported by mplex") + if args.has("X"): + mode = pane.top_mode() + if not isinstance(mode, MplexCopyMode): + raise MplexError("pane is not in a mode") + if not words: + raise MplexError("send-keys -X needs a command") + if len(words) > 1 and words[1] == "--": + del words[1] + if args.has("F"): + words = [self.fmt(item, session, window, pane, ctx.client) for item in words] + if count: + pane.prefix_count = count + mode.command(words[0], words[1:], ctx.client) + return "" + if args.has("R"): + pane.screen.reset() + if not words: + if count: + pane.prefix_count = count + return "" + if args.has("F"): + words = [self.fmt(item, session, window, pane, ctx.client) for item in words] + data = b"" + if args.has("l"): + data = "".join(words).encode("utf-8") + elif args.has("H"): + try: + data = bytes(int(item, 16) for item in words) + except ValueError: + raise MplexError("bad hex byte in send-keys -H") + else: + for word in words: + try: + data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor) + except MplexError: + data += word.encode("utf-8") + mode = pane.top_mode() + for _i in range(max(1, min(count or 1, 1000))): + if mode is not None and not args.has("l") and not args.has("H"): + tokens, _rest = MplexKeys.split(data, True) + for name, raw in tokens: + mode.key(name, raw, ctx.client) + elif not pane.dead: + pane.write(data) + self.s.mark_dirty(window) + return "" + + def cmd_bind_key(self, ctx, args): + table = "root" if args.has("n") else (args.get("T") or "prefix") + key = MplexKeys.canonical(args.positional[0]) + words = args.positional[1:] + if not words: + raise MplexError("bind-key needs a command") + command = " ".join(MplexCommandParser.quote(word) for word in words) + for parsed in MplexCommandParser.parse(command): + self.resolve(parsed[0]) + self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "") + return "" + + def cmd_unbind_key(self, ctx, args): + table = "root" if args.has("n") else (args.get("T") or "prefix") + config = self.s.config + if args.has("a"): + config.tables[table] = {} + return "" + if not args.positional: + raise MplexError("unbind-key needs a key (or -a)") + key = MplexKeys.canonical(args.positional[0]) + if not config.unbind(table, key) and not args.has("q"): + raise MplexError("unknown key: %s" % args.positional[0]) + return "" + + def cmd_list_keys(self, ctx, args): + config = self.s.config + opts = ctx.session.options if ctx.session else self.s.options + only = MplexKeys.canonical(args.positional[0]) if args.positional else None + tables = [args.get("T")] if args.get("T") else list(config.tables) + lines = [] + if args.has("N"): + prefix = opts.get("prefix") + rows = [] + for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]): + for key, binding in config.tables.get(table, {}).items(): + if only is not None and key != only: + continue + note = binding.note or (binding.command if args.has("a") else "") + if not note: + continue + label = key if table == "root" else "%s %s" % (prefix, key) + if args.get("P") is not None: + label = args.get("P") + key + rows.append((label, note)) + if only is not None and not rows: + raise MplexError("unknown key: %s" % args.positional[0]) + if args.has("1"): + rows = rows[:1] + width = max([len(label) for label, _note in rows] or [0]) + return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows) + for table in tables: + for key, binding in config.tables.get(table, {}).items(): + if only is not None and key != only: + continue + lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command)) + if args.has("1") and lines: + lines = lines[:1] + return "\n".join(lines) + + def cmd_command_prompt(self, ctx, args): + s = self.s + client = self.need_client(ctx) + template = args.positional[0] if args.positional else None + prompts = args.get("p").split(",") if args.get("p") else [] + inputs = args.get("I").split(",") if args.get("I") else [] + prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts] + inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs] + if template is not None and args.has("F"): + template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client) + kind = args.get("T") or "command" + history = s.prompt_history.setdefault(kind, []) + answers = [] + count = max(1, len(prompts)) + pane = ctx.pane + copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None + + def ask(position): + label = (prompts[position] + " ") if prompts else ":" + initial = inputs[position] if position < len(inputs) else "" + + def done(value): + answers.append(value) + if position + 1 < count: + ask(position + 1) + return + if template is None: + self.run_later(ctx, "%%", answers) + else: + self.run_later(ctx, template, answers) + + kwargs = {} + if args.has("i") and template is not None: + kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text]) + kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text]) + if copy_mode is not None: + kwargs["on_cancel"] = copy_mode.search_cancel + client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs) + client.dirty = True + + ask(0) + return "" + + def cmd_confirm_before(self, ctx, args): + client = self.need_client(ctx) + command = args.positional[0] + text = args.get("p") or "Confirm '%s'? (y/n)" % command + text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client) + pane = ctx.pane + + def run(): + fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True) + fresh.depth = ctx.depth + 1 + self.s.run_checked(fresh, command) + + self.s.ask_confirm(client, text, run) + return "" + + def cmd_display_message(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"] + text = self.fmt(template, session, window, pane, ctx.client) + text = MplexFormat.strip_styles(text) + if args.has("p"): + return text + client = ctx.client + if client is not None and client.attached: + delay = args.int("d", None) + self.s.message(client, text, delay) + return "" + + def cmd_display_panes(self, ctx, args): + client = self.need_client(ctx) + window = client.session.window() + duration = args.int("d", None) or window.options.get("display-panes-time") + client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0) + client.dirty = True + return "" + + def cmd_clock_mode(self, ctx, args): + _s, window, pane = self.pane_target(ctx, args) + if pane.find_mode(MplexClockMode) is None: + pane.push_mode(MplexClockMode(self.s, pane)) + self.s.mark_dirty(window) + return "" + + def cmd_copy_mode(self, ctx, args): + if args.has("M"): + raise MplexError("copy-mode -M (mouse) is not supported by mplex") + _s, window, pane = self.pane_target(ctx, args) + existing = pane.find_mode(MplexCopyMode) + if args.has("q"): + if existing is not None: + existing.close() + return "" + if existing is None: + existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H")) + pane.push_mode(existing) + if args.has("u"): + existing.command("page-up", [], ctx.client) + self.s.mark_dirty(window) + return "" + + def cmd_show_messages(self, ctx, args): + return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages) + + # ---- buffers --------------------------------------------------------- + + def buffer_rows(self): + rows = [] + for item in self.s.buffers: + sample = item["data"][:50].replace("\n", " ") + rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample})) + return rows + + def cmd_list_buffers(self, ctx, args): + return self.listing("list-buffers", args, self.buffer_rows()) + + def cmd_set_buffer(self, ctx, args): + name = args.get("b") + if args.get("n"): + item = self.s.find_buffer(name) + item["name"] = args.get("n") + return "" + if not args.positional: + raise MplexError("set-buffer needs data") + data = args.positional[0] + if args.has("a"): + item = self.s.find_buffer(name) + item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX] + else: + self.s.add_buffer(data, name) + return "" + + def cmd_show_buffer(self, ctx, args): + return self.s.find_buffer(args.get("b"))["data"] + + def cmd_delete_buffer(self, ctx, args): + item = self.s.find_buffer(args.get("b")) + self.s.buffers.remove(item) + self.s.refresh_choosers() + return "" + + def cmd_save_buffer(self, ctx, args): + item = self.s.find_buffer(args.get("b")) + path = os.path.expanduser(args.positional[0]) + try: + with open(path, "ab" if args.has("a") else "wb") as handle: + handle.write(item["data"].encode("utf-8")) + except OSError as exc: + raise MplexError("cannot write %s: %s" % (path, short_error(exc))) + return "" + + def cmd_load_buffer(self, ctx, args): + path = os.path.expanduser(args.positional[0]) + try: + with open(path, "rb") as handle: + data = handle.read(MPLEX_PASTE_MAX) + except OSError as exc: + raise MplexError("cannot read %s: %s" % (path, short_error(exc))) + self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b")) + return "" + + def cmd_paste_buffer(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + item = self.s.find_buffer(args.get("b")) + separator = args.get("s") if args.get("s") is not None else "\r" + text = item["data"] if args.has("r") else item["data"].replace("\n", separator) + data = text.encode("utf-8") + if args.has("p") and pane.screen.bracketed_paste: + data = b"\x1b[200~" + data + b"\x1b[201~" + if not pane.dead: + pane.write(data) + if args.has("d"): + self.s.buffers.remove(item) + return "" + + # ---- choosers -------------------------------------------------------- + + def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None): + s = self.s + pane = ctx.pane + if pane is None: + raise MplexError("no pane") + window = pane.window + zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1 + if zoom_on_open: + window.zoomed = pane + window.relayout() + + def restore(): + if zoom_on_open and window.zoomed is pane: + window.zoomed = None + window.relayout() + + def choose(node, client): + kind_tag, ident = node.payload + if kind_tag == "window": + target = s.windows.get(ident) + if target is None: + return + if client is not None and client.session is not target.session: + s.attach_client(client, target.session) + target.session.select(target.session.index_of(target)) + elif kind_tag == "pane": + target = s.panes.get(ident) + if target is None or target.window is None: + return + if client is not None and client.session is not target.window.session: + s.attach_client(client, target.window.session) + target.window.session.select(target.window.session.index_of(target.window)) + s.select_pane(target.window, target) + elif kind_tag == "session": + target = s.sessions.get(ident) + if target is not None and client is not None: + s.attach_client(client, target) + s.mark_all_dirty() + + mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore) + pane.push_mode(mode) + s.mark_dirty(window) + return "" + + def cmd_choose_tree(self, ctx, args): + s = self.s + sessions_only = args.has("s") + windows_collapsed = args.has("w") + + def builder(): + items = [] + for session in sorted(s.sessions.values(), key=lambda x: x.id): + kids = [] + for index in session.indexes(): + window = session.windows[index] + panes = [] + for number, pane in enumerate(window.panes()): + panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id))) + kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height), + ("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1)) + attached = " (attached)" if s.attached_clients(session) else "" + items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only)) + return items + + def preview(node, width, height): + kind_tag, ident = node.payload + pane = None + if kind_tag == "pane": + pane = s.panes.get(ident) + elif kind_tag == "window": + window = s.windows.get(ident) + pane = window.active if window else None + else: + session = s.sessions.get(ident) + pane = session.window().active if session else None + if pane is None: + return [] + blank = MplexCanvas.BLANK + return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]] + + def delete(node, client): + kind_tag, ident = node.payload + if kind_tag == "session" and ident in s.sessions: + s.kill_session(s.sessions[ident]) + elif kind_tag == "window" and ident in s.windows: + s.kill_window(s.windows[ident]) + elif kind_tag == "pane" and ident in s.panes: + s.kill_pane(s.panes[ident]) + + selected = ("window", ctx.window.id) if ctx.window is not None else None + return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected) + + def cmd_choose_buffer(self, ctx, args): + s = self.s + pane = ctx.pane + + def builder(): + return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers] + + def choose(node, client): + if pane is not None and pane.id in s.panes and not pane.dead: + item = s.find_buffer(node.payload[1]) + pane.write(item["data"].replace("\n", "\r").encode("utf-8")) + + def delete(node, client): + s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]] + + def preview(node, width, height): + data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "") + return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]] + + if not s.buffers: + raise MplexError("no buffers") + return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview) + + def cmd_choose_client(self, ctx, args): + s = self.s + + def builder(): + return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()] + + def choose(node, client): + for target in s.attached_clients(): + if target.id == node.payload[1]: + s.detach_client(target, "[detached (from session %s)]" % target.session.name) + + return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose) + + # ---- options, shell, files ------------------------------------------- + + def option_name(self, name): + if name in MplexOptions.DEFS: + return name + matches = [item for item in MplexOptions.DEFS if item.startswith(name)] + if len(matches) == 1: + return matches[0] + raise MplexError("unknown or unsupported option: %s" % name) + + def option_scope(self, ctx, args, window_scope): + if args.has("g") or args.has("s"): + return self.s.options + if window_scope or args.has("w"): + return MplexTargets.window(ctx, args.get("t"))[2].options + return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options + + def set_option(self, ctx, args, window_scope): + try: + name = self.option_name(args.positional[0]) + except MplexError: + if args.has("q"): + return "" + raise + scope = self.option_scope(ctx, args, window_scope) + if args.has("u"): + scope.unset(name) + else: + if len(args.positional) < 2: + scope.toggle(name) + else: + value = args.positional[1] + if args.has("F"): + value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client) + if args.has("o") and scope.is_set(name): + raise MplexError("already set: %s" % name) + scope.set(name, value, args.has("a")) + self.s.resize_all() + self.s.mark_all_dirty() + return "" + + def cmd_set_option(self, ctx, args): + return self.set_option(ctx, args, False) + + def cmd_set_window_option(self, ctx, args): + return self.set_option(ctx, args, True) + + def show_options(self, ctx, args, window_scope): + scope = self.option_scope(ctx, args, window_scope) + names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS) + lines = [] + for name in names: + if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name): + value = scope.get(name) + text = MplexOptions.show(value) + if args.has("v"): + lines.append(text) + else: + lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text)) + return "\n".join(lines) + + def cmd_show_options(self, ctx, args): + return self.show_options(ctx, args, False) + + def cmd_show_window_options(self, ctx, args): + return self.show_options(ctx, args, True) + + def shell_in(self, ctx, command, pane): + cwd = pane.current_path() if pane is not None else None + env = dict(ctx.session.env) if ctx.session is not None else None + try: + done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env) + except (OSError, subprocess.SubprocessError) as exc: + raise MplexError("shell command failed: " + short_error(exc)) + return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace") + + def cmd_if_shell(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + condition = args.positional[0] + if args.has("F"): + ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client)) + else: + ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0 + chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "") + if not chosen: + return "" + return self.s.run_line(ctx, chosen, ctx.depth + 1) + + def cmd_run_shell(self, ctx, args): + session, window, pane = self.pane_target(ctx, args) + if args.get("d"): + raise MplexError("run-shell -d (delay) is not supported by mplex") + if not args.positional: + return "" + command = self.fmt(args.positional[0], session, window, pane, ctx.client) + if args.has("C"): + return self.s.run_line(ctx, command, ctx.depth + 1) + if args.has("b"): + try: + subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + cwd=pane.current_path() or None, start_new_session=True) + except OSError as exc: + raise MplexError("shell command failed: " + short_error(exc)) + return "" + return self.shell_in(ctx, command, pane)[1].rstrip("\n") + + def cmd_source_file(self, ctx, args): + s = self.s + out = [] + for raw in args.positional: + path = os.path.expanduser(raw) + if not os.path.exists(path): + if args.has("q"): + continue + raise MplexError("no such file: %s" % raw) + if path.endswith(".toml"): + before = s.config.skipped + s.config.apply_file(path) + for problem in s.config.errors[before:]: + s.log_message("mplex.toml: " + problem) + skipped = s.config.skipped - before + if skipped: + out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped)) + s.resize_all() + s.mark_all_dirty() + continue + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read(MPLEX_CONFIG_MAX).splitlines() + except OSError as exc: + raise MplexError("cannot read %s: %s" % (path, short_error(exc))) + for number, line in enumerate(lines, 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + try: + text = s.run_line(ctx, line, ctx.depth + 1) + except MplexError as exc: + raise MplexError("%s:%d: %s" % (raw, number, exc)) + if text: + out.append(text) + return "\n".join(out) + + +class MplexClient: + """The attached terminal: forwards keys and resizes to the server, paints what it sends.""" + + def __init__(self, home, commands): + self.home = home + self.commands = commands + self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) + self.stdin_fd = None + self.saved = None + self.attached = False + self.wake_r = None + self.wake_w = None + + @staticmethod + def frame(kind, payload=b""): + return kind + struct.pack(">I", len(payload)) + payload + + def size(self): + try: + packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8) + rows, cols = struct.unpack("HH", packed[:4]) + if rows and cols: + return rows, cols + except (OSError, ValueError, AttributeError): + pass + return 24, 80 + + def hello(self): + rows, cols = self.size() + pane = os.environ.get(PLEXER_PANE_ENV, "") + info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(), + "tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands, + "env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""} + return self.frame(b"H", json.dumps(info).encode("utf-8")) + + def enter(self): + self.stdin_fd = sys.stdin.fileno() + try: + self.saved = termios.tcgetattr(self.stdin_fd) + tty.setraw(self.stdin_fd) + except (termios.error, OSError): + self.saved = None + os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H") + self.attached = True + + def leave(self): + if not self.attached: + return + self.attached = False + try: + os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l") + except OSError: + pass + if self.saved is not None: + try: + termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved) + except (termios.error, OSError): + pass + + def write_out(self, data): + fd = sys.stdout.fileno() + view = memoryview(data) + while view: + try: + written = os.write(fd, view) + except BlockingIOError: + select.select([], [fd], [], 1.0) + continue + view = view[written:] + + def on_winch(self, _signum, _frame): + try: + os.write(self.wake_w, b"w") + except OSError: + pass + + def run(self): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(self.sock_path) + except OSError: + sys.stderr.write("no server running on %s\n" % self.sock_path) + return 1 + sock.sendall(MPLEX_MAGIC + self.hello()) + self.wake_r, self.wake_w = os.pipe() + os.set_blocking(self.wake_w, False) + previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None + buf = bytearray() + code = 0 + message = "" + try: + while True: + watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else []) + try: + ready, _w, _x = select.select(watch, [], []) + except InterruptedError: + continue + if self.wake_r in ready: + os.read(self.wake_r, 4096) + rows, cols = self.size() + sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) + if self.attached and self.stdin_fd in ready: + data = os.read(self.stdin_fd, 4096) + if not data: + message = "[detached]" + break + sock.sendall(self.frame(b"I", data)) + if sock in ready: + data = sock.recv(262144) + if not data: + code = 1 + message = "[lost server]" + break + buf += data + done = self.handle_frames(sock, buf) + if done is not None: + code, message = done + break + finally: + self.leave() + if previous is not None: + signal.signal(signal.SIGWINCH, previous) + sock.close() + os.close(self.wake_r) + os.close(self.wake_w) + if message: + (sys.stderr if code else sys.stdout).write(message + "\n") + return code + + def handle_frames(self, sock, buf): + while len(buf) >= 5: + length = struct.unpack(">I", bytes(buf[1:5]))[0] + if len(buf) < 5 + length: + return None + kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length]) + del buf[:5 + length] + if kind == b"A": + self.enter() + elif kind == b"O": + self.write_out(payload) + elif kind == b"M": + sys.stdout.write(payload.decode("utf-8", "replace") + "\n") + sys.stdout.flush() + elif kind == b"E": + return 1, payload.decode("utf-8", "replace") + elif kind in (b"D", b"X"): + return 0, payload.decode("utf-8", "replace") + elif kind == b"Z": + self.suspend(sock) + return None + + def suspend(self, sock): + self.leave() + os.kill(os.getpid(), signal.SIGTSTP) + self.enter() + rows, cols = self.size() + sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) + + +class MplexCli: + """tai.py --mplex : any mplex command; new-session and attach-session attach this terminal.""" + + @staticmethod + def attaches(name, rest): + if name == "attach-session": + return True + if name != "new-session": + return False + try: + return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d") + except MplexError: + return True + + @staticmethod + def split_commands(words): + """argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument.""" + commands = [[]] + for word in words: + if word == ";": + commands.append([]) + else: + commands[-1].append(";" if word == "\\;" else word) + return [item for item in commands if item] + + @classmethod + def main(cls, home, words): + commands = cls.split_commands(words) or [["new-session"]] + try: + name = MplexCommands.resolve(commands[0][0]) + except MplexError as exc: + sys.stderr.write("%s\n" % exc) + return 1 + commands[0][0] = name + words = commands[0] + if cls.attaches(name, words[1:]): + if os.environ.get(PLEXER_ACTIVE_ENV): + sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV) + return 1 + if not (sys.stdin.isatty() and sys.stdout.isatty()): + sys.stderr.write("open terminal failed: not a terminal\n") + return 1 + if name in ("new-session", "start-server"): + if not MplexServer.ensure_running(home): + sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME)) + return 1 + return MplexClient(home, commands).run() + + +class NativePlexerBackend(PlexerBackend): + """What the shell hook execs into: a new mplex session attached to this terminal.""" + + def __init__(self, home, profile, shell_cmd=None): + self.home = home + self.profile = profile + self.shell_cmd = shell_cmd + + def run(self): + if pty is None or termios is None or select is None or fcntl is None: + return "error: pty/termios/select not available on this platform" + return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else [])) diff --git a/tai.py b/tai.py old mode 100755 new mode 100644 index c6f75e6..fc0f539 --- a/tai.py +++ b/tai.py @@ -331,13 +331,13 @@ def orientation_block(directory=None, sandbox=False): def capability_block(yolo=False, auto=False): """Live approval state for the system prompt. Grounds the model in what - this session can actually do, so it never invents a read-only jail.""" + this session can actually do, so it never invents false limits.""" if yolo or auto: return ("\n\n## Session capabilities\nApprovals are off and every tool is callable, " - "including shell and file writes. This session is not read-only: only a failed " + "including shell and file writes. Only a failed " "tool call proves a limit, so act first and adjust on evidence.") return ("\n\n## Session capabilities\nEvery tool is callable, including shell and file writes; " - "some commands ask for approval first. This session is not read-only: only a failed " + "some commands ask for approval first. Only a failed " "tool call proves a limit, so act first and adjust on evidence.") @@ -4441,6 +4441,13 @@ def codex_parse_output(stdout, outfile_text="", used_schema=False): return opencode_parse(stdout) +def codex_sandbox_args(): + """Codex CLI sandbox flags. Default off; set TAI_CODEX_SANDBOX=1 to re-enable.""" + if (os.environ.get("TAI_CODEX_SANDBOX") or "").strip().lower() in ("1", "true", "yes", "on", "read-only", "readonly"): + return ["--sandbox", "read-only"] + return [] + + def codex_run(prompt, timeout): binary = codex_binary() if not binary: @@ -4451,7 +4458,7 @@ def codex_run(prompt, timeout): out_path = os.path.join(folder, "last.txt") with open(schema_path, "w", encoding="utf-8") as handle: handle.write(schema) - base = [binary, "exec", "--json", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check"] + base = [binary, "exec", "--json", "--ephemeral"] + codex_sandbox_args() + ["--skip-git-repo-check"] if len(prompt.encode("utf-8", "replace")) > 120000: variants = [ (base + ["--output-schema", schema_path, "-o", out_path, "-"], prompt, True), @@ -4509,16 +4516,23 @@ def gemini_parse_output(stdout): return opencode_parse(stdout) +def gemini_plan_enabled(): + """Gemini plan-mode variant. Default off; set TAI_GEMINI_PLAN=1 to re-enable.""" + return (os.environ.get("TAI_GEMINI_PLAN") or "").strip().lower() in ("1", "true", "yes", "on", "plan") + + def gemini_run(prompt, timeout): binary = gemini_binary() if not binary: raise BackendError("gemini CLI not found (GEMINI_BIN or PATH)") base = [binary, "-p", prompt] - variants = [ - base + ["--output-format", "json", "--approval-mode", "plan"], + variants = [] + if gemini_plan_enabled(): + variants.append(base + ["--output-format", "json", "--approval-mode", "plan"]) + variants.extend([ base + ["--output-format", "json"], base, - ] + ]) last_error = "" for argv in variants: done = _cli_run(argv, None, timeout, "gemini") diff --git a/test_tai.py b/test_tai.py index 855ac86..55617dd 100644 --- a/test_tai.py +++ b/test_tai.py @@ -2569,13 +2569,39 @@ class CapabilityTests(unittest.TestCase): def test_yolo_variant(self): block = tai.capability_block(yolo=True) self.assertIn("Approvals are off", block) - self.assertIn("not read-only", block) - self.assertIn("only a failed tool call proves a limit", block) + self.assertIn("Only a failed tool call proves a limit", block) + self.assertNotIn("read-only", block.lower()) def test_default_variant(self): block = tai.capability_block() self.assertIn("ask for approval", block) - self.assertIn("not read-only", block) + self.assertIn("Only a failed tool call proves a limit", block) + self.assertNotIn("read-only", block.lower()) + + def test_no_session_mode_mentions(self): + for block in (tai.capability_block(), tai.capability_block(yolo=True), tai.capability_block(auto=True)): + lowered = block.lower() + self.assertNotIn("read-only", lowered) + self.assertNotIn("read only", lowered) + self.assertNotIn("readonly", lowered) + + def test_codex_sandbox_default_off(self): + os.environ.pop("TAI_CODEX_SANDBOX", None) + self.assertEqual(tai.codex_sandbox_args(), []) + os.environ["TAI_CODEX_SANDBOX"] = "1" + try: + self.assertEqual(tai.codex_sandbox_args(), ["--sandbox", "read-only"]) + finally: + os.environ.pop("TAI_CODEX_SANDBOX", None) + + def test_gemini_plan_default_off(self): + os.environ.pop("TAI_GEMINI_PLAN", None) + self.assertFalse(tai.gemini_plan_enabled()) + os.environ["TAI_GEMINI_PLAN"] = "1" + try: + self.assertTrue(tai.gemini_plan_enabled()) + finally: + os.environ.pop("TAI_GEMINI_PLAN", None) def test_apply_system_injects_capabilities(self): system = self.agent.messages[0]["content"] @@ -4445,7 +4471,7 @@ class ToolArgumentsTests(unittest.TestCase): class OpencodeAgentTests(unittest.TestCase): def test_agent_is_not_plan(self): - # "plan" makes opencode inject "Plan mode ACTIVE - READ-ONLY" into the + # "plan" makes opencode inject a plan-mode notice into the # model, which then refuses to emit tai's write tool calls. self.assertNotEqual(tai.OPENCODE_AGENT, "plan") captured = {} @@ -10563,6 +10589,8 @@ class CtxStoreTests(unittest.TestCase): self.assertTrue(any("c00" in line for line in lines)) self.assertFalse(any("c04" in line for line in lines)) + + # --------------------------------------------------------------------------- # The grr client behind `tai.py --tui`: strings, formats, client, stream, # router, page engine and the shell against a small fake grr server.