diff --git a/GuestTools/README.md b/GuestTools/README.md new file mode 100644 index 0000000..f95d84f --- /dev/null +++ b/GuestTools/README.md @@ -0,0 +1,34 @@ +# SimpleVM Guest Tools for Linux + +This bundle implements protocol version 2 with Python 3 standard-library code. +It targets modern systemd Debian/Ubuntu and Arch/Omarchy guests. + +## Architecture + +`simplevm-guest-tools.service` is the root agent. It concurrently retries the +QEMU virtio-serial port `/dev/virtio-ports/com.simplevm.agent.0` and an +AF_VSOCK listener on port 1021 for Apple Virtualization. Failure of either +transport does not stop the other. A user systemd service handles only desktop +clipboard and narrowly gated Hyprland display resizing. + +The processes communicate over `/run/simplevm-guest-tools/session.sock`. The +socket is group restricted; Linux `SO_PEERCRED` checks require an unprivileged +user on the session side and UID 0 on the daemon side. + +## Install + +Run `./install.sh`. It requests sudo itself, creates the `simplevm-agent` +account/group, installs and enables both units, and starts the system service. +Use `--with-wayland-clipboard` to install `wl-clipboard`, or +`--with-x11-agent` to install `spice-vdagent`. + +Wayland clipboard support uses `wl-copy` and `wl-paste`. X11/GNOME does not +advertise SimpleVM clipboard support; a separately configured spice channel +may use spice-vdagent. Vanilla spice-vdagent does not solve Hyprland Wayland +clipboard or display resizing. Hyprland resize is advertised only when a live +Hyprland session and `hyprctl` are available. + +Run `./uninstall.sh` to remove the code and units. The shared mount directory +is intentionally preserved. Run `./self-test.sh` for safe local tests. +For additional desktop users, add each user to `simplevm-agent` and have that +user sign out and in before starting the user unit. diff --git a/GuestTools/SECURITY.md b/GuestTools/SECURITY.md new file mode 100644 index 0000000..36e11f3 --- /dev/null +++ b/GuestTools/SECURITY.md @@ -0,0 +1,18 @@ +# Security notes + +The host API is an exact allowlist. It has no command execution request and +accepts no host-provided executable, shell text, mount source, mount option, or +path. Privileged subprocesses use fixed argument arrays with `shell=False`. +The only mount is virtiofs tag `share` at `/mnt/simplevm-share`; power actions +are `systemctl poweroff` and `systemctl reboot`. + +Frames are UTF-8 JSON prefixed by a four-byte big-endian length. The 2 MiB +frame limit is checked before allocation. Clipboard UTF-8 is limited to exactly +1 MiB. Unknown, malformed, and oversized host frames close the connection. +Display dimensions are integers in 640..16384 by 480..16384. + +The root daemon never reads desktop-user files. The unprivileged helper derives +session details from its environment and `/etc/os-release`. Internal IPC uses a +fixed runtime socket, restrictive filesystem permissions, and peer credentials. +The root unit intentionally avoids a private mount namespace so the fixed +shared-directory mount is visible to the rest of the guest. diff --git a/GuestTools/VERSION b/GuestTools/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/GuestTools/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/GuestTools/bin/simplevm-guest-tools-daemon b/GuestTools/bin/simplevm-guest-tools-daemon new file mode 100755 index 0000000..307abea --- /dev/null +++ b/GuestTools/bin/simplevm-guest-tools-daemon @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys + +sys.path.insert(0, "/usr/lib/simplevm-guest-tools") + +from simplevm_guest_tools.daemon import main + +main() diff --git a/GuestTools/bin/simplevm-guest-tools-session b/GuestTools/bin/simplevm-guest-tools-session new file mode 100755 index 0000000..9619055 --- /dev/null +++ b/GuestTools/bin/simplevm-guest-tools-session @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys + +sys.path.insert(0, "/usr/lib/simplevm-guest-tools") + +from simplevm_guest_tools.user_helper import main + +main() diff --git a/GuestTools/install.sh b/GuestTools/install.sh new file mode 100755 index 0000000..a9d788c --- /dev/null +++ b/GuestTools/install.sh @@ -0,0 +1,105 @@ +#!/bin/sh +set -eu + +SOURCE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +INSTALL_USER=${SIMPLEVM_INSTALL_USER:-${SUDO_USER:-}} +INSTALL_WAYLAND=0 +INSTALL_X11=0 + +usage() { + echo "Usage: $0 [--with-wayland-clipboard] [--with-x11-agent]" +} + +for argument in "$@"; do + case "$argument" in + --with-wayland-clipboard) INSTALL_WAYLAND=1 ;; + --with-x11-agent) INSTALL_X11=1 ;; + --help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "SimpleVM Guest Tools requires root installation; requesting sudo." + exec sudo env SIMPLEVM_INSTALL_USER="${USER:-}" "$0" "$@" +fi + +if ! command -v systemctl >/dev/null 2>&1; then + echo "Error: systemd is required." >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + echo "Python 3 is missing; installing it with the system package manager." + if command -v apt-get >/dev/null 2>&1; then + apt-get update + apt-get install -y python3 + elif command -v pacman >/dev/null 2>&1; then + pacman -S --needed --noconfirm python + else + echo "Error: Python 3 is required and apt-get/pacman was not found." >&2 + exit 1 + fi +fi + +install_optional_packages() { + packages= + [ "$INSTALL_WAYLAND" -eq 1 ] && packages="$packages wl-clipboard" + [ "$INSTALL_X11" -eq 1 ] && packages="$packages spice-vdagent" + [ -z "$packages" ] && return + if command -v apt-get >/dev/null 2>&1; then + apt-get update + # shellcheck disable=SC2086 + apt-get install -y $packages + elif command -v pacman >/dev/null 2>&1; then + # shellcheck disable=SC2086 + pacman -S --needed --noconfirm $packages + else + echo "Error: optional packages requested, but apt-get/pacman was not found." >&2 + exit 1 + fi +} + +install_optional_packages + +if ! getent group simplevm-agent >/dev/null 2>&1; then + groupadd --system simplevm-agent +fi +if ! id simplevm-agent >/dev/null 2>&1; then + useradd --system --gid simplevm-agent --home-dir /nonexistent \ + --shell /usr/sbin/nologin simplevm-agent +fi +if [ -n "$INSTALL_USER" ] && [ "$INSTALL_USER" != root ] && id "$INSTALL_USER" >/dev/null 2>&1; then + usermod -a -G simplevm-agent "$INSTALL_USER" +fi + +install -d -m 0755 /usr/lib/simplevm-guest-tools +rm -rf /usr/lib/simplevm-guest-tools/simplevm_guest_tools +install -d -m 0755 /usr/lib/simplevm-guest-tools/simplevm_guest_tools +for module in "$SOURCE_DIR"/src/simplevm_guest_tools/*.py; do + install -m 0644 "$module" /usr/lib/simplevm-guest-tools/simplevm_guest_tools/ +done +install -m 0755 "$SOURCE_DIR/bin/simplevm-guest-tools-daemon" \ + /usr/sbin/simplevm-guest-tools-daemon +install -m 0755 "$SOURCE_DIR/bin/simplevm-guest-tools-session" \ + /usr/bin/simplevm-guest-tools-session +install -m 0644 "$SOURCE_DIR/systemd/simplevm-guest-tools.service" \ + /etc/systemd/system/simplevm-guest-tools.service +install -m 0644 "$SOURCE_DIR/systemd/simplevm-guest-tools-session.service" \ + /etc/systemd/user/simplevm-guest-tools-session.service +install -d -m 0755 /mnt/simplevm-share + +systemctl daemon-reload +systemctl --global enable simplevm-guest-tools-session.service +systemctl --global is-enabled --quiet simplevm-guest-tools-session.service +systemctl enable --now simplevm-guest-tools.service +systemctl is-enabled --quiet simplevm-guest-tools.service +systemctl is-active --quiet simplevm-guest-tools.service + +echo "SimpleVM Guest Tools 2.0.0 installation and system service setup completed." +echo "The user helper is globally enabled and starts with the next desktop login." +if [ -n "$INSTALL_USER" ] && [ "$INSTALL_USER" != root ]; then + echo "$INSTALL_USER must sign out and in once for simplevm-agent group access." +fi +echo "Wayland clipboard requires wl-clipboard." +echo "Vanilla spice-vdagent can help X11 guests; it does not provide" +echo "SimpleVM clipboard or resizing for a Hyprland Wayland session." diff --git a/GuestTools/manifest.json b/GuestTools/manifest.json new file mode 100644 index 0000000..45d6fa9 --- /dev/null +++ b/GuestTools/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "SimpleVM Guest Tools for Linux", + "version": "2.0.0", + "protocolVersion": 2, + "python": ">=3.9", + "transports": [ + "virtio-serial:com.simplevm.agent.0", + "vsock:1021" + ], + "installPrefix": "/usr/lib/simplevm-guest-tools", + "systemUnit": "simplevm-guest-tools.service", + "userUnit": "simplevm-guest-tools-session.service" +} diff --git a/GuestTools/self-test.sh b/GuestTools/self-test.sh new file mode 100755 index 0000000..bdf7703 --- /dev/null +++ b/GuestTools/self-test.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export PYTHONPATH="$ROOT/src" + +for asset in \ + manifest.json VERSION README.md SECURITY.md install.sh uninstall.sh \ + systemd/simplevm-guest-tools.service \ + systemd/simplevm-guest-tools-session.service \ + bin/simplevm-guest-tools-daemon bin/simplevm-guest-tools-session +do + test -f "$ROOT/$asset" || { + echo "Missing installer asset: $asset" >&2 + exit 1 + } +done + +python3 -m unittest discover -s "$ROOT/tests" -p 'test_*.py' -v diff --git a/GuestTools/src/simplevm_guest_tools/__init__.py b/GuestTools/src/simplevm_guest_tools/__init__.py new file mode 100644 index 0000000..802169a --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/__init__.py @@ -0,0 +1,4 @@ +"""SimpleVM Linux guest tools.""" + +AGENT_VERSION = "2.0.0" +PROTOCOL_VERSION = 2 diff --git a/GuestTools/src/simplevm_guest_tools/daemon.py b/GuestTools/src/simplevm_guest_tools/daemon.py new file mode 100644 index 0000000..e303ccd --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/daemon.py @@ -0,0 +1,187 @@ +"""Root daemon for QEMU virtio-serial and Apple VZ AF_VSOCK transports.""" + +import errno +import logging +import os +import socket +import threading +import time + +from . import AGENT_VERSION, PROTOCOL_VERSION +from .detection import ip_addresses, system_identity +from .ipc import SessionBroker +from .operations import mount_shared_directory, power, shared_mount_status +from .protocol import ( + MAX_CLIPBOARD_SIZE, + ProtocolError, + decode_request, + read_frame, + response_message, + write_frame, +) + +VIRTIO_PORT = "/dev/virtio-ports/com.simplevm.agent.0" +VSOCK_PORT = 1021 +LOG = logging.getLogger("simplevm-guest-tools") + + +class Agent: + def __init__(self, broker=None): + self.broker = broker if broker is not None else SessionBroker() + + def status(self): + session = self.broker.snapshot() + identity = system_identity() + capabilities = [ + "gracefulShutdown", + "gracefulReboot", + "mountSharedDirectory", + ] + desktop = "other" + session_type = "other" + if session: + desktop = session["desktopEnvironment"] + session_type = session["sessionType"] + capabilities.extend(session["capabilities"]) + identity["distroID"] = session["distroID"] + identity["distroVersion"] = session["distroVersion"] + return { + "type": "status", + "protocolVersion": PROTOCOL_VERSION, + "agentVersion": AGENT_VERSION, + **identity, + "ipAddresses": ip_addresses(), + "desktopEnvironment": desktop, + "sessionType": session_type, + "capabilities": capabilities, + "sharedMountStatus": shared_mount_status(), + } + + def dispatch(self, request): + kind = request["type"] + if kind == "status": + return self.status() + if kind in ("shutdown", "reboot"): + action = "poweroff" if kind == "shutdown" else "reboot" + ok, message = power(action) + if ok: + return {"type": "accepted", "operation": kind} + return _failure("powerOperationFailed", message) + if kind == "mountSharedDirectory": + state = mount_shared_directory() + if state.get("state") == "error" or ( + state.get("mounted") and state.get("state") == "occupied" + ): + return _failure( + "mountFailed", state.get("error", "mount point is occupied"), state + ) + return { + "type": "accepted", + "operation": kind, + "sharedMountStatus": state, + } + if kind in ("readClipboard", "writeClipboard", "resizeDisplay"): + payload = {"type": kind} + for key in ("text", "width", "height"): + if key in request: + payload[key] = request[key] + try: + response = self.broker.call(payload) + except ConnectionError as exc: + return _failure("sessionUnavailable", str(exc)) + if not isinstance(response, dict): + return _failure("invalidSessionResponse", "session helper response is invalid") + if ( + response.get("type") == "clipboard" + and isinstance(response.get("text"), str) + and len(response["text"].encode("utf-8")) <= MAX_CLIPBOARD_SIZE + ): + return response + if response.get("type") in ("accepted", "failure"): + return response + return _failure("invalidSessionResponse", "session helper response is invalid") + return _failure("notAllowed", "operation is not allowed") + + def serve_stream(self, stream): + while True: + try: + message = read_frame(stream) + request = decode_request(message) + response = self.dispatch(request) + write_frame(stream, response_message(request, response)) + except (EOFError, OSError, ProtocolError): + return + + +def _failure(code, message, state=None): + result = {"type": "failure", "code": code, "message": str(message)[:512]} + if state is not None: + result["sharedMountStatus"] = state + return result + + +def _virtio_loop(agent): + while True: + try: + descriptor = os.open(VIRTIO_PORT, os.O_RDWR | os.O_CLOEXEC) + with os.fdopen(descriptor, "r+b", buffering=0) as stream: + LOG.info("using QEMU virtio-serial transport at %s", VIRTIO_PORT) + agent.serve_stream(stream) + except OSError as exc: + LOG.debug("virtio-serial unavailable: %s", exc) + time.sleep(2) + continue + # The host opens one chardev connection per request. Avoid a busy loop + # while still making the next request available promptly. + time.sleep(0.05) + + +def _vsock_loop(agent): + if not hasattr(socket, "AF_VSOCK") or not hasattr(socket, "VMADDR_CID_ANY"): + LOG.warning("AF_VSOCK is unavailable; Apple VZ transport disabled") + return + while True: + listener = None + try: + listener = socket.socket(socket.AF_VSOCK, socket.SOCK_STREAM) + listener.bind((socket.VMADDR_CID_ANY, VSOCK_PORT)) + listener.listen(8) + LOG.info("listening for Apple VZ connections on AF_VSOCK port %d", VSOCK_PORT) + while True: + connection, _ = listener.accept() + threading.Thread( + target=_serve_socket, args=(agent, connection), daemon=True + ).start() + except OSError as exc: + if exc.errno == errno.EACCES: + LOG.error( + "permission denied binding AF_VSOCK port %d; " + "the service requires CAP_NET_BIND_SERVICE", + VSOCK_PORT, + ) + return + LOG.warning("AF_VSOCK port %d unavailable: %s", VSOCK_PORT, exc) + time.sleep(5) + finally: + if listener is not None: + listener.close() + + +def _serve_socket(agent, connection): + with connection: + agent.serve_stream(connection) + + +def main(): + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + broker = SessionBroker() + broker.start() + agent = Agent(broker) + threading.Thread(target=_virtio_loop, args=(agent,), daemon=True).start() + threading.Thread(target=_vsock_loop, args=(agent,), daemon=True).start() + while True: + time.sleep(3600) + + +if __name__ == "__main__": + main() diff --git a/GuestTools/src/simplevm_guest_tools/detection.py b/GuestTools/src/simplevm_guest_tools/detection.py new file mode 100644 index 0000000..e62f3f3 --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/detection.py @@ -0,0 +1,73 @@ +"""Safe Linux and desktop environment detection.""" + +import os +import socket + + +def parse_os_release(path="/etc/os-release"): + values = {} + try: + with open(path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if not key.replace("_", "").isalnum(): + continue + if len(value) >= 2 and value[0] == value[-1] == '"': + value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\") + values[key] = value + except (OSError, UnicodeError): + pass + return values + + +def detect_desktop(environ=None): + env = os.environ if environ is None else environ + desktop_text = " ".join( + [ + env.get("XDG_CURRENT_DESKTOP", ""), + env.get("XDG_SESSION_DESKTOP", ""), + env.get("DESKTOP_SESSION", ""), + ] + ).lower() + if env.get("HYPRLAND_INSTANCE_SIGNATURE") or "hyprland" in desktop_text: + desktop = "hyprland" + elif "gnome" in desktop_text: + desktop = "gnome" + else: + desktop = "other" + + session_text = env.get("XDG_SESSION_TYPE", "").lower() + if session_text == "wayland" or env.get("WAYLAND_DISPLAY"): + session_type = "wayland" + elif session_text == "x11" or env.get("DISPLAY"): + session_type = "x11" + else: + session_type = "other" + return desktop, session_type + + +def system_identity(os_release_path="/etc/os-release"): + release = parse_os_release(os_release_path) + return { + "hostname": socket.gethostname(), + "operatingSystem": "Linux", + "distroID": release.get("ID", "unknown"), + "distroVersion": release.get("VERSION_ID", "unknown"), + } + + +def ip_addresses(): + addresses = set() + try: + for result in socket.getaddrinfo( + socket.gethostname(), None, socket.AF_UNSPEC, socket.SOCK_STREAM + ): + address = result[4][0].split("%", 1)[0] + if address and not address.startswith("127.") and address != "::1": + addresses.add(address) + except socket.gaierror: + pass + return sorted(addresses) diff --git a/GuestTools/src/simplevm_guest_tools/ipc.py b/GuestTools/src/simplevm_guest_tools/ipc.py new file mode 100644 index 0000000..cbf364c --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/ipc.py @@ -0,0 +1,152 @@ +"""Authenticated IPC between the root daemon and one desktop session helper.""" + +import os +import socket +import struct +import threading + +from .protocol import ProtocolError, read_frame, write_frame + +RUNTIME_DIRECTORY = "/run/simplevm-guest-tools" +SOCKET_PATH = RUNTIME_DIRECTORY + "/session.sock" +_CREDENTIALS = struct.Struct("3i") + + +def peer_credentials(connection): + if not hasattr(socket, "SO_PEERCRED"): + raise OSError("SO_PEERCRED is unavailable") + raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, _CREDENTIALS.size) + return _CREDENTIALS.unpack(raw) + + +class SessionBroker: + def __init__(self, socket_path=SOCKET_PATH): + self.socket_path = socket_path + self._listener = None + self._connection = None + self._session = None + self._lock = threading.Lock() + self._stop = threading.Event() + + def start(self): + os.makedirs(os.path.dirname(self.socket_path), mode=0o750, exist_ok=True) + try: + os.unlink(self.socket_path) + except FileNotFoundError: + pass + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(self.socket_path) + os.chmod(self.socket_path, 0o660) + listener.listen(4) + listener.settimeout(1) + self._listener = listener + threading.Thread(target=self._accept_loop, daemon=True).start() + + def stop(self): + self._stop.set() + if self._listener: + self._listener.close() + with self._lock: + self._close_connection() + try: + os.unlink(self.socket_path) + except FileNotFoundError: + pass + + def _accept_loop(self): + while not self._stop.is_set(): + try: + connection, _ = self._listener.accept() + except socket.timeout: + continue + except OSError: + return + try: + _, uid, _ = peer_credentials(connection) + if uid == 0: + raise PermissionError("session helper must be unprivileged") + connection.settimeout(5) + registration = read_frame(connection) + self._validate_registration(registration, uid) + connection.settimeout(None) + with self._lock: + self._close_connection() + self._connection = connection + self._session = registration + except (OSError, EOFError, ProtocolError, PermissionError, ValueError): + connection.close() + + @staticmethod + def _validate_registration(registration, uid): + required = { + "type", + "uid", + "desktopEnvironment", + "sessionType", + "capabilities", + "distroID", + "distroVersion", + } + if set(registration) != required or registration.get("type") != "register": + raise ValueError("invalid session registration") + if registration["uid"] != uid: + raise ValueError("registration UID does not match peer") + if registration["desktopEnvironment"] not in ("gnome", "hyprland", "other"): + raise ValueError("invalid desktop") + if registration["sessionType"] not in ("wayland", "x11", "other"): + raise ValueError("invalid session type") + allowed = {"clipboardRead", "clipboardWrite", "displayResize"} + capabilities = registration["capabilities"] + if ( + not isinstance(capabilities, list) + or len(capabilities) != len(set(capabilities)) + or not set(capabilities) <= allowed + ): + raise ValueError("invalid session capabilities") + for key in ("distroID", "distroVersion"): + if ( + not isinstance(registration[key], str) + or len(registration[key].encode("utf-8")) > 128 + ): + raise ValueError("invalid distro data") + + def snapshot(self): + with self._lock: + if self._connection is None: + return None + return dict(self._session) + + def call(self, request): + with self._lock: + if self._connection is None: + raise ConnectionError("no desktop session helper is connected") + try: + self._connection.settimeout(8) + write_frame(self._connection, request) + response = read_frame(self._connection) + self._connection.settimeout(None) + return response + except (OSError, EOFError, ProtocolError) as exc: + self._close_connection() + raise ConnectionError("desktop session helper disconnected") from exc + + def _close_connection(self): + if self._connection is not None: + try: + self._connection.close() + except OSError: + pass + self._connection = None + self._session = None + + +def connect_to_root(socket_path=SOCKET_PATH): + connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + connection.settimeout(5) + connection.connect(socket_path) + _, uid, _ = peer_credentials(connection) + if uid != 0: + connection.close() + raise PermissionError("session IPC peer is not root") + connection.settimeout(None) + return connection diff --git a/GuestTools/src/simplevm_guest_tools/operations.py b/GuestTools/src/simplevm_guest_tools/operations.py new file mode 100644 index 0000000..fdea5e7 --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/operations.py @@ -0,0 +1,97 @@ +"""Fixed, allowlisted privileged operations.""" + +import os +import subprocess + +SYSTEMCTL = "/usr/bin/systemctl" +MOUNT = "/usr/bin/mount" +MOUNT_TAG = "share" +MOUNT_POINT = "/mnt/simplevm-share" +MOUNT_FILESYSTEM = "virtiofs" + + +def validate_mount_request(request): + return request == {"type": "mountSharedDirectory"} + + +def shared_mount_status(mountinfo_path="/proc/self/mountinfo"): + status = { + "mounted": False, + "state": "notMounted", + "mountPoint": MOUNT_POINT, + "tag": MOUNT_TAG, + "filesystem": MOUNT_FILESYSTEM, + } + try: + with open(mountinfo_path, "r", encoding="utf-8") as handle: + for line in handle: + left, separator, right = line.partition(" - ") + if not separator: + continue + fields = left.split() + fs_fields = right.split() + if len(fields) > 4 and fields[4] == MOUNT_POINT: + status["mounted"] = True + status["filesystem"] = fs_fields[0] if fs_fields else "unknown" + source = fs_fields[1] if len(fs_fields) > 1 else "unknown" + status["state"] = ( + "mounted" + if status["filesystem"] == MOUNT_FILESYSTEM + and source == MOUNT_TAG + else "occupied" + ) + return status + except OSError: + status["state"] = "unavailable" + return status + + +def mount_shared_directory(run=subprocess.run, makedirs=os.makedirs): + before = shared_mount_status() + if before["mounted"]: + if before["filesystem"] == MOUNT_FILESYSTEM: + return before + return dict(before, error="fixed mount point is occupied") + try: + makedirs(MOUNT_POINT, mode=0o755, exist_ok=True) + completed = run( + [MOUNT, "-t", MOUNT_FILESYSTEM, MOUNT_TAG, MOUNT_POINT], + shell=False, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError) as exc: + return dict(before, state="error", error=str(exc)) + if completed.returncode: + raced = shared_mount_status() + if raced["mounted"] and raced["state"] == "mounted": + return raced + message = (completed.stderr or "mount failed").strip()[:512] + return dict(before, state="error", error=message) + after = shared_mount_status() + if not after["mounted"] or after["state"] != "mounted": + return dict(after, state="error", error="mount did not become visible") + return after + + +def power(action, run=subprocess.run): + if action not in ("poweroff", "reboot"): + raise ValueError("power action is not allowed") + try: + completed = run( + [SYSTEMCTL, action], + shell=False, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError) as exc: + return False, str(exc) + if completed.returncode: + return False, (completed.stderr or "systemctl failed").strip()[:512] + return True, "" diff --git a/GuestTools/src/simplevm_guest_tools/protocol.py b/GuestTools/src/simplevm_guest_tools/protocol.py new file mode 100644 index 0000000..1123438 --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/protocol.py @@ -0,0 +1,177 @@ +"""Length-prefixed host protocol and strict request validation.""" + +import json +import struct + +from . import PROTOCOL_VERSION + +MAX_FRAME_SIZE = 2 * 1024 * 1024 +MAX_CLIPBOARD_SIZE = 1024 * 1024 +_HEADER = struct.Struct(">I") +_SIMPLE_TYPES = { + "status", + "shutdown", + "reboot", + "mountSharedDirectory", + "readClipboard", +} +_ALL_TYPES = _SIMPLE_TYPES | {"writeClipboard", "resizeDisplay"} +_LEGACY_TYPES = {"hello", "status", "shutdown", "reboot"} + + +class ProtocolError(ValueError): + """A frame or request that must not be processed.""" + + +def encode_frame(message): + try: + payload = json.dumps( + message, ensure_ascii=False, separators=(",", ":") + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise ProtocolError("message is not JSON encodable") from exc + if len(payload) > MAX_FRAME_SIZE: + raise ProtocolError("frame exceeds 2 MiB") + return _HEADER.pack(len(payload)) + payload + + +def read_frame(stream): + header = _read_exact(stream, _HEADER.size) + length = _HEADER.unpack(header)[0] + if length == 0 or length > MAX_FRAME_SIZE: + raise ProtocolError("invalid frame length") + raw = _read_exact(stream, length) + try: + value = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_strict_object, + parse_constant=_reject_constant, + ) + except (UnicodeDecodeError, ValueError) as exc: + raise ProtocolError("frame is not UTF-8 JSON") from exc + if not isinstance(value, dict): + raise ProtocolError("top-level JSON value must be an object") + return value + + +def _strict_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON object key") + result[key] = value + return result + + +def _reject_constant(value): + raise ValueError("non-finite JSON number") + + +def write_frame(stream, message): + data = encode_frame(message) + if hasattr(stream, "sendall"): + stream.sendall(data) + else: + stream.write(data) + if hasattr(stream, "flush"): + stream.flush() + + +def _read_exact(stream, count): + chunks = [] + remaining = count + while remaining: + if hasattr(stream, "recv"): + chunk = stream.recv(remaining) + else: + chunk = stream.read(remaining) + if not chunk: + raise EOFError("transport closed") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def decode_request(message): + """Return a normalized request with ``legacy`` and ``request_id`` fields.""" + if set(message) == {"protocolVersion", "requestID", "request"}: + if ( + type(message["protocolVersion"]) is not int + or message["protocolVersion"] != PROTOCOL_VERSION + ): + raise ProtocolError("unsupported protocol version") + request_id = message["requestID"] + if ( + not isinstance(request_id, str) + or not request_id + or len(request_id.encode("utf-8")) > 256 + ): + raise ProtocolError("invalid requestID") + request = message["request"] + normalized = _validate_new_request(request) + normalized.update({"legacy": False, "request_id": request_id}) + return normalized + + if len(message) == 1: + kind, body = next(iter(message.items())) + if kind in _LEGACY_TYPES and isinstance(body, dict): + if kind == "hello": + if set(body) != {"protocolVersion"}: + raise ProtocolError("malformed legacy hello") + if ( + type(body["protocolVersion"]) is not int + or body["protocolVersion"] != 1 + ): + raise ProtocolError("unsupported legacy protocol version") + kind = "status" + elif body: + raise ProtocolError("legacy request body must be empty") + return {"type": kind, "legacy": True, "request_id": None} + + raise ProtocolError("unrecognized request envelope") + + +def _validate_new_request(request): + if not isinstance(request, dict) or not isinstance(request.get("type"), str): + raise ProtocolError("request must contain a string type") + kind = request["type"] + if kind not in _ALL_TYPES: + raise ProtocolError("request type is not allowed") + if kind in _SIMPLE_TYPES: + if set(request) != {"type"}: + raise ProtocolError("unexpected fields for request type") + return {"type": kind} + if kind == "writeClipboard": + if set(request) != {"type", "text"} or not isinstance( + request.get("text"), str + ): + raise ProtocolError("writeClipboard requires text only") + if len(request["text"].encode("utf-8")) > MAX_CLIPBOARD_SIZE: + raise ProtocolError("clipboard exceeds 1 MiB") + return {"type": kind, "text": request["text"]} + if set(request) != {"type", "width", "height"}: + raise ProtocolError("resizeDisplay requires width and height only") + width = request["width"] + height = request["height"] + if ( + isinstance(width, bool) + or isinstance(height, bool) + or not isinstance(width, int) + or not isinstance(height, int) + or not 640 <= width <= 16384 + or not 480 <= height <= 16384 + ): + raise ProtocolError("display dimensions are outside the allowed range") + return {"type": kind, "width": width, "height": height} + + +def response_message(request, response): + if request["legacy"]: + kind = response.get("type") + body = {key: value for key, value in response.items() if key != "type"} + return {kind: body} + return { + "protocolVersion": PROTOCOL_VERSION, + "requestID": request["request_id"], + "response": response, + } diff --git a/GuestTools/src/simplevm_guest_tools/user_helper.py b/GuestTools/src/simplevm_guest_tools/user_helper.py new file mode 100644 index 0000000..b8bbadf --- /dev/null +++ b/GuestTools/src/simplevm_guest_tools/user_helper.py @@ -0,0 +1,223 @@ +"""Unprivileged desktop-session clipboard and display helper.""" + +import json +import os +import re +import selectors +import shutil +import subprocess +import time + +from .detection import detect_desktop, parse_os_release +from .ipc import connect_to_root +from .protocol import MAX_CLIPBOARD_SIZE, ProtocolError, read_frame, write_frame + +_MONITOR_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,128}$") + + +class SessionHelper: + def __init__(self, environ=None, which=shutil.which): + self.environ = dict(os.environ if environ is None else environ) + self.desktop, self.session_type = detect_desktop(self.environ) + self.wl_copy = which("wl-copy") + self.wl_paste = which("wl-paste") + self.hyprctl = which("hyprctl") + + def capabilities(self): + capabilities = [] + if ( + self.session_type == "wayland" + and self.environ.get("WAYLAND_DISPLAY") + and self.wl_copy + and self.wl_paste + ): + capabilities.extend(["clipboardRead", "clipboardWrite"]) + if ( + self.desktop == "hyprland" + and self.session_type == "wayland" + and self.environ.get("HYPRLAND_INSTANCE_SIGNATURE") + and self.hyprctl + ): + capabilities.append("displayResize") + return capabilities + + def registration(self): + release = parse_os_release() + return { + "type": "register", + "uid": os.getuid(), + "desktopEnvironment": self.desktop, + "sessionType": self.session_type, + "capabilities": self.capabilities(), + "distroID": release.get("ID", "unknown"), + "distroVersion": release.get("VERSION_ID", "unknown"), + } + + def handle(self, request): + if not isinstance(request, dict) or not isinstance(request.get("type"), str): + return _failure("malformedRequest", "invalid session request") + kind = request["type"] + if kind == "readClipboard" and set(request) == {"type"}: + if "clipboardRead" not in self.capabilities(): + return _failure("unavailable", "clipboard read is unavailable") + try: + output = _capture_limited( + [self.wl_paste, "--no-newline", "--type", "text"], + self.environ, + MAX_CLIPBOARD_SIZE, + ) + text = output.decode("utf-8") + except (OSError, subprocess.SubprocessError, UnicodeDecodeError) as exc: + return _failure("clipboardReadFailed", str(exc)) + return {"type": "clipboard", "text": text} + if kind == "writeClipboard" and set(request) == {"type", "text"}: + text = request.get("text") + if not isinstance(text, str): + return _failure("malformedRequest", "clipboard text must be UTF-8") + data = text.encode("utf-8") + if len(data) > MAX_CLIPBOARD_SIZE: + return _failure("clipboardTooLarge", "clipboard exceeds 1 MiB") + if "clipboardWrite" not in self.capabilities(): + return _failure("unavailable", "clipboard write is unavailable") + try: + completed = subprocess.run( + [self.wl_copy, "--type", "text/plain;charset=utf-8"], + input=data, + shell=False, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=3, + env=self.environ, + ) + except (OSError, subprocess.SubprocessError) as exc: + return _failure("clipboardWriteFailed", str(exc)) + if completed.returncode: + return _failure("clipboardWriteFailed", "wl-copy failed") + return {"type": "accepted", "operation": kind} + if kind == "resizeDisplay" and set(request) == {"type", "width", "height"}: + width, height = request.get("width"), request.get("height") + if not valid_dimensions(width, height): + return _failure("invalidDimensions", "display dimensions are invalid") + if "displayResize" not in self.capabilities(): + return _failure("unavailable", "display resize is unavailable") + return self._resize_hyprland(width, height) + return _failure("notAllowed", "session request is not allowed") + + def _resize_hyprland(self, width, height): + try: + raw = _capture_limited( + [self.hyprctl, "-j", "monitors"], self.environ, 256 * 1024 + ) + monitors = json.loads(raw.decode("utf-8")) + if not isinstance(monitors, list) or not monitors: + raise ValueError("Hyprland returned no monitors") + monitor = next( + (item for item in monitors if item.get("focused") is True), monitors[0] + ) + name = monitor.get("name") + if not isinstance(name, str) or not _MONITOR_NAME.fullmatch(name): + raise ValueError("Hyprland returned an unsafe monitor name") + completed = subprocess.run( + [ + self.hyprctl, + "keyword", + "monitor", + f"{name},{width}x{height},auto,1", + ], + shell=False, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=5, + env=self.environ, + ) + except ( + OSError, + subprocess.SubprocessError, + UnicodeDecodeError, + json.JSONDecodeError, + ValueError, + ) as exc: + return _failure("displayResizeFailed", str(exc)) + if completed.returncode: + return _failure("displayResizeFailed", "hyprctl failed") + return { + "type": "accepted", + "operation": "resizeDisplay", + "width": width, + "height": height, + } + + +def valid_dimensions(width, height): + return ( + isinstance(width, int) + and not isinstance(width, bool) + and isinstance(height, int) + and not isinstance(height, bool) + and 640 <= width <= 16384 + and 480 <= height <= 16384 + ) + + +def _capture_limited(argv, environ, maximum, timeout=3): + process = subprocess.Popen( + argv, + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=environ, + ) + output = bytearray() + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ) + deadline = time.monotonic() + timeout + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired(argv, timeout) + events = selector.select(remaining) + if not events: + raise subprocess.TimeoutExpired(argv, timeout) + chunk = os.read(process.stdout.fileno(), min(65536, maximum + 1 - len(output))) + if not chunk: + break + output.extend(chunk) + if len(output) > maximum: + raise OSError("command output exceeds size limit") + return_code = process.wait(timeout=max(0.01, deadline - time.monotonic())) + if return_code: + raise subprocess.CalledProcessError(return_code, argv) + return bytes(output) + finally: + selector.close() + if process.poll() is None: + process.kill() + process.wait() + + +def _failure(code, message): + return {"type": "failure", "code": code, "message": message[:512]} + + +def main(): + helper = SessionHelper() + while True: + connection = None + try: + connection = connect_to_root() + write_frame(connection, helper.registration()) + while True: + request = read_frame(connection) + write_frame(connection, helper.handle(request)) + except (OSError, EOFError, ProtocolError, PermissionError): + time.sleep(2) + finally: + if connection is not None: + connection.close() + + +if __name__ == "__main__": + main() diff --git a/GuestTools/systemd/simplevm-guest-tools-session.service b/GuestTools/systemd/simplevm-guest-tools-session.service new file mode 100644 index 0000000..8446290 --- /dev/null +++ b/GuestTools/systemd/simplevm-guest-tools-session.service @@ -0,0 +1,17 @@ +[Unit] +Description=SimpleVM Guest Tools desktop session helper +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/simplevm-guest-tools-session +Restart=on-failure +RestartSec=2 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=read-only + +[Install] +WantedBy=default.target diff --git a/GuestTools/systemd/simplevm-guest-tools.service b/GuestTools/systemd/simplevm-guest-tools.service new file mode 100644 index 0000000..77efd34 --- /dev/null +++ b/GuestTools/systemd/simplevm-guest-tools.service @@ -0,0 +1,27 @@ +[Unit] +Description=SimpleVM Guest Tools root agent +After=systemd-udev-settle.service + +[Service] +Type=simple +User=root +Group=simplevm-agent +ExecStart=/usr/sbin/simplevm-guest-tools-daemon +Restart=always +RestartSec=2 +RuntimeDirectory=simplevm-guest-tools +RuntimeDirectoryMode=0750 +UMask=0007 +NoNewPrivileges=yes +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_SYS_BOOT CAP_NET_BIND_SERVICE +ProtectClock=yes +ProtectKernelLogs=yes +RestrictAddressFamilies=AF_UNIX AF_VSOCK AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/GuestTools/tests/test_daemon_status.py b/GuestTools/tests/test_daemon_status.py new file mode 100644 index 0000000..ad99e97 --- /dev/null +++ b/GuestTools/tests/test_daemon_status.py @@ -0,0 +1,106 @@ +import socket +import threading +import unittest +from unittest import mock + +from simplevm_guest_tools.daemon import Agent +from simplevm_guest_tools.protocol import read_frame, write_frame + + +class FakeBroker: + def snapshot(self): + return { + "desktopEnvironment": "hyprland", + "sessionType": "wayland", + "capabilities": [ + "clipboardRead", + "clipboardWrite", + "displayResize", + ], + "distroID": "arch", + "distroVersion": "rolling", + } + + +class StatusTests(unittest.TestCase): + def test_status_has_required_fields_and_capabilities(self): + mount_status = { + "mounted": False, + "state": "notMounted", + "mountPoint": "/mnt/simplevm-share", + "tag": "share", + "filesystem": "virtiofs", + } + identity = { + "hostname": "guest", + "operatingSystem": "Linux", + "distroID": "unused", + "distroVersion": "unused", + } + with mock.patch( + "simplevm_guest_tools.daemon.system_identity", return_value=identity + ), mock.patch( + "simplevm_guest_tools.daemon.ip_addresses", + return_value=["192.0.2.2"], + ), mock.patch( + "simplevm_guest_tools.daemon.shared_mount_status", + return_value=mount_status, + ): + status = Agent(FakeBroker()).status() + required = { + "type", + "protocolVersion", + "agentVersion", + "hostname", + "ipAddresses", + "operatingSystem", + "distroID", + "distroVersion", + "desktopEnvironment", + "sessionType", + "capabilities", + "sharedMountStatus", + } + self.assertEqual(set(status), required) + self.assertEqual(status["protocolVersion"], 2) + self.assertEqual(status["desktopEnvironment"], "hyprland") + self.assertEqual( + set(status["capabilities"]), + { + "gracefulShutdown", + "gracefulReboot", + "mountSharedDirectory", + "clipboardRead", + "clipboardWrite", + "displayResize", + }, + ) + + def test_status_round_trip_over_live_stream(self): + host, guest = socket.socketpair() + agent = Agent(FakeBroker()) + worker = threading.Thread( + target=agent.serve_stream, args=(guest,), daemon=True + ) + worker.start() + try: + write_frame( + host, + { + "protocolVersion": 2, + "requestID": "status-live-1", + "request": {"type": "status"}, + }, + ) + response = read_frame(host) + self.assertEqual(response["protocolVersion"], 2) + self.assertEqual(response["requestID"], "status-live-1") + self.assertEqual(response["response"]["type"], "status") + finally: + host.close() + guest.close() + worker.join(timeout=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/GuestTools/tests/test_detection_helper.py b/GuestTools/tests/test_detection_helper.py new file mode 100644 index 0000000..4c2e0e2 --- /dev/null +++ b/GuestTools/tests/test_detection_helper.py @@ -0,0 +1,103 @@ +import os +import subprocess +import tempfile +import unittest +from unittest import mock + +from simplevm_guest_tools.detection import detect_desktop, parse_os_release +from simplevm_guest_tools.protocol import MAX_CLIPBOARD_SIZE +from simplevm_guest_tools.user_helper import SessionHelper, valid_dimensions + + +class DetectionTests(unittest.TestCase): + def test_distro_detection_from_safe_file(self): + tests_directory = os.path.dirname(__file__) + with tempfile.TemporaryDirectory(dir=tests_directory) as directory: + path = os.path.join(directory, "os-release") + with open(path, "w", encoding="utf-8") as handle: + handle.write('ID=arch\nVERSION_ID="rolling"\nNAME=Arch Linux\n') + values = parse_os_release(path) + self.assertEqual(values["ID"], "arch") + self.assertEqual(values["VERSION_ID"], "rolling") + + def test_desktop_and_session_detection(self): + self.assertEqual( + detect_desktop( + { + "XDG_CURRENT_DESKTOP": "Hyprland", + "XDG_SESSION_TYPE": "wayland", + "HYPRLAND_INSTANCE_SIGNATURE": "test", + } + ), + ("hyprland", "wayland"), + ) + self.assertEqual( + detect_desktop( + {"XDG_CURRENT_DESKTOP": "GNOME", "XDG_SESSION_TYPE": "x11"} + ), + ("gnome", "x11"), + ) + self.assertEqual(detect_desktop({}), ("other", "other")) + + +class SessionHelperTests(unittest.TestCase): + @staticmethod + def helper(): + commands = { + "wl-copy": "/usr/bin/wl-copy", + "wl-paste": "/usr/bin/wl-paste", + "hyprctl": "/usr/bin/hyprctl", + } + return SessionHelper( + { + "XDG_CURRENT_DESKTOP": "Hyprland", + "XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-1", + "HYPRLAND_INSTANCE_SIGNATURE": "instance", + }, + which=commands.get, + ) + + def test_capability_gating(self): + self.assertEqual( + self.helper().capabilities(), + ["clipboardRead", "clipboardWrite", "displayResize"], + ) + x11 = SessionHelper( + {"XDG_CURRENT_DESKTOP": "GNOME", "XDG_SESSION_TYPE": "x11"}, + which=lambda name: "/usr/bin/" + name, + ) + self.assertEqual(x11.capabilities(), []) + + def test_clipboard_exact_limit_and_rejection(self): + helper = self.helper() + completed = subprocess.CompletedProcess([], 0, b"", b"") + with mock.patch("simplevm_guest_tools.user_helper.subprocess.run") as run: + run.return_value = completed + response = helper.handle( + {"type": "writeClipboard", "text": "x" * MAX_CLIPBOARD_SIZE} + ) + self.assertEqual(response["type"], "accepted") + self.assertFalse(run.call_args.kwargs["shell"]) + response = helper.handle( + {"type": "writeClipboard", "text": "x" * (MAX_CLIPBOARD_SIZE + 1)} + ) + self.assertEqual(response["code"], "clipboardTooLarge") + + def test_resize_dimensions(self): + self.assertTrue(valid_dimensions(640, 480)) + self.assertTrue(valid_dimensions(16384, 16384)) + self.assertFalse(valid_dimensions(639, 480)) + self.assertFalse(valid_dimensions(640, 16385)) + self.assertFalse(valid_dimensions(True, 800)) + + def test_helper_rejects_non_allowlisted_fields(self): + helper = self.helper() + response = helper.handle( + {"type": "resizeDisplay", "width": 800, "height": 600, "command": "x"} + ) + self.assertEqual(response["code"], "notAllowed") + + +if __name__ == "__main__": + unittest.main() diff --git a/GuestTools/tests/test_operations_assets.py b/GuestTools/tests/test_operations_assets.py new file mode 100644 index 0000000..846b26a --- /dev/null +++ b/GuestTools/tests/test_operations_assets.py @@ -0,0 +1,104 @@ +import json +import os +import subprocess +import unittest +from unittest import mock + +from simplevm_guest_tools import operations + + +class OperationTests(unittest.TestCase): + def test_fixed_mount_validation(self): + self.assertTrue( + operations.validate_mount_request({"type": "mountSharedDirectory"}) + ) + self.assertFalse( + operations.validate_mount_request( + {"type": "mountSharedDirectory", "source": "evil"} + ) + ) + + def test_mount_uses_only_fixed_arguments(self): + before = { + "mounted": False, + "state": "notMounted", + "mountPoint": operations.MOUNT_POINT, + "tag": operations.MOUNT_TAG, + "filesystem": operations.MOUNT_FILESYSTEM, + } + after = dict(before, mounted=True, state="mounted") + run = mock.Mock( + return_value=subprocess.CompletedProcess([], returncode=0, stderr="") + ) + with mock.patch( + "simplevm_guest_tools.operations.shared_mount_status", + side_effect=[before, after], + ): + result = operations.mount_shared_directory( + run=run, makedirs=mock.Mock() + ) + self.assertEqual(result["state"], "mounted") + self.assertEqual( + run.call_args.args[0], + [ + "/usr/bin/mount", + "-t", + "virtiofs", + "share", + "/mnt/simplevm-share", + ], + ) + self.assertFalse(run.call_args.kwargs["shell"]) + + def test_power_allowlist(self): + with self.assertRaises(ValueError): + operations.power("halt") + + +class AssetTests(unittest.TestCase): + def test_installer_assets_and_manifest(self): + root = os.path.dirname(os.path.dirname(__file__)) + required = [ + "install.sh", + "uninstall.sh", + "README.md", + "SECURITY.md", + "VERSION", + "manifest.json", + "bin/simplevm-guest-tools-daemon", + "bin/simplevm-guest-tools-session", + "systemd/simplevm-guest-tools.service", + "systemd/simplevm-guest-tools-session.service", + ] + for relative_path in required: + with self.subTest(path=relative_path): + self.assertTrue(os.path.isfile(os.path.join(root, relative_path))) + with open(os.path.join(root, "manifest.json"), encoding="utf-8") as handle: + manifest = json.load(handle) + self.assertEqual(manifest["protocolVersion"], 2) + self.assertIn("vsock:1021", manifest["transports"]) + with open( + os.path.join(root, "systemd/simplevm-guest-tools.service"), + encoding="utf-8", + ) as handle: + root_unit = handle.read() + self.assertIn("CAP_NET_BIND_SERVICE", root_unit) + self.assertNotIn("PrivateTmp=yes", root_unit) + + def test_scripts_are_executable(self): + root = os.path.dirname(os.path.dirname(__file__)) + for relative_path in ( + "install.sh", + "uninstall.sh", + "self-test.sh", + "bin/simplevm-guest-tools-daemon", + "bin/simplevm-guest-tools-session", + ): + self.assertTrue( + os.access(os.path.join(root, relative_path), os.X_OK), + relative_path, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/GuestTools/tests/test_protocol.py b/GuestTools/tests/test_protocol.py new file mode 100644 index 0000000..8be2c7e --- /dev/null +++ b/GuestTools/tests/test_protocol.py @@ -0,0 +1,147 @@ +import io +import json +import struct +import unittest + +from simplevm_guest_tools.protocol import ( + MAX_CLIPBOARD_SIZE, + MAX_FRAME_SIZE, + ProtocolError, + decode_request, + encode_frame, + read_frame, + response_message, +) + + +class ProtocolTests(unittest.TestCase): + def test_new_frame_round_trip(self): + message = { + "protocolVersion": 2, + "requestID": "abc", + "request": {"type": "status"}, + } + self.assertEqual(read_frame(io.BytesIO(encode_frame(message))), message) + request = decode_request(message) + self.assertEqual(request["type"], "status") + self.assertEqual( + response_message(request, {"type": "accepted"}), + { + "protocolVersion": 2, + "requestID": "abc", + "response": {"type": "accepted"}, + }, + ) + + def test_legacy_swift_requests(self): + cases = [ + ({"hello": {"protocolVersion": 1}}, "status"), + ({"status": {}}, "status"), + ({"shutdown": {}}, "shutdown"), + ({"reboot": {}}, "reboot"), + ] + for wire, expected in cases: + with self.subTest(wire=wire): + request = decode_request(wire) + self.assertTrue(request["legacy"]) + self.assertEqual(request["type"], expected) + self.assertEqual( + response_message(request, {"type": "accepted"}), + {"accepted": {}}, + ) + + def test_malformed_and_oversized_frames_fail(self): + malformed = struct.pack(">I", 2) + b"[]" + with self.assertRaises(ProtocolError): + read_frame(io.BytesIO(malformed)) + oversized = struct.pack(">I", MAX_FRAME_SIZE + 1) + with self.assertRaises(ProtocolError): + read_frame(io.BytesIO(oversized)) + bad_utf8 = struct.pack(">I", 1) + b"\xff" + with self.assertRaises(ProtocolError): + read_frame(io.BytesIO(bad_utf8)) + duplicate = b'{"status":{},"status":{}}' + with self.assertRaises(ProtocolError): + read_frame(io.BytesIO(struct.pack(">I", len(duplicate)) + duplicate)) + + def test_clipboard_limit_is_utf8_bytes(self): + exact = "x" * MAX_CLIPBOARD_SIZE + request = decode_request( + { + "protocolVersion": 2, + "requestID": "clip", + "request": {"type": "writeClipboard", "text": exact}, + } + ) + self.assertEqual(request["text"], exact) + with self.assertRaises(ProtocolError): + decode_request( + { + "protocolVersion": 2, + "requestID": "clip", + "request": {"type": "writeClipboard", "text": exact + "x"}, + } + ) + with self.assertRaises(ProtocolError): + decode_request( + { + "protocolVersion": 2, + "requestID": "clip", + "request": { + "type": "writeClipboard", + "text": "\u00e9" * (MAX_CLIPBOARD_SIZE // 2 + 1), + }, + } + ) + + def test_allowlist_rejects_extra_and_unknown_fields(self): + dangerous = [ + {"type": "runCommand", "command": "id"}, + {"type": "mountSharedDirectory", "source": "/dev/sda"}, + {"type": "mountSharedDirectory", "path": "/root"}, + {"type": "shutdown", "command": "anything"}, + {"type": "readClipboard", "path": "/etc/shadow"}, + ] + for body in dangerous: + with self.subTest(body=body), self.assertRaises(ProtocolError): + decode_request( + { + "protocolVersion": 2, + "requestID": "reject", + "request": body, + } + ) + + def test_resize_validation(self): + for width, height in ((640, 480), (16384, 16384)): + request = decode_request( + { + "protocolVersion": 2, + "requestID": "resize", + "request": { + "type": "resizeDisplay", + "width": width, + "height": height, + }, + } + ) + self.assertEqual((request["width"], request["height"]), (width, height)) + for width, height in ((639, 480), (640, 479), (16385, 480), (True, 800)): + with self.subTest(width=width, height=height), self.assertRaises( + ProtocolError + ): + decode_request( + { + "protocolVersion": 2, + "requestID": "resize", + "request": { + "type": "resizeDisplay", + "width": width, + "height": height, + }, + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/GuestTools/uninstall.sh b/GuestTools/uninstall.sh new file mode 100755 index 0000000..b4392dd --- /dev/null +++ b/GuestTools/uninstall.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "SimpleVM Guest Tools removal requires root; requesting sudo." + exec sudo "$0" "$@" +fi + +systemctl disable --now simplevm-guest-tools.service 2>/dev/null || true +systemctl --global disable simplevm-guest-tools-session.service 2>/dev/null || true +rm -f /etc/systemd/system/simplevm-guest-tools.service +rm -f /etc/systemd/user/simplevm-guest-tools-session.service +rm -f /usr/sbin/simplevm-guest-tools-daemon +rm -f /usr/bin/simplevm-guest-tools-session +rm -rf /usr/lib/simplevm-guest-tools +rm -f /run/simplevm-guest-tools/session.sock +rmdir /run/simplevm-guest-tools 2>/dev/null || true +systemctl daemon-reload + +if id simplevm-agent >/dev/null 2>&1; then + userdel simplevm-agent +fi +if getent group simplevm-agent >/dev/null 2>&1; then + groupdel simplevm-agent +fi + +echo "SimpleVM Guest Tools removed. /mnt/simplevm-share was left intact." diff --git a/Packages/SimpleVMCore/Sources/SimpleVMCore/GuestAgent/GuestAgentProtocol.swift b/Packages/SimpleVMCore/Sources/SimpleVMCore/GuestAgent/GuestAgentProtocol.swift index 2580e56..1fa1a0f 100644 --- a/Packages/SimpleVMCore/Sources/SimpleVMCore/GuestAgent/GuestAgentProtocol.swift +++ b/Packages/SimpleVMCore/Sources/SimpleVMCore/GuestAgent/GuestAgentProtocol.swift @@ -1,46 +1,650 @@ import Foundation -public enum GuestAgentRequest: Codable, Equatable, Sendable { +public enum GuestAgentProtocol { + public static let currentVersion = 2 + public static let agentPort: UInt32 = 1_021 + public static let qemuPortName = "com.simplevm.agent.0" + public static let sharedDirectoryTag = "share" + public static let sharedDirectoryMountPoint = "/mnt/simplevm-share" + public static let maximumClipboardSize = 1 * 1_024 * 1_024 +} + +public enum GuestDesktopEnvironment: String, Codable, Equatable, Sendable { + case gnome + case hyprland + case other +} + +public enum GuestSessionType: String, Codable, Equatable, Sendable { + case wayland + case x11 + case other +} + +public enum GuestAgentCapability: String, Codable, CaseIterable, Hashable, + Sendable +{ + case gracefulShutdown + case gracefulReboot + case mountSharedDirectory + case clipboardRead + case clipboardWrite + case displayResize +} + +public enum GuestSharedMountState: String, Equatable, Sendable { + case unavailable + case unmounted + case mounted + case failed +} + +extension GuestSharedMountState: Codable { + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + switch value { + case "unavailable": + self = .unavailable + case "unmounted", "notMounted": + self = .unmounted + case "mounted": + self = .mounted + case "failed", "error", "occupied": + self = .failed + default: + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unknown shared-mount state \(value)." + ) + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public struct GuestSharedMountStatus: Codable, Equatable, Sendable { + public let state: GuestSharedMountState + public let tag: String + public let mountPoint: String + public let message: String? + + public init( + state: GuestSharedMountState, + tag: String = GuestAgentProtocol.sharedDirectoryTag, + mountPoint: String = GuestAgentProtocol.sharedDirectoryMountPoint, + message: String? = nil + ) { + self.state = state + self.tag = tag + self.mountPoint = mountPoint + self.message = message + } +} + +public enum GuestAgentRequest: Equatable, Sendable { case hello(protocolVersion: Int) case status case shutdown case reboot + case mountSharedDirectory + case readClipboard + case writeClipboard(text: String) + case resizeDisplay(width: Int, height: Int) +} + +extension GuestAgentRequest: Codable { + private enum CodingKeys: String, CodingKey { + case type + case protocolVersion + case text + case width + case height + case hello + case status + case shutdown + case reboot + } + + private enum RequestType: String, Codable { + case hello + case status + case shutdown + case reboot + case mountSharedDirectory + case readClipboard + case writeClipboard + case resizeDisplay + } + + private struct LegacyHello: Codable { + let protocolVersion: Int + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let type = try container.decodeIfPresent( + RequestType.self, + forKey: .type + ) { + switch type { + case .hello: + self = .hello( + protocolVersion: try container.decode( + Int.self, + forKey: .protocolVersion + ) + ) + case .status: + self = .status + case .shutdown: + self = .shutdown + case .reboot: + self = .reboot + case .mountSharedDirectory: + self = .mountSharedDirectory + case .readClipboard: + self = .readClipboard + case .writeClipboard: + self = .writeClipboard( + text: try container.decode(String.self, forKey: .text) + ) + case .resizeDisplay: + self = .resizeDisplay( + width: try container.decode(Int.self, forKey: .width), + height: try container.decode(Int.self, forKey: .height) + ) + } + return + } + + if container.contains(.hello) { + let hello = try container.decode( + LegacyHello.self, + forKey: .hello + ) + self = .hello(protocolVersion: hello.protocolVersion) + } else if container.contains(.status) { + self = .status + } else if container.contains(.shutdown) { + self = .shutdown + } else if container.contains(.reboot) { + self = .reboot + } else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unknown guest-agent request." + ) + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .hello(let protocolVersion): + try container.encode(RequestType.hello, forKey: .type) + try container.encode(protocolVersion, forKey: .protocolVersion) + case .status: + try container.encode(RequestType.status, forKey: .type) + case .shutdown: + try container.encode(RequestType.shutdown, forKey: .type) + case .reboot: + try container.encode(RequestType.reboot, forKey: .type) + case .mountSharedDirectory: + try container.encode( + RequestType.mountSharedDirectory, + forKey: .type + ) + case .readClipboard: + try container.encode(RequestType.readClipboard, forKey: .type) + case .writeClipboard(let text): + guard text.utf8.count <= GuestAgentProtocol.maximumClipboardSize else { + throw GuestAgentProtocolError.clipboardTooLarge + } + try container.encode(RequestType.writeClipboard, forKey: .type) + try container.encode(text, forKey: .text) + case .resizeDisplay(let width, let height): + guard GuestDisplaySize.isValid(width: width, height: height) else { + throw GuestAgentProtocolError.invalidDisplaySize + } + try container.encode(RequestType.resizeDisplay, forKey: .type) + try container.encode(width, forKey: .width) + try container.encode(height, forKey: .height) + } + } } public struct GuestAgentStatus: Codable, Equatable, Sendable { public let protocolVersion: Int + public let agentVersion: String public let hostname: String public let ipAddresses: [String] public let operatingSystem: String - public let sharedDirectories: [String] + public let distroID: String + public let distroVersion: String + public let desktopEnvironment: GuestDesktopEnvironment + public let sessionType: GuestSessionType + public let capabilities: Set + public let sharedMountStatus: GuestSharedMountStatus + + private enum CodingKeys: String, CodingKey { + case protocolVersion + case agentVersion + case hostname + case ipAddresses + case operatingSystem + case distroID + case distroVersion + case desktopEnvironment + case sessionType + case capabilities + case sharedMountStatus + case sharedDirectories + } public init( protocolVersion: Int, + agentVersion: String, hostname: String, ipAddresses: [String], operatingSystem: String, - sharedDirectories: [String] + distroID: String, + distroVersion: String, + desktopEnvironment: GuestDesktopEnvironment, + sessionType: GuestSessionType, + capabilities: Set, + sharedMountStatus: GuestSharedMountStatus ) { self.protocolVersion = protocolVersion + self.agentVersion = agentVersion self.hostname = hostname self.ipAddresses = ipAddresses self.operatingSystem = operatingSystem - self.sharedDirectories = sharedDirectories + self.distroID = distroID + self.distroVersion = distroVersion + self.desktopEnvironment = desktopEnvironment + self.sessionType = sessionType + self.capabilities = capabilities + self.sharedMountStatus = sharedMountStatus + } + + public init( + protocolVersion: Int, + hostname: String, + ipAddresses: [String], + operatingSystem: String, + sharedDirectories: [String] + ) { + self.init( + protocolVersion: protocolVersion, + agentVersion: "unknown", + hostname: hostname, + ipAddresses: ipAddresses, + operatingSystem: operatingSystem, + distroID: "unknown", + distroVersion: "unknown", + desktopEnvironment: .other, + sessionType: .other, + capabilities: [], + sharedMountStatus: GuestSharedMountStatus( + state: sharedDirectories.isEmpty ? .unmounted : .mounted, + message: sharedDirectories.first + ) + ) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try container.decode( + Int.self, + forKey: .protocolVersion + ) + agentVersion = try container.decodeIfPresent( + String.self, + forKey: .agentVersion + ) ?? "unknown" + hostname = try container.decode(String.self, forKey: .hostname) + ipAddresses = try container.decodeIfPresent( + [String].self, + forKey: .ipAddresses + ) ?? [] + operatingSystem = try container.decodeIfPresent( + String.self, + forKey: .operatingSystem + ) ?? "Linux" + distroID = try container.decodeIfPresent( + String.self, + forKey: .distroID + ) ?? "unknown" + distroVersion = try container.decodeIfPresent( + String.self, + forKey: .distroVersion + ) ?? "unknown" + desktopEnvironment = try container.decodeIfPresent( + GuestDesktopEnvironment.self, + forKey: .desktopEnvironment + ) ?? .other + sessionType = try container.decodeIfPresent( + GuestSessionType.self, + forKey: .sessionType + ) ?? .other + capabilities = Set( + try container.decodeIfPresent( + [GuestAgentCapability].self, + forKey: .capabilities + ) ?? [] + ) + if let status = try container.decodeIfPresent( + GuestSharedMountStatus.self, + forKey: .sharedMountStatus + ) { + sharedMountStatus = status + } else { + let directories = try container.decodeIfPresent( + [String].self, + forKey: .sharedDirectories + ) ?? [] + sharedMountStatus = GuestSharedMountStatus( + state: directories.isEmpty ? .unmounted : .mounted, + message: directories.first + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(protocolVersion, forKey: .protocolVersion) + try container.encode(agentVersion, forKey: .agentVersion) + try container.encode(hostname, forKey: .hostname) + try container.encode(ipAddresses, forKey: .ipAddresses) + try container.encode(operatingSystem, forKey: .operatingSystem) + try container.encode(distroID, forKey: .distroID) + try container.encode(distroVersion, forKey: .distroVersion) + try container.encode(desktopEnvironment, forKey: .desktopEnvironment) + try container.encode(sessionType, forKey: .sessionType) + try container.encode( + capabilities.sorted { $0.rawValue < $1.rawValue }, + forKey: .capabilities + ) + try container.encode(sharedMountStatus, forKey: .sharedMountStatus) } } -public enum GuestAgentResponse: Codable, Equatable, Sendable { +public struct GuestAgentFailure: Codable, Equatable, Sendable { + public let code: String + public let message: String + + public init(code: String, message: String) { + self.code = code + self.message = message + } +} + +public enum GuestAgentResponse: Equatable, Sendable { case hello(protocolVersion: Int) case status(GuestAgentStatus) case accepted - case failure(message: String) + case clipboard(text: String) + case failure(GuestAgentFailure) +} + +extension GuestAgentResponse: Codable { + private enum CodingKeys: String, CodingKey { + case type + case protocolVersion + case status + case text + case code + case message + case hello + case accepted + case failure + } + + private enum ResponseType: String, Codable { + case hello + case status + case accepted + case clipboard + case failure + } + + private struct LegacyHello: Codable { + let protocolVersion: Int + } + + private struct LegacyFailure: Codable { + let message: String + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let type = try container.decodeIfPresent( + ResponseType.self, + forKey: .type + ) { + switch type { + case .hello: + self = .hello( + protocolVersion: try container.decode( + Int.self, + forKey: .protocolVersion + ) + ) + case .status: + if container.contains(.status) { + self = .status( + try container.decode( + GuestAgentStatus.self, + forKey: .status + ) + ) + } else { + self = .status(try GuestAgentStatus(from: decoder)) + } + case .accepted: + self = .accepted + case .clipboard: + let text = try container.decode(String.self, forKey: .text) + guard text.utf8.count + <= GuestAgentProtocol.maximumClipboardSize else { + throw GuestAgentProtocolError.clipboardTooLarge + } + self = .clipboard(text: text) + case .failure: + self = .failure( + GuestAgentFailure( + code: try container.decodeIfPresent( + String.self, + forKey: .code + ) ?? "guestError", + message: try container.decode( + String.self, + forKey: .message + ) + ) + ) + } + return + } + + if container.contains(.hello) { + let hello = try container.decode( + LegacyHello.self, + forKey: .hello + ) + self = .hello(protocolVersion: hello.protocolVersion) + } else if container.contains(.status) { + self = .status( + try container.decode(GuestAgentStatus.self, forKey: .status) + ) + } else if container.contains(.accepted) { + self = .accepted + } else if container.contains(.failure) { + let failure = try container.decode( + LegacyFailure.self, + forKey: .failure + ) + self = .failure( + GuestAgentFailure( + code: "legacyFailure", + message: failure.message + ) + ) + } else { + throw DecodingError.dataCorrupted( + .init( + codingPath: decoder.codingPath, + debugDescription: "Unknown guest-agent response." + ) + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .hello(let protocolVersion): + try container.encode(ResponseType.hello, forKey: .type) + try container.encode(protocolVersion, forKey: .protocolVersion) + case .status(let status): + try container.encode(ResponseType.status, forKey: .type) + try container.encode(status, forKey: .status) + case .accepted: + try container.encode(ResponseType.accepted, forKey: .type) + case .clipboard(let text): + guard text.utf8.count <= GuestAgentProtocol.maximumClipboardSize else { + throw GuestAgentProtocolError.clipboardTooLarge + } + try container.encode(ResponseType.clipboard, forKey: .type) + try container.encode(text, forKey: .text) + case .failure(let failure): + try container.encode(ResponseType.failure, forKey: .type) + try container.encode(failure.code, forKey: .code) + try container.encode(failure.message, forKey: .message) + } + } +} + +public struct GuestAgentRequestEnvelope: Codable, Equatable, Sendable { + public let protocolVersion: Int + public let requestID: String? + public let request: GuestAgentRequest + + private enum CodingKeys: String, CodingKey { + case protocolVersion + case requestID + case request + } + + public init( + protocolVersion: Int = GuestAgentProtocol.currentVersion, + requestID: String = UUID().uuidString, + request: GuestAgentRequest + ) { + self.protocolVersion = protocolVersion + self.requestID = requestID + self.request = request + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.request) { + protocolVersion = try container.decode( + Int.self, + forKey: .protocolVersion + ) + requestID = try container.decodeIfPresent( + String.self, + forKey: .requestID + ) + request = try container.decode( + GuestAgentRequest.self, + forKey: .request + ) + } else { + request = try GuestAgentRequest(from: decoder) + requestID = nil + if case .hello(let version) = request { + protocolVersion = version + } else { + protocolVersion = 1 + } + } + } +} + +public struct GuestAgentResponseEnvelope: Codable, Equatable, Sendable { + public let protocolVersion: Int + public let requestID: String? + public let response: GuestAgentResponse + + private enum CodingKeys: String, CodingKey { + case protocolVersion + case requestID + case response + } + + public init( + protocolVersion: Int = GuestAgentProtocol.currentVersion, + requestID: String?, + response: GuestAgentResponse + ) { + self.protocolVersion = protocolVersion + self.requestID = requestID + self.response = response + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if container.contains(.response) { + protocolVersion = try container.decode( + Int.self, + forKey: .protocolVersion + ) + requestID = try container.decodeIfPresent( + String.self, + forKey: .requestID + ) + response = try container.decode( + GuestAgentResponse.self, + forKey: .response + ) + } else { + response = try GuestAgentResponse(from: decoder) + requestID = nil + switch response { + case .hello(let version): + protocolVersion = version + case .status(let status): + protocolVersion = status.protocolVersion + case .accepted, .clipboard, .failure: + protocolVersion = 1 + } + } + } +} + +public enum GuestDisplaySize { + public static func isValid(width: Int, height: Int) -> Bool { + (640...16_384).contains(width) + && (480...16_384).contains(height) + } } public enum GuestAgentFrameCodec { - public static let maximumPayloadSize = 1 * 1_024 * 1_024 + public static let maximumPayloadSize = 2 * 1_024 * 1_024 public static func encode(_ message: T) throws -> Data { - let payload = try JSONEncoder().encode(message) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let payload = try encoder.encode(message) guard payload.count <= maximumPayloadSize else { throw GuestAgentProtocolError.payloadTooLarge } @@ -66,21 +670,43 @@ public enum GuestAgentFrameCodec { guard frame.count == Int(length) + 4 else { throw GuestAgentProtocolError.incompleteFrame } - return try JSONDecoder().decode(T.self, from: frame.dropFirst(4)) + do { + return try JSONDecoder().decode(T.self, from: frame.dropFirst(4)) + } catch let error as GuestAgentProtocolError { + throw error + } catch { + throw GuestAgentProtocolError.invalidMessage( + error.localizedDescription + ) + } } } public enum GuestAgentProtocolError: LocalizedError, Equatable { case payloadTooLarge + case clipboardTooLarge case incompleteFrame + case invalidDisplaySize + case invalidMessage(String) + case incompatibleVersion(Int) + case mismatchedRequestID public var errorDescription: String? { switch self { case .payloadTooLarge: "The guest-agent message exceeds the size limit." + case .clipboardTooLarge: + "Clipboard text exceeds the 1 MiB Guest Tools limit." case .incompleteFrame: "The guest-agent message is incomplete." + case .invalidDisplaySize: + "The requested guest display size is outside the safe range." + case .invalidMessage(let message): + "The guest-agent message is invalid: \(message)" + case .incompatibleVersion(let version): + "Guest Tools protocol version \(version) is not supported." + case .mismatchedRequestID: + "Guest Tools returned a response for a different request." } } } - diff --git a/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift b/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift index 03d30d2..345ac58 100644 --- a/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift +++ b/Packages/SimpleVMCore/Sources/SimpleVMCore/Virtualization/QEMU/QEMUConfiguration.swift @@ -7,6 +7,7 @@ public struct QEMUConfiguration: Equatable, Sendable { public let qmpSocketURL: URL public let logURL: URL public let spiceSocketURL: URL? + public let agentSocketURL: URL? public init( executableURL: URL, @@ -14,7 +15,8 @@ public struct QEMUConfiguration: Equatable, Sendable { vncPort: UInt16, qmpSocketURL: URL, logURL: URL, - spiceSocketURL: URL? = nil + spiceSocketURL: URL? = nil, + agentSocketURL: URL? = nil ) { self.executableURL = executableURL self.arguments = arguments @@ -22,6 +24,7 @@ public struct QEMUConfiguration: Equatable, Sendable { self.qmpSocketURL = qmpSocketURL self.logURL = logURL self.spiceSocketURL = spiceSocketURL + self.agentSocketURL = agentSocketURL } } @@ -168,7 +171,8 @@ public enum QEMUConfigurationBuilder { logURL: logURL, spiceSocketURL: runtime.displayBackend == .vnc ? nil - : spiceURL + : spiceURL, + agentSocketURL: agentURL ) } } diff --git a/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/IntegrationCapabilityTests.swift b/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/IntegrationCapabilityTests.swift index ef93695..3dd9f16 100644 --- a/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/IntegrationCapabilityTests.swift +++ b/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/IntegrationCapabilityTests.swift @@ -4,23 +4,119 @@ import Testing @Test func roundTripsVersionedGuestAgentFrames() throws { - let request = GuestAgentRequest.hello(protocolVersion: 1) + let request = GuestAgentRequestEnvelope( + requestID: "request-1", + request: .resizeDisplay(width: 1_920, height: 1_080) + ) let frame = try GuestAgentFrameCodec.encode(request) #expect( try GuestAgentFrameCodec.decode( - GuestAgentRequest.self, + GuestAgentRequestEnvelope.self, from: frame ) == request ) #expect(throws: GuestAgentProtocolError.incompleteFrame) { - try GuestAgentFrameCodec.decode( - GuestAgentRequest.self, + _ = try GuestAgentFrameCodec.decode( + GuestAgentRequestEnvelope.self, from: frame.dropLast() ) } } +@Test +func decodesLegacyGuestAgentFramesAndStatus() throws { + let legacyRequest = try framedJSON(#"{"status":{}}"#) + let decodedRequest = try GuestAgentFrameCodec.decode( + GuestAgentRequestEnvelope.self, + from: legacyRequest + ) + #expect(decodedRequest.protocolVersion == 1) + #expect(decodedRequest.requestID == nil) + #expect(decodedRequest.request == .status) + + let legacyStatus = try framedJSON( + """ + {"status":{"protocolVersion":1,"hostname":"guest","ipAddresses":["192.0.2.2"],"operatingSystem":"Linux","sharedDirectories":["/mnt/share"]}} + """ + ) + let response = try GuestAgentFrameCodec.decode( + GuestAgentResponseEnvelope.self, + from: legacyStatus + ) + guard case .status(let status) = response.response else { + Issue.record("Expected a legacy status response.") + return + } + #expect(status.agentVersion == "unknown") + #expect(status.desktopEnvironment == .other) + #expect(status.sharedMountStatus.state == .mounted) +} + +@Test +func decodesGuestToolsStatusWireShape() throws { + let frame = try framedJSON( + """ + {"protocolVersion":2,"requestID":"status-1","response":{"type":"status","protocolVersion":2,"agentVersion":"2.0.0","hostname":"omarchy","ipAddresses":[],"operatingSystem":"Linux","distroID":"arch","distroVersion":"rolling","desktopEnvironment":"hyprland","sessionType":"wayland","capabilities":["clipboardRead","clipboardWrite","displayResize"],"sharedMountStatus":{"mounted":false,"state":"notMounted","mountPoint":"/mnt/simplevm-share","tag":"share","filesystem":"virtiofs"}}} + """ + ) + let response = try GuestAgentFrameCodec.decode( + GuestAgentResponseEnvelope.self, + from: frame + ) + guard case .status(let status) = response.response else { + Issue.record("Expected a status response.") + return + } + #expect(response.requestID == "status-1") + #expect(status.desktopEnvironment == .hyprland) + #expect(status.sessionType == .wayland) + #expect(status.capabilities.contains(.displayResize)) + #expect(status.sharedMountStatus.state == .unmounted) +} + +@Test +func rejectsUnsafeGuestAgentPayloads() throws { + #expect(throws: GuestAgentProtocolError.clipboardTooLarge) { + try GuestAgentFrameCodec.encode( + GuestAgentRequestEnvelope( + request: .writeClipboard( + text: String( + repeating: "x", + count: GuestAgentProtocol.maximumClipboardSize + 1 + ) + ) + ) + ) + } + #expect(throws: GuestAgentProtocolError.invalidDisplaySize) { + try GuestAgentFrameCodec.encode( + GuestAgentRequestEnvelope( + request: .resizeDisplay(width: 100, height: 100) + ) + ) + } + do { + _ = try GuestAgentFrameCodec.decode( + GuestAgentRequestEnvelope.self, + from: try framedJSON( + #"{"protocolVersion":2,"requestID":"x","request":{"type":"runCommand","command":"id"}}"# + ) + ) + Issue.record("Expected an unknown operation to be rejected.") + } catch GuestAgentProtocolError.invalidMessage { + // Expected. + } +} + +private func framedJSON(_ json: String) throws -> Data { + let payload = Data(json.utf8) + var length = UInt32(payload.count).bigEndian + var frame = withUnsafeBytes(of: &length) { Data($0) } + frame.append(payload) + return frame +} + @Test func createsCopyOnWriteDiskCloneOnAPFS() throws { let directory = FileManager.default.temporaryDirectory.appending( @@ -58,4 +154,3 @@ func validatesPortForwardRules() throws { ) } } - diff --git a/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift b/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift index 599d281..0e9df1b 100644 --- a/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift +++ b/Packages/SimpleVMCore/Tests/SimpleVMCoreTests/QEMUBackendTests.swift @@ -112,6 +112,13 @@ func buildsExplicitQEMUArgumentsAndPersistentFirmware() throws { atPath: backendURL.appending(path: "efi-vars.fd").path )) #expect(configuration.qmpSocketURL.path.utf8.count < 104) + #expect(configuration.agentSocketURL?.lastPathComponent.hasSuffix( + "-agent.sock" + ) == true) + #expect(configuration.arguments.contains { + $0.contains(configuration.agentSocketURL!.path) + && $0.contains("server=on,wait=off") + }) let accelerated = try QEMUConfigurationBuilder.make( machine: machine, diff --git a/README.md b/README.md index 55d3914..b12a06d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ guests and a QEMU/SPICE/Metal path for x86_64 compatibility. - Exportable library media and stopped-machine raw disks for migration - Persistent disks, EFI state, snapshots, restore, and APFS-backed clones - NAT networking, TCP port forwarding, and virtiofs directory sharing +- First-party, capability-gated SimpleVM Guest Tools for Linux integration - Rosetta support for Intel Linux binaries in supported ARM64 guests - Preinstalled raw disks, rootfs archives, and OCI image provisioning @@ -122,6 +123,92 @@ VM disks and installer media are intentionally excluded from Git. Machine exports contain the disk only. CPU, memory, networking, EFI variables, shares, snapshots, and other machine settings are not included. +## SimpleVM Guest Tools + +Guest Tools are optional. VMs boot and remain usable when the agent is absent, +stopped, incompatible, or temporarily disconnected. SimpleVM does not install +software unattended, automate a guest password, or modify a guest disk to +bootstrap the agent. + +Open **Guest Tools** in Machine Detail to see connection state, agent and Linux +version, detected desktop/session, shared-folder mount state, and the exact +capabilities advertised by that guest. + +### Install + +For an Apple Virtualization machine with a configured shared directory: + +1. Start the VM and open **Guest Tools**. +2. Choose **Copy to Shared Folder**. This delivers + `simplevm-guest-tools.tar.gz`; it does not install it. +3. Run the exact command shown by SimpleVM inside the guest: + + ```sh + cd /mnt/simplevm-share && tar -xzf simplevm-guest-tools.tar.gz \ + && cd GuestTools \ + && ./install.sh --with-wayland-clipboard --with-x11-agent + ``` + +For other machines, choose **Export Tools Bundle...**, move the archive into +the guest using a method you trust, change to the directory containing it, and +run: + +```sh +tar -xzf simplevm-guest-tools.tar.gz \ + && cd GuestTools \ + && ./install.sh --with-wayland-clipboard --with-x11-agent +``` + +The installer supports modern systemd Debian/Ubuntu and Arch/Omarchy. It asks +for `sudo`, installs a dedicated system service and per-user session service, +and enables them only after validating the required runtime. Sign out and in +once when prompted so the desktop user receives `simplevm-agent` group access. + +Optional installer flags install `wl-clipboard` for Wayland and +`spice-vdagent` for supported X11/SPICE sessions. Vanilla `spice-vdagent` does +not provide Hyprland Wayland clipboard integration or Hyprland display resize. + +To uninstall, run `./uninstall.sh` from the extracted bundle. The uninstaller +removes the services and agent code but deliberately preserves +`/mnt/simplevm-share` and its contents. + +### Supported integration + +| Guest/backend | Status and power | Shared folder | Clipboard | Display resize | +| --- | --- | --- | --- | --- | +| Apple VZ, GNOME/X11 | Guest Tools | `share` virtiofs auto-mount | Not currently available | Native VZ fallback | +| Apple VZ, Wayland/Hyprland | Guest Tools | `share` virtiofs auto-mount | Guest Tools with `wl-copy`/`wl-paste` | Guest Tools when Hyprland advertises support | +| QEMU/SPICE, GNOME/X11 | Guest Tools over virtio-serial | Not currently exposed by the QEMU backend | SPICE and `spice-vdagent` | SPICE monitor configuration | +| QEMU/SPICE, Wayland/Hyprland | Guest Tools over virtio-serial | Not currently exposed by the QEMU backend | Guest Tools with `wl-copy`/`wl-paste` | Guest Tools when Hyprland advertises support | + +Clipboard integration is UTF-8 text only and rejects content over 1 MiB. +SimpleVM compares clipboard fingerprints and change counters to suppress echo +loops, does not log clipboard content, and polls only while the app and VM +integration are active. Image, file, and rich-text clipboard formats are not +forwarded. + +When a machine uses the **Automatic** desktop and input profile, a connected +agent's detected GNOME or Hyprland desktop selects the active runtime mapping. +An explicit profile selection is never overwritten. Without an agent, the +existing machine-name fallback remains in effect. + +### Security model + +The host protocol is length-bounded, versioned JSON over QEMU's named +virtio-serial port or Apple VZ AF_VSOCK port 1021. It exposes only status, +graceful shutdown/reboot, fixed `share` mounting, bounded text clipboard, and +validated display-size requests. It has no command-execution request. + +Inside Linux, a small root service owns the transport and only invokes fixed +argument arrays for power and mounting the `share` tag at +`/mnt/simplevm-share`. Desktop clipboard and compositor operations run in an +unprivileged user service. Their Unix socket is group-restricted and validates +peer credentials. See `GuestTools/SECURITY.md` and the Python sources in +`GuestTools/src/` for the complete auditable boundary. + +Connection failures are shown in Machine Detail and never prevent VM startup. +Use **Retry Connection** after starting or updating the guest services. + ## Immersion and permissions Immersion removes the surrounding SimpleVM interface and routes host-level @@ -260,7 +347,10 @@ The fixture path and installer image remain local and are never committed. - No prebuilt or notarized release artifact - x86_64 CPU execution uses TCG software emulation - The accelerated x86_64 build currently expects UTM in `/Applications` -- Guest tools are not installed automatically +- Guest Tools require an explicit in-guest install and currently target + systemd Debian/Ubuntu and Arch/Omarchy +- QEMU does not yet expose configured host directories as virtiofs shares; + use bundle export for QEMU guests - System workspace-swipe capture relies on macOS event behavior that may change between macOS releases diff --git a/SimpleVM.xcodeproj/project.pbxproj b/SimpleVM.xcodeproj/project.pbxproj index 6fb5ad6..d6f07d3 100644 --- a/SimpleVM.xcodeproj/project.pbxproj +++ b/SimpleVM.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 4D9938ECE002EE1709AA6262 /* gmodule-2.0.0.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DE21ECE968D0F962D0195CB0 /* gmodule-2.0.0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 540F604F448222C219C101FC /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB18762CB489CBC5B9CAACFE /* FilePicker.swift */; }; 5697088CB571EF754255C317 /* CocoaSpiceNoUsb in Frameworks */ = {isa = PBXBuildFile; productRef = 793FE81581C6AD27F34E2119 /* CocoaSpiceNoUsb */; }; + 59D099DB2098BAD0D59AA854 /* GuestToolsBundleExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A34CB0A7B15B7B9D1EC0B8 /* GuestToolsBundleExporter.swift */; }; 5F8915C5582CD37861B19ACE /* NewMachineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985115328A2DBD84F72F7B4C /* NewMachineView.swift */; }; 648C2BCE43058F50E43CC6DC /* glib-2.0.0.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 2E4EA8BB684DC4CE0C51CCB0 /* glib-2.0.0.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 658ADFE96F5C96B18AB2B905 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F6CF288BE530F5F2AFC2AEC /* RootView.swift */; }; @@ -38,13 +39,16 @@ A04BC5D803484483321046C3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D3C57FAEE82D92275B30667B /* Assets.xcassets */; }; A1D441994B601F267BFD24EE /* AppLifecycleDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F28839F625F13DFFBF555EC /* AppLifecycleDelegate.swift */; }; A24AB58487D28B09DD450DCC /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2FBB02FB71E409A7D821C2 /* LibraryView.swift */; }; + A2D0D23D29BF9D8C7083E954 /* GuestToolsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2E48A4AAC1C6F7815B656E /* GuestToolsCoordinator.swift */; }; A59D789DF86E4DB11A121332 /* KeyboardMappingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A118B3525D21D3B293C0492A /* KeyboardMappingSettings.swift */; }; A84556531E6AA0C83BF90944 /* LoopbackPortAllocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EEECC9AF70ED42593B51C7 /* LoopbackPortAllocator.swift */; }; AF2DF78A26F795AE3580E341 /* LaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0DCB98047E5FF0D5E7B40D /* LaunchTests.swift */; }; B01AAA137DEC36F98DB948A3 /* SimpleVMCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8B4DA1DDD021526302779E3B /* SimpleVMCore */; }; BDB9E93688AB2349F1E1C73B /* SimpleVMCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4431965DBEACBD8427BF0413 /* SimpleVMCore */; }; CA35A66CE7597FE74FC55417 /* QEMUMachineRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7584A6E0368D9AA54B787B85 /* QEMUMachineRuntime.swift */; }; + CC95870D1AD4AE5F95C5E8CF /* GuestTools in Resources */ = {isa = PBXBuildFile; fileRef = F9B11F4E5A09D4CED2A25BEF /* GuestTools */; }; E42B12C82D5D53828403D4CE /* ImmersionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980C0CC18CAC585118FDBC68 /* ImmersionController.swift */; }; + E4840718E4AAFEF0058C03DC /* GuestAgentSocketTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E1A8C634307047236B3D7D8 /* GuestAgentSocketTransport.swift */; }; E65C5B0273A8A2DD186DBD72 /* SPICEConnectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96503BBDA44669BFF9BA596C /* SPICEConnectionController.swift */; }; E85D6235650454CD47E7001A /* FoundationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D904C8CFEF1E135E5D9855F /* FoundationTests.swift */; }; E8B894E2D7B66328ADF0CFEC /* gio-2.0.0.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 24FD90058EA44DE8C6E30A3A /* gio-2.0.0.framework */; }; @@ -106,17 +110,20 @@ 192761E63ECE83BF88D5A3B7 /* CocoaSpice */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CocoaSpice; path = Packages/CocoaSpice; sourceTree = SOURCE_ROOT; }; 196CF9291A01A3300651A1F1 /* SimpleVMApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleVMApp.swift; sourceTree = ""; }; 1D904C8CFEF1E135E5D9855F /* FoundationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationTests.swift; sourceTree = ""; }; + 1E1A8C634307047236B3D7D8 /* GuestAgentSocketTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestAgentSocketTransport.swift; sourceTree = ""; }; 1F28839F625F13DFFBF555EC /* AppLifecycleDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLifecycleDelegate.swift; sourceTree = ""; }; - 24FD90058EA44DE8C6E30A3A /* gio-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gio-2.0.0.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/gio-2.0.0.framework"; sourceTree = ""; }; + 1F2E48A4AAC1C6F7815B656E /* GuestToolsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestToolsCoordinator.swift; sourceTree = ""; }; + 20A34CB0A7B15B7B9D1EC0B8 /* GuestToolsBundleExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestToolsBundleExporter.swift; sourceTree = ""; }; + 24FD90058EA44DE8C6E30A3A /* gio-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gio-2.0.0.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/gio-2.0.0.framework"; sourceTree = ""; }; 267CD823610747CC49B50478 /* QEMUMachineDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QEMUMachineDisplayView.swift; sourceTree = ""; }; - 2E4EA8BB684DC4CE0C51CCB0 /* glib-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "glib-2.0.0.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/glib-2.0.0.framework"; sourceTree = ""; }; + 2E4EA8BB684DC4CE0C51CCB0 /* glib-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "glib-2.0.0.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/glib-2.0.0.framework"; sourceTree = ""; }; 5A1CB140D317CF0248162C45 /* MachineDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineDetailView.swift; sourceTree = ""; }; 6568581FFFE96E82F58328EF /* SPICEMachineDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SPICEMachineDisplayView.swift; sourceTree = ""; }; - 6E9820C6519C87545CEB5A62 /* spice-client-glib-2.0.8.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "spice-client-glib-2.0.8.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/spice-client-glib-2.0.8.framework"; sourceTree = ""; }; + 6E9820C6519C87545CEB5A62 /* spice-client-glib-2.0.8.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "spice-client-glib-2.0.8.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/spice-client-glib-2.0.8.framework"; sourceTree = ""; }; 746AF525D76C096CB5EBCF74 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 7584A6E0368D9AA54B787B85 /* QEMUMachineRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QEMUMachineRuntime.swift; sourceTree = ""; }; 7F6CF288BE530F5F2AFC2AEC /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; - 821A198A1A52724AFFBB089D /* gobject-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gobject-2.0.0.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/gobject-2.0.0.framework"; sourceTree = ""; }; + 821A198A1A52724AFFBB089D /* gobject-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gobject-2.0.0.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/gobject-2.0.0.framework"; sourceTree = ""; }; 86F6149CE1A73CC0B812B1CE /* MachineDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachineDisplayView.swift; sourceTree = ""; }; 8772888F8FF4E4B68580A3A3 /* SimpleVM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleVM.app; sourceTree = BUILT_PRODUCTS_DIR; }; 96503BBDA44669BFF9BA596C /* SPICEConnectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SPICEConnectionController.swift; sourceTree = ""; }; @@ -130,17 +137,18 @@ B84B5C8E69ADAD9771588F5A /* AppleGuestAgentTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleGuestAgentTransport.swift; sourceTree = ""; }; BD2FBB02FB71E409A7D821C2 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; C7EEECC9AF70ED42593B51C7 /* LoopbackPortAllocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopbackPortAllocator.swift; sourceTree = ""; }; - CAB202727DFBCCBA7B09171D /* gstreamer-1.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gstreamer-1.0.0.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/gstreamer-1.0.0.framework"; sourceTree = ""; }; - D1B9214B0414C7A737CBBA59 /* qemu-x86_64-softmmu.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "qemu-x86_64-softmmu.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/qemu-x86_64-softmmu.framework"; sourceTree = ""; }; + CAB202727DFBCCBA7B09171D /* gstreamer-1.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gstreamer-1.0.0.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/gstreamer-1.0.0.framework"; sourceTree = ""; }; + D1B9214B0414C7A737CBBA59 /* qemu-x86_64-softmmu.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "qemu-x86_64-softmmu.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/qemu-x86_64-softmmu.framework"; sourceTree = ""; }; D3C57FAEE82D92275B30667B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D505BA5F4159F9F9F8AA155E /* SimpleVMAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SimpleVMAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D640994605252415E8310D95 /* SimpleVMUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SimpleVMUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D872E56BAD09218EDACF6C1B /* TCPPortForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPPortForwarder.swift; sourceTree = ""; }; DB18762CB489CBC5B9CAACFE /* FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePicker.swift; sourceTree = ""; }; - DE21ECE968D0F962D0195CB0 /* gmodule-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gmodule-2.0.0.framework"; path = "../../../../../Applications/UTM.app/Contents/Frameworks/gmodule-2.0.0.framework"; sourceTree = ""; }; + DE21ECE968D0F962D0195CB0 /* gmodule-2.0.0.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = "gmodule-2.0.0.framework"; path = "../../../../../../../Applications/UTM.app/Contents/Frameworks/gmodule-2.0.0.framework"; sourceTree = ""; }; E0ADC12DEFE4931F54E578EC /* KarabinerInputBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KarabinerInputBridge.swift; sourceTree = ""; }; E5C74D1D84F26328E4E21CA4 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; F654F0BC4E0BE5F292B939D3 /* UTMQEMULauncher */ = {isa = PBXFileReference; includeInIndex = 0; path = UTMQEMULauncher; sourceTree = BUILT_PRODUCTS_DIR; }; + F9B11F4E5A09D4CED2A25BEF /* GuestTools */ = {isa = PBXFileReference; lastKnownFileType = folder; path = GuestTools; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -254,6 +262,14 @@ path = Networking; sourceTree = ""; }; + 7ECEABE2E2A0D2940C9EF0CE /* GuestTools */ = { + isa = PBXGroup; + children = ( + 20A34CB0A7B15B7B9D1EC0B8 /* GuestToolsBundleExporter.swift */, + ); + path = GuestTools; + sourceTree = ""; + }; 8B22C940DED2906A5BE63CAC /* Display */ = { isa = PBXGroup; children = ( @@ -269,6 +285,8 @@ isa = PBXGroup; children = ( B84B5C8E69ADAD9771588F5A /* AppleGuestAgentTransport.swift */, + 1E1A8C634307047236B3D7D8 /* GuestAgentSocketTransport.swift */, + 1F2E48A4AAC1C6F7815B656E /* GuestToolsCoordinator.swift */, C7EEECC9AF70ED42593B51C7 /* LoopbackPortAllocator.swift */, A51018C6C120FAD79979FC93 /* MachineRuntime.swift */, 7584A6E0368D9AA54B787B85 /* QEMUMachineRuntime.swift */, @@ -288,6 +306,7 @@ A55C4126CDA6BDF2CC56A622 = { isa = PBXGroup; children = ( + F9B11F4E5A09D4CED2A25BEF /* GuestTools */, C643AB1A1F9584BD0F08BE37 /* Packages */, F42FF3EFF274E98119AE349D /* Resources */, DC5EA99895D5099849B09488 /* SimpleVMApp */, @@ -345,6 +364,7 @@ 227FE4D072D88E4E77B1FE95 /* Application */, 8B22C940DED2906A5BE63CAC /* Display */, 3C4F34143BAC484D99CE59C3 /* Features */, + 7ECEABE2E2A0D2940C9EF0CE /* GuestTools */, E122DAAB01C47BE399A87297 /* Input */, 71A5A7330EFC6FD8442193D8 /* Networking */, 9BC60EB0B6AD02A2BA3C09E2 /* Virtualization */, @@ -509,6 +529,7 @@ buildActionMask = 2147483647; files = ( A04BC5D803484483321046C3 /* Assets.xcassets in Resources */, + CC95870D1AD4AE5F95C5E8CF /* GuestTools in Resources */, FDA1CA6F16E63B81D335ECAD /* UTMQEMULauncher in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -572,6 +593,9 @@ EFB927F13064A5117DF1E5C9 /* AppleGuestAgentTransport.swift in Sources */, 540F604F448222C219C101FC /* FilePicker.swift in Sources */, 360FBD2E05C06C166E6A713A /* GStreamerPluginStubs.c in Sources */, + E4840718E4AAFEF0058C03DC /* GuestAgentSocketTransport.swift in Sources */, + 59D099DB2098BAD0D59AA854 /* GuestToolsBundleExporter.swift in Sources */, + A2D0D23D29BF9D8C7083E954 /* GuestToolsCoordinator.swift in Sources */, E42B12C82D5D53828403D4CE /* ImmersionController.swift in Sources */, 813E606F254FE1337BEBA76D /* ImmersiveInputCapture.swift in Sources */, 6B86D7566374617B23B05B88 /* KarabinerInputBridge.swift in Sources */, diff --git a/SimpleVMApp/Application/AppModel.swift b/SimpleVMApp/Application/AppModel.swift index a760e1f..360cbc1 100644 --- a/SimpleVMApp/Application/AppModel.swift +++ b/SimpleVMApp/Application/AppModel.swift @@ -109,6 +109,43 @@ final class AppModel { } return image } + if ProcessInfo.processInfo.environment[ + "SIMPLEVM_UI_TEST_GUEST_TOOLS" + ] == "1", machines.isEmpty { + let machineID = UUID() + let files = try await machineStore.createFiles( + machineID: machineID, + diskCapacityBytes: 1 * 1_024 * 1_024, + backend: .appleVirtualization + ) + let shareURL = layout.rootURL.appending( + path: "GuestToolsTestShare", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory( + at: shareURL, + withIntermediateDirectories: true + ) + machines = [ + Machine( + id: machineID, + name: "Guest Tools Fixture", + spec: MachineSpec( + cpuCount: 2, + memorySizeBytes: 2 * 1_024 * 1_024 * 1_024, + diskSizeBytes: files.0.capacityBytes, + architecture: .arm64, + sharedDirectoryPath: shareURL.path + ), + sourceImageID: UUID(), + disk: files.0, + provisioningState: .ready, + bootMedia: .systemDisk, + backend: .appleVirtualization, + backendState: files.1 + ) + ] + } for index in machines.indices where machines[index].provisioningState.isInterrupted { let sourceImage = images.first { @@ -665,7 +702,9 @@ final class AppModel { backendStateURL: files.backendStateURL ) await appleRuntime(for: machine).start( - configuration: configuration + configuration: configuration, + sharedDirectoryConfigured: + machine.spec.sharedDirectoryPath != nil ) case .qemu: await qemuRuntime(for: machine).start( @@ -683,7 +722,7 @@ final class AppModel { func requestStop(_ machine: Machine) async { switch machine.backend { case .appleVirtualization: - appleRuntime(for: machine).requestStop() + await appleRuntime(for: machine).requestStop() case .qemu: await qemuRuntime(for: machine).stop() } @@ -698,6 +737,15 @@ final class AppModel { } } + func requestReboot(_ machine: Machine) async { + switch machine.backend { + case .appleVirtualization: + await appleRuntime(for: machine).requestReboot() + case .qemu: + await qemuRuntime(for: machine).requestReboot() + } + } + func ejectInstaller(_ machine: Machine) async { guard !startingMachineIDs.contains(machine.id) else { present(error: AppModelError.machineStartInProgress) @@ -936,6 +984,7 @@ final class AppModel { guard let layout else { throw AppModelError.notInitialized } + guard exportingMachineIDs.insert(machine.id).inserted else { throw AppModelError.exportInProgress } @@ -964,6 +1013,26 @@ final class AppModel { ) } + func exportGuestTools(to destinationURL: URL) async throws { + try await Task.detached(priority: .userInitiated) { + try GuestToolsBundleExporter().export(to: destinationURL) + }.value + } + + func copyGuestToolsToSharedDirectory( + for machine: Machine + ) async throws -> URL { + guard machine.backend == .appleVirtualization, + let path = machine.spec.sharedDirectoryPath else { + throw GuestToolsBundleError.destinationUnavailable + } + return try await Task.detached(priority: .userInitiated) { + try GuestToolsBundleExporter().copyToSharedDirectory( + URL(filePath: path, directoryHint: .isDirectory) + ) + }.value + } + func dismissError() { errorMessage = nil } diff --git a/SimpleVMApp/Display/MachineDisplayView.swift b/SimpleVMApp/Display/MachineDisplayView.swift index 4dc1684..3f27cdf 100644 --- a/SimpleVMApp/Display/MachineDisplayView.swift +++ b/SimpleVMApp/Display/MachineDisplayView.swift @@ -41,6 +41,7 @@ final class ImmersiveVZMachineView: VZVirtualMachineView { var pointerInteractionHandler: ((Bool, NSEvent.ModifierFlags) -> Void)? private var buttonMask: UInt8 = 0 + private var resizeTask: Task? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -50,6 +51,27 @@ final class ImmersiveVZMachineView: VZVirtualMachineView { nil } + override func layout() { + super.layout() + guard let window else { return } + let width = Int((bounds.width * window.backingScaleFactor).rounded()) + let height = Int((bounds.height * window.backingScaleFactor).rounded()) + resizeTask?.cancel() + resizeTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + self?.runtime?.requestDisplaySize(width: width, height: height) + } + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { + resizeTask?.cancel() + resizeTask = nil + } + super.viewWillMove(toWindow: newWindow) + } + override func mouseDown(with event: NSEvent) { if buttonMask == 0 { pointerInteractionHandler?(true, event.modifierFlags) diff --git a/SimpleVMApp/Features/Machines/MachineDetailView.swift b/SimpleVMApp/Features/Machines/MachineDetailView.swift index 3d57b61..c5c7199 100644 --- a/SimpleVMApp/Features/Machines/MachineDetailView.swift +++ b/SimpleVMApp/Features/Machines/MachineDetailView.swift @@ -9,6 +9,8 @@ struct MachineDetailView: View { let immersion: ImmersionController @State private var confirmsDeletion = false + @State private var showsGuestTools = false + @State private var deliveredToolsMessage: String? private var runtimeState: MachineRuntimeState { model.runtimeState(for: machine) @@ -123,6 +125,9 @@ struct MachineDetailView: View { ) } } + Button("Guest Tools Setup...") { + showsGuestTools = true + } if machine.hasInstallerAttached { Button("Eject Installer") { Task { @@ -213,6 +218,25 @@ struct MachineDetailView: View { } message: { Text("Its virtual disk and machine state will be permanently removed.") } + .popover(isPresented: $showsGuestTools) { + GuestToolsPanel( + machine: machine, + runtimeState: runtimeState, + state: guestTools.state, + notice: guestTools.notice, + deliveredMessage: deliveredToolsMessage, + retry: { + guestTools.retry() + }, + exportBundle: exportGuestTools, + copyToShare: copyGuestToolsToShare, + reboot: { + Task { + await model.requestReboot(machine) + } + } + ) + } } @ViewBuilder @@ -349,6 +373,19 @@ struct MachineDetailView: View { ) ) StatusValue(title: "Profile", value: inputProfileStatus) + Button { + showsGuestTools.toggle() + } label: { + StatusValue( + title: "Guest Tools", + value: guestToolsStatus + ) + } + .buttonStyle(.plain) + .accessibilityLabel( + "Guest Tools status: \(guestToolsStatus)" + ) + .accessibilityIdentifier("guestTools.status") StatusValue(title: "Network", value: "Shared NAT") Spacer() } @@ -359,7 +396,7 @@ struct MachineDetailView: View { private func enterImmersion() { KeyboardMappingSettings.shared.activate( - profile: machine.spec.inputProfile, + profile: resolvedInputProfile, forMachineNamed: machine.name ) switch machine.backend { @@ -397,7 +434,18 @@ struct MachineDetailView: View { } private var resolvedInputProfile: MachineInputProfile { - machine.spec.inputProfile.resolved(forMachineNamed: machine.name) + switch machine.backend { + case .appleVirtualization: + model.appleRuntime(for: machine).guestTools.resolvedInputProfile( + configuredProfile: machine.spec.inputProfile, + machineName: machine.name + ) + case .qemu: + model.qemuRuntime(for: machine).guestTools.resolvedInputProfile( + configuredProfile: machine.spec.inputProfile, + machineName: machine.name + ) + } } private var inputProfileStatus: String { @@ -407,6 +455,294 @@ struct MachineDetailView: View { return resolvedInputProfile.displayName } + private var guestTools: GuestToolsCoordinator { + switch machine.backend { + case .appleVirtualization: + model.appleRuntime(for: machine).guestTools + case .qemu: + model.qemuRuntime(for: machine).guestTools + } + } + + private var guestToolsStatus: String { + switch guestTools.state { + case .stopped: + "Stopped" + case .checking: + "Checking..." + case .notConnected: + "Not connected" + case .connected: + "Connected" + case .incompatible: + "Incompatible" + case .failed: + "Error" + } + } + + private func exportGuestTools() { + guard let destinationURL = FilePicker.chooseSaveFile( + suggestedName: GuestToolsBundleExporter.archiveName, + allowedContentType: + UTType(filenameExtension: "gz") ?? .archive + ) else { + return + } + Task { + do { + try await model.exportGuestTools(to: destinationURL) + deliveredToolsMessage = + "Exported to \(destinationURL.lastPathComponent). Move the archive into the guest, then run the shown command from its folder." + } catch { + model.present(error: error) + } + } + } + + private func copyGuestToolsToShare() { + Task { + do { + let destinationURL = + try await model.copyGuestToolsToSharedDirectory( + for: machine + ) + deliveredToolsMessage = + "Copied \(destinationURL.lastPathComponent) to the configured shared folder. Delivery does not install or start Guest Tools." + } catch { + model.present(error: error) + } + } + } + + private struct GuestToolsPanel: View { + let machine: Machine + let runtimeState: MachineRuntimeState + let state: GuestToolsConnectionState + let notice: String? + let deliveredMessage: String? + let retry: () -> Void + let exportBundle: () -> Void + let copyToShare: () -> Void + let reboot: () -> Void + + private var status: GuestAgentStatus? { + state.status + } + + private var installCommand: String { + machine.hasConfiguredVirtioFSShare + ? GuestToolsBundleExporter.guestInstallCommand + : GuestToolsBundleExporter.manualInstallCommand + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Image(systemName: statusSymbol) + .foregroundStyle(statusColor) + VStack(alignment: .leading, spacing: 2) { + Text("SimpleVM Guest Tools") + .font(.headline) + .accessibilityIdentifier("guestTools.panel") + Text(statusTitle) + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + if runtimeState == .running, + status == nil { + Button("Retry Connection", action: retry) + .accessibilityIdentifier("guestTools.retry") + } + } + + if let status { + connectedDetails(status) + } else if let message = stateMessage { + Text(message) + .font(.callout) + .foregroundStyle( + isErrorState ? Color.orange : Color.secondary + ) + } + if let notice { + Label(notice, systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.orange) + .accessibilityIdentifier("guestTools.notice") + } + + Divider() + Text("Setup and updates") + .font(.subheadline.weight(.semibold)) + Text( + "SimpleVM can deliver the bundle, but you run the command in the guest. It never supplies a password or modifies the guest disk." + ) + .font(.caption) + .foregroundStyle(.secondary) + HStack { + if machine.hasConfiguredVirtioFSShare { + Button("Copy to Shared Folder", action: copyToShare) + .accessibilityIdentifier("guestTools.copyToShare") + } + Button("Export Tools Bundle...", action: exportBundle) + .accessibilityIdentifier("guestTools.export") + } + if let deliveredMessage { + Label(deliveredMessage, systemImage: "checkmark.circle") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("guestTools.deliveryStatus") + } + + Text( + machine.hasConfiguredVirtioFSShare + ? "In the guest, run:" + : "After moving the archive into the guest, run:" + ) + .font(.caption.weight(.medium)) + HStack(alignment: .top) { + Text(installCommand) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("guestTools.installCommand") + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + installCommand, + forType: .string + ) + } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.borderless) + .help("Copy guest command") + .accessibilityLabel("Copy guest command") + .accessibilityIdentifier("guestTools.copyCommand") + } + .padding(10) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + .padding(18) + .frame(width: 440) + } + + @ViewBuilder + private func connectedDetails(_ status: GuestAgentStatus) -> some View { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 5) { + detailRow("Agent", status.agentVersion) + detailRow( + "System", + "\(status.distroID) \(status.distroVersion)" + ) + detailRow( + "Session", + "\(status.desktopEnvironment.rawValue) / \(status.sessionType.rawValue)" + ) + detailRow( + "Shared folder", + status.sharedMountStatus.state.rawValue + ) + } + Text("Capabilities") + .font(.subheadline.weight(.semibold)) + ForEach(GuestAgentCapability.allCases, id: \.self) { capability in + let enabled = status.capabilities.contains(capability) + Label( + capability.displayName.capitalized, + systemImage: enabled ? "checkmark.circle.fill" : "minus.circle" + ) + .font(.caption) + .foregroundStyle(enabled ? .primary : .secondary) + .accessibilityIdentifier( + "guestTools.capability.\(capability.rawValue)" + ) + } + if status.capabilities.contains(.gracefulReboot) { + Button("Restart Guest", action: reboot) + .accessibilityIdentifier("guestTools.reboot") + } + } + + private func detailRow(_ title: String, _ value: String) -> some View { + GridRow { + Text(title) + .foregroundStyle(.secondary) + Text(value) + .textSelection(.enabled) + } + .font(.caption) + } + + private var statusTitle: String { + switch state { + case .stopped: + "VM stopped" + case .checking: + "Checking for Guest Tools..." + case .notConnected: + "Not connected" + case .connected: + "Connected" + case .incompatible: + "Incompatible version" + case .failed: + "Connection error" + } + } + + private var stateMessage: String? { + switch state { + case .stopped: + "Start the VM to check whether Guest Tools are installed." + case .checking: + "Waiting for the guest agent. The VM remains usable without it." + case .notConnected(let message): + message + ?? "Guest Tools may not be installed or its service may not be running." + case .incompatible(let message), .failed(let message): + message + case .connected: + nil + } + } + + private var isErrorState: Bool { + switch state { + case .incompatible, .failed: + true + case .stopped, .checking, .notConnected, .connected: + false + } + } + + private var statusSymbol: String { + switch state { + case .connected: + "checkmark.circle.fill" + case .checking: + "arrow.triangle.2.circlepath" + case .incompatible, .failed: + "exclamationmark.triangle.fill" + case .stopped, .notConnected: + "wrench.and.screwdriver" + } + } + + private var statusColor: Color { + switch state { + case .connected: + .green + case .incompatible, .failed: + .orange + case .stopped, .checking, .notConnected: + .secondary + } + } + } + private func exportDisk() { let sanitizedName = machine.name .replacingOccurrences(of: "/", with: "-") @@ -501,6 +837,11 @@ private extension Machine { } return false } + + var hasConfiguredVirtioFSShare: Bool { + backend == .appleVirtualization + && spec.sharedDirectoryPath != nil + } } private extension MachineRuntimeState { diff --git a/SimpleVMApp/GuestTools/GuestToolsBundleExporter.swift b/SimpleVMApp/GuestTools/GuestToolsBundleExporter.swift new file mode 100644 index 0000000..1f58d61 --- /dev/null +++ b/SimpleVMApp/GuestTools/GuestToolsBundleExporter.swift @@ -0,0 +1,139 @@ +import Foundation + +struct GuestToolsBundleExporter: Sendable { + static let archiveName = "simplevm-guest-tools.tar.gz" + static let manualInstallCommand = + "tar -xzf simplevm-guest-tools.tar.gz && cd GuestTools && ./install.sh --with-wayland-clipboard --with-x11-agent" + static let guestInstallCommand = + "cd /mnt/simplevm-share && tar -xzf simplevm-guest-tools.tar.gz && cd GuestTools && ./install.sh --with-wayland-clipboard --with-x11-agent" + + private let sourceURL: URL + + init(bundle: Bundle = .main) throws { + guard let resourceURL = bundle.resourceURL else { + throw GuestToolsBundleError.missingResources + } + let sourceURL = resourceURL.appending( + path: "GuestTools", + directoryHint: .isDirectory + ) + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + throw GuestToolsBundleError.missingResources + } + self.sourceURL = sourceURL + } + + init(sourceURL: URL) { + self.sourceURL = sourceURL + } + + func export(to destinationURL: URL) throws { + let fileManager = FileManager.default + let parentURL = destinationURL.deletingLastPathComponent() + guard fileManager.fileExists(atPath: parentURL.path) else { + throw GuestToolsBundleError.destinationUnavailable + } + let temporaryURL = parentURL.appending( + path: ".\(Self.archiveName).\(UUID().uuidString).tmp" + ) + let tarURL = temporaryURL.appendingPathExtension("tar") + defer { + try? fileManager.removeItem(at: tarURL) + try? fileManager.removeItem(at: temporaryURL) + } + + let tarProcess = Process() + tarProcess.executableURL = URL(filePath: "/usr/bin/tar") + tarProcess.arguments = [ + "-cf", + tarURL.path, + "--uid", + "0", + "--gid", + "0", + "--uname", + "root", + "--gname", + "root", + "-C", + sourceURL.deletingLastPathComponent().path, + sourceURL.lastPathComponent + ] + var environment = ProcessInfo.processInfo.environment + environment["COPYFILE_DISABLE"] = "1" + tarProcess.environment = environment + try run(tarProcess, fallbackError: "tar exited with an error") + + guard fileManager.createFile( + atPath: temporaryURL.path, + contents: nil + ) else { + throw GuestToolsBundleError.destinationUnavailable + } + let output = try FileHandle(forWritingTo: temporaryURL) + defer { try? output.close() } + let gzipProcess = Process() + gzipProcess.executableURL = URL(filePath: "/usr/bin/gzip") + gzipProcess.arguments = ["-n", "-c", tarURL.path] + gzipProcess.standardOutput = output + try run(gzipProcess, fallbackError: "gzip exited with an error") + try output.close() + + if fileManager.fileExists(atPath: destinationURL.path) { + _ = try fileManager.replaceItemAt( + destinationURL, + withItemAt: temporaryURL + ) + } else { + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + } + } + + private func run( + _ process: Process, + fallbackError: String + ) throws { + let errorPipe = Pipe() + process.standardError = errorPipe + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let data = errorPipe.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + throw GuestToolsBundleError.archiveFailed( + message?.isEmpty == false ? message! : fallbackError + ) + } + } + + func copyToSharedDirectory(_ directoryURL: URL) throws -> URL { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists( + atPath: directoryURL.path, + isDirectory: &isDirectory + ), isDirectory.boolValue else { + throw GuestToolsBundleError.destinationUnavailable + } + let destinationURL = directoryURL.appending(path: Self.archiveName) + try export(to: destinationURL) + return destinationURL + } +} + +enum GuestToolsBundleError: LocalizedError, Equatable { + case missingResources + case destinationUnavailable + case archiveFailed(String) + + var errorDescription: String? { + switch self { + case .missingResources: + "The SimpleVM Guest Tools resources are missing from this build." + case .destinationUnavailable: + "The selected Guest Tools destination is unavailable." + case .archiveFailed(let message): + "Could not create the Guest Tools bundle: \(message)" + } + } +} diff --git a/SimpleVMApp/Virtualization/AppleGuestAgentTransport.swift b/SimpleVMApp/Virtualization/AppleGuestAgentTransport.swift index 92f47c4..c7daf58 100644 --- a/SimpleVMApp/Virtualization/AppleGuestAgentTransport.swift +++ b/SimpleVMApp/Virtualization/AppleGuestAgentTransport.swift @@ -4,98 +4,117 @@ import SimpleVMCore import Virtualization enum AppleGuestAgentTransport { + @MainActor static func request( _ request: GuestAgentRequest, virtualMachine: VZVirtualMachine, - port: UInt32 = 1_021 + port: UInt32 = GuestAgentProtocol.agentPort, + timeout: TimeInterval = GuestAgentSocketTransport.defaultTimeout ) async throws -> GuestAgentResponse { guard let socketDevice = virtualMachine.socketDevices.first as? VZVirtioSocketDevice else { throw GuestAgentTransportError.unavailable } - let connectionBox = try await withCheckedThrowingContinuation { - ( - continuation: - CheckedContinuation - ) in - socketDevice.connect(toPort: port) { result in - switch result { - case .success(let connection): - continuation.resume( - returning: SocketConnectionBox(connection) + let completion = SocketConnectionCompletion() + let connectionBox = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + completion.install(continuation) + Task { + try? await Task.sleep(for: .seconds(timeout)) + completion.complete( + .failure(GuestAgentTransportError.timedOut) ) - case .failure(let error): - continuation.resume( - throwing: GuestAgentTransportError.connectionFailed( - error.localizedDescription + } + socketDevice.connect(toPort: port) { result in + switch result { + case .success(let connection): + completion.complete( + .success(SocketConnectionBox(connection)) ) - ) + case .failure(let error): + completion.complete( + .failure( + GuestAgentTransportError.connectionFailed( + error.localizedDescription + ) + ) + ) + } } } + } onCancel: { + completion.complete(.failure(CancellationError())) } - let connection = connectionBox.value - let descriptor = dup(connection.fileDescriptor) - connection.close() - guard descriptor >= 0 else { - throw POSIXError(.EBADF) - } - let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) - try handle.write(contentsOf: GuestAgentFrameCodec.encode(request)) - let header = try await readExactly(4, from: handle) - let payloadLength = header.reduce(UInt32(0)) { - ($0 << 8) | UInt32($1) - } - guard payloadLength <= GuestAgentFrameCodec.maximumPayloadSize else { - throw GuestAgentProtocolError.payloadTooLarge + defer { connectionBox.value.close() } + return try await GuestAgentSocketTransport.request( + request, + timeout: timeout + ) { + let descriptor = dup(connectionBox.value.fileDescriptor) + guard descriptor >= 0 else { + throw POSIXError(.EBADF) + } + return descriptor } - let payload = try await readExactly(Int(payloadLength), from: handle) - var frame = header - frame.append(payload) - return try GuestAgentFrameCodec.decode( - GuestAgentResponse.self, - from: frame - ) } +} - private static func readExactly( - _ count: Int, - from handle: FileHandle - ) async throws -> Data { - var result = Data() - while result.count < count { - guard let chunk = try handle.read( - upToCount: count - result.count - ), !chunk.isEmpty else { - throw GuestAgentTransportError.disconnected - } - result.append(chunk) - } - return result +private final class SocketConnectionBox: @unchecked Sendable { + let value: VZVirtioSocketConnection + + init(_ value: VZVirtioSocketConnection) { + self.value = value } } -enum GuestAgentTransportError: LocalizedError { - case unavailable - case disconnected - case connectionFailed(String) +private final class SocketConnectionCompletion: @unchecked Sendable { + private typealias ConnectionResult = Result + private typealias ConnectionContinuation = + CheckedContinuation + + private let lock = NSLock() + private var continuation: ConnectionContinuation? + private var pendingResult: ConnectionResult? + private var isCompleted = false + + func install( + _ continuation: CheckedContinuation + ) { + lock.lock() + let result = pendingResult + if result == nil { + self.continuation = continuation + } else { + pendingResult = nil + } + lock.unlock() - var errorDescription: String? { - switch self { - case .unavailable: - "The guest-agent transport is unavailable." - case .disconnected: - "The guest agent disconnected." - case .connectionFailed(let message): - "The guest agent connection failed: \(message)" + if let result { + continuation.resume(with: result) } } -} -private final class SocketConnectionBox: @unchecked Sendable { - let value: VZVirtioSocketConnection + func complete(_ result: Result) { + lock.lock() + guard !isCompleted else { + lock.unlock() + if case .success(let box) = result { + box.value.close() + } + return + } + isCompleted = true + let continuation = self.continuation + if continuation == nil { + pendingResult = result + } else { + self.continuation = nil + } + lock.unlock() - init(_ value: VZVirtioSocketConnection) { - self.value = value + if let continuation { + continuation.resume(with: result) + } } } diff --git a/SimpleVMApp/Virtualization/GuestAgentSocketTransport.swift b/SimpleVMApp/Virtualization/GuestAgentSocketTransport.swift new file mode 100644 index 0000000..71e573d --- /dev/null +++ b/SimpleVMApp/Virtualization/GuestAgentSocketTransport.swift @@ -0,0 +1,290 @@ +import Darwin +import Foundation +import SimpleVMCore + +enum GuestAgentSocketTransport { + static let defaultTimeout: TimeInterval = 3 + + static func request( + _ request: GuestAgentRequest, + timeout: TimeInterval = defaultTimeout, + connect: @escaping @Sendable () throws -> Int32 + ) async throws -> GuestAgentResponse { + let envelope = GuestAgentRequestEnvelope(request: request) + let operation = Task.detached(priority: .userInitiated) { + try Task.checkCancellation() + let descriptor = try connect() + defer { Darwin.close(descriptor) } + try configure(descriptor) + let deadline = Date().addingTimeInterval(timeout) + try write( + GuestAgentFrameCodec.encode(envelope), + to: descriptor, + deadline: deadline + ) + let header = try readExactly( + 4, + from: descriptor, + deadline: deadline + ) + let payloadLength = header.reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + guard payloadLength <= GuestAgentFrameCodec.maximumPayloadSize else { + throw GuestAgentProtocolError.payloadTooLarge + } + let payload = try readExactly( + Int(payloadLength), + from: descriptor, + deadline: deadline + ) + var frame = header + frame.append(payload) + let response = try GuestAgentFrameCodec.decode( + GuestAgentResponseEnvelope.self, + from: frame + ) + guard (1...GuestAgentProtocol.currentVersion).contains( + response.protocolVersion + ) else { + throw GuestAgentProtocolError.incompatibleVersion( + response.protocolVersion + ) + } + if response.protocolVersion >= 2, + response.requestID != envelope.requestID { + throw GuestAgentProtocolError.mismatchedRequestID + } + if response.protocolVersion == 1, + let responseID = response.requestID, + responseID != envelope.requestID { + throw GuestAgentProtocolError.mismatchedRequestID + } + return response.response + } + return try await withTaskCancellationHandler { + try await operation.value + } onCancel: { + operation.cancel() + } + } + + static func connectUnixSocket(at socketURL: URL) throws -> Int32 { + let path = socketURL.path + guard !path.isEmpty else { + throw GuestAgentTransportError.invalidSocketPath + } + var address = sockaddr_un() + let capacity = MemoryLayout.size(ofValue: address.sun_path) + guard path.utf8.count + 1 <= capacity else { + throw GuestAgentTransportError.invalidSocketPath + } + address.sun_family = sa_family_t(AF_UNIX) + _ = withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: capacity) { + destination in + path.withCString { source in + memcpy(destination, source, path.utf8.count + 1) + } + } + } + + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { + throw GuestAgentTransportError.posix( + operation: "create the Unix socket", + code: errno + ) + } + do { + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect( + descriptor, + $0, + socklen_t( + MemoryLayout.size + + path.utf8.count + 1 + ) + ) + } + } + guard result == 0 else { + throw GuestAgentTransportError.posix( + operation: "connect to \(path)", + code: errno + ) + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func configure(_ descriptor: Int32) throws { + var enabled: Int32 = 1 + guard setsockopt( + descriptor, + SOL_SOCKET, + SO_NOSIGPIPE, + &enabled, + socklen_t(MemoryLayout.size(ofValue: enabled)) + ) == 0 else { + throw GuestAgentTransportError.posix( + operation: "configure the guest-agent socket", + code: errno + ) + } + } + + private static func write( + _ data: Data, + to descriptor: Int32, + deadline: Date + ) throws { + try data.withUnsafeBytes { bytes in + guard let baseAddress = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + try wait( + descriptor: descriptor, + events: Int16(POLLOUT), + deadline: deadline + ) + let count = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + bytes.count - offset + ) + if count > 0 { + offset += count + } else if count < 0, errno == EINTR || errno == EAGAIN { + continue + } else { + throw GuestAgentTransportError.posix( + operation: "write to the guest agent", + code: count == 0 ? EPIPE : errno + ) + } + } + } + } + + private static func readExactly( + _ count: Int, + from descriptor: Int32, + deadline: Date + ) throws -> Data { + var result = Data(count: count) + var offset = 0 + while offset < count { + try wait( + descriptor: descriptor, + events: Int16(POLLIN), + deadline: deadline + ) + let readCount = result.withUnsafeMutableBytes { bytes in + Darwin.read( + descriptor, + bytes.baseAddress!.advanced(by: offset), + count - offset + ) + } + if readCount > 0 { + offset += readCount + } else if readCount == 0 { + throw GuestAgentTransportError.disconnected + } else if errno == EINTR || errno == EAGAIN { + continue + } else { + throw GuestAgentTransportError.posix( + operation: "read from the guest agent", + code: errno + ) + } + } + return result + } + + private static func wait( + descriptor: Int32, + events: Int16, + deadline: Date + ) throws { + while true { + try Task.checkCancellation() + let remaining = deadline.timeIntervalSinceNow + guard remaining > 0 else { + throw GuestAgentTransportError.timedOut + } + var item = pollfd(fd: descriptor, events: events, revents: 0) + let milliseconds = Int32( + min(max(remaining * 1_000, 1), 200).rounded(.up) + ) + let result = Darwin.poll(&item, 1, milliseconds) + if result == 0 { + continue + } + if result < 0 { + if errno == EINTR { + continue + } + throw GuestAgentTransportError.posix( + operation: "wait for the guest agent", + code: errno + ) + } + if item.revents & Int16(POLLNVAL | POLLERR) != 0 { + throw GuestAgentTransportError.disconnected + } + if item.revents & events != 0 { + return + } + if item.revents & Int16(POLLHUP) != 0 { + throw GuestAgentTransportError.disconnected + } + } + } +} + +enum QEMUGuestAgentTransport { + static func request( + _ request: GuestAgentRequest, + socketURL: URL, + timeout: TimeInterval = GuestAgentSocketTransport.defaultTimeout + ) async throws -> GuestAgentResponse { + try await GuestAgentSocketTransport.request( + request, + timeout: timeout + ) { + try GuestAgentSocketTransport.connectUnixSocket(at: socketURL) + } + } +} + +enum GuestAgentTransportError: LocalizedError, Equatable { + case unavailable + case disconnected + case timedOut + case invalidSocketPath + case connectionFailed(String) + case posix(operation: String, code: Int32) + + var errorDescription: String? { + switch self { + case .unavailable: + "The Guest Tools transport is unavailable." + case .disconnected: + "Guest Tools disconnected before replying." + case .timedOut: + "Guest Tools did not reply before the request timed out." + case .invalidSocketPath: + "The Guest Tools Unix socket path is invalid." + case .connectionFailed(let message): + "The Guest Tools connection failed: \(message)" + case .posix(let operation, let code): + "Could not \(operation): \(String(cString: strerror(code)))." + } + } +} diff --git a/SimpleVMApp/Virtualization/GuestToolsCoordinator.swift b/SimpleVMApp/Virtualization/GuestToolsCoordinator.swift new file mode 100644 index 0000000..5d2f427 --- /dev/null +++ b/SimpleVMApp/Virtualization/GuestToolsCoordinator.swift @@ -0,0 +1,443 @@ +import AppKit +import Foundation +import Observation +import SimpleVMCore + +enum GuestToolsConnectionState: Equatable { + case stopped + case checking + case notConnected(String?) + case connected(GuestAgentStatus) + case incompatible(String) + case failed(String) + + var status: GuestAgentStatus? { + if case .connected(let status) = self { + return status + } + return nil + } +} + +enum GuestToolsOperationError: LocalizedError, Equatable { + case notConnected + case unsupported(GuestAgentCapability) + case unexpectedResponse + case guest(GuestAgentFailure) + + var errorDescription: String? { + switch self { + case .notConnected: + "SimpleVM Guest Tools are not connected." + case .unsupported(let capability): + "Guest Tools do not advertise \(capability.displayName)." + case .unexpectedResponse: + "Guest Tools returned an unexpected response." + case .guest(let failure): + "Guest Tools reported \(failure.code): \(failure.message)" + } + } +} + +@MainActor +@Observable +final class GuestToolsCoordinator { + typealias RequestHandler = ( + GuestAgentRequest + ) async throws -> GuestAgentResponse + + private(set) var state: GuestToolsConnectionState = .stopped + private(set) var notice: String? + + @ObservationIgnored + var statusHandler: ((GuestAgentStatus?) -> Void)? + + @ObservationIgnored + private var requestHandler: RequestHandler? + + @ObservationIgnored + private var sharedDirectoryConfigured = false + + @ObservationIgnored + private var connectionTask: Task? + + @ObservationIgnored + private let clipboardSynchronizer = GuestClipboardSynchronizer() + + func start( + sharedDirectoryConfigured: Bool, + requestHandler: @escaping RequestHandler + ) { + stop() + self.requestHandler = requestHandler + self.sharedDirectoryConfigured = sharedDirectoryConfigured + retry() + } + + func retry() { + guard requestHandler != nil else { + state = .stopped + return + } + connectionTask?.cancel() + clipboardSynchronizer.stop() + notice = nil + state = .checking + statusHandler?(nil) + connectionTask = Task { [weak self] in + await self?.probe() + } + } + + func stop() { + connectionTask?.cancel() + connectionTask = nil + clipboardSynchronizer.stop() + requestHandler = nil + state = .stopped + notice = nil + statusHandler?(nil) + } + + func supports(_ capability: GuestAgentCapability) -> Bool { + state.status?.capabilities.contains(capability) == true + } + + func reportNotice(_ message: String) { + notice = message + } + + func resolvedInputProfile( + configuredProfile: MachineInputProfile, + machineName: String + ) -> MachineInputProfile { + guard configuredProfile == .automatic, + let desktop = state.status?.desktopEnvironment else { + return configuredProfile.resolved(forMachineNamed: machineName) + } + switch desktop { + case .hyprland: + return .macOSHyprland + case .gnome: + return .macOSGNOME + case .other: + return configuredProfile.resolved(forMachineNamed: machineName) + } + } + + func requestShutdown() async throws { + try await expectAccepted( + .shutdown, + capability: .gracefulShutdown + ) + } + + func requestReboot() async throws { + try await expectAccepted(.reboot, capability: .gracefulReboot) + } + + func requestDisplayResize(width: Int, height: Int) async throws { + guard GuestDisplaySize.isValid(width: width, height: height) else { + throw GuestAgentProtocolError.invalidDisplaySize + } + try await expectAccepted( + .resizeDisplay(width: width, height: height), + capability: .displayResize + ) + } + + private func probe() async { + guard let requestHandler else { return } + do { + var status = try await fetchStatus(using: requestHandler) + try Task.checkCancellation() + guard status.protocolVersion <= GuestAgentProtocol.currentVersion else { + state = .incompatible( + "Guest protocol \(status.protocolVersion) is newer than the host protocol \(GuestAgentProtocol.currentVersion)." + ) + statusHandler?(nil) + return + } + if sharedDirectoryConfigured, + status.capabilities.contains(.mountSharedDirectory), + status.sharedMountStatus.state != .mounted { + do { + let response = try await requestHandler( + .mountSharedDirectory + ) + try Task.checkCancellation() + switch response { + case .accepted: + status = try await fetchStatus(using: requestHandler) + try Task.checkCancellation() + case .failure(let failure): + notice = + "Guest Tools connected, but the shared folder could not be mounted: \(failure.message)" + case .hello, .status, .clipboard: + notice = + "Guest Tools connected, but returned an unexpected shared-folder response." + } + } catch is CancellationError { + throw CancellationError() + } catch { + notice = + "Guest Tools connected, but the shared folder could not be mounted: \(error.localizedDescription)" + } + } + try Task.checkCancellation() + state = .connected(status) + statusHandler?(status) + if status.sessionType == .wayland, + status.capabilities.contains(.clipboardRead), + status.capabilities.contains(.clipboardWrite) { + clipboardSynchronizer.start( + requestHandler: requestHandler + ) { [weak self] message in + self?.notice = message + } + } + } catch is CancellationError { + return + } catch GuestAgentProtocolError.incompatibleVersion(let version) { + state = .incompatible( + "Guest protocol \(version) is not supported." + ) + statusHandler?(nil) + } catch { + state = .notConnected(error.localizedDescription) + statusHandler?(nil) + } + } + + private func fetchStatus( + using requestHandler: RequestHandler + ) async throws -> GuestAgentStatus { + let response = try await requestHandler(.status) + switch response { + case .status(let status): + return status + case .failure(let failure): + throw GuestToolsOperationError.guest(failure) + case .hello, .accepted, .clipboard: + throw GuestToolsOperationError.unexpectedResponse + } + } + + private func expectAccepted( + _ request: GuestAgentRequest, + capability: GuestAgentCapability + ) async throws { + guard supports(capability) else { + throw GuestToolsOperationError.unsupported(capability) + } + guard let requestHandler else { + throw GuestToolsOperationError.notConnected + } + try validateAccepted(try await requestHandler(request)) + } + + private func validateAccepted(_ response: GuestAgentResponse) throws { + switch response { + case .accepted: + return + case .failure(let failure): + throw GuestToolsOperationError.guest(failure) + case .hello, .status, .clipboard: + throw GuestToolsOperationError.unexpectedResponse + } + } +} + +struct ClipboardLoopGuard: Equatable { + private(set) var lastSentToGuest: UInt64? + private(set) var lastAppliedFromGuest: UInt64? + + mutating func shouldSendToGuest(_ text: String) -> Bool { + let value = Self.fingerprint(text) + guard value != lastAppliedFromGuest, + value != lastSentToGuest else { + return false + } + lastAppliedFromGuest = nil + markSentToGuest(text) + return true + } + + func canSendToGuest(_ text: String) -> Bool { + let value = Self.fingerprint(text) + return value != lastAppliedFromGuest && value != lastSentToGuest + } + + mutating func markSentToGuest(_ text: String) { + let value = Self.fingerprint(text) + if value != lastAppliedFromGuest { + lastAppliedFromGuest = nil + } + lastSentToGuest = value + } + + mutating func shouldAnnounceHostChange(_ text: String) -> Bool { + let value = Self.fingerprint(text) + guard value != lastAppliedFromGuest else { + return false + } + lastAppliedFromGuest = nil + return value != lastSentToGuest + } + + mutating func shouldApplyFromGuest(_ text: String) -> Bool { + let value = Self.fingerprint(text) + guard value != lastSentToGuest, value != lastAppliedFromGuest else { + return false + } + lastAppliedFromGuest = value + return true + } + + static func fingerprint(_ text: String) -> UInt64 { + text.utf8.reduce(14_695_981_039_346_656_037) { + ($0 ^ UInt64($1)) &* 1_099_511_628_211 + } + } +} + +@MainActor +final class GuestClipboardSynchronizer { + private var task: Task? + private var lastPasteboardChangeCount = NSPasteboard.general.changeCount + private var loopGuard = ClipboardLoopGuard() + private var pendingHostText: String? + private var pendingHostWriteAttempts = 0 + + func start( + requestHandler: @escaping GuestToolsCoordinator.RequestHandler, + noticeHandler: @escaping (String) -> Void + ) { + stop() + lastPasteboardChangeCount = NSPasteboard.general.changeCount + loopGuard = ClipboardLoopGuard() + pendingHostText = nil + pendingHostWriteAttempts = 0 + task = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + if NSApp.isActive { + await synchronize( + using: requestHandler, + noticeHandler: noticeHandler + ) + } + try? await Task.sleep(for: .milliseconds(600)) + } + } + } + + func stop() { + task?.cancel() + task = nil + pendingHostText = nil + pendingHostWriteAttempts = 0 + } + + private func synchronize( + using requestHandler: GuestToolsCoordinator.RequestHandler, + noticeHandler: (String) -> Void + ) async { + let pasteboard = NSPasteboard.general + if pasteboard.changeCount != lastPasteboardChangeCount { + lastPasteboardChangeCount = pasteboard.changeCount + pendingHostText = nil + pendingHostWriteAttempts = 0 + if let text = pasteboard.string(forType: .string) { + guard text.utf8.count + <= GuestAgentProtocol.maximumClipboardSize else { + pendingHostText = nil + noticeHandler( + "Clipboard sync skipped because the text exceeds 1 MiB." + ) + return + } + if loopGuard.canSendToGuest(text) { + pendingHostText = text + } + } + } + + if let pendingHostText { + do { + let response = try await requestHandler( + .writeClipboard(text: pendingHostText) + ) + if case .accepted = response { + loopGuard.markSentToGuest(pendingHostText) + self.pendingHostText = nil + pendingHostWriteAttempts = 0 + } else { + pendingHostWriteAttempts += 1 + noticeHandler( + "Guest Tools rejected the host clipboard text." + ) + if pendingHostWriteAttempts < 3 { + return + } + self.pendingHostText = nil + pendingHostWriteAttempts = 0 + } + } catch { + pendingHostWriteAttempts += 1 + noticeHandler( + "Clipboard text could not be sent to the guest." + ) + if pendingHostWriteAttempts < 3 { + return + } + self.pendingHostText = nil + pendingHostWriteAttempts = 0 + } + } + + let response: GuestAgentResponse + do { + response = try await requestHandler(.readClipboard) + } catch { + noticeHandler("Guest clipboard text could not be read.") + return + } + guard case .clipboard(let text) = response, + text.utf8.count <= GuestAgentProtocol.maximumClipboardSize, + loopGuard.shouldApplyFromGuest(text), + pasteboard.string(forType: .string) != text else { + return + } + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + lastPasteboardChangeCount = pasteboard.changeCount + } +} + +extension GuestAgentCapability { + var displayName: String { + switch self { + case .gracefulShutdown: + "graceful shutdown" + case .gracefulReboot: + "graceful reboot" + case .mountSharedDirectory: + "shared-directory mounting" + case .clipboardRead: + "clipboard reading" + case .clipboardWrite: + "clipboard writing" + case .displayResize: + "display resizing" + } + } +} + +extension GuestAgentStatus { + var supportsAgentClipboardTransport: Bool { + sessionType == .wayland + && capabilities.contains(.clipboardRead) + && capabilities.contains(.clipboardWrite) + } +} diff --git a/SimpleVMApp/Virtualization/MachineRuntime.swift b/SimpleVMApp/Virtualization/MachineRuntime.swift index 6187c96..6a92549 100644 --- a/SimpleVMApp/Virtualization/MachineRuntime.swift +++ b/SimpleVMApp/Virtualization/MachineRuntime.swift @@ -8,6 +8,7 @@ import Virtualization final class MachineRuntime { private(set) var state: MachineRuntimeState private(set) var virtualMachine: VZVirtualMachine? + let guestTools = GuestToolsCoordinator() @ObservationIgnored var stateHandler: ((MachineRuntimeState) -> Void)? @@ -30,6 +31,9 @@ final class MachineRuntime { @ObservationIgnored private var lastWorkspaceSwipeTime = 0.0 + @ObservationIgnored + private var lastRequestedDisplaySize: (Int, Int)? + init(state: MachineRuntimeState = .stopped) { switch state { case .running, .starting, .stopping: @@ -39,7 +43,10 @@ final class MachineRuntime { } } - func start(configuration: VZVirtualMachineConfiguration) async { + func start( + configuration: VZVirtualMachineConfiguration, + sharedDirectoryConfigured: Bool + ) async { guard state.canStart else { return } @@ -56,22 +63,65 @@ final class MachineRuntime { do { try await virtualMachine.start() transition(to: .running) + guestTools.start( + sharedDirectoryConfigured: sharedDirectoryConfigured + ) { [weak virtualMachine] request in + guard let virtualMachine else { + throw GuestAgentTransportError.unavailable + } + return try await AppleGuestAgentTransport.request( + request, + virtualMachine: virtualMachine + ) + } } catch { clearVirtualMachine() transition(to: .failed(message: error.localizedDescription)) } } - func requestStop() { + func requestStop() async { guard let virtualMachine, state == .running else { return } + if guestTools.supports(.gracefulShutdown) { + do { + try await guestTools.requestShutdown() + guard self.virtualMachine === virtualMachine, + state == .running else { + return + } + transition(to: .stopping) + return + } catch { + guard self.virtualMachine === virtualMachine, + state == .running else { + return + } + errorHandler?(error) + } + } + do { try virtualMachine.requestStop() - transition(to: .stopping) + if self.virtualMachine === virtualMachine, + state == .running { + transition(to: .stopping) + } + } catch { + if self.virtualMachine === virtualMachine, + state == .running { + errorHandler?(error) + } + } + } + + func requestReboot() async { + guard state == .running else { return } + do { + try await guestTools.requestReboot() } catch { - transition(to: .running) errorHandler?(error) } } @@ -230,6 +280,27 @@ final class MachineRuntime { ) } + func requestDisplaySize(width: Int, height: Int) { + guard guestTools.supports(.displayResize), + GuestDisplaySize.isValid(width: width, height: height), + lastRequestedDisplaySize?.0 != width + || lastRequestedDisplaySize?.1 != height else { + return + } + lastRequestedDisplaySize = (width, height) + Task { [weak self] in + do { + try await self?.guestTools.requestDisplayResize( + width: width, + height: height + ) + } catch { + self?.lastRequestedDisplaySize = nil + self?.errorHandler?(error) + } + } + } + private func handleDelegateState(_ state: MachineRuntimeState) { clearVirtualMachine() transition(to: state) @@ -241,8 +312,10 @@ final class MachineRuntime { } private func clearVirtualMachine() { + guestTools.stop() pressedModifierKeyCodes.removeAll() pressedKeyEvents.removeAll() + lastRequestedDisplaySize = nil virtualMachine = nil virtualMachineDelegate = nil } diff --git a/SimpleVMApp/Virtualization/QEMUMachineRuntime.swift b/SimpleVMApp/Virtualization/QEMUMachineRuntime.swift index 432487a..508883d 100644 --- a/SimpleVMApp/Virtualization/QEMUMachineRuntime.swift +++ b/SimpleVMApp/Virtualization/QEMUMachineRuntime.swift @@ -11,6 +11,7 @@ final class QEMUMachineRuntime { private(set) var hasDisplay = false private(set) var requiresDiskPassword = false private(set) var usesAcceleratedDisplay = false + let guestTools = GuestToolsCoordinator() @ObservationIgnored private(set) var framebuffer: CGImage? @@ -138,6 +139,9 @@ final class QEMUMachineRuntime { spice.errorHandler = { [weak self] error in self?.errorHandler?(error) } + spice.clipboardNoticeHandler = { [weak self] message in + self?.guestTools.reportNotice(message) + } spice.displayResizeSupportHandler = { [weak self] supported in guard let self else { return } guard supported else { @@ -158,6 +162,30 @@ final class QEMUMachineRuntime { try await spice.connect(to: socketURL) log("SPICE GL connected") } + if let agentSocketURL = configuration.agentSocketURL { + guestTools.statusHandler = { [weak self] status in + guard let self else { return } + self.spiceController?.setClipboardSharingAllowed( + status?.supportsAgentClipboardTransport != true + ) + if status?.desktopEnvironment == .hyprland, + let size = self.spiceDisplayView? + .preferredGuestPixelSize { + self.requestDisplaySize( + width: Int(size.width), + height: Int(size.height) + ) + } + } + guestTools.start( + sharedDirectoryConfigured: false + ) { request in + try await QEMUGuestAgentTransport.request( + request, + socketURL: agentSocketURL + ) + } + } transition(to: .running) } catch { log("start failed: \(error.localizedDescription)") @@ -172,6 +200,14 @@ final class QEMUMachineRuntime { return } transition(to: .stopping) + if guestTools.supports(.gracefulShutdown) { + do { + try await guestTools.requestShutdown() + return + } catch { + errorHandler?(error) + } + } vncClient?.errorHandler = nil vncClient?.disconnect() await processController?.stop() @@ -191,6 +227,15 @@ final class QEMUMachineRuntime { transition(to: .stopped) } + func requestReboot() async { + guard state == .running else { return } + do { + try await guestTools.requestReboot() + } catch { + errorHandler?(error) + } + } + func sendKey(_ keysym: UInt32, isDown: Bool) { if let keySink { keySink(keysym, isDown) @@ -213,6 +258,33 @@ final class QEMUMachineRuntime { UInt16(clamping: displaySize.width), UInt16(clamping: displaySize.height) ) + if guestTools.state.status?.desktopEnvironment == .hyprland, + guestTools.supports(.displayResize) { + guard lastRequestedDisplaySize?.0 != requested.0 + || lastRequestedDisplaySize?.1 != requested.1 else { + return + } + lastRequestedDisplaySize = requested + Task { [weak self] in + do { + try await self?.guestTools.requestDisplayResize( + width: Int(requested.0), + height: Int(requested.1) + ) + } catch { + guard let self else { return } + self.lastRequestedDisplaySize = nil + self.requestBackendDisplaySize(requested) + } + } + return + } + requestBackendDisplaySize(requested) + } + + private func requestBackendDisplaySize( + _ requested: (UInt16, UInt16) + ) { if let spiceController { guard spiceController.supportsDisplayResize, let display = spiceController.display else { @@ -479,6 +551,8 @@ final class QEMUMachineRuntime { } private func clearRuntime() { + guestTools.stop() + guestTools.statusHandler = nil serialMonitorTask?.cancel() serialMonitorTask = nil serialReadOffset = 0 diff --git a/SimpleVMApp/Virtualization/SPICEConnectionController.swift b/SimpleVMApp/Virtualization/SPICEConnectionController.swift index c551976..979f09a 100644 --- a/SimpleVMApp/Virtualization/SPICEConnectionController.swift +++ b/SimpleVMApp/Virtualization/SPICEConnectionController.swift @@ -1,19 +1,26 @@ @preconcurrency import CocoaSpiceNoUsb +import AppKit import Foundation +import SimpleVMCore @MainActor final class SPICEConnectionController: NSObject { private(set) var display: CSDisplay? private(set) var input: CSInput? private(set) var supportsDisplayResize = false + private(set) var supportsClipboard = false var displayHandler: ((CSDisplay) -> Void)? var displayResizeSupportHandler: ((Bool) -> Void)? + var clipboardNoticeHandler: ((String) -> Void)? var errorHandler: ((any Error) -> Void)? private var connection: CSConnection? private var connectionContinuation: CheckedContinuation? + private let pasteboardBridge = SPICEPasteboardBridge() + private var clipboardPollingTask: Task? + private var clipboardSharingAllowed = true func connect(to socketURL: URL) async throws { guard CSMain.shared.spiceStart() || CSMain.shared.running else { @@ -25,6 +32,11 @@ final class SPICEConnectionController: NSObject { let connection = CSConnection( unixSocketFile: socketURL ) + pasteboardBridge.noticeHandler = { [weak self] message in + self?.clipboardNoticeHandler?(message) + } + connection.session.pasteboardDelegate = pasteboardBridge + connection.session.shareClipboard = false connection.delegate = self self.connection = connection do { @@ -52,11 +64,21 @@ final class SPICEConnectionController: NSObject { } func disconnect() { + clipboardPollingTask?.cancel() + clipboardPollingTask = nil + connection?.session.shareClipboard = false + connection?.session.pasteboardDelegate = nil connection?.disconnect() connection = nil display = nil input = nil supportsDisplayResize = false + supportsClipboard = false + } + + func setClipboardSharingAllowed(_ allowed: Bool) { + clipboardSharingAllowed = allowed + updateClipboardSharing() } func sendKey(_ event: GuestKeyEvent) { @@ -89,6 +111,21 @@ final class SPICEConnectionController: NSObject { func releaseKeys() { input?.releaseKeys() } + + private func updateClipboardSharing() { + let enabled = supportsClipboard && clipboardSharingAllowed + connection?.session.shareClipboard = enabled + clipboardPollingTask?.cancel() + clipboardPollingTask = nil + guard enabled else { return } + pasteboardBridge.pollForHostChange() + clipboardPollingTask = Task { [weak self] in + while !Task.isCancelled { + self?.pasteboardBridge.pollForHostChange() + try? await Task.sleep(for: .milliseconds(500)) + } + } + } } extension SPICEConnectionController: CSConnectionDelegate { @@ -209,7 +246,9 @@ extension SPICEConnectionController: CSConnectionDelegate { let supportsDisplayResize = features.rawValue != 0 Task { @MainActor [weak self] in self?.supportsDisplayResize = supportsDisplayResize + self?.supportsClipboard = true self?.displayResizeSupportHandler?(supportsDisplayResize) + self?.updateClipboardSharing() } } @@ -218,7 +257,9 @@ extension SPICEConnectionController: CSConnectionDelegate { ) { Task { @MainActor [weak self] in self?.supportsDisplayResize = false + self?.supportsClipboard = false self?.displayResizeSupportHandler?(false) + self?.updateClipboardSharing() } } @@ -233,6 +274,155 @@ extension SPICEConnectionController: CSConnectionDelegate { ) {} } +private final class SPICEPasteboardBridge: NSObject, + CSPasteboardDelegate, + @unchecked Sendable +{ + private let lock = NSLock() + private var lastChangeCount = -1 + private var cachedHostText: String? + private var loopGuard = ClipboardLoopGuard() + private var lastOversizeChangeCount: Int? + private var storedNoticeHandler: (@MainActor @Sendable (String) -> Void)? + + var noticeHandler: (@MainActor @Sendable (String) -> Void)? { + get { withLock { storedNoticeHandler } } + set { withLock { storedNoticeHandler = newValue } } + } + + func canReadItem(for type: CSPasteboardType) -> Bool { + guard type == .string else { return false } + return withLock { + guard let cachedHostText else { return false } + return loopGuard.canSendToGuest(cachedHostText) + } + } + + func data(for type: CSPasteboardType) -> Data? { + guard type == .string else { return nil } + return withLock { + guard let cachedHostText, + loopGuard.canSendToGuest(cachedHostText) else { + return nil + } + return Data(cachedHostText.utf8) + } + } + + func setData(_ data: Data, for type: CSPasteboardType) { + guard type == .string, + data.count <= GuestAgentProtocol.maximumClipboardSize, + let text = String(data: data, encoding: .utf8), + !text.isEmpty else { + if data.count > GuestAgentProtocol.maximumClipboardSize { + emitNotice( + "Guest clipboard sync skipped because the text exceeds 1 MiB." + ) + } + return + } + setString(text) + } + + func string() -> String? { + withLock { + guard let cachedHostText, + loopGuard.canSendToGuest(cachedHostText) else { + return nil + } + return cachedHostText + } + } + + func setString(_ string: String) { + guard !string.isEmpty, + string.utf8.count + <= GuestAgentProtocol.maximumClipboardSize else { + if string.utf8.count + > GuestAgentProtocol.maximumClipboardSize { + emitNotice( + "Guest clipboard sync skipped because the text exceeds 1 MiB." + ) + } + return + } + let shouldApply = withLock { + loopGuard.shouldApplyFromGuest(string) + } + guard shouldApply else { return } + Task { @MainActor [weak self] in + self?.applyGuestString(string) + } + } + + func clearContents() { + // A guest clipboard release must not erase unrelated host clipboard data. + } + + @MainActor + func pollForHostChange() { + let pasteboard = NSPasteboard.general + let changeCount = pasteboard.changeCount + let text = pasteboard.string(forType: .string) + var shouldAnnounce = false + var shouldReportOversize = false + withLock { + guard changeCount != lastChangeCount else { return } + lastChangeCount = changeCount + cachedHostText = nil + guard let text else { return } + guard text.utf8.count + <= GuestAgentProtocol.maximumClipboardSize else { + if lastOversizeChangeCount != changeCount { + lastOversizeChangeCount = changeCount + shouldReportOversize = true + } + return + } + cachedHostText = text + shouldAnnounce = loopGuard.shouldAnnounceHostChange(text) + } + if shouldReportOversize { + emitNotice( + "Clipboard sync skipped because the text exceeds 1 MiB." + ) + } + if shouldAnnounce { + NotificationCenter.default.post( + name: .csPasteboardChanged, + object: nil + ) + } + } + + @MainActor + private func applyGuestString(_ string: String) { + let pasteboard = NSPasteboard.general + if pasteboard.string(forType: .string) != string { + pasteboard.clearContents() + pasteboard.setString(string, forType: .string) + } + let changeCount = pasteboard.changeCount + withLock { + cachedHostText = string + lastChangeCount = changeCount + } + } + + private func emitNotice(_ message: String) { + let handler = noticeHandler + Task { @MainActor in + handler?(message) + } + } + + private func withLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } +} + private enum SPICEConnectionError: LocalizedError { case startFailed case connectionFailed diff --git a/SimpleVMAppTests/FoundationTests.swift b/SimpleVMAppTests/FoundationTests.swift index 60b01c9..076950a 100644 --- a/SimpleVMAppTests/FoundationTests.swift +++ b/SimpleVMAppTests/FoundationTests.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import Security import SimpleVMCore import Virtualization @@ -1122,6 +1123,366 @@ final class FoundationTests: XCTestCase { } } + @MainActor + func testGuestToolsDetectionResolvesOnlyAutomaticProfile() async throws { + let coordinator = GuestToolsCoordinator() + let unmounted = guestToolsStatus( + mountState: .unmounted, + capabilities: [.mountSharedDirectory, .displayResize] + ) + let mounted = guestToolsStatus( + mountState: .mounted, + capabilities: [.mountSharedDirectory, .displayResize] + ) + var statusRequests = 0 + var mountRequests = 0 + coordinator.start(sharedDirectoryConfigured: true) { request in + switch request { + case .status: + statusRequests += 1 + return .status(statusRequests == 1 ? unmounted : mounted) + case .mountSharedDirectory: + mountRequests += 1 + return .accepted + default: + return .failure( + GuestAgentFailure( + code: "unexpected", + message: "Unexpected test request." + ) + ) + } + } + for _ in 0..<100 where coordinator.state.status == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(mountRequests, 1) + XCTAssertEqual(coordinator.state.status?.sharedMountStatus.state, .mounted) + XCTAssertEqual( + coordinator.resolvedInputProfile( + configuredProfile: .automatic, + machineName: "Generic Linux" + ), + .macOSHyprland + ) + XCTAssertEqual( + coordinator.resolvedInputProfile( + configuredProfile: .macOSGNOME, + machineName: "Omarchy" + ), + .macOSGNOME + ) + coordinator.stop() + } + + @MainActor + func testGuestToolsMountFailurePreservesConnection() async throws { + let coordinator = GuestToolsCoordinator() + let status = guestToolsStatus( + mountState: .unmounted, + capabilities: [.mountSharedDirectory, .gracefulShutdown] + ) + coordinator.start(sharedDirectoryConfigured: true) { request in + switch request { + case .status: + return .status(status) + case .mountSharedDirectory: + return .failure( + GuestAgentFailure( + code: "mountFailed", + message: "fixed mount point is occupied" + ) + ) + default: + return .failure( + GuestAgentFailure( + code: "unexpected", + message: "Unexpected test request." + ) + ) + } + } + for _ in 0..<100 where coordinator.state.status == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(coordinator.state.status, status) + XCTAssertTrue(coordinator.supports(.gracefulShutdown)) + XCTAssertTrue( + coordinator.notice?.contains("could not be mounted") == true + ) + coordinator.stop() + } + + @MainActor + func testGuestToolsMountTimeoutPreservesConnection() async throws { + let coordinator = GuestToolsCoordinator() + let status = guestToolsStatus( + mountState: .unmounted, + capabilities: [.mountSharedDirectory, .gracefulReboot] + ) + coordinator.start(sharedDirectoryConfigured: true) { request in + switch request { + case .status: + return .status(status) + case .mountSharedDirectory: + throw GuestAgentTransportError.timedOut + default: + return .failure( + GuestAgentFailure( + code: "unexpected", + message: "Unexpected test request." + ) + ) + } + } + for _ in 0..<100 where coordinator.state.status == nil { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(coordinator.state.status, status) + XCTAssertTrue(coordinator.supports(.gracefulReboot)) + XCTAssertTrue( + coordinator.notice?.contains("could not be mounted") == true + ) + coordinator.stop() + } + + func testClipboardLoopGuardSuppressesEchoes() { + var guardState = ClipboardLoopGuard() + XCTAssertTrue(guardState.shouldSendToGuest("host")) + XCTAssertFalse(guardState.shouldApplyFromGuest("host")) + XCTAssertTrue(guardState.shouldApplyFromGuest("guest")) + XCTAssertFalse(guardState.shouldAnnounceHostChange("guest")) + XCTAssertTrue(guardState.shouldAnnounceHostChange("new host")) + XCTAssertTrue(guardState.shouldSendToGuest("guest")) + XCTAssertFalse(guardState.shouldApplyFromGuest("guest")) + } + + func testClipboardLoopGuardAllowsGuestTextAfterDistinctHostWrite() { + var guardState = ClipboardLoopGuard() + XCTAssertTrue(guardState.shouldApplyFromGuest("guest")) + XCTAssertTrue(guardState.canSendToGuest("host")) + guardState.markSentToGuest("host") + XCTAssertTrue(guardState.shouldApplyFromGuest("guest")) + } + + func testAgentClipboardRoutingRequiresBothWaylandCapabilities() { + let partial = guestToolsStatus( + capabilities: [.clipboardRead], + sessionType: .wayland + ) + let complete = guestToolsStatus( + capabilities: [.clipboardRead, .clipboardWrite], + sessionType: .wayland + ) + let x11 = guestToolsStatus( + capabilities: [.clipboardRead, .clipboardWrite], + sessionType: .x11 + ) + + XCTAssertFalse(partial.supportsAgentClipboardTransport) + XCTAssertTrue(complete.supportsAgentClipboardTransport) + XCTAssertFalse(x11.supportsAgentClipboardTransport) + } + + func testGuestToolsBundleExportsAndAtomicallyReplacesArchive() throws { + let directory = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory + ) + defer { try? FileManager.default.removeItem(at: directory) } + let sourceURL = directory.appending( + path: "GuestTools", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory( + at: sourceURL, + withIntermediateDirectories: true + ) + try Data("#!/bin/sh\n".utf8).write( + to: sourceURL.appending(path: "install.sh") + ) + let shareURL = directory.appending( + path: "share", + directoryHint: .isDirectory + ) + try FileManager.default.createDirectory( + at: shareURL, + withIntermediateDirectories: true + ) + let exporter = GuestToolsBundleExporter(sourceURL: sourceURL) + + let archiveURL = try exporter.copyToSharedDirectory(shareURL) + let firstArchive = try Data(contentsOf: archiveURL) + XCTAssertFalse(firstArchive.isEmpty) + try Data("#!/bin/sh\necho updated\n".utf8).write( + to: sourceURL.appending(path: "install.sh") + ) + XCTAssertEqual( + try exporter.copyToSharedDirectory(shareURL), + archiveURL + ) + let updatedArchive = try Data(contentsOf: archiveURL) + XCTAssertNotEqual(updatedArchive, firstArchive) + XCTAssertEqual( + try exporter.copyToSharedDirectory(shareURL), + archiveURL + ) + XCTAssertEqual(try Data(contentsOf: archiveURL), updatedArchive) + + let list = Process() + list.executableURL = URL(filePath: "/usr/bin/tar") + list.arguments = ["-tzf", archiveURL.path] + let output = Pipe() + list.standardOutput = output + try list.run() + list.waitUntilExit() + XCTAssertEqual(list.terminationStatus, 0) + let contents = String( + data: output.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) + XCTAssertTrue(contents?.contains("GuestTools/install.sh") == true) + } + + func testGuestAgentSocketTransportRoundTripAndTimeout() async throws { + var descriptors: [Int32] = [0, 0] + XCTAssertEqual( + socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors), + 0 + ) + let client = descriptors[0] + let server = descriptors[1] + let status = guestToolsStatus( + mountState: .mounted, + capabilities: [.gracefulShutdown] + ) + let serverTask = Task.detached { + defer { Darwin.close(server) } + let handle = FileHandle( + fileDescriptor: server, + closeOnDealloc: false + ) + let header = try handle.read(upToCount: 4) ?? Data() + let length = header.reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + let payload = try handle.read(upToCount: Int(length)) ?? Data() + var requestFrame = header + requestFrame.append(payload) + let request = try GuestAgentFrameCodec.decode( + GuestAgentRequestEnvelope.self, + from: requestFrame + ) + let response = try GuestAgentFrameCodec.encode( + GuestAgentResponseEnvelope( + requestID: request.requestID, + response: .status(status) + ) + ) + try handle.write(contentsOf: response.prefix(2)) + try handle.write(contentsOf: response.dropFirst(2)) + } + let response = try await GuestAgentSocketTransport.request( + .status, + timeout: 1 + ) { + client + } + guard case .status(let received) = response else { + XCTFail("Expected status response.") + return + } + XCTAssertEqual(received.hostname, "guest") + _ = try await serverTask.value + + var timeoutDescriptors: [Int32] = [0, 0] + XCTAssertEqual( + socketpair(AF_UNIX, SOCK_STREAM, 0, &timeoutDescriptors), + 0 + ) + let timeoutClient = timeoutDescriptors[0] + let timeoutServer = timeoutDescriptors[1] + defer { Darwin.close(timeoutServer) } + do { + _ = try await GuestAgentSocketTransport.request( + .status, + timeout: 0.05 + ) { + timeoutClient + } + XCTFail("Expected a timeout.") + } catch GuestAgentTransportError.timedOut { + // Expected. + } + } + + func testGuestAgentSocketTransportRejectsMismatchedIDs() async throws { + var descriptors: [Int32] = [0, 0] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors), 0) + let client = descriptors[0] + let server = descriptors[1] + let serverTask = Task.detached { + defer { Darwin.close(server) } + let handle = FileHandle( + fileDescriptor: server, + closeOnDealloc: false + ) + let header = try handle.read(upToCount: 4) ?? Data() + let length = header.reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + _ = try handle.read(upToCount: Int(length)) + let response = try GuestAgentFrameCodec.encode( + GuestAgentResponseEnvelope( + requestID: "not-the-request-id", + response: .accepted + ) + ) + try handle.write(contentsOf: response) + } + + do { + _ = try await GuestAgentSocketTransport.request( + .status, + timeout: 1 + ) { + client + } + XCTFail("Expected a mismatched request ID.") + } catch GuestAgentProtocolError.mismatchedRequestID { + // Expected. + } + _ = try await serverTask.value + } + + func testGuestAgentSocketTransportCancelsPendingRead() async throws { + var descriptors: [Int32] = [0, 0] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors), 0) + let client = descriptors[0] + let server = descriptors[1] + defer { Darwin.close(server) } + let requestTask = Task { + try await GuestAgentSocketTransport.request( + .status, + timeout: 10 + ) { + client + } + } + + try await Task.sleep(for: .milliseconds(50)) + requestTask.cancel() + do { + _ = try await requestTask.value + XCTFail("Expected cancellation.") + } catch is CancellationError { + // Expected. + } + } + @MainActor func testRealARM64EFIISOStaysRunningWithDisplayAttached() async throws { guard let fixturePath = fixturePath( @@ -1305,4 +1666,24 @@ final class FoundationTests: XCTestCase { return path } + private func guestToolsStatus( + mountState: GuestSharedMountState = .unmounted, + capabilities: Set, + sessionType: GuestSessionType = .wayland + ) -> GuestAgentStatus { + GuestAgentStatus( + protocolVersion: GuestAgentProtocol.currentVersion, + agentVersion: "2.0.0", + hostname: "guest", + ipAddresses: [], + operatingSystem: "Linux", + distroID: "arch", + distroVersion: "rolling", + desktopEnvironment: .hyprland, + sessionType: sessionType, + capabilities: capabilities, + sharedMountStatus: GuestSharedMountStatus(state: mountState) + ) + } + } diff --git a/SimpleVMUITests/LaunchTests.swift b/SimpleVMUITests/LaunchTests.swift index ab74049..06e2a13 100644 --- a/SimpleVMUITests/LaunchTests.swift +++ b/SimpleVMUITests/LaunchTests.swift @@ -67,4 +67,41 @@ final class LaunchTests: XCTestCase { XCTAssertTrue(app.buttons["Open"].waitForExistence(timeout: 3)) app.typeKey(.escape, modifierFlags: []) } + + func testGuestToolsSetupShowsTruthfulDeliveryFlow() { + let app = XCUIApplication() + let storageRoot = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + app.launchEnvironment["SIMPLEVM_STORAGE_ROOT"] = storageRoot.path + app.launchEnvironment["SIMPLEVM_UI_TEST_GUEST_TOOLS"] = "1" + app.launch() + + let machine = app.staticTexts["Guest Tools Fixture"] + XCTAssertTrue(machine.waitForExistence(timeout: 5)) + machine.click() + + let status = app.buttons["guestTools.status"] + XCTAssertTrue(status.waitForExistence(timeout: 5)) + status.click() + XCTAssertTrue( + app.staticTexts["guestTools.panel"].waitForExistence(timeout: 3) + ) + XCTAssertTrue(app.buttons["guestTools.export"].exists) + XCTAssertTrue(app.buttons["guestTools.copyToShare"].exists) + XCTAssertTrue(app.staticTexts["guestTools.installCommand"].exists) + + app.buttons["guestTools.copyToShare"].click() + XCTAssertTrue( + app.staticTexts["guestTools.deliveryStatus"] + .waitForExistence(timeout: 5) + ) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: storageRoot + .appending(path: "GuestToolsTestShare") + .appending(path: "simplevm-guest-tools.tar.gz") + .path + ) + ) + } } diff --git a/project.yml b/project.yml index fb6f1cd..a5adb7b 100644 --- a/project.yml +++ b/project.yml @@ -32,6 +32,9 @@ targets: sources: - path: SimpleVMApp - path: Resources + - path: GuestTools + type: folder + buildPhase: resources dependencies: - package: SimpleVMCore - target: UTMQEMULauncher