From 06d1b194d4ce46524bc03628ecdf15fa2e135e5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:02:28 +0000 Subject: Rename decky_plugin_installer.py to decky_client.py Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- .github/workflows/release-on-tag.yml | 2 +- README.md | 8 +- decky_client.py | 324 +++++++++++++++++++++++++++++++++++ decky_plugin_installer.py | 324 ----------------------------------- test.sh | 2 +- user_install_script.sh | 4 +- 6 files changed, 332 insertions(+), 332 deletions(-) create mode 100644 decky_client.py delete mode 100644 decky_plugin_installer.py diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index fb8c021..5512646 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -32,5 +32,5 @@ jobs: tag_name: ${{ github.ref_name }} files: | user_install_script.sh - decky_plugin_installer.py + decky_client.py decky_installer.desktop diff --git a/README.md b/README.md index bfa1184..53f0122 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,17 @@ The installer now supports configuring custom plugin store URLs: #### Configure a Custom Store URL ```bash -python3 decky_plugin_installer.py --configure-store "https://your-custom-store.com/plugins" +python3 decky_client.py --configure-store "https://your-custom-store.com/plugins" ``` #### Get the Currently Configured Store URL ```bash -python3 decky_plugin_installer.py --get-store +python3 decky_client.py --get-store ``` #### Install from a Custom Store ```bash -python3 decky_plugin_installer.py --target-id 42 --store-url "https://your-custom-store.com/plugins" +python3 decky_client.py --target-id 42 --store-url "https://your-custom-store.com/plugins" ``` ## Mock Server for Testing @@ -47,7 +47,7 @@ python3 mock_decky_server.py --auto-confirm ### Test with the Mock Server ```bash -python3 decky_plugin_installer.py --target-id 42 +python3 decky_client.py --target-id 42 ``` The mock server implements the following Decky Loader backend routes: diff --git a/decky_client.py b/decky_client.py new file mode 100644 index 0000000..8d61f49 --- /dev/null +++ b/decky_client.py @@ -0,0 +1,324 @@ +import argparse +import asyncio +import base64 +import json +import os +import struct +import sys +import urllib.request +from typing import Any, Dict, List, Optional + +# Decky Loader Message Types +CALL = 0 +REPLY = 1 +ERROR = -1 +EVENT = 3 + +# Default store URL +DEFAULT_STORE_URL = "https://plugins.deckbrew.xyz/plugins" + + +def log(*args: Any) -> None: + """Print formatted logs to stderr.""" + print("[DeckyInstaller]", *args, file=sys.stderr, flush=True) + + +class DeckyClient: + """ + A robust client for Decky Loader using asyncio streams. + """ + + def __init__(self, host: str = "127.0.0.1", port: int = 1337): + self.host = host + self.port = port + self.reader: Optional[asyncio.StreamReader] = None + self.writer: Optional[asyncio.StreamWriter] = None + self.msg_id = 0 + + async def get_token(self) -> str: + """Fetch the CSRF token via HTTP GET.""" + url = f"http://{self.host}:{self.port}/auth/token" + # Using a context manager for the request + with urllib.request.urlopen(url, timeout=5) as response: + return response.read().decode().strip() + + + async def connect(self, token: str) -> None: + """Connect and perform WebSocket handshake.""" + self.reader, self.writer = await asyncio.open_connection(self.host, self.port) + + # Build handshake + key = base64.b64encode(os.urandom(16)).decode() + handshake = ( + f"GET /ws?auth={token} HTTP/1.1\r\n" + f"Host: {self.host}:{self.port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ) + self.writer.write(handshake.encode()) + await self.writer.drain() + + # Read response headers (terminated by \r\n\r\n) + header_data = b"" + while b"\r\n\r\n" not in header_data: + chunk = await self.reader.read(1024) + if not chunk: + raise ConnectionError("Server closed connection during handshake") + header_data += chunk + + if b"101 Switching Protocols" not in header_data: + raise RuntimeError(f"Handshake failed: {header_data.decode(errors='ignore')}") + + # Note: Any data after \r\n\r\n is the start of the first WS frame + # asyncio.StreamReader handles the internal buffer automatically. + + async def send(self, msg_type: int, method: str, args: List[Any]) -> None: + """Send a masked WebSocket text frame.""" + self.msg_id += 1 + + message_dict = { + "type": msg_type, + "id": self.msg_id, + "route": method, + "args": args, + } + payload = json.dumps(message_dict).encode() + length = len(payload) + + # Header: FIN=1, Opcode=1 (Text) + frame = bytearray([0x81]) + + if length < 126: + frame.append(length | 0x80) + elif length < 65536: + frame.append(126 | 0x80) + frame.extend(struct.pack("!H", length)) + else: + frame.append(127 | 0x80) + frame.extend(struct.pack("!Q", length)) + + # Client must mask data + mask = os.urandom(4) + frame.extend(mask) + masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + frame.extend(masked_payload) + + self.writer.write(frame) + await self.writer.drain() + + async def recv(self) -> Optional[Dict[str, Any]]: + """Receive and parse one WebSocket text frame.""" + try: + # Read first 2 bytes: Opcode and Length + head = await self.reader.readexactly(2) + # opcode = head[0] & 0x0F + has_mask = head[1] & 0x80 + length = head[1] & 0x7F + + if length == 126: + ext_len = await self.reader.readexactly(2) + length = struct.unpack("!H", ext_len)[0] + elif length == 127: + ext_len = await self.reader.readexactly(8) + length = struct.unpack("!Q", ext_len)[0] + + if has_mask: + mask = await self.reader.readexactly(4) + + payload_raw = await self.reader.readexactly(length) + + if has_mask: + payload_raw = bytes(b ^ mask[i % 4] for i, b in enumerate(payload_raw)) + + return json.loads(payload_raw.decode()) + except (asyncio.IncompleteReadError, ConnectionError): + return None + + async def close(self) -> None: + """Send a WebSocket close frame and close the stream.""" + if not self.writer: + return + try: + # FIN=1, opcode=8 (Close), masked payload with status 1000 + payload = struct.pack("!H", 1000) + frame = bytearray([0x88, 0x80 | len(payload)]) + mask = os.urandom(4) + frame.extend(mask) + masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + frame.extend(masked_payload) + self.writer.write(frame) + await self.writer.drain() + except Exception: + pass + finally: + self.writer.close() + await self.writer.wait_closed() + + +async def run_installer(target_id: int, store_url: str) -> None: + """Installation workflow.""" + client = DeckyClient() + success = False + error: Optional[BaseException] = None + try: + log(f"Contacting Mock Server at {client.host}:{client.port}...") + token = await client.get_token() + await client.connect(token) + + log(f"Connection established. Fetching plugin metadata for ID: {target_id}") + with urllib.request.urlopen(store_url, timeout=10) as response: + store_raw = response.read().decode() + plugins = json.loads(store_raw) + target = next((p for p in plugins if int(p.get("id")) == int(target_id)), None) + if not target: + raise RuntimeError(f"plugin id {target_id} not found") + + plugin_name = target.get("name") or f"plugin-{target_id}" + versions = target.get("versions") or [] + if not versions: + raise RuntimeError("store entry missing versions") + + latest = sorted(versions, key=lambda v: (v.get("name") or ""))[-1] + version_name = latest.get("name") or "dev" + artifact_url = latest.get("artifact") or "" + hash_ = latest.get("hash") or "" + if not artifact_url: + raise RuntimeError("latest version missing artifact URL") + + log(f"Installing {plugin_name} v{version_name}") + await client.send(CALL, "utilities/install_plugin", + [artifact_url, plugin_name, version_name, hash_, 0]) + + while True: + msg = await client.recv() + if msg is None: + log("Connection closed by server.") + break + + m_type = msg.get("type") + + if m_type == EVENT and msg.get("event") == "loader/add_plugin_install_prompt": + m_args = msg.get("args", []) + if len(m_args) < 3: + log(f"Invalid install prompt args: {m_args}") + continue + request_id = m_args[2] + log("Prompt received, sending confirmation...") + await client.send(CALL, "utilities/confirm_plugin_install", + [request_id]) + + elif m_type == EVENT and msg.get("event") == "loader/plugin_download_finish": + log(f"Installation successful: {msg.get('args')}") + success = True + break + + elif m_type == REPLY: + log(f"Server reply: {msg.get('result')}") + + elif m_type == ERROR: + log(f"Server error: {msg.get('error')}") + + except Exception as e: + log(f"Error: {e}") + error = e + finally: + await client.close() + + if error: + raise error + if not success: + raise RuntimeError("Installation did not complete successfully") + + +async def configure_store_url(store_url: str) -> None: + """Configure custom store URL in Decky settings.""" + client = DeckyClient() + try: + log(f"Connecting to Decky server at {client.host}:{client.port}...") + token = await client.get_token() + await client.connect(token) + + log(f"Setting custom store URL: {store_url}") + await client.send(CALL, "utilities/settings/set", ["store_url", store_url]) + + # Wait for reply + msg = await client.recv() + if msg is None: + raise RuntimeError("Connection closed by server") + + m_type = msg.get("type") + + if m_type == REPLY: + log(f"Store URL configured successfully: {msg.get('result')}") + elif m_type == ERROR: + log(f"Server error: {msg.get('error')}") + raise RuntimeError(f"Failed to set store URL: {msg.get('error')}") + + except Exception as e: + log(f"Error: {e}") + raise + finally: + await client.close() + + +async def get_store_url() -> str: + """Get the configured custom store URL from Decky settings.""" + client = DeckyClient() + try: + log(f"Connecting to Decky server at {client.host}:{client.port}...") + token = await client.get_token() + await client.connect(token) + + log("Getting configured store URL...") + await client.send(CALL, "utilities/settings/get", ["store_url", DEFAULT_STORE_URL]) + + # Wait for reply + msg = await client.recv() + if msg is None: + raise RuntimeError("Connection closed by server") + + m_type = msg.get("type") + + if m_type == REPLY: + store_url = msg.get('result') + log(f"Current store URL: {store_url}") + return store_url + elif m_type == ERROR: + log(f"Server error: {msg.get('error')}") + raise RuntimeError(f"Failed to get store URL: {msg.get('error')}") + + raise RuntimeError("Unexpected response type") + + except Exception as e: + log(f"Error: {e}") + raise + finally: + await client.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Decky Plugin Installer") + parser.add_argument("--store-url", default="http://127.0.0.1:1337/plugins", + help="Plugin store URL to fetch plugins from") + parser.add_argument("--target-id", type=int, default=42, + help="Plugin ID to install") + parser.add_argument("--configure-store", metavar="URL", + help="Configure custom store URL in Decky settings") + parser.add_argument("--get-store", action="store_true", + help="Get the configured custom store URL") + args = parser.parse_args() + + if args.configure_store: + # Configure store URL + asyncio.run(configure_store_url(args.configure_store)) + elif args.get_store: + # Get configured store URL + asyncio.run(get_store_url()) + else: + # Run installer - only pass expected parameters + asyncio.run(run_installer( + target_id=args.target_id, + store_url=args.store_url + )) diff --git a/decky_plugin_installer.py b/decky_plugin_installer.py deleted file mode 100644 index 8d61f49..0000000 --- a/decky_plugin_installer.py +++ /dev/null @@ -1,324 +0,0 @@ -import argparse -import asyncio -import base64 -import json -import os -import struct -import sys -import urllib.request -from typing import Any, Dict, List, Optional - -# Decky Loader Message Types -CALL = 0 -REPLY = 1 -ERROR = -1 -EVENT = 3 - -# Default store URL -DEFAULT_STORE_URL = "https://plugins.deckbrew.xyz/plugins" - - -def log(*args: Any) -> None: - """Print formatted logs to stderr.""" - print("[DeckyInstaller]", *args, file=sys.stderr, flush=True) - - -class DeckyClient: - """ - A robust client for Decky Loader using asyncio streams. - """ - - def __init__(self, host: str = "127.0.0.1", port: int = 1337): - self.host = host - self.port = port - self.reader: Optional[asyncio.StreamReader] = None - self.writer: Optional[asyncio.StreamWriter] = None - self.msg_id = 0 - - async def get_token(self) -> str: - """Fetch the CSRF token via HTTP GET.""" - url = f"http://{self.host}:{self.port}/auth/token" - # Using a context manager for the request - with urllib.request.urlopen(url, timeout=5) as response: - return response.read().decode().strip() - - - async def connect(self, token: str) -> None: - """Connect and perform WebSocket handshake.""" - self.reader, self.writer = await asyncio.open_connection(self.host, self.port) - - # Build handshake - key = base64.b64encode(os.urandom(16)).decode() - handshake = ( - f"GET /ws?auth={token} HTTP/1.1\r\n" - f"Host: {self.host}:{self.port}\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - f"Sec-WebSocket-Key: {key}\r\n" - "Sec-WebSocket-Version: 13\r\n\r\n" - ) - self.writer.write(handshake.encode()) - await self.writer.drain() - - # Read response headers (terminated by \r\n\r\n) - header_data = b"" - while b"\r\n\r\n" not in header_data: - chunk = await self.reader.read(1024) - if not chunk: - raise ConnectionError("Server closed connection during handshake") - header_data += chunk - - if b"101 Switching Protocols" not in header_data: - raise RuntimeError(f"Handshake failed: {header_data.decode(errors='ignore')}") - - # Note: Any data after \r\n\r\n is the start of the first WS frame - # asyncio.StreamReader handles the internal buffer automatically. - - async def send(self, msg_type: int, method: str, args: List[Any]) -> None: - """Send a masked WebSocket text frame.""" - self.msg_id += 1 - - message_dict = { - "type": msg_type, - "id": self.msg_id, - "route": method, - "args": args, - } - payload = json.dumps(message_dict).encode() - length = len(payload) - - # Header: FIN=1, Opcode=1 (Text) - frame = bytearray([0x81]) - - if length < 126: - frame.append(length | 0x80) - elif length < 65536: - frame.append(126 | 0x80) - frame.extend(struct.pack("!H", length)) - else: - frame.append(127 | 0x80) - frame.extend(struct.pack("!Q", length)) - - # Client must mask data - mask = os.urandom(4) - frame.extend(mask) - masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) - frame.extend(masked_payload) - - self.writer.write(frame) - await self.writer.drain() - - async def recv(self) -> Optional[Dict[str, Any]]: - """Receive and parse one WebSocket text frame.""" - try: - # Read first 2 bytes: Opcode and Length - head = await self.reader.readexactly(2) - # opcode = head[0] & 0x0F - has_mask = head[1] & 0x80 - length = head[1] & 0x7F - - if length == 126: - ext_len = await self.reader.readexactly(2) - length = struct.unpack("!H", ext_len)[0] - elif length == 127: - ext_len = await self.reader.readexactly(8) - length = struct.unpack("!Q", ext_len)[0] - - if has_mask: - mask = await self.reader.readexactly(4) - - payload_raw = await self.reader.readexactly(length) - - if has_mask: - payload_raw = bytes(b ^ mask[i % 4] for i, b in enumerate(payload_raw)) - - return json.loads(payload_raw.decode()) - except (asyncio.IncompleteReadError, ConnectionError): - return None - - async def close(self) -> None: - """Send a WebSocket close frame and close the stream.""" - if not self.writer: - return - try: - # FIN=1, opcode=8 (Close), masked payload with status 1000 - payload = struct.pack("!H", 1000) - frame = bytearray([0x88, 0x80 | len(payload)]) - mask = os.urandom(4) - frame.extend(mask) - masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) - frame.extend(masked_payload) - self.writer.write(frame) - await self.writer.drain() - except Exception: - pass - finally: - self.writer.close() - await self.writer.wait_closed() - - -async def run_installer(target_id: int, store_url: str) -> None: - """Installation workflow.""" - client = DeckyClient() - success = False - error: Optional[BaseException] = None - try: - log(f"Contacting Mock Server at {client.host}:{client.port}...") - token = await client.get_token() - await client.connect(token) - - log(f"Connection established. Fetching plugin metadata for ID: {target_id}") - with urllib.request.urlopen(store_url, timeout=10) as response: - store_raw = response.read().decode() - plugins = json.loads(store_raw) - target = next((p for p in plugins if int(p.get("id")) == int(target_id)), None) - if not target: - raise RuntimeError(f"plugin id {target_id} not found") - - plugin_name = target.get("name") or f"plugin-{target_id}" - versions = target.get("versions") or [] - if not versions: - raise RuntimeError("store entry missing versions") - - latest = sorted(versions, key=lambda v: (v.get("name") or ""))[-1] - version_name = latest.get("name") or "dev" - artifact_url = latest.get("artifact") or "" - hash_ = latest.get("hash") or "" - if not artifact_url: - raise RuntimeError("latest version missing artifact URL") - - log(f"Installing {plugin_name} v{version_name}") - await client.send(CALL, "utilities/install_plugin", - [artifact_url, plugin_name, version_name, hash_, 0]) - - while True: - msg = await client.recv() - if msg is None: - log("Connection closed by server.") - break - - m_type = msg.get("type") - - if m_type == EVENT and msg.get("event") == "loader/add_plugin_install_prompt": - m_args = msg.get("args", []) - if len(m_args) < 3: - log(f"Invalid install prompt args: {m_args}") - continue - request_id = m_args[2] - log("Prompt received, sending confirmation...") - await client.send(CALL, "utilities/confirm_plugin_install", - [request_id]) - - elif m_type == EVENT and msg.get("event") == "loader/plugin_download_finish": - log(f"Installation successful: {msg.get('args')}") - success = True - break - - elif m_type == REPLY: - log(f"Server reply: {msg.get('result')}") - - elif m_type == ERROR: - log(f"Server error: {msg.get('error')}") - - except Exception as e: - log(f"Error: {e}") - error = e - finally: - await client.close() - - if error: - raise error - if not success: - raise RuntimeError("Installation did not complete successfully") - - -async def configure_store_url(store_url: str) -> None: - """Configure custom store URL in Decky settings.""" - client = DeckyClient() - try: - log(f"Connecting to Decky server at {client.host}:{client.port}...") - token = await client.get_token() - await client.connect(token) - - log(f"Setting custom store URL: {store_url}") - await client.send(CALL, "utilities/settings/set", ["store_url", store_url]) - - # Wait for reply - msg = await client.recv() - if msg is None: - raise RuntimeError("Connection closed by server") - - m_type = msg.get("type") - - if m_type == REPLY: - log(f"Store URL configured successfully: {msg.get('result')}") - elif m_type == ERROR: - log(f"Server error: {msg.get('error')}") - raise RuntimeError(f"Failed to set store URL: {msg.get('error')}") - - except Exception as e: - log(f"Error: {e}") - raise - finally: - await client.close() - - -async def get_store_url() -> str: - """Get the configured custom store URL from Decky settings.""" - client = DeckyClient() - try: - log(f"Connecting to Decky server at {client.host}:{client.port}...") - token = await client.get_token() - await client.connect(token) - - log("Getting configured store URL...") - await client.send(CALL, "utilities/settings/get", ["store_url", DEFAULT_STORE_URL]) - - # Wait for reply - msg = await client.recv() - if msg is None: - raise RuntimeError("Connection closed by server") - - m_type = msg.get("type") - - if m_type == REPLY: - store_url = msg.get('result') - log(f"Current store URL: {store_url}") - return store_url - elif m_type == ERROR: - log(f"Server error: {msg.get('error')}") - raise RuntimeError(f"Failed to get store URL: {msg.get('error')}") - - raise RuntimeError("Unexpected response type") - - except Exception as e: - log(f"Error: {e}") - raise - finally: - await client.close() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Decky Plugin Installer") - parser.add_argument("--store-url", default="http://127.0.0.1:1337/plugins", - help="Plugin store URL to fetch plugins from") - parser.add_argument("--target-id", type=int, default=42, - help="Plugin ID to install") - parser.add_argument("--configure-store", metavar="URL", - help="Configure custom store URL in Decky settings") - parser.add_argument("--get-store", action="store_true", - help="Get the configured custom store URL") - args = parser.parse_args() - - if args.configure_store: - # Configure store URL - asyncio.run(configure_store_url(args.configure_store)) - elif args.get_store: - # Get configured store URL - asyncio.run(get_store_url()) - else: - # Run installer - only pass expected parameters - asyncio.run(run_installer( - target_id=args.target_id, - store_url=args.store_url - )) diff --git a/test.sh b/test.sh index 1e69371..53b5ab6 100755 --- a/test.sh +++ b/test.sh @@ -8,4 +8,4 @@ server_pid=$! echo "Mock Decky Server is running. Logs are being written to /tmp/mock_decky_server.log" trap "kill $server_pid" EXIT -python3 decky_plugin_installer.py +python3 decky_client.py diff --git a/user_install_script.sh b/user_install_script.sh index c411321..49acea2 100644 --- a/user_install_script.sh +++ b/user_install_script.sh @@ -35,8 +35,8 @@ if [ "$SKIP_DECKY_INSTALL" != true ]; then fi # Download and run decky plugin installer helper (mirror-hosted). -plugin_installer="/tmp/decky_plugin_installer.py" -if curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_plugin_installer.py" -o "${plugin_installer}"; then +plugin_installer="/tmp/decky_client.py" +if curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_client.py" -o "${plugin_installer}"; then python3 "${plugin_installer}" \ --store-url "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" \ --target-id "${DECKY_PLUGIN_TARGET_ID}" -- cgit v1.2.3