diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index f83a7af..076c123 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -2,7 +2,7 @@ pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.05.06.r394.ga5f7c9a +pkgver=2026.05.14.r396.ge2f9a7c pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" diff --git a/src/cthulhu/plugins/CthulhuRemote/__init__.py b/src/cthulhu/plugins/CthulhuRemote/__init__.py new file mode 100644 index 0000000..d0212af --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/__init__.py @@ -0,0 +1 @@ +"""Cthulhu Remote plugin package.""" diff --git a/src/cthulhu/plugins/CthulhuRemote/connection_info.py b/src/cthulhu/plugins/CthulhuRemote/connection_info.py new file mode 100644 index 0000000..763b988 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/connection_info.py @@ -0,0 +1,78 @@ +"""Connection metadata and URL parsing for Cthulhu Remote.""" + +from dataclasses import dataclass +from enum import Enum +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from .protocol import SERVER_PORT, URL_PREFIX +from .socket_utils import host_port_to_address + + +class URLParsingError(Exception): + """Raised when a remote connection URL is incomplete or invalid.""" + + +class ConnectionMode(Enum): + """Remote session role.""" + + MASTER = "master" + SLAVE = "slave" + + +class ConnectionState(Enum): + """Remote session connection state.""" + + CONNECTED = "connected" + CONNECTING = "connecting" + DISCONNECTED = "disconnected" + DISCONNECTING = "disconnecting" + + +@dataclass +class ConnectionInfo: + """Relay connection settings.""" + + hostname: str + mode: ConnectionMode + key: str + port: int = SERVER_PORT + insecure: bool = False + + def __post_init__(self) -> None: + self.port = self.port or SERVER_PORT + self.mode = ConnectionMode(self.mode) + + @classmethod + def from_url(cls, url: str) -> "ConnectionInfo": + parsedUrl = urlparse(url) + parsedQuery = parse_qs(parsedUrl.query) + hostname = parsedUrl.hostname + port = parsedUrl.port or SERVER_PORT + key = parsedQuery.get("key", [""])[0] + mode = parsedQuery.get("mode", [""])[0].lower() + insecure = parsedQuery.get("insecure", ["false"])[0].lower() == "true" + if not hostname: + raise URLParsingError("No hostname provided") + if not key: + raise URLParsingError("No key provided") + if not mode: + raise URLParsingError("No mode provided") + try: + ConnectionMode(mode) + except ValueError as error: + raise URLParsingError(f"Invalid mode provided: {mode!r}") from error + return cls(hostname=hostname, mode=ConnectionMode(mode), key=key, port=port, insecure=insecure) + + def get_address(self) -> str: + return host_port_to_address((self.hostname, self.port)) + + def get_url(self, mode: ConnectionMode | None = None) -> str: + mode = mode or self.mode + query = {"key": self.key, "mode": mode.value} + if self.insecure: + query["insecure"] = "true" + return urlunparse((URL_PREFIX.split("://", 1)[0], self.get_address(), "", "", urlencode(query), "")) + + def get_url_to_connect(self) -> str: + mode = ConnectionMode.SLAVE if self.mode == ConnectionMode.MASTER else ConnectionMode.MASTER + return self.get_url(mode) diff --git a/src/cthulhu/plugins/CthulhuRemote/local_machine.py b/src/cthulhu/plugins/CthulhuRemote/local_machine.py new file mode 100644 index 0000000..39a48d3 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/local_machine.py @@ -0,0 +1,156 @@ +"""Linux desktop integration for Cthulhu Remote commands.""" + +from __future__ import annotations + +import logging +from typing import Any + +import gi + +gi.require_version("Atspi", "2.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Atspi, Gdk, GLib, Gtk + +from cthulhu import speech + +logger = logging.getLogger(__name__) + + +WINDOWS_VK_TO_ATSPI_KEYSYM = { + 0x08: "BackSpace", + 0x09: "Tab", + 0x0D: "Return", + 0x10: "Shift_L", + 0x11: "Control_L", + 0x12: "Alt_L", + 0x13: "Pause", + 0x14: "Caps_Lock", + 0x1B: "Escape", + 0x20: "space", + 0x21: "Page_Up", + 0x22: "Page_Down", + 0x23: "End", + 0x24: "Home", + 0x25: "Left", + 0x26: "Up", + 0x27: "Right", + 0x28: "Down", + 0x2D: "Insert", + 0x2E: "Delete", + 0x5B: "Super_L", + 0x5C: "Super_R", + 0x60: "KP_0", + 0x61: "KP_1", + 0x62: "KP_2", + 0x63: "KP_3", + 0x64: "KP_4", + 0x65: "KP_5", + 0x66: "KP_6", + 0x67: "KP_7", + 0x68: "KP_8", + 0x69: "KP_9", + 0x6A: "KP_Multiply", + 0x6B: "KP_Add", + 0x6D: "KP_Subtract", + 0x6E: "KP_Decimal", + 0x6F: "KP_Divide", +} + +for _digit in range(0x30, 0x3A): + WINDOWS_VK_TO_ATSPI_KEYSYM[_digit] = chr(_digit) +for _letter in range(0x41, 0x5B): + WINDOWS_VK_TO_ATSPI_KEYSYM[_letter] = chr(_letter).lower() +for _functionKey in range(1, 25): + WINDOWS_VK_TO_ATSPI_KEYSYM[0x6F + _functionKey] = f"F{_functionKey}" + + +class LocalMachine: + """Apply incoming remote commands to the local Linux desktop.""" + + def __init__(self, presenter) -> None: + self._presenter = presenter + self.isMuted = False + + def speak(self, sequence: Any = None, **kwargs: Any) -> None: + if self.isMuted: + return + text = self._speech_sequence_to_text(sequence) + if text: + speech.speak(text, interrupt=False) + + def cancel_speech(self, **kwargs: Any) -> None: + if not self.isMuted: + speech.stop() + + def pause_speech(self, switch: bool = False, **kwargs: Any) -> None: + if switch: + speech.stop() + + def set_clipboard_text(self, text: str = "", **kwargs: Any) -> None: + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(text, -1) + clipboard.store() + self._presenter("Remote clipboard received") + + def send_key( + self, + vk_code: int | None = None, + keysym: str | None = None, + pressed: bool | None = True, + **kwargs: Any, + ) -> None: + keyval = self._resolve_keyval(vk_code=vk_code, keysym=keysym) + if keyval is None: + logger.debug("Ignoring unmapped remote key: vk_code=%r keysym=%r", vk_code, keysym) + return + eventType = Atspi.KeySynthType.PRESS if pressed else Atspi.KeySynthType.RELEASE + GLib.idle_add(self._generate_key_event, keyval, eventType) + + def display(self, cells: list[int] | None = None, **kwargs: Any) -> None: + logger.debug("Remote braille display update ignored until braille routing is implemented: %r", cells) + + def braille_input(self, **kwargs: Any) -> None: + logger.debug("Remote braille input ignored until braille routing is implemented: %r", kwargs) + + def set_braille_display_size(self, **kwargs: Any) -> None: + logger.debug("Remote braille size negotiation ignored until braille routing is implemented: %r", kwargs) + + def send_secure_attention_sequence(self, **kwargs: Any) -> None: + self._presenter("Ctrl Alt Delete is not available on Linux desktops") + + def _generate_key_event(self, keyval: int, eventType: Atspi.KeySynthType) -> bool: + try: + Atspi.generate_keyboard_event(keyval, None, eventType) + except Exception: + logger.exception("Failed to generate remote key event") + return False + + def _resolve_keyval(self, vk_code: int | None, keysym: str | None) -> int | None: + if keysym: + keyval = Gdk.keyval_from_name(keysym) + return keyval if keyval else None + if vk_code is None: + return None + mappedKeysym = WINDOWS_VK_TO_ATSPI_KEYSYM.get(int(vk_code)) + if not mappedKeysym: + return None + keyval = Gdk.keyval_from_name(mappedKeysym) + return keyval if keyval else None + + def _speech_sequence_to_text(self, sequence: Any) -> str: + if sequence is None: + return "" + if isinstance(sequence, str): + return sequence + if not isinstance(sequence, list): + return str(sequence) + parts = [] + for item in sequence: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, list): + nested = self._speech_sequence_to_text(item) + if nested: + parts.append(nested) + return " ".join(parts) diff --git a/src/cthulhu/plugins/CthulhuRemote/meson.build b/src/cthulhu/plugins/CthulhuRemote/meson.build new file mode 100644 index 0000000..cf579c2 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/meson.build @@ -0,0 +1,20 @@ +cthulhu_remote_python_sources = files([ + '__init__.py', + 'connection_info.py', + 'local_machine.py', + 'plugin.py', + 'protocol.py', + 'serializer.py', + 'socket_utils.py', + 'transport.py' +]) + +python3.install_sources( + cthulhu_remote_python_sources, + subdir: 'cthulhu/plugins/CthulhuRemote' +) + +install_data( + 'plugin.info', + install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'CthulhuRemote' +) diff --git a/src/cthulhu/plugins/CthulhuRemote/plugin.info b/src/cthulhu/plugins/CthulhuRemote/plugin.info new file mode 100644 index 0000000..aae2024 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/plugin.info @@ -0,0 +1,7 @@ +[Plugin] +Name = Cthulhu Remote +Module = CthulhuRemote +Description = Remote access plugin compatible with NVDA Remote relay servers +Authors = Storm Dragon +Version = 0.1 +Category = Utilities diff --git a/src/cthulhu/plugins/CthulhuRemote/plugin.py b/src/cthulhu/plugins/CthulhuRemote/plugin.py new file mode 100644 index 0000000..8061345 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/plugin.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""Cthulhu Remote plugin.""" + +from __future__ import annotations + +import logging +import secrets +from typing import Any + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +from gi.repository import Gdk, Gtk + +from cthulhu import dbus_service +from cthulhu.plugin import Plugin, cthulhu_hookimpl + +from cthulhu.plugins.CthulhuRemote.connection_info import ( + ConnectionInfo, + ConnectionMode, + ConnectionState, + URLParsingError, +) +from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine +from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType, SERVER_PORT +from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer +from cthulhu.plugins.CthulhuRemote.socket_utils import address_to_host_port +from cthulhu.plugins.CthulhuRemote.transport import RelayTransport + +logger = logging.getLogger(__name__) + + +class CthulhuRemote(Plugin): + """Remote access plugin compatible with NVDA Remote relay servers.""" + + def __init__(self) -> None: + super().__init__() + self._transport: RelayTransport | None = None + self._connectionInfo: ConnectionInfo | None = None + self._connectionState = ConnectionState.DISCONNECTED + self._localMachine = LocalMachine(self._present_message) + self._muted = False + + @cthulhu_hookimpl + def activate(self, plugin=None): + if plugin is not None and plugin is not self: + return + self.registerGestureByString( + self.disconnect, + "Disconnect Cthulhu Remote", + "kb:cthulhu+alt+page_down", + ) + self.registerGestureByString( + self.toggle_mute, + "Toggle Cthulhu Remote mute", + "kb:cthulhu+alt+delete", + ) + self.registerGestureByString( + self.push_clipboard, + "Push clipboard to Cthulhu Remote peers", + "kb:cthulhu+control+shift+c", + ) + logger.info("Cthulhu Remote activated") + + @cthulhu_hookimpl + def deactivate(self, plugin=None): + if plugin is not None and plugin is not self: + return + self.disconnect(notify_user=False) + logger.info("Cthulhu Remote deactivated") + + @dbus_service.parameterized_command + def connect( + self, + host: str, + key: str, + mode: str = "master", + port: int = SERVER_PORT, + insecure: bool = False, + notify_user: bool = True, + ) -> bool: + """Connect to a Cthulhu Remote relay server.""" + + if not host or not key: + if notify_user: + self._present_message("Cthulhu Remote requires host and key") + return False + try: + connectionInfo = ConnectionInfo( + hostname=host, + port=port, + key=key, + mode=ConnectionMode(mode.lower()), + insecure=insecure, + ) + except ValueError: + if notify_user: + self._present_message("Cthulhu Remote mode must be master or slave") + return False + return self._connect(connectionInfo, notify_user=notify_user) + + @dbus_service.parameterized_command + def connect_to_address( + self, + address: str, + key: str, + mode: str = "master", + insecure: bool = False, + notify_user: bool = True, + ) -> bool: + """Connect using host[:port] address text.""" + + host, port = address_to_host_port(address) + return self.connect(host, key, mode=mode, port=port, insecure=insecure, notify_user=notify_user) + + @dbus_service.parameterized_command + def connect_to_url(self, url: str, notify_user: bool = True) -> bool: + """Connect using a cthulhuremote:// or nvdaremote:// URL.""" + + if url.startswith("nvdaremote://"): + url = "cthulhuremote://" + url.split("://", 1)[1] + try: + connectionInfo = ConnectionInfo.from_url(url) + except URLParsingError as error: + logger.warning("Invalid Cthulhu Remote URL: %s", error) + if notify_user: + self._present_message("Invalid Cthulhu Remote URL") + return False + return self._connect(connectionInfo, notify_user=notify_user) + + @dbus_service.command + def disconnect(self, script=None, inputEvent=None, notify_user: bool = True) -> bool: + """Disconnect the current Cthulhu Remote session.""" + + if self._transport is not None: + self._connectionState = ConnectionState.DISCONNECTING + self._transport.close() + self._transport = None + self._connectionState = ConnectionState.DISCONNECTED + if notify_user: + self._present_message("Cthulhu Remote disconnected") + return True + + @dbus_service.command + def toggle_mute(self, script=None, inputEvent=None) -> bool: + """Mute or unmute incoming remote output.""" + + self._muted = not self._muted + self._localMachine.isMuted = self._muted + self._present_message("Cthulhu Remote muted" if self._muted else "Cthulhu Remote unmuted") + return True + + @dbus_service.command + def push_clipboard(self, script=None, inputEvent=None) -> bool: + """Push local clipboard text to connected remote peers.""" + + if not self._transport or not self._transport.connected: + self._present_message("Cthulhu Remote is not connected") + return True + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + text = clipboard.wait_for_text() + if not text: + self._present_message("Clipboard does not contain text") + return True + self._transport.send(RemoteMessageType.set_clipboard_text, text=text) + self._present_message("Clipboard pushed") + return True + + @dbus_service.command + def copy_invite_url(self, script=None, inputEvent=None) -> bool: + """Copy a remote invite URL for the opposite connection role.""" + + if not self._connectionInfo: + self._present_message("Cthulhu Remote is not connected") + return True + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(self._connectionInfo.get_url_to_connect(), -1) + clipboard.store() + self._present_message("Cthulhu Remote invite copied") + return True + + @dbus_service.command + def generate_key(self) -> str: + """Generate a random remote connection key.""" + + return secrets.token_urlsafe(18) + + @dbus_service.getter + def get_state(self) -> str: + """Return the Cthulhu Remote connection state.""" + + return self._connectionState.value + + @dbus_service.getter + def get_connection_url(self) -> str: + """Return the current connection URL, if connected.""" + + if not self._connectionInfo: + return "" + return self._connectionInfo.get_url() + + def _connect(self, connectionInfo: ConnectionInfo, notify_user: bool = True) -> bool: + self.disconnect(notify_user=False) + serializer = JSONSerializer() + transport = RelayTransport.create(connectionInfo, serializer) + self._register_transport_handlers(transport, connectionInfo.mode) + transport.transportConnected.register(self._handle_transport_connected) + transport.transportDisconnected.register(self._handle_transport_disconnected) + transport.transportConnectionFailed.register(self._handle_transport_failed) + transport.transportCertificateAuthenticationFailed.register(self._handle_certificate_failed) + self._transport = transport + self._connectionInfo = connectionInfo + self._connectionState = ConnectionState.CONNECTING + transport.start() + if notify_user: + self._present_message("Cthulhu Remote connecting") + return True + + def _register_transport_handlers(self, transport: RelayTransport, mode: ConnectionMode) -> None: + transport.register_inbound(RemoteMessageType.speak, self._localMachine.speak) + transport.register_inbound(RemoteMessageType.cancel, self._localMachine.cancel_speech) + transport.register_inbound(RemoteMessageType.pause_speech, self._localMachine.pause_speech) + transport.register_inbound(RemoteMessageType.set_clipboard_text, self._localMachine.set_clipboard_text) + transport.register_inbound(RemoteMessageType.motd, self._handle_motd) + transport.register_inbound(RemoteMessageType.version_mismatch, self._handle_version_mismatch) + transport.register_inbound(RemoteMessageType.channel_joined, self._handle_channel_joined) + transport.register_inbound(RemoteMessageType.client_joined, self._handle_client_joined) + transport.register_inbound(RemoteMessageType.client_left, self._handle_client_left) + if mode == ConnectionMode.SLAVE: + transport.register_inbound(RemoteMessageType.key, self._localMachine.send_key) + transport.register_inbound(RemoteMessageType.braille_input, self._localMachine.braille_input) + transport.register_inbound( + RemoteMessageType.send_SAS, + self._localMachine.send_secure_attention_sequence, + ) + else: + transport.register_inbound(RemoteMessageType.display, self._localMachine.display) + transport.register_inbound( + RemoteMessageType.set_display_size, + self._localMachine.set_braille_display_size, + ) + + def _handle_transport_connected(self, **kwargs: Any) -> None: + self._connectionState = ConnectionState.CONNECTED + self._present_message("Cthulhu Remote connected") + + def _handle_transport_disconnected(self, **kwargs: Any) -> None: + if self._connectionState != ConnectionState.DISCONNECTING: + self._connectionState = ConnectionState.DISCONNECTED + self._present_message("Cthulhu Remote disconnected") + + def _handle_transport_failed(self, **kwargs: Any) -> None: + self._connectionState = ConnectionState.DISCONNECTED + self._present_message("Cthulhu Remote connection failed") + + def _handle_certificate_failed(self, **kwargs: Any) -> None: + self._connectionState = ConnectionState.DISCONNECTED + fingerprint = self._transport.lastFailFingerprint if self._transport else None + if fingerprint: + logger.warning("Cthulhu Remote certificate fingerprint: %s", fingerprint) + self._present_message("Cthulhu Remote certificate verification failed") + + def _handle_motd(self, motd: str = "", **kwargs: Any) -> None: + if motd: + self._present_message(motd) + + def _handle_version_mismatch(self, **kwargs: Any) -> None: + self._present_message("Cthulhu Remote relay protocol mismatch") + self.disconnect(notify_user=False) + + def _handle_channel_joined(self, **kwargs: Any) -> None: + logger.info("Cthulhu Remote channel joined: %r", kwargs) + + def _handle_client_joined(self, client: dict[str, Any] | None = None, **kwargs: Any) -> None: + logger.info("Cthulhu Remote client joined: %r", client or kwargs) + self._present_message("Cthulhu Remote client joined") + + def _handle_client_left(self, client: dict[str, Any] | None = None, **kwargs: Any) -> None: + logger.info("Cthulhu Remote client left: %r", client or kwargs) + self._present_message("Cthulhu Remote client left") + + def _present_message(self, message: str) -> None: + if not self.app: + return + try: + state = self.app.getDynamicApiManager().getAPI("CthulhuState") + if state and state.activeScript: + state.activeScript.presentMessage(message, resetStyles=False) + except Exception: + logger.exception("Failed to present Cthulhu Remote message") diff --git a/src/cthulhu/plugins/CthulhuRemote/protocol.py b/src/cthulhu/plugins/CthulhuRemote/protocol.py new file mode 100644 index 0000000..428d53b --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/protocol.py @@ -0,0 +1,41 @@ +"""Protocol constants for Cthulhu Remote. + +The wire protocol is intentionally compatible with NVDA Remote protocol +version 2 so relay servers can be shared. +""" + +from enum import Enum + + +PROTOCOL_VERSION = 2 +SERVER_PORT = 6837 +URL_PREFIX = "cthulhuremote://" + + +class RemoteMessageType(Enum): + """Message types used by the remote relay protocol.""" + + protocol_version = "protocol_version" + join = "join" + channel_joined = "channel_joined" + client_joined = "client_joined" + client_left = "client_left" + generate_key = "generate_key" + key = "key" + speak = "speak" + cancel = "cancel" + pause_speech = "pause_speech" + tone = "tone" + wave = "wave" + send_SAS = "send_SAS" + index = "index" + display = "display" + braille_input = "braille_input" + set_braille_info = "set_braille_info" + set_display_size = "set_display_size" + set_clipboard_text = "set_clipboard_text" + motd = "motd" + version_mismatch = "version_mismatch" + ping = "ping" + error = "error" + nvda_not_connected = "nvda_not_connected" diff --git a/src/cthulhu/plugins/CthulhuRemote/serializer.py b/src/cthulhu/plugins/CthulhuRemote/serializer.py new file mode 100644 index 0000000..2f54e80 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/serializer.py @@ -0,0 +1,20 @@ +"""JSON message serialization for Cthulhu Remote.""" + +import json +from enum import Enum +from typing import Any + + +class JSONSerializer: + """Serialize newline-delimited JSON remote messages.""" + + separator = b"\n" + + def serialize(self, messageType=None, **payload: Any) -> bytes: + if isinstance(messageType, Enum): + messageType = messageType.value + payload["type"] = messageType + return json.dumps(payload).encode("utf-8") + self.separator + + def deserialize(self, data: bytes) -> dict[str, Any]: + return json.loads(data.decode("utf-8")) diff --git a/src/cthulhu/plugins/CthulhuRemote/socket_utils.py b/src/cthulhu/plugins/CthulhuRemote/socket_utils.py new file mode 100644 index 0000000..c7e71c9 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/socket_utils.py @@ -0,0 +1,23 @@ +"""Socket address helpers for Cthulhu Remote.""" + +import urllib.parse + +from .protocol import SERVER_PORT + + +def address_to_host_port(address: str) -> tuple[str, int]: + """Convert host[:port] text to a host, port tuple.""" + + parsedAddress = urllib.parse.urlparse("//" + address) + return parsedAddress.hostname or "", parsedAddress.port or SERVER_PORT + + +def host_port_to_address(hostPort: tuple[str, int]) -> str: + """Convert a host, port tuple to compact host[:port] text.""" + + host, port = hostPort + if ":" in host: + host = f"[{host}]" + if port != SERVER_PORT: + return f"{host}:{port}" + return host diff --git a/src/cthulhu/plugins/CthulhuRemote/transport.py b/src/cthulhu/plugins/CthulhuRemote/transport.py new file mode 100644 index 0000000..766c201 --- /dev/null +++ b/src/cthulhu/plugins/CthulhuRemote/transport.py @@ -0,0 +1,269 @@ +"""Threaded TLS transport for Cthulhu Remote relay connections.""" + +from __future__ import annotations + +import hashlib +import logging +import queue +import select +import socket +import ssl +import threading +import time +from collections.abc import Callable +from enum import Enum +from typing import Any + +from gi.repository import GLib + +from .connection_info import ConnectionInfo +from .protocol import PROTOCOL_VERSION, RemoteMessageType +from .serializer import JSONSerializer +from .socket_utils import host_port_to_address + +logger = logging.getLogger(__name__) + + +class Action: + """Small callback registrar used by the transport layer.""" + + def __init__(self) -> None: + self._handlers: list[Callable[..., Any]] = [] + self._lock = threading.Lock() + + def register(self, handler: Callable[..., Any]) -> None: + with self._lock: + if handler not in self._handlers: + self._handlers.append(handler) + + def unregister(self, handler: Callable[..., Any]) -> None: + with self._lock: + if handler in self._handlers: + self._handlers.remove(handler) + + def notify(self, **kwargs: Any) -> None: + with self._lock: + handlers = list(self._handlers) + for handler in handlers: + handler(**kwargs) + + +class RelayTransport: + """Connect to an NVDA Remote compatible relay server over TLS.""" + + def __init__( + self, + serializer: JSONSerializer, + address: tuple[str, int], + channel: str, + connectionType: str, + insecure: bool = False, + timeout: int = 0, + protocolVersion: int = PROTOCOL_VERSION, + ) -> None: + self.serializer = serializer + self.address = address + self.channel = channel + self.connectionType = connectionType + self.insecure = insecure + self.timeout = timeout + self.protocolVersion = protocolVersion + self.connected = False + self.closed = True + self.successfulConnects = 0 + self.lastFailFingerprint: str | None = None + self._buffer = b"" + self._socket: ssl.SSLSocket | None = None + self._socketLock = threading.Lock() + self._sendQueue: queue.Queue[bytes | None] = queue.Queue() + self._sendThread: threading.Thread | None = None + self._connectorThread: threading.Thread | None = None + self._running = threading.Event() + self._handlers: dict[RemoteMessageType, Action] = {} + self.transportConnected = Action() + self.transportDisconnected = Action() + self.transportCertificateAuthenticationFailed = Action() + self.transportConnectionFailed = Action() + self.transportClosing = Action() + self.transportConnected.register(self._send_join_messages) + + @classmethod + def create(cls, connectionInfo: ConnectionInfo, serializer: JSONSerializer) -> "RelayTransport": + return cls( + serializer=serializer, + address=(connectionInfo.hostname, connectionInfo.port), + channel=connectionInfo.key, + connectionType=connectionInfo.mode.value, + insecure=connectionInfo.insecure, + ) + + def start(self) -> None: + if self._connectorThread and self._connectorThread.is_alive(): + return + self.closed = False + self._running.set() + self._connectorThread = threading.Thread( + target=self._connector_loop, + name="CthulhuRemoteConnector", + daemon=True, + ) + self._connectorThread.start() + + def close(self) -> None: + self.transportClosing.notify() + self.closed = True + self._running.clear() + self._disconnect() + + def register_inbound(self, messageType: RemoteMessageType, handler: Callable[..., Any]) -> None: + self._handlers.setdefault(messageType, Action()).register(handler) + + def unregister_inbound(self, messageType: RemoteMessageType, handler: Callable[..., Any]) -> None: + if messageType in self._handlers: + self._handlers[messageType].unregister(handler) + + def send(self, messageType: str | Enum, **payload: Any) -> None: + if not self.connected: + logger.debug("Dropping remote message while disconnected: %r", messageType) + return + self._sendQueue.put(self.serializer.serialize(messageType=messageType, **payload)) + + def _connector_loop(self) -> None: + while self._running.is_set(): + try: + self._run_once() + except ssl.SSLCertVerificationError: + logger.warning("Cthulhu Remote certificate verification failed") + except OSError as error: + logger.info("Cthulhu Remote connection failed: %s", error) + except Exception: + logger.exception("Cthulhu Remote connector failed") + if self._running.is_set(): + time.sleep(5) + + def _run_once(self) -> None: + try: + self._socket = self._create_socket() + self._socket.connect(self.address) + except ssl.SSLCertVerificationError: + self._capture_failed_fingerprint() + self.transportCertificateAuthenticationFailed.notify() + raise + except Exception: + self.transportConnectionFailed.notify() + raise + + self.connected = True + self.successfulConnects += 1 + self.transportConnected.notify() + self._sendThread = threading.Thread(target=self._send_loop, name="CthulhuRemoteSend", daemon=True) + self._sendThread.start() + + try: + while self._running.is_set() and self._socket is not None: + readers, _, errors = select.select([self._socket], [], [self._socket], 1.0) + if errors: + break + if readers: + self._read_available() + finally: + self.connected = False + self.transportDisconnected.notify() + self._disconnect() + + def _create_socket(self, insecure: bool | None = None) -> ssl.SSLSocket: + insecure = self.insecure if insecure is None else insecure + host, port = self.address + family, socktype, proto, _, _ = socket.getaddrinfo(host, port)[0] + plainSocket = socket.socket(family, socktype, proto) + if self.timeout: + plainSocket.settimeout(self.timeout) + plainSocket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + plainSocket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + context = ssl._create_unverified_context() if insecure else ssl.create_default_context() + return context.wrap_socket(plainSocket, server_hostname=host) + + def _capture_failed_fingerprint(self) -> None: + try: + socketForCert = self._create_socket(insecure=True) + socketForCert.connect(self.address) + certificate = socketForCert.getpeercert(binary_form=True) + socketForCert.close() + if certificate: + self.lastFailFingerprint = hashlib.sha256(certificate).hexdigest().lower() + except Exception: + self.lastFailFingerprint = None + + def _send_join_messages(self) -> None: + self.send(RemoteMessageType.protocol_version, version=self.protocolVersion) + self.send(RemoteMessageType.join, channel=self.channel, connection_type=self.connectionType) + + def _read_available(self) -> None: + if self._socket is None: + return + with self._socketLock: + self._socket.setblocking(False) + try: + data = self._buffer + self._socket.recv(16384) + except ssl.SSLWantReadError: + return + finally: + self._socket.setblocking(True) + self._buffer = b"" + if not data: + self._disconnect() + return + while b"\n" in data: + line, _, data = data.partition(b"\n") + self._parse_line(line) + self._buffer = data + + def _parse_line(self, line: bytes) -> None: + try: + message = self.serializer.deserialize(line) + messageType = RemoteMessageType(message.pop("type")) + except Exception: + logger.exception("Ignoring invalid remote message: %r", line) + return + action = self._handlers.get(messageType) + if action is None: + logger.debug("No handler registered for remote message type: %s", messageType.value) + return + GLib.idle_add(self._notify_action, action, message) + + def _notify_action(self, action: Action, payload: dict[str, Any]) -> bool: + action.notify(**payload) + return False + + def _send_loop(self) -> None: + while True: + item = self._sendQueue.get() + if item is None: + return + try: + with self._socketLock: + if self._socket is not None: + self._socket.sendall(item) + except OSError: + return + + def _disconnect(self) -> None: + if self._sendThread is not None: + self._sendQueue.put(None) + self._sendThread.join(timeout=2) + self._sendThread = None + self._clear_send_queue() + if self._socket is not None: + try: + self._socket.close() + except OSError: + pass + self._socket = None + self._buffer = b"" + + def _clear_send_queue(self) -> None: + try: + while True: + self._sendQueue.get_nowait() + except queue.Empty: + pass diff --git a/src/cthulhu/plugins/meson.build b/src/cthulhu/plugins/meson.build index 4654d79..d4f7682 100644 --- a/src/cthulhu/plugins/meson.build +++ b/src/cthulhu/plugins/meson.build @@ -2,6 +2,7 @@ subdir('AIAssistant') subdir('ByeCthulhu') subdir('Clipboard') +subdir('CthulhuRemote') subdir('DisplayVersion') subdir('HelloCthulhu') subdir('GameMode') diff --git a/tests/test_cthulhu_remote_plugin.py b/tests/test_cthulhu_remote_plugin.py new file mode 100644 index 0000000..d037e7a --- /dev/null +++ b/tests/test_cthulhu_remote_plugin.py @@ -0,0 +1,66 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +input_event_manager_stub = types.ModuleType("cthulhu.input_event_manager") +input_event_manager_stub.get_manager = mock.Mock(return_value=mock.Mock()) +sys.modules["cthulhu.input_event_manager"] = input_event_manager_stub + +from cthulhu.plugin_system_manager import PluginSystemManager +from cthulhu.plugins.CthulhuRemote.connection_info import ConnectionInfo, ConnectionMode +from cthulhu.plugins.CthulhuRemote.local_machine import LocalMachine +from cthulhu.plugins.CthulhuRemote.protocol import RemoteMessageType +from cthulhu.plugins.CthulhuRemote.serializer import JSONSerializer + + +class CthulhuRemotePluginTests(unittest.TestCase): + def test_connection_info_accepts_nvda_remote_style_fields(self): + info = ConnectionInfo.from_url("cthulhuremote://example.com:1234?key=abc&mode=slave") + + self.assertEqual(info.hostname, "example.com") + self.assertEqual(info.port, 1234) + self.assertEqual(info.key, "abc") + self.assertEqual(info.mode, ConnectionMode.SLAVE) + self.assertEqual( + info.get_url_to_connect(), + "cthulhuremote://example.com:1234?key=abc&mode=master", + ) + + def test_json_serializer_uses_remote_message_type_values(self): + serializer = JSONSerializer() + + payload = serializer.deserialize(serializer.serialize(RemoteMessageType.join, channel="abc")) + + self.assertEqual(payload["type"], "join") + self.assertEqual(payload["channel"], "abc") + + def test_local_machine_maps_common_windows_vk_codes_to_keyvals(self): + machine = LocalMachine(lambda message: None) + + self.assertEqual(machine._resolve_keyval(0x41, None), machine._resolve_keyval(None, "a")) + self.assertEqual(machine._resolve_keyval(0x70, None), machine._resolve_keyval(None, "F1")) + self.assertIsNone(machine._resolve_keyval(0xFF, None)) + + @mock.patch("cthulhu.plugin_system_manager.dbus_service.get_remote_controller") + def test_plugin_manager_can_load_cthulhu_remote(self, remote_controller): + remote_controller.return_value = mock.Mock() + app = mock.Mock() + app.getSignalManager.return_value = mock.Mock() + app.getAPIHelper.return_value = None + manager = PluginSystemManager(app) + manager.rescanPlugins() + plugin_info = manager._resolve_plugin_info("CthulhuRemote") + + self.assertIsNotNone(plugin_info) + self.assertTrue(manager.loadPlugin(plugin_info)) + + self.assertEqual(plugin_info.instance.__class__.__name__, "CthulhuRemote") + self.assertTrue(manager.unloadPlugin(plugin_info)) + + +if __name__ == "__main__": + unittest.main()