-- cgit v1.2.3 From ee0dfbeec44706c8b498f6582d1cfa8e5f765453 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:50:44 +0000 Subject: Add custom store configuration support to mock server and installer Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- decky_plugin_installer.py | 88 +++++++++++++++++++++++++++++++++++++++++++++-- mock_decky_server.py | 76 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/decky_plugin_installer.py b/decky_plugin_installer.py index 7c70235..5814427 100644 --- a/decky_plugin_installer.py +++ b/decky_plugin_installer.py @@ -229,9 +229,91 @@ async def run_installer(target_id: int, store_url: str) -> None: 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", "https://plugins.deckbrew.xyz/plugins"]) + + # 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") - parser.add_argument("--target-id", type=int, default=42) + 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() - asyncio.run(run_installer(**vars(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 + asyncio.run(run_installer(**{k: v for k, v in vars(args).items() + if k in ['target_id', 'store_url']})) diff --git a/mock_decky_server.py b/mock_decky_server.py index 7a7341e..7a74dcd 100644 --- a/mock_decky_server.py +++ b/mock_decky_server.py @@ -42,6 +42,9 @@ CSRF_TOKEN = "decky-" + os.urandom(16).hex() # Plugin install requests storage (simulates PluginBrowser.install_requests) install_requests: Dict[str, Dict[str, str]] = {} +# Settings storage (simulates SettingsManager) +settings_store: Dict[str, Any] = {} + logging.basicConfig( level=logging.INFO, @@ -198,6 +201,8 @@ def handle_call_route( "utilities/install_plugin": handle_install_plugin, "utilities/confirm_plugin_install": handle_confirm_plugin_install, "utilities/cancel_plugin_install": handle_cancel_plugin_install, + "utilities/settings/get": handle_get_setting, + "utilities/settings/set": handle_set_setting, } if route not in routes: @@ -409,6 +414,77 @@ def handle_cancel_plugin_install( return None +def handle_get_setting( + sock: socket.socket, + args: List[Any], + config: Dict[str, bool] +) -> Any: + """Handle utilities/settings/get route. + + Corresponds to: + - utilities.py async def get_setting() (line 272) + - settings.py def getSetting() (line 58) + + Function signature from utilities.py: + async def get_setting(self, key: str, default: Any) + + Args: + sock: The WebSocket socket (unused). + args: [key, default]. + config: Server configuration (unused). + + Returns: + The setting value or the default if not found. + """ + if len(args) < 1: + raise ValueError("get_setting requires key argument") + + key = args[0] + default = args[1] if len(args) > 1 else None + + value = settings_store.get(key, default) + logger.info("[get_setting] key=%s, default=%s, value=%s", key, default, value) + + return value + + +def handle_set_setting( + sock: socket.socket, + args: List[Any], + config: Dict[str, bool] +) -> Any: + """Handle utilities/settings/set route. + + Corresponds to: + - utilities.py async def set_setting() (line 275) + - settings.py def setSetting() (line 61) + + Function signature from utilities.py: + async def set_setting(self, key: str, value: Any) + + Args: + sock: The WebSocket socket (unused). + args: [key, value]. + config: Server configuration (unused). + + Returns: + The value that was set. + + Raises: + ValueError: If key or value is missing. + """ + if len(args) < 2: + raise ValueError("set_setting requires key and value arguments") + + key = args[0] + value = args[1] + + settings_store[key] = value + logger.info("[set_setting] key=%s, value=%s", key, value) + + return value + + def _do_install(sock: socket.socket, artifact: str, name: str, version: str, hash_val: str) -> None: """Simulate the installation process with progress events. -- cgit v1.2.3 From 6f40607b692ee4be76b954ec647fc0d8617c86bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:51:40 +0000 Subject: Update README with custom store configuration documentation Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/README.md b/README.md index 3dda5ed..bfa1184 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,55 @@ A local mirror version of the Decky Installer for Steam Deck. This repository al - Local hosting of Decky Installer files - Easy installation of Decky plugins - No dependency on external servers +- **Custom store configuration support** - Configure and use custom plugin store URLs ## Usage +### Basic Plugin Installation + 1. Download the `user_install_script.sh` or the `decky_installer.desktop` file from the releases section. 2. Place the downloaded file in a convenient location on your Steam Deck. 3. Run the script or launch the desktop file to start the Decky Installer. +### Custom Store Configuration + +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" +``` + +#### Get the Currently Configured Store URL +```bash +python3 decky_plugin_installer.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" +``` + +## Mock Server for Testing + +This repository includes a mock Decky Loader server for testing purposes: + +### Start the Mock Server +```bash +python3 mock_decky_server.py --auto-confirm +``` + +### Test with the Mock Server +```bash +python3 decky_plugin_installer.py --target-id 42 +``` + +The mock server implements the following Decky Loader backend routes: +- `utilities/ping` - Health check +- `utilities/install_plugin` - Plugin installation +- `utilities/confirm_plugin_install` - Confirm installation +- `utilities/cancel_plugin_install` - Cancel installation +- `utilities/settings/get` - Get configuration settings +- `utilities/settings/set` - Set configuration settings + -- cgit v1.2.3 From 1b1148cacc4bc90cc4cefb9f543f87c94d19c7df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:52:40 +0000 Subject: Address code review feedback: extract constants and fix parameter passing Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- decky_plugin_installer.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/decky_plugin_installer.py b/decky_plugin_installer.py index 5814427..8d61f49 100644 --- a/decky_plugin_installer.py +++ b/decky_plugin_installer.py @@ -14,6 +14,9 @@ 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.""" @@ -269,7 +272,7 @@ async def get_store_url() -> str: await client.connect(token) log("Getting configured store URL...") - await client.send(CALL, "utilities/settings/get", ["store_url", "https://plugins.deckbrew.xyz/plugins"]) + await client.send(CALL, "utilities/settings/get", ["store_url", DEFAULT_STORE_URL]) # Wait for reply msg = await client.recv() @@ -314,6 +317,8 @@ if __name__ == "__main__": # Get configured store URL asyncio.run(get_store_url()) else: - # Run installer - asyncio.run(run_installer(**{k: v for k, v in vars(args).items() - if k in ['target_id', 'store_url']})) + # Run installer - only pass expected parameters + asyncio.run(run_installer( + target_id=args.target_id, + store_url=args.store_url + )) -- cgit v1.2.3 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 From a1164d396c54c8a7a3d17746b12c6e62816ed344 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:08:50 +0000 Subject: Refactor CLI to use subcommands for better semantics Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- README.md | 23 ++++++++++++++---- decky_client.py | 65 +++++++++++++++++++++++++++++++++++++------------- test.sh | 2 +- user_install_script.sh | 2 +- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 53f0122..390f529 100644 --- a/README.md +++ b/README.md @@ -17,23 +17,38 @@ A local mirror version of the Decky Installer for Steam Deck. This repository al 2. Place the downloaded file in a convenient location on your Steam Deck. 3. Run the script or launch the desktop file to start the Decky Installer. +### Command Structure + +The `decky_client.py` script uses subcommands for different operations: + +```bash +# Install a plugin (default operation) +python3 decky_client.py install [options] + +# Configure custom store URL +python3 decky_client.py configure-store + +# Get configured store URL +python3 decky_client.py get-store +``` + ### Custom Store Configuration The installer now supports configuring custom plugin store URLs: #### Configure a Custom Store URL ```bash -python3 decky_client.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_client.py --get-store +python3 decky_client.py get-store ``` #### Install from a Custom Store ```bash -python3 decky_client.py --target-id 42 --store-url "https://your-custom-store.com/plugins" +python3 decky_client.py install --target-id 42 --store-url "https://your-custom-store.com/plugins" ``` ## Mock Server for Testing @@ -47,7 +62,7 @@ python3 mock_decky_server.py --auto-confirm ### Test with the Mock Server ```bash -python3 decky_client.py --target-id 42 +python3 decky_client.py install --target-id 42 ``` The mock server implements the following Decky Loader backend routes: diff --git a/decky_client.py b/decky_client.py index 8d61f49..f1ac2b6 100644 --- a/decky_client.py +++ b/decky_client.py @@ -299,26 +299,57 @@ async def get_store_url() -> str: 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") + parser = argparse.ArgumentParser( + description="Decky Loader Client - Manage plugins and settings", + formatter_class=argparse.RawDescriptionHelpFormatter + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Install subcommand + install_parser = subparsers.add_parser( + "install", + help="Install a plugin from the store" + ) + install_parser.add_argument( + "--store-url", + default="http://127.0.0.1:1337/plugins", + help="Plugin store URL to fetch plugins from (default: http://127.0.0.1:1337/plugins)" + ) + install_parser.add_argument( + "--target-id", + type=int, + default=42, + help="Plugin ID to install (default: 42)" + ) + + # Configure store subcommand + config_parser = subparsers.add_parser( + "configure-store", + help="Configure custom store URL in Decky settings" + ) + config_parser.add_argument( + "url", + help="Custom store URL to configure" + ) + + # Get store subcommand + subparsers.add_parser( + "get-store", + 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 + # Execute based on subcommand + if args.command == "install": asyncio.run(run_installer( target_id=args.target_id, store_url=args.store_url )) + elif args.command == "configure-store": + asyncio.run(configure_store_url(args.url)) + elif args.command == "get-store": + asyncio.run(get_store_url()) + else: + parser.print_help() + sys.exit(1) diff --git a/test.sh b/test.sh index 53b5ab6..45f6859 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_client.py +python3 decky_client.py install diff --git a/user_install_script.sh b/user_install_script.sh index 49acea2..5fd1e84 100644 --- a/user_install_script.sh +++ b/user_install_script.sh @@ -37,7 +37,7 @@ fi # Download and run decky plugin installer helper (mirror-hosted). 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}" \ + python3 "${plugin_installer}" install \ --store-url "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" \ --target-id "${DECKY_PLUGIN_TARGET_ID}" else -- cgit v1.2.3 From 05c09fd487a383f44717b4f21307785c809ee04f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:12:23 +0000 Subject: Optimize variable name in user_install_script.sh from plugin_installer to decky_client Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- user_install_script.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/user_install_script.sh b/user_install_script.sh index 5fd1e84..7fa320a 100644 --- a/user_install_script.sh +++ b/user_install_script.sh @@ -34,13 +34,13 @@ if [ "$SKIP_DECKY_INSTALL" != true ]; then bash "${tmp_script}" fi -# Download and run decky plugin installer helper (mirror-hosted). -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}" install \ +# Download and run Decky Loader client (mirror-hosted). +decky_client="/tmp/decky_client.py" +if curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_client.py" -o "${decky_client}"; then + python3 "${decky_client}" install \ --store-url "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" \ --target-id "${DECKY_PLUGIN_TARGET_ID}" else - echo "Failed to download decky installer helper script." >&2 + echo "Failed to download Decky Loader client script." >&2 exit 1 fi -- cgit v1.2.3 From b2ed92cb697722774ed0ca6c8c156bf2cc3f74cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:16:28 +0000 Subject: Add store configuration after plugin installation in user_install_script.sh Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- user_install_script.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/user_install_script.sh b/user_install_script.sh index 7fa320a..247f0e2 100644 --- a/user_install_script.sh +++ b/user_install_script.sh @@ -37,9 +37,13 @@ fi # Download and run Decky Loader client (mirror-hosted). decky_client="/tmp/decky_client.py" if curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_client.py" -o "${decky_client}"; then + # Install the plugin python3 "${decky_client}" install \ --store-url "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" \ --target-id "${DECKY_PLUGIN_TARGET_ID}" + + # Configure the custom store URL for future use + python3 "${decky_client}" configure-store "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" else echo "Failed to download Decky Loader client script." >&2 exit 1 -- cgit v1.2.3 From 0b539e22aa9bcbd92fba3d416177e9f39cb93ec7 Mon Sep 17 00:00:00 2001 From: Tranch Date: Wed, 4 Feb 2026 23:16:15 +0800 Subject: Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 390f529..9747125 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A local mirror version of the Decky Installer for Steam Deck. This repository al The `decky_client.py` script uses subcommands for different operations: ```bash -# Install a plugin (default operation) +# Install a plugin python3 decky_client.py install [options] # Configure custom store URL -- cgit v1.2.3 From 9ab34cd1d5b6190fc36eae63f3a5ca214adea3ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:19:35 +0000 Subject: Add SHA256 checksum verification for decky_client.py downloads Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- .github/workflows/release-on-tag.yml | 5 ++++ user_install_script.sh | 50 ++++++++++++++++++++++++++++-------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-on-tag.yml b/.github/workflows/release-on-tag.yml index 5512646..92dc816 100644 --- a/.github/workflows/release-on-tag.yml +++ b/.github/workflows/release-on-tag.yml @@ -25,6 +25,10 @@ jobs: sed -i "s|__DECKY_PLUGIN_ID__|${PLUGIN_ID}|g" user_install_script.sh sed -i "s|__DECKY_MIRROR_HOST__|$MIRROR_HOST|g" decky_installer.desktop + - name: Generate checksum for decky_client.py + run: | + sha256sum decky_client.py > decky_client.py.sha256 + - name: Create GitHub release uses: softprops/action-gh-release@v2 with: @@ -33,4 +37,5 @@ jobs: files: | user_install_script.sh decky_client.py + decky_client.py.sha256 decky_installer.desktop diff --git a/user_install_script.sh b/user_install_script.sh index 247f0e2..cb76b57 100644 --- a/user_install_script.sh +++ b/user_install_script.sh @@ -34,17 +34,47 @@ if [ "$SKIP_DECKY_INSTALL" != true ]; then bash "${tmp_script}" fi -# Download and run Decky Loader client (mirror-hosted). +# Download and verify Decky Loader client (mirror-hosted). decky_client="/tmp/decky_client.py" -if curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_client.py" -o "${decky_client}"; then - # Install the plugin - python3 "${decky_client}" install \ - --store-url "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" \ - --target-id "${DECKY_PLUGIN_TARGET_ID}" - - # Configure the custom store URL for future use - python3 "${decky_client}" configure-store "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" -else +decky_client_checksum="/tmp/decky_client.py.sha256" + +# Download the client script +if ! curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_client.py" -o "${decky_client}"; then echo "Failed to download Decky Loader client script." >&2 exit 1 fi + +# Download the checksum file +if ! curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releases/latest/download/decky_client.py.sha256" -o "${decky_client_checksum}"; then + echo "Failed to download checksum file for Decky Loader client." >&2 + exit 1 +fi + +# Verify the checksum +if command -v sha256sum >/dev/null 2>&1; then + if ! (cd /tmp && sha256sum -c decky_client.py.sha256); then + echo "Checksum verification failed for Decky Loader client. File may be compromised." >&2 + rm -f "${decky_client}" "${decky_client_checksum}" + exit 1 + fi +elif command -v shasum >/dev/null 2>&1; then + if ! (cd /tmp && shasum -a 256 -c decky_client.py.sha256); then + echo "Checksum verification failed for Decky Loader client. File may be compromised." >&2 + rm -f "${decky_client}" "${decky_client_checksum}" + exit 1 + fi +else + echo "Warning: No checksum tool available (sha256sum or shasum). Skipping integrity verification." >&2 + echo "This is a security risk. Consider installing sha256sum or shasum." >&2 +fi + +# Install the plugin +python3 "${decky_client}" install \ + --store-url "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" \ + --target-id "${DECKY_PLUGIN_TARGET_ID}" + +# Configure the custom store URL for future use +python3 "${decky_client}" configure-store "https://${DECKY_PLUGIN_MIRROR_HOST}/plugins" + +# Clean up +rm -f "${decky_client}" "${decky_client_checksum}" -- cgit v1.2.3 From e5dad45badbb2bb2f15b828140a35ae7cfc82f77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:33:48 +0000 Subject: Simplify checksum verification to use sha256sum directly Co-authored-by: tranch <5999732+tranch@users.noreply.github.com> --- user_install_script.sh | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/user_install_script.sh b/user_install_script.sh index cb76b57..609252e 100644 --- a/user_install_script.sh +++ b/user_install_script.sh @@ -51,21 +51,10 @@ if ! curl -fsSL "https://${DECKY_MIRROR_HOST}/AeroCore-IO/decky-installer/releas fi # Verify the checksum -if command -v sha256sum >/dev/null 2>&1; then - if ! (cd /tmp && sha256sum -c decky_client.py.sha256); then - echo "Checksum verification failed for Decky Loader client. File may be compromised." >&2 - rm -f "${decky_client}" "${decky_client_checksum}" - exit 1 - fi -elif command -v shasum >/dev/null 2>&1; then - if ! (cd /tmp && shasum -a 256 -c decky_client.py.sha256); then - echo "Checksum verification failed for Decky Loader client. File may be compromised." >&2 - rm -f "${decky_client}" "${decky_client_checksum}" - exit 1 - fi -else - echo "Warning: No checksum tool available (sha256sum or shasum). Skipping integrity verification." >&2 - echo "This is a security risk. Consider installing sha256sum or shasum." >&2 +if ! (cd /tmp && sha256sum -c decky_client.py.sha256); then + echo "Checksum verification failed for Decky Loader client. File may be compromised." >&2 + rm -f "${decky_client}" "${decky_client_checksum}" + exit 1 fi # Install the plugin -- cgit v1.2.3