From f851910ec518002265217fc9cf84a45089dd89af Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 6 Oct 2026 23:11:12 -0700 Subject: [PATCH] Add native Linux guest cursors for X11 and Wayland Send cursor shapes outside cursor-free desktop video through the existing VirtIO protocol. Install in the graphical session with access limited to the display port. Keep separate-window capture unavailable on Linux. Validated Debian 12 x86_64 with ASan/UBSan and Xvfb, and Ubuntu ARM64 Wayland with reconnect, lock/unlock, and PipeWire restart recovery. Windows architecture and cursor tests pass. Assisted-by: gpt-6.1-sol --- guest/DESIGN.md | 14 +- guest/build.py | 19 +- guest/cursor.c | 2 + guest/install.sh | 37 +++ guest/linux-test.py | 88 ++++++ guest/linux-tests.c | 64 +++++ guest/linux.c | 680 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 900 insertions(+), 4 deletions(-) create mode 100755 guest/install.sh create mode 100644 guest/linux-test.py create mode 100644 guest/linux-tests.c create mode 100644 guest/linux.c diff --git a/guest/DESIGN.md b/guest/DESIGN.md index ba8edf00a88b8b7b36e7244f235d6e7927c223ad..4b4f8e69f3442c0de5f8edff2df4a5d06efc376c 100644 --- a/guest/DESIGN.md +++ b/guest/DESIGN.md @@ -1,6 +1,6 @@ # Guest display stream -The executable is freestanding C99. `cursor.c` contains pixel conversion and dirty-rectangle detection; `windows.c` owns desktop capture and transport; `capture.c` implements modern desktop capture and optional window surfaces. Zig supplies the cross compiler and Windows headers, with no Zig or C runtime in the executable. x86 and x64 target Windows 7; ARM64 targets Windows 10. PE subsystem versions and imported DLLs are checked by `python3 guest/test.py`. Runtime validation uses Windows 10 and 11; Windows 7 runtime behavior is unverified. +The Windows executable is freestanding C99. `cursor.c` contains pixel conversion and dirty-rectangle detection; `windows.c` owns desktop capture and transport; `capture.c` implements modern desktop capture and optional window surfaces. Zig supplies the cross compiler and Windows headers, with no Zig or C runtime in the executable. x86 and x64 target Windows 7; ARM64 targets Windows 10. PE subsystem versions and imported DLLs are checked by `python3 guest/test.py`. Runtime validation uses Windows 10 and 11; Windows 7 runtime behavior is unverified. Build with `python3 guest/build.py --arch x86_64` (also `x86` and `aarch64`). In an elevated PowerShell session inside the logged-in administrator's desktop, run `./install.ps1 -Binary ./build/snowglobe-guest-x86_64.exe`. The installer uses Task Scheduler COM APIs available to PowerShell 2, installs into protected Program Files, and starts an elevated task at that user's logon. `-Remove` removes that task and executable. Elevation is required by the VirtIO serial driver's device ACL; capture must run in the interactive session, not session 0. The guest needs a compatible VirtIO serial driver and the channel named in `protocol.json`. A standard-user launcher requires a privileged handle broker; it is not part of this first backend. @@ -8,6 +8,16 @@ Windows 10 build 19041 and later use Windows Graphics Capture for the primary mo `GetCursorInfo` supplies the shape, visibility, and observed position. `GetIconInfo` and black/white `DrawIconEx` renders recover RGBA and binary XOR planes, including colored XOR. Nonbinary color XOR and unsupported cursor sizes explicitly fall back to the browser's default cursor, preserving video. Animation timing uses the optional `GetCursorFrameInfo` user32 export, resolved by name. Missing or unusable animation metadata falls back to a static frame. Animation starts a browser-local clock; Windows does not expose the current animation phase through this interface. Ordinary shapes use the browser cursor; XOR shapes use local layers with difference blending. Neither follows streamed positions. +## Linux cursor backend + +`linux.c` uses the same protocol and browser cursor renderer. Build natively with `python3 guest/build.py --platform linux`; install with `sudo sh guest/install.sh guest/build/snowglobe-guest`. Build dependencies are a C compiler, pkg-config, GLib/GIO, PipeWire/SPA, Xlib, XFixes, and Xext development packages. The executable links the distribution's libraries; build it on the oldest distribution that it must support. Linux builds use libc, while Windows retains its freestanding import boundary. + +The installer grants the active local user access to only the display VirtIO port through udev/logind and starts the agent in the graphical session through XDG autostart. Sign out and back in after installation. The VM needs the channel from `protocol.json`; there is no guest network listener. `--remove` removes the installation for subsequent logins. + +X11 uses XFixes cursor notifications and premultiplied ARGB images, with shared-memory root capture and an XGetImage fallback. Root images exclude the pointer. Wayland uses the ScreenCast portal's metadata cursor mode and CPU-mapped PipeWire BGRx frames. The first session requires selecting a monitor and approving sharing; a returned restore token is saved privately for later logins. Compositors without cursor metadata support keep QEMU capture. Cursor animation is forwarded as observed shape updates; these APIs do not supply a complete animation timeline. + +Desktop locking and inactive logind sessions suspend guest capture and release the port. Closing viewers pauses capture; reconnects resend the cached cursor and a complete desktop frame. Output is nonblocking and bounded, and a stalled transport falls back after three seconds. Linux advertises no separate-window capability and never sends window surfaces. Run `python3 guest/linux-test.py` on Linux for metadata bounds, alpha conversion, dirty frames, fragmented requests, cursor-free X11 capture, shape changes, and reconnect validation. + ## Wire format `protocol.json` is the only home for packet IDs, field order, channel name, flags, and bounds. The build generates its private C header; the host reads that same schema. Each packet begins with the four-byte magic, a big-endian uint32 type, and a big-endian uint32 payload length. Fields listed in the schema are big-endian uint32 values. @@ -35,4 +45,4 @@ The experiment is off by default. Unsupported, layered, translucent, shell-popup ## Later backends -Keep the core independent of an OS and add real platform implementations when their capture and cursor APIs can be tested. X11 cursor notifications, Wayland's compositor/portal permissions, and macOS capture permissions need separate backend decisions, not empty adapters. A window rectangle does not imply a uniform cursor: application hit testing can change the shape anywhere inside it. Region prediction should be based on validated application information or observations with invalidation, not fabricated bounding boxes. +Keep the core independent of an OS and add real platform implementations when their capture and cursor APIs can be tested. macOS capture permissions need a separate backend decision. A window rectangle does not imply a uniform cursor: application hit testing can change the shape anywhere inside it. Region prediction should be based on validated application information or observations with invalidation, not fabricated bounding boxes. diff --git a/guest/build.py b/guest/build.py index 354f885b9a67f1766e77ff0261cf7b5eddad8a23..329c0bb9e54deb3bebcfe7346ab81d54c94bbec9 100644 --- a/guest/build.py +++ b/guest/build.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 import argparse import json +import os from pathlib import Path import re +import shlex import subprocess parser = argparse.ArgumentParser() -parser.add_argument("--arch", choices=["x86", "x86_64", "aarch64"], default="x86_64") +parser.add_argument("--platform", choices=["windows", "linux"], default="windows") +parser.add_argument("--arch", choices=["x86", "x86_64", "aarch64"]) parser.add_argument("--output", type=Path, default=Path(__file__).parent / "build") args = parser.parse_args() source = Path(__file__).resolve().parent @@ -21,8 +24,20 @@ for name, packet in schema["packets"].items(): header += [f"#define SG_{name.upper()}_{field.upper()} {index}u" for index, field in enumerate(packet["fields"])] header += [f"#define SG_WINDOW_WORDS {len(schema['window_fields'])}u"] header += [f"#define SG_WINDOW_{field.upper()} {index}u" for index, field in enumerate(schema["window_fields"])] -header += [f'#define SG_MAGIC "{schema["magic"]}"', f'#define SG_CHANNEL L"{schema["channel"]}"', "#endif"] +header += [f'#define SG_MAGIC "{schema["magic"]}"', f'#define SG_CHANNEL_NAME "{schema["channel"]}"', f'#define SG_CHANNEL L"{schema["channel"]}"', "#endif"] (args.output / "protocol.h").write_text("\n".join(header) + "\n") +if args.platform == "linux": + if args.arch: + parser.error("Linux builds use the native compiler; omit --arch.") + packages = ["gio-unix-2.0", "libpipewire-0.3", "x11", "xfixes", "xext"] + flags = shlex.split(subprocess.check_output(["pkg-config", "--cflags", "--libs", *packages], text=True)) + subprocess.run([ + *shlex.split(os.environ.get("CC", "cc")), "-std=gnu99", "-O2", "-Wall", "-Wextra", "-Werror", + "-I", str(args.output), str(source / "linux.c"), str(source / "cursor.c"), + *flags, "-o", str(args.output / "snowglobe-guest"), + ], check=True) + raise SystemExit(0) +args.arch = args.arch or "x86_64" subsystem = "10.0" if args.arch == "aarch64" else "6.1" environment = subprocess.check_output(["zig", "env"], text=True) match = re.search(r'\.lib_dir\s*=\s*"([^"]+)"', environment) diff --git a/guest/cursor.c b/guest/cursor.c index 2f510494056d72d70cc81bd76cb5fd9d3675c78c..9156376be7daa621b7ed965bcbbeabfca6d335e9 100644 --- a/guest/cursor.c +++ b/guest/cursor.c @@ -54,6 +54,7 @@ int sg_rgba(const uint8_t *black, const uint8_t *white, uint8_t *rgba, uint8_t * return 1; } +#if !__STDC_HOSTED__ void *memcpy(void *destination, const void *source, size_t length) { uint8_t *out = destination; const uint8_t *in = source; @@ -68,3 +69,4 @@ void *memset(void *destination, int value, size_t length) { for (i = 0; i < length; ++i) out[i] = (uint8_t)value; return destination; } +#endif diff --git a/guest/install.sh b/guest/install.sh new file mode 100755 index 0000000000000000000000000000000000000000..7b1b0195027858220edae41258bd5b5514b0711c --- /dev/null +++ b/guest/install.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu +if [ "$(id -u)" != 0 ]; then + printf '%s\n' 'Run this installer with sudo to install the guest agent.' >&2 + exit 1 +fi +if [ "${1-}" = --remove ]; then + rm -f /etc/xdg/autostart/snowglobe-guest.desktop /etc/udev/rules.d/70-snowglobe-guest.rules /usr/local/bin/snowglobe-guest + udevadm control --reload-rules + udevadm trigger --subsystem-match=virtio-ports + udevadm settle + port=$(python3 -c 'import json, sys; print("/dev/virtio-ports/" + json.load(open(sys.argv[1]))["channel"])' "$(dirname "$0")/protocol.json") + if [ -e "$port" ]; then chmod 0600 "$port"; fi + exit 0 +fi +if [ "$#" != 1 ] || [ ! -f "$1" ]; then + printf '%s\n' 'Pass the path to the Linux guest executable.' >&2 + exit 1 +fi +install -D -m 0755 "$1" /usr/local/bin/snowglobe-guest +mkdir -p /etc/udev/rules.d /etc/xdg/autostart +python3 - "$(dirname "$0")/protocol.json" > /etc/udev/rules.d/70-snowglobe-guest.rules <<'PYTHON' +import json, sys +channel = json.load(open(sys.argv[1]))['channel'] +print('SUBSYSTEM=="virtio-ports", ATTR{name}==' + json.dumps(channel) + ', TAG+="uaccess"') +PYTHON +cat > /etc/xdg/autostart/snowglobe-guest.desktop <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=Snowglobe Guest +Exec=/usr/local/bin/snowglobe-guest +NoDisplay=true +DESKTOP +udevadm control --reload-rules +udevadm trigger --subsystem-match=virtio-ports +udevadm settle +printf '%s\n' 'Installed. Sign out and back in to start the guest agent.' diff --git a/guest/linux-test.py b/guest/linux-test.py new file mode 100644 index 0000000000000000000000000000000000000000..9401fc077fdc66c09c21e8923815acc926085136 --- /dev/null +++ b/guest/linux-test.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import pty +import select +import shlex +import struct +import subprocess +import tempfile +import time +import tty + +source = Path(__file__).resolve().parent +subprocess.run(['python3', str(source / 'build.py'), '--platform', 'linux'], check=True) +schema = json.loads((source / 'protocol.json').read_text()) +ids = {key: value['id'] for key, value in schema['packets'].items()} +flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'gio-unix-2.0', 'libpipewire-0.3', 'x11', 'xfixes', 'xext'], text=True)) +with tempfile.TemporaryDirectory() as temporary: + binary = Path(temporary) / 'linux-tests' + subprocess.run(['cc', '-std=gnu99', '-Wall', '-Wextra', '-Werror', '-g', '-fsanitize=address,undefined', '-no-pie', '-I', str(source / 'build'), str(source / 'linux-tests.c'), str(source / 'cursor.c'), *flags, '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + fixture = Path(temporary) / 'fixture.c' + fixture.write_text('''#include +#include +#include +int main(void) { +Display *d = XOpenDisplay(0); if (!d) return 1; +Window root = DefaultRootWindow(d); +XSetWindowBackground(d, root, 0x314159); XClearWindow(d, root); +XDefineCursor(d, root, XCreateFontCursor(d, XC_left_ptr)); +XWarpPointer(d, None, root, 0, 0, 0, 0, 40, 40); XSync(d, 0); +sleep(2); XDefineCursor(d, root, XCreateFontCursor(d, XC_xterm)); XSync(d, 0); +sleep(10); XCloseDisplay(d); return 0; +}''') + helper = Path(temporary) / 'fixture' + subprocess.run(['cc', str(fixture), '-lX11', '-o', str(helper)], check=True) + xvfb = subprocess.Popen(['Xvfb', '-displayfd', '1', '-screen', '0', '160x100x24', '-nolisten', 'tcp'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + display = ':' + xvfb.stdout.readline().strip() + env = {key: value for key, value in os.environ.items() if key not in ('WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS')} + env['DISPLAY'] = display + fixture_process = subprocess.Popen([str(helper)], env=env) + master, slave = pty.openpty(); tty.setraw(slave) + agent = subprocess.Popen([str(source / 'build/snowglobe-guest'), os.ttyname(slave)], env=env, stderr=subprocess.PIPE) + pending = bytearray() + def send(kind, data=b''): + os.write(master, struct.pack('!4sII', schema['magic'].encode(), ids[kind], len(data)) + data) + def packet(timeout=5): + deadline = time.monotonic() + timeout + while True: + if len(pending) >= 12: + magic, kind, length = struct.unpack('!4sII', pending[:12]) + assert magic == schema['magic'].encode() + if len(pending) >= 12 + length: + payload = bytes(pending[12:12 + length]); del pending[:12 + length] + return kind, payload + remaining = deadline - time.monotonic() + if remaining <= 0 or not select.select([master], [], [], remaining)[0]: raise TimeoutError((agent.poll(), agent.stderr.read() if agent.poll() is not None else b'')) + pending.extend(os.read(master, 65536)) + try: + kind, payload = packet(); assert kind == ids['capabilities'] and struct.unpack('!II', payload) == (0, 1) + send('stream', struct.pack('!I', 1)); send('refresh') + cursors = set(); first_frame = None + deadline = time.monotonic() + 6 + while time.monotonic() < deadline and (len(cursors) < 2 or first_frame is None): + kind, payload = packet() + if kind == ids['cursor']: + cursor_id, w, h, hot_x, hot_y, frames = struct.unpack('!6I', payload[:24]) + assert w and h and hot_x < w and hot_y < h and frames == 1 + cursors.add(payload[28:28 + w * h * 4]) + if kind == ids['screen']: + w, h, x, y, rw, rh = struct.unpack('!6I', payload[:24]); assert (w, h, x, y, rw, rh) == (160, 100, 0, 0, 160, 100) + first_frame = payload[24:] + assert all(first_frame[i:i + 3] == bytes.fromhex('594131') for i in range(0, len(first_frame), 4)), 'Cursor leaked into screen pixels' + assert kind not in (ids['windows'], ids['surface']) + assert len(cursors) >= 2 and first_frame is not None + send('stop') + while packet()[0] != ids['stop']: pass + send('refresh'); send('stream', struct.pack('!I', 1)) + seen = set() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not {ids['cursor'], ids['screen']} <= seen: seen.add(packet()[0]) + assert {ids['cursor'], ids['screen']} <= seen + finally: + for process in (agent, fixture_process, xvfb): + process.terminate(); process.wait(timeout=5) + os.close(master); os.close(slave) +print('Linux cursor metadata bounds, alpha, dirty frames, fragmented stop, X11 shape changes, cursor-free video, and reconnect passed') diff --git a/guest/linux-tests.c b/guest/linux-tests.c new file mode 100644 index 0000000000000000000000000000000000000000..66203444f2c40fc795cd37aa54ea792fd13ff53f --- /dev/null +++ b/guest/linux-tests.c @@ -0,0 +1,64 @@ +#define main guest_main +#include "linux.c" +#undef main +#include +#include + +static uint32_t word(const uint8_t *p) { return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; } + +int main(void) { + out = g_byte_array_new(); cursor_packet = g_byte_array_new(); + int sockets[2]; assert(!socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sockets)); + channel = sockets[0]; + uint8_t metadata[sizeof(struct spa_meta_cursor) + sizeof(struct spa_meta_bitmap) + 8] = {0}; + struct spa_meta_cursor *cursor = (void *)metadata; + struct spa_meta_bitmap *bitmap = (void *)(metadata + sizeof(*cursor)); + cursor->id = 7; cursor->position.x = -17; cursor->position.y = 25; + cursor->bitmap_offset = sizeof(*cursor); cursor->hotspot.x = 1; + bitmap->format = SPA_VIDEO_FORMAT_BGRA; bitmap->size = SPA_RECTANGLE(2, 1); + bitmap->stride = 8; bitmap->offset = sizeof(*bitmap); + uint8_t *pixels = metadata + sizeof(*cursor) + sizeof(*bitmap); + memcpy(pixels, (uint8_t[]){16, 32, 64, 128, 0, 0, 0, 0}, 8); + struct spa_meta meta = {SPA_META_Cursor, sizeof(metadata), metadata}; + struct spa_buffer buffer = {.n_metas = 1, .metas = &meta}; + pw_cursor(&buffer); + assert(cursor_packet->len == SG_CURSOR_WORDS * 4 + 4 + 10); + const uint8_t *rgba = cursor_packet->data + SG_CURSOR_WORDS * 4 + 4; + assert(rgba[0] == 128 && rgba[1] == 64 && rgba[2] == 32 && rgba[3] == 128); + assert(!rgba[4] && !rgba[5] && !rgba[6] && !rgba[7]); + assert(word(cursor_packet->data + SG_CURSOR_HOT_X * 4) == 1); + assert((int32_t)pointer[SG_POINTER_X] == -17 && pointer[SG_POINTER_VISIBLE]); + size_t length = cursor_packet->len; + cursor->bitmap_offset = UINT32_MAX; + pw_cursor(&buffer); assert(cursor_packet->len == length); + cursor->bitmap_offset = sizeof(*cursor) + 1; pw_cursor(&buffer); + cursor->bitmap_offset = sizeof(*cursor); bitmap->stride = -8; + pw_cursor(&buffer); assert(cursor_packet->len == SG_CURSOR_WORDS * 4); + bitmap->stride = 8; bitmap->offset = UINT32_MAX; + pw_cursor(&buffer); assert(cursor_packet->len == SG_CURSOR_WORDS * 4); + bitmap->offset = sizeof(*bitmap); cursor->hotspot.x = 2; + pw_cursor(&buffer); assert(cursor_packet->len == SG_CURSOR_WORDS * 4); + cursor->hotspot.x = 1; bitmap->offset = sizeof(*bitmap); pw_cursor(&buffer); + assert(pointer[SG_POINTER_VISIBLE]); + cursor->id = 0; pw_cursor(&buffer); assert(pointer[SG_POINTER_VISIBLE]); + cursor->id = 7; bitmap->offset = 0; pw_cursor(&buffer); assert(!pointer[SG_POINTER_VISIBLE]); + cursor->bitmap_offset = 0; pw_cursor(&buffer); assert(!pointer[SG_POINTER_VISIBLE]); + cursor->bitmap_offset = sizeof(*cursor); bitmap->format = 0; pw_cursor(&buffer); assert(!pointer[SG_POINTER_VISIBLE]); + bitmap->format = SPA_VIDEO_FORMAT_BGRA; bitmap->offset = sizeof(*bitmap); pw_cursor(&buffer); assert(pointer[SG_POINTER_VISIBLE]); + uint8_t drain[4096]; while (read(sockets[1], drain, sizeof(drain)) > 0) {} + g_byte_array_set_size(cursor_packet, 0); + capturing = 1; uint8_t screen[16] = {0, 0, 255, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0}; + screen_frame(screen, 2, 2); + ssize_t count = read(sockets[1], drain, sizeof(drain)); + assert(count == 12 + SG_SCREEN_WORDS * 4 + 16 && word(drain + 4) == SG_SCREEN); + screen_frame(screen, 2, 2); assert(read(sockets[1], drain, sizeof(drain)) < 0 && errno == EAGAIN); + screen[4] = 128; screen_frame(screen, 2, 2); + count = read(sockets[1], drain, sizeof(drain)); + assert(count == 12 + SG_SCREEN_WORDS * 4 + 4 && word(drain + 12 + SG_SCREEN_X * 4) == 1 && word(drain + 12 + SG_SCREEN_RECT_WIDTH * 4) == 1); + uint8_t stop[12] = SG_MAGIC; sg_word(stop + 4, SG_STOP); sg_word(stop + 8, 0); + assert(write(sockets[1], stop, 3) == 3); assert(read_requests(channel, G_IO_IN, NULL) == G_SOURCE_CONTINUE); + assert(write(sockets[1], stop + 3, 9) == 9); assert(read_requests(channel, G_IO_IN, NULL) == G_SOURCE_REMOVE); + count = read(sockets[1], drain, sizeof(drain)); assert(count == 12 && word(drain + 4) == SG_STOP && channel == -1); + close(sockets[1]); free(before); g_byte_array_unref(out); g_byte_array_unref(cursor_packet); + return 0; +} diff --git a/guest/linux.c b/guest/linux.c new file mode 100644 index 0000000000000000000000000000000000000000..fceb80de7b5899087d64e2d67e3e9a49663fe443 --- /dev/null +++ b/guest/linux.c @@ -0,0 +1,680 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "cursor.h" + +static GMainLoop *main_loop; +static int channel = -1, capturing, initial = 1, stopping, suspended; +static guint read_watch, write_watch; +static GByteArray *out, *cursor_packet; +static size_t sent; +static gint64 queued_at; +static uint8_t request[16]; +static size_t received, request_size = 12; +static uint8_t *before; +static uint32_t width, height, cursor_id, pointer[SG_POINTER_WORDS]; +static const char *channel_path; +static uint32_t stats[SG_CAPTURE_STATS_WORDS]; +static gint64 stats_at; +static Display *display; +static Window root; +static XImage *ximage; +static XShmSegmentInfo shared = {.shmid = -1}; +static int shm_attached, x_error, cursor_event, cursor_changed = 1; +static unsigned long cursor_serial; +static gint64 next_capture; +static GDBusConnection *bus, *login_bus; +static char *session_path, *request_path; +static int portal_step, session_active = 1, session_locked, restart; +static struct pw_main_loop *pw_loop; +static struct pw_context *pw_context; +static struct pw_core *pw_core; +static struct pw_stream *pw_stream; +static struct spa_hook stream_listener; +static struct spa_video_info_raw video_info; +static char *restore_path; +static guint portal_subscription, session_subscription, pw_watch; + +static void close_channel(void) { + if (read_watch) { g_source_remove(read_watch); read_watch = 0; } + if (write_watch) { g_source_remove(write_watch); write_watch = 0; } + if (channel >= 0) close(channel); + channel = -1; capturing = 0; stopping = 0; initial = 1; + received = 0; request_size = 12; sent = 0; + g_byte_array_set_size(out, 0); + if (pw_stream) pw_stream_set_active(pw_stream, false); +} + +static gboolean flush_output(gint fd, GIOCondition condition, gpointer unused) { + (void)fd; (void)unused; + write_watch = 0; + if (condition & (G_IO_HUP | G_IO_ERR)) { close_channel(); return G_SOURCE_REMOVE; } + while (sent < out->len) { + size_t amount = out->len - sent; + if (amount > 65536) amount = 65536; + ssize_t count = write(channel, out->data + sent, amount); + if (count < 0 && errno == EINTR) continue; + if (count < 0 && errno == EAGAIN) { + write_watch = g_unix_fd_add(channel, G_IO_OUT | G_IO_HUP | G_IO_ERR, flush_output, NULL); + return G_SOURCE_REMOVE; + } + if (count <= 0) { close_channel(); return G_SOURCE_REMOVE; } + sent += (size_t)count; + } + g_byte_array_set_size(out, 0); sent = 0; + if (stopping) close_channel(); + return G_SOURCE_REMOVE; +} + +static int packet(uint32_t type, const uint32_t *words, uint32_t count, const uint8_t *data, size_t length) { + if (channel < 0 || stopping) return 0; + size_t payload = count * 4 + length; + if (payload > SG_MAX_PACKET_BYTES || out->len - sent + payload + 12 > SG_MAX_PACKET_BYTES + 131072u) { + close_channel(); return 0; + } + uint8_t header[12 + SG_SCREEN_WORDS * 4]; + memcpy(header, SG_MAGIC, 4); sg_word(header + 4, type); sg_word(header + 8, (uint32_t)payload); + for (uint32_t i = 0; i < count; ++i) sg_word(header + 12 + i * 4, words[i]); + if (sent) { g_byte_array_remove_range(out, 0, (guint)sent); sent = 0; } + if (!out->len) queued_at = g_get_monotonic_time(); + g_byte_array_append(out, header, 12 + count * 4); + if (length) g_byte_array_append(out, data, (guint)length); + if (!write_watch) flush_output(channel, 0, NULL); + return channel >= 0; +} + +static void capabilities(void) { + const uint32_t words[SG_CAPABILITIES_WORDS] = {0, 1}; + packet(SG_CAPABILITIES, words, SG_CAPABILITIES_WORDS, NULL, 0); +} + +static void replay_cursor(void) { + if (cursor_packet->len) { + uint32_t words[SG_CURSOR_WORDS]; + for (uint32_t i = 0; i < SG_CURSOR_WORDS; ++i) { + const uint8_t *p = cursor_packet->data + i * 4; + words[i] = ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; + } + packet(SG_CURSOR, words, SG_CURSOR_WORDS, cursor_packet->data + sizeof(words), cursor_packet->len - sizeof(words)); + packet(SG_POINTER, pointer, SG_POINTER_WORDS, NULL, 0); + } +} + +static void cursor_shape(uint32_t w, uint32_t h, int hot_x, int hot_y, const uint8_t *rgba) { + uint32_t words[SG_CURSOR_WORDS] = {0}; + words[SG_CURSOR_ID] = ++cursor_id; + size_t pixels = (size_t)w * h; + if (w && h && w <= SG_MAX_CURSOR_SIZE && h <= SG_MAX_CURSOR_SIZE && hot_x >= 0 && hot_y >= 0 && (uint32_t)hot_x < w && (uint32_t)hot_y < h) { + words[SG_CURSOR_WIDTH] = w; words[SG_CURSOR_HEIGHT] = h; + words[SG_CURSOR_HOT_X] = (uint32_t)hot_x; words[SG_CURSOR_HOT_Y] = (uint32_t)hot_y; words[SG_CURSOR_FRAMES] = 1; + } else pixels = 0; + g_byte_array_set_size(cursor_packet, sizeof(words) + (pixels ? 4 + pixels * 5 : 0)); + for (uint32_t i = 0; i < SG_CURSOR_WORDS; ++i) sg_word(cursor_packet->data + i * 4, words[i]); + if (pixels) { + sg_word(cursor_packet->data + sizeof(words), 1000); + memcpy(cursor_packet->data + sizeof(words) + 4, rgba, pixels * 4); + memset(cursor_packet->data + sizeof(words) + 4 + pixels * 4, 0, pixels); + } + pointer[SG_POINTER_ID] = cursor_id; + replay_cursor(); +} + +static void position(int x, int y, int visible) { + uint32_t words[SG_POINTER_WORDS] = {cursor_id, (uint32_t)x, (uint32_t)y, (uint32_t)visible}; + if (memcmp(pointer, words, sizeof(words))) { + memcpy(pointer, words, sizeof(words)); + packet(SG_POINTER, words, SG_POINTER_WORDS, NULL, 0); + } +} + +static void screen_frame(const uint8_t *pixels, uint32_t w, uint32_t h) { + if (!w || !h || w > SG_MAX_DIMENSION || h > SG_MAX_DIMENSION || w * h > SG_MAX_PIXELS) return; + if (out->len - sent > 65536 || channel < 0 || !capturing || suspended) return; + gint64 start = g_get_monotonic_time(); + if (w != width || h != height) { + free(before); before = malloc((size_t)w * h * 4); + if (!before) { close_channel(); return; } + width = w; height = h; initial = 1; + } + struct sg_rect rect = {0, 0, w, h}; + if (!initial && !sg_difference(before, pixels, w, h, &rect)) return; + stats[SG_CAPTURE_STATS_SCAN_US] += (uint32_t)(g_get_monotonic_time() - start); + start = g_get_monotonic_time(); + uint32_t words[SG_SCREEN_WORDS] = {w, h, rect.x, rect.y, rect.width, rect.height}; + size_t length = (size_t)rect.width * rect.height * 4; + uint8_t *packed = malloc(length); + if (!packed) { close_channel(); return; } + for (uint32_t y = 0; y < rect.height; ++y) { + size_t offset = ((size_t)(rect.y + y) * w + rect.x) * 4; + memcpy(packed + (size_t)y * rect.width * 4, pixels + offset, rect.width * 4); + memcpy(before + offset, pixels + offset, rect.width * 4); + } + if (initial) replay_cursor(); + if (packet(SG_SCREEN, words, SG_SCREEN_WORDS, packed, length)) initial = 0; + free(packed); + stats[SG_CAPTURE_STATS_WRITE_US] += (uint32_t)(g_get_monotonic_time() - start); +} + +static gboolean read_requests(gint fd, GIOCondition condition, gpointer unused) { + (void)unused; + if (condition & (G_IO_HUP | G_IO_ERR)) { read_watch = 0; close_channel(); return G_SOURCE_REMOVE; } + for (;;) { + ssize_t count = read(fd, request + received, request_size - received); + if (count < 0 && errno == EINTR) continue; + if (count < 0 && errno == EAGAIN) return G_SOURCE_CONTINUE; + if (count <= 0) break; + received += (size_t)count; + if (received < request_size) continue; + if (request_size == 12) { + if (memcmp(request, SG_MAGIC, 4) || request[4] || request[5] || request[6] || request[8] || request[9] || request[10]) break; + if ((request[7] == SG_STREAM || request[7] == SG_EXPERIMENT) && request[11] == 4) { request_size = 16; continue; } + if (request[11]) break; + if (request[7] == SG_STOP) { + read_watch = 0; + capturing = 0; + if (pw_stream) pw_stream_set_active(pw_stream, false); + packet(SG_STOP, NULL, 0, NULL, 0); + stopping = 1; + if (!out->len) close_channel(); + return G_SOURCE_REMOVE; + } + if (request[7] != SG_REFRESH) break; + initial = 1; cursor_changed = 1; capabilities(); replay_cursor(); + } else { + if (request[12] || request[13] || request[14] || request[15] > 1) break; + if (request[7] == SG_STREAM) { + int enabled = request[15]; + if (capturing != enabled) { + capturing = enabled; initial = 1; cursor_changed = 1; + if (enabled) replay_cursor(); + if (pw_stream) pw_stream_set_active(pw_stream, capturing && !suspended); + } + } + } + received = 0; request_size = 12; + if (channel < 0) { read_watch = 0; return G_SOURCE_REMOVE; } + } + read_watch = 0; close_channel(); return G_SOURCE_REMOVE; +} + +static int x_error_handler(Display *d, XErrorEvent *e) { (void)d; (void)e; x_error = 1; return 0; } + +static void free_ximage(void) { + if (shm_attached) { XShmDetach(display, &shared); XSync(display, False); shm_attached = 0; } + if (ximage) { + if (shared.shmid >= 0) ximage->data = NULL; + XDestroyImage(ximage); ximage = NULL; + } + if (shared.shmid >= 0) { shmdt(shared.shmaddr); shmctl(shared.shmid, IPC_RMID, NULL); shared.shmid = -1; } +} + +static void x_cursor(void) { + XFixesCursorImage *image = XFixesGetCursorImage(display); + if (!image) return; + if (cursor_changed || image->cursor_serial != cursor_serial) { + uint8_t rgba[SG_MAX_CURSOR_SIZE * SG_MAX_CURSOR_SIZE * 4]; + uint32_t w = image->width, h = image->height; + int visible = 0; + if (w <= SG_MAX_CURSOR_SIZE && h <= SG_MAX_CURSOR_SIZE) { + for (size_t i = 0; i < (size_t)w * h; ++i) { + uint32_t pixel = (uint32_t)image->pixels[i]; + unsigned alpha = pixel >> 24; + visible |= alpha != 0; + rgba[i * 4 + 3] = (uint8_t)alpha; + for (unsigned c = 0; c < 3; ++c) { + unsigned value = (pixel >> (16 - c * 8)) & 255; + value = alpha ? (value * 255 + alpha / 2) / alpha : 0; + rgba[i * 4 + c] = (uint8_t)(value > 255 ? 255 : value); + } + } + position(image->x, image->y, visible); + cursor_shape(w, h, image->xhot, image->yhot, rgba); + } else { position(image->x, image->y, 1); cursor_shape(0, 0, 0, 0, NULL); } + cursor_serial = image->cursor_serial; cursor_changed = 0; + } else position(image->x, image->y, pointer[SG_POINTER_VISIBLE]); + XFree(image); +} + +static gboolean x_tick(gpointer unused) { + (void)unused; + while (XPending(display)) { + XEvent event; XNextEvent(display, &event); + if (event.type == cursor_event + XFixesCursorNotify) cursor_changed = 1; + } + if (!capturing || suspended || channel < 0 || stopping) return G_SOURCE_CONTINUE; + x_cursor(); + gint64 now = g_get_monotonic_time(); + if (now < next_capture || out->len - sent > 65536) return G_SOURCE_CONTINUE; + next_capture = now + 16667; + XWindowAttributes attr; + x_error = 0; + if (!XGetWindowAttributes(display, root, &attr) || attr.width <= 0 || attr.height <= 0 || attr.width > (int)SG_MAX_DIMENSION || attr.height > (int)SG_MAX_DIMENSION || (uint32_t)attr.width * attr.height > SG_MAX_PIXELS) return G_SOURCE_CONTINUE; + if (ximage && (ximage->width != attr.width || ximage->height != attr.height)) free_ximage(); + if (!ximage && XShmQueryExtension(display)) { + ximage = XShmCreateImage(display, attr.visual, (unsigned)attr.depth, ZPixmap, NULL, &shared, (unsigned)attr.width, (unsigned)attr.height); + if (ximage) { + shared.shmid = shmget(IPC_PRIVATE, (size_t)ximage->bytes_per_line * ximage->height, IPC_CREAT | 0600); + if (shared.shmid >= 0) { + shared.shmaddr = shmat(shared.shmid, NULL, 0); shared.readOnly = False; + if (shared.shmaddr != (void *)-1) { + ximage->data = shared.shmaddr; + shm_attached = XShmAttach(display, &shared); XSync(display, False); + if (x_error) shm_attached = 0; + shmctl(shared.shmid, IPC_RMID, NULL); + } + } + if (!shm_attached) free_ximage(); + } + } + int temporary = !ximage; + XImage *image = ximage; + if (image) { if (!XShmGetImage(display, root, image, 0, 0, AllPlanes)) return G_SOURCE_CONTINUE; } + else image = XGetImage(display, root, 0, 0, (unsigned)attr.width, (unsigned)attr.height, AllPlanes, ZPixmap); + if (!image || x_error) { if (temporary && image) XDestroyImage(image); return G_SOURCE_CONTINUE; } + ++stats[SG_CAPTURE_STATS_FRAMES]; + stats[SG_CAPTURE_STATS_CAPTURE_US] += (uint32_t)(g_get_monotonic_time() - now); + if (image->bits_per_pixel == 32 && image->byte_order == LSBFirst && image->red_mask == 0xff0000 && image->green_mask == 0xff00 && image->blue_mask == 0xff && image->bytes_per_line == image->width * 4) { + screen_frame((uint8_t *)image->data, (uint32_t)image->width, (uint32_t)image->height); + } else { + uint8_t *pixels = malloc((size_t)image->width * image->height * 4); + if (pixels) { + unsigned shifts[3] = {0}, ranges[3]; + unsigned long masks[3] = {image->blue_mask, image->green_mask, image->red_mask}; + for (unsigned c = 0; c < 3; ++c) { + while (masks[c] && !(masks[c] & 1)) { ++shifts[c]; masks[c] >>= 1; } + ranges[c] = (unsigned)masks[c]; + } + for (int y = 0; y < image->height; ++y) for (int x = 0; x < image->width; ++x) { + unsigned long pixel = XGetPixel(image, x, y); + size_t offset = ((size_t)y * image->width + x) * 4; + for (unsigned c = 0; c < 3; ++c) pixels[offset + c] = ranges[c] ? (uint8_t)(((pixel >> shifts[c]) & ranges[c]) * 255 / ranges[c]) : 0; + pixels[offset + 3] = 0; + } + screen_frame(pixels, (uint32_t)image->width, (uint32_t)image->height); free(pixels); + } + } + if (temporary) XDestroyImage(image); + return G_SOURCE_CONTINUE; +} + +static void pw_cursor(struct spa_buffer *buffer) { + struct spa_meta *meta = spa_buffer_find_meta(buffer, SPA_META_Cursor); + if (!meta || meta->size < sizeof(struct spa_meta_cursor)) return; + struct spa_meta_cursor cursor; memcpy(&cursor, meta->data, sizeof(cursor)); + if (!cursor.id) return; + position(cursor.position.x, cursor.position.y, pointer[SG_POINTER_VISIBLE]); + if (!cursor.bitmap_offset) return; + if (cursor.bitmap_offset < sizeof(cursor) || cursor.bitmap_offset > meta->size - sizeof(struct spa_meta_bitmap)) return; + struct spa_meta_bitmap bitmap; memcpy(&bitmap, (uint8_t *)meta->data + cursor.bitmap_offset, sizeof(bitmap)); + if (!bitmap.format) return; + if (!bitmap.offset) { position(cursor.position.x, cursor.position.y, 0); return; } + uint32_t w = bitmap.size.width, h = bitmap.size.height; + size_t available = meta->size - cursor.bitmap_offset; + if (!w || !h || w > SG_MAX_CURSOR_SIZE || h > SG_MAX_CURSOR_SIZE || bitmap.stride < (int32_t)w * 4 || bitmap.offset < sizeof(bitmap) || bitmap.offset > available || (size_t)(h - 1) * bitmap.stride + w * 4 > available - bitmap.offset) { + position(cursor.position.x, cursor.position.y, 1); cursor_shape(0, 0, 0, 0, NULL); return; + } + unsigned offsets[4]; + switch (bitmap.format) { + case SPA_VIDEO_FORMAT_RGBA: offsets[0] = 0; offsets[1] = 1; offsets[2] = 2; offsets[3] = 3; break; + case SPA_VIDEO_FORMAT_BGRA: offsets[0] = 2; offsets[1] = 1; offsets[2] = 0; offsets[3] = 3; break; + case SPA_VIDEO_FORMAT_ARGB: offsets[0] = 1; offsets[1] = 2; offsets[2] = 3; offsets[3] = 0; break; + case SPA_VIDEO_FORMAT_ABGR: offsets[0] = 3; offsets[1] = 2; offsets[2] = 1; offsets[3] = 0; break; + default: position(cursor.position.x, cursor.position.y, 1); cursor_shape(0, 0, 0, 0, NULL); return; + } + uint8_t rgba[SG_MAX_CURSOR_SIZE * SG_MAX_CURSOR_SIZE * 4]; + const uint8_t *pixels = (uint8_t *)meta->data + cursor.bitmap_offset + bitmap.offset; + int visible = 0; + for (uint32_t y = 0; y < h; ++y) for (uint32_t x = 0; x < w; ++x) { + const uint8_t *src = pixels + (size_t)y * bitmap.stride + x * 4; + uint8_t *dst = rgba + ((size_t)y * w + x) * 4; + unsigned alpha = src[offsets[3]]; dst[3] = (uint8_t)alpha; visible |= alpha != 0; + for (unsigned c = 0; c < 3; ++c) { + unsigned value = alpha ? (src[offsets[c]] * 255u + alpha / 2) / alpha : 0; + dst[c] = (uint8_t)(value > 255 ? 255 : value); + } + } + position(cursor.position.x, cursor.position.y, visible); + cursor_shape(w, h, cursor.hotspot.x, cursor.hotspot.y, rgba); +} + +static void pw_process(void *unused) { + (void)unused; + struct pw_buffer *current = NULL, *next; + while ((next = pw_stream_dequeue_buffer(pw_stream))) { + /* Cursor updates can exist on a buffer whose video is superseded. */ + if (capturing && !suspended) pw_cursor(next->buffer); + if (current) pw_stream_queue_buffer(pw_stream, current); + current = next; + } + if (!current) return; + struct spa_buffer *buffer = current->buffer; + uint32_t w = video_info.size.width, h = video_info.size.height; + if (capturing && !suspended && buffer->n_datas && w && h && w <= SG_MAX_DIMENSION && h <= SG_MAX_DIMENSION && w * h <= SG_MAX_PIXELS) { + struct spa_data *data = &buffer->datas[0]; + struct spa_meta_videotransform *transform = spa_buffer_find_meta_data(buffer, SPA_META_VideoTransform, sizeof(*transform)); + if (transform && transform->transform != SPA_META_TRANSFORMATION_None) { + packet(SG_SUSPEND, NULL, 0, NULL, 0); initial = 1; + } else if (data->data && data->chunk && !(data->chunk->flags & SPA_CHUNK_FLAG_CORRUPTED)) { + int32_t stride = data->chunk->stride; + size_t offset = data->chunk->offset; + size_t length = stride > 0 ? (size_t)(h - 1) * stride + w * 4 : 0; + if (stride >= (int32_t)w * 4 && offset <= data->maxsize && length <= data->maxsize - offset && length <= data->chunk->size) { + gint64 start = g_get_monotonic_time(); + uint8_t *pixels = malloc((size_t)w * h * 4); + if (pixels) { + for (uint32_t y = 0; y < h; ++y) memcpy(pixels + (size_t)y * w * 4, (uint8_t *)data->data + offset + (size_t)y * stride, w * 4); + ++stats[SG_CAPTURE_STATS_FRAMES]; + stats[SG_CAPTURE_STATS_CAPTURE_US] += (uint32_t)(g_get_monotonic_time() - start); + screen_frame(pixels, w, h); free(pixels); + } + } + } + } + pw_stream_queue_buffer(pw_stream, current); +} + +static void pw_format(void *unused, uint32_t id, const struct spa_pod *param) { + (void)unused; + if (id != SPA_PARAM_Format || !param) return; + memset(&video_info, 0, sizeof(video_info)); + if (spa_format_video_raw_parse(param, &video_info) < 0 || video_info.format != SPA_VIDEO_FORMAT_BGRx) { close_channel(); return; } + uint8_t memory[1024]; struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(memory, sizeof(memory)); + const struct spa_pod *params[3]; + params[0] = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers, + SPA_PARAM_BUFFERS_buffers, SPA_POD_CHOICE_RANGE_Int(3, 2, 8), + SPA_PARAM_BUFFERS_dataType, SPA_POD_CHOICE_FLAGS_Int((1 << SPA_DATA_MemPtr) | (1 << SPA_DATA_MemFd))); + params[1] = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta, + SPA_PARAM_META_type, SPA_POD_Id(SPA_META_Cursor), + SPA_PARAM_META_size, SPA_POD_Int(sizeof(struct spa_meta_cursor) + sizeof(struct spa_meta_bitmap) + SG_MAX_CURSOR_SIZE * SG_MAX_CURSOR_SIZE * 4)); + params[2] = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta, + SPA_PARAM_META_type, SPA_POD_Id(SPA_META_VideoTransform), SPA_PARAM_META_size, SPA_POD_Int(sizeof(struct spa_meta_videotransform))); + pw_stream_update_params(pw_stream, params, 3); + initial = 1; +} + +static void capture_failed(void) { + if (!g_main_loop_is_running(main_loop)) return; + packet(SG_SUSPEND, NULL, 0, NULL, 0); + restart = 1; g_main_loop_quit(main_loop); +} + +static void pw_state(void *unused, enum pw_stream_state old, enum pw_stream_state state, const char *error) { + (void)unused; (void)old; + if (state == PW_STREAM_STATE_ERROR || state == PW_STREAM_STATE_UNCONNECTED) { + if (error) g_printerr("PipeWire: %s\n", error); + capture_failed(); + } +} + +static const struct pw_stream_events stream_events = { + PW_VERSION_STREAM_EVENTS, .state_changed = pw_state, .param_changed = pw_format, .process = pw_process, +}; + +static gboolean pw_dispatch(gint fd, GIOCondition condition, gpointer unused) { + (void)fd; (void)unused; + if ((condition & (G_IO_HUP | G_IO_ERR)) || pw_loop_iterate(pw_main_loop_get_loop(pw_loop), 0) < 0) { + capture_failed(); pw_watch = 0; return G_SOURCE_REMOVE; + } + return G_SOURCE_CONTINUE; +} + +static void portal_call(const char *method, GVariantBuilder *options); + +static void portal_closed(GDBusConnection *connection, const char *sender, const char *path, const char *interface, const char *signal, GVariant *parameters, gpointer unused) { + (void)connection; (void)sender; (void)path; (void)interface; (void)signal; (void)parameters; (void)unused; + if (pw_stream) pw_stream_set_active(pw_stream, false); + capture_failed(); + g_clear_pointer(&session_path, g_free); +} + +static void portal_response(GDBusConnection *connection, const char *sender, const char *path, const char *interface, const char *signal, GVariant *parameters, gpointer unused) { + (void)connection; (void)sender; (void)interface; (void)signal; (void)unused; + if (!request_path || strcmp(path, request_path)) return; + guint response; GVariant *results; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + g_clear_pointer(&request_path, g_free); + if (response) { g_printerr("ScreenCast request ended: %u\n", response); portal_step = -1; if (suspended) capture_failed(); g_variant_unref(results); return; } + GVariantBuilder options; g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + if (portal_step == 0) { + const char *session; + if (!g_variant_lookup(results, "session_handle", "&s", &session)) { capture_failed(); goto done; } + session_path = g_strdup(session); + session_subscription = g_dbus_connection_signal_subscribe(bus, "org.freedesktop.portal.Desktop", "org.freedesktop.portal.Session", "Closed", session_path, NULL, G_DBUS_SIGNAL_FLAGS_NONE, portal_closed, NULL, NULL); + g_variant_builder_add(&options, "{sv}", "types", g_variant_new_uint32(1)); + g_variant_builder_add(&options, "{sv}", "multiple", g_variant_new_boolean(false)); + g_variant_builder_add(&options, "{sv}", "cursor_mode", g_variant_new_uint32(4)); + g_variant_builder_add(&options, "{sv}", "persist_mode", g_variant_new_uint32(2)); + char *token = NULL; + if (g_file_get_contents(restore_path, &token, NULL, NULL) && *token) g_variant_builder_add(&options, "{sv}", "restore_token", g_variant_new_string(token)); + g_free(token); ++portal_step; + portal_call("SelectSources", &options); + } else if (portal_step == 1) { + ++portal_step; + portal_call("Start", &options); + } else { + GVariant *streams = g_variant_lookup_value(results, "streams", G_VARIANT_TYPE("a(ua{sv})")); + if (!streams || g_variant_n_children(streams) != 1) { if (streams) g_variant_unref(streams); capture_failed(); goto done; } + guint node; GVariant *properties; + g_variant_get_child(streams, 0, "(u@a{sv})", &node, &properties); + guint64 serial = 0; g_variant_lookup(properties, "pipewire-serial", "t", &serial); + char target[32]; g_snprintf(target, sizeof(target), "%" G_GUINT64_FORMAT, serial); + const char *token; + if (g_variant_lookup(results, "restore_token", "&s", &token)) { + char *directory = g_path_get_dirname(restore_path); + if (!g_mkdir_with_parents(directory, 0700)) { + int fd = open(restore_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0600); + if (fd >= 0) { if (write(fd, token, strlen(token)) < 0) g_printerr("Could not save ScreenCast token\n"); close(fd); } + } + g_free(directory); + } + GUnixFDList *fds = NULL; GError *error = NULL; + GVariant *reply = g_dbus_connection_call_with_unix_fd_list_sync(bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast", "OpenPipeWireRemote", + g_variant_new("(o@a{sv})", session_path, g_variant_new_array(G_VARIANT_TYPE("{sv}"), NULL, 0)), G_VARIANT_TYPE("(h)"), G_DBUS_CALL_FLAGS_NONE, 5000, NULL, &fds, NULL, &error); + if (reply) { + int index; g_variant_get(reply, "(h)", &index); + int fd = g_unix_fd_list_get(fds, index, &error); + if (fd >= 0) { + pw_loop = pw_main_loop_new(NULL); + if (pw_loop) pw_loop_enter(pw_main_loop_get_loop(pw_loop)); + pw_context = pw_loop ? pw_context_new(pw_main_loop_get_loop(pw_loop), NULL, 0) : NULL; + if (pw_context) pw_core = pw_context_connect_fd(pw_context, fd, NULL, 0); + else close(fd); + if (pw_core) { + struct pw_properties *settings = pw_properties_new(PW_KEY_MEDIA_TYPE, "Video", PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Screen", NULL); + if (serial) pw_properties_set(settings, PW_KEY_TARGET_OBJECT, target); + pw_stream = pw_stream_new(pw_core, "Snowglobe guest", settings); + } + if (pw_stream) { + pw_stream_add_listener(pw_stream, &stream_listener, &stream_events, NULL); + uint8_t memory[512]; struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(memory, sizeof(memory)); + const struct spa_pod *format = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat, + SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_video), SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw), + SPA_FORMAT_VIDEO_format, SPA_POD_Id(SPA_VIDEO_FORMAT_BGRx), + SPA_FORMAT_VIDEO_size, SPA_POD_CHOICE_RANGE_Rectangle(&SPA_RECTANGLE(1280, 720), &SPA_RECTANGLE(1, 1), &SPA_RECTANGLE(SG_MAX_DIMENSION, SG_MAX_DIMENSION)), + SPA_FORMAT_VIDEO_framerate, SPA_POD_CHOICE_RANGE_Fraction(&SPA_FRACTION(60, 1), &SPA_FRACTION(0, 1), &SPA_FRACTION(60, 1))); + if (pw_stream_connect(pw_stream, PW_DIRECTION_INPUT, serial ? PW_ID_ANY : node, PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_INACTIVE, &format, 1) >= 0) { + pw_watch = g_unix_fd_add(pw_loop_get_fd(pw_main_loop_get_loop(pw_loop)), G_IO_IN | G_IO_ERR | G_IO_HUP, pw_dispatch, NULL); + pw_stream_set_active(pw_stream, capturing && !suspended); + } + } + } + g_variant_unref(reply); g_object_unref(fds); + } + if (error) { g_printerr("ScreenCast remote: %s\n", error->message); g_error_free(error); } + g_variant_unref(properties); g_variant_unref(streams); + if (!pw_watch) capture_failed(); + } +done: + g_variant_unref(results); +} + +static void portal_called(GObject *object, GAsyncResult *result, gpointer unused) { + (void)unused; + GError *error = NULL; + GVariant *reply = g_dbus_connection_call_finish(G_DBUS_CONNECTION(object), result, &error); + if (reply) g_variant_unref(reply); + if (error) { g_printerr("ScreenCast: %s\n", error->message); g_error_free(error); g_clear_pointer(&request_path, g_free); capture_failed(); } +} + +static void portal_call(const char *method, GVariantBuilder *options) { + static unsigned sequence; + char token[64]; g_snprintf(token, sizeof(token), "snowglobe_%u_%u", (unsigned)getpid(), ++sequence); + const char *sender = g_dbus_connection_get_unique_name(bus); + char *name = g_strdup(sender + 1); + for (char *p = name; *p; ++p) if (*p == '.') *p = '_'; + request_path = g_strdup_printf("/org/freedesktop/portal/desktop/request/%s/%s", name, token); g_free(name); + g_variant_builder_add(options, "{sv}", "handle_token", g_variant_new_string(token)); + GVariant *args; + if (!portal_step) args = g_variant_new("(@a{sv})", g_variant_builder_end(options)); + else if (portal_step == 1) args = g_variant_new("(o@a{sv})", session_path, g_variant_builder_end(options)); + else args = g_variant_new("(os@a{sv})", session_path, "", g_variant_builder_end(options)); + g_dbus_connection_call(bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast", method, args, G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, 5000, NULL, portal_called, NULL); +} + +static void login_state(GVariant *properties) { + gboolean value; + if (g_variant_lookup(properties, "Active", "b", &value)) session_active = value; + if (g_variant_lookup(properties, "LockedHint", "b", &value)) session_locked = value; + int blocked = !session_active || session_locked; + if (blocked == suspended) return; + suspended = blocked; initial = 1; cursor_changed = 1; + if (blocked) { packet(SG_SUSPEND, NULL, 0, NULL, 0); close_channel(); } + else { capabilities(); replay_cursor(); } + if (pw_stream) pw_stream_set_active(pw_stream, capturing && !suspended); +} + +static void login_changed(GDBusConnection *connection, const char *sender, const char *path, const char *interface, const char *signal, GVariant *parameters, gpointer unused) { + (void)connection; (void)sender; (void)path; (void)interface; (void)signal; (void)unused; + const char *name; GVariant *properties, *invalid; + g_variant_get(parameters, "(&s@a{sv}@as)", &name, &properties, &invalid); + if (!strcmp(name, "org.freedesktop.login1.Session")) login_state(properties); + g_variant_unref(properties); g_variant_unref(invalid); +} + +static gboolean heartbeat(gpointer unused) { + (void)unused; + if (suspended) { stats_at = g_get_monotonic_time(); memset(stats, 0, sizeof(stats)); return G_SOURCE_CONTINUE; } + if (bus && !portal_step && !request_path && !session_path) { + GVariantBuilder options; g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + char token[64]; g_snprintf(token, sizeof(token), "snowglobe_session_%u", (unsigned)getpid()); + g_variant_builder_add(&options, "{sv}", "session_handle_token", g_variant_new_string(token)); + portal_call("CreateSession", &options); + } + if (out->len && g_get_monotonic_time() - queued_at >= 3000000) close_channel(); + if (channel < 0) { + channel = open(channel_path, O_RDWR | O_NONBLOCK | O_CLOEXEC); + if (channel >= 0) { + if (flock(channel, LOCK_EX | LOCK_NB)) { close(channel); channel = -1; return G_SOURCE_CONTINUE; } + read_watch = g_unix_fd_add(channel, G_IO_IN | G_IO_ERR | G_IO_HUP, read_requests, NULL); + capabilities(); + } + } + if (channel >= 0 && !stopping) { + gint64 now = g_get_monotonic_time(); + stats[SG_CAPTURE_STATS_ELAPSED_MS] = (uint32_t)((now - stats_at) / 1000); + packet(SG_CAPTURE_STATS, stats, SG_CAPTURE_STATS_WORDS, NULL, 0); + memset(stats, 0, sizeof(stats)); stats_at = now; + packet(SG_HEARTBEAT, NULL, 0, NULL, 0); + } else stats_at = g_get_monotonic_time(); + return G_SOURCE_CONTINUE; +} + +static gboolean quit(gpointer unused) { (void)unused; g_main_loop_quit(main_loop); return G_SOURCE_REMOVE; } + +int main(int argc, char **argv) { + if (argc > 2) return 2; + channel_path = argc == 2 ? argv[1] : "/dev/virtio-ports/" SG_CHANNEL_NAME; + signal(SIGPIPE, SIG_IGN); + pw_init(NULL, NULL); + main_loop = g_main_loop_new(NULL, false); + out = g_byte_array_new(); cursor_packet = g_byte_array_new(); stats_at = g_get_monotonic_time(); + pointer[SG_POINTER_VISIBLE] = 1; + if (getenv("WAYLAND_DISPLAY") || (getenv("XDG_SESSION_TYPE") && !strcmp(getenv("XDG_SESSION_TYPE"), "wayland"))) { + bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, NULL); + if (!bus) { restart = 1; goto cleanup; } + restore_path = g_build_filename(g_get_user_config_dir(), "snowglobe", "screencast-token", NULL); + GVariant *reply = g_dbus_connection_call_sync(bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.DBus.Properties", "Get", + g_variant_new("(ss)", "org.freedesktop.portal.ScreenCast", "AvailableCursorModes"), G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 5000, NULL, NULL); + if (!reply) { restart = 1; goto cleanup; } + guint modes = 0; + GVariant *value; g_variant_get(reply, "(v)", &value); if (g_variant_is_of_type(value, G_VARIANT_TYPE_UINT32)) modes = g_variant_get_uint32(value); g_variant_unref(value); g_variant_unref(reply); + if (!(modes & 4)) { g_printerr("ScreenCast cursor metadata is unavailable\n"); return 1; } + portal_subscription = g_dbus_connection_signal_subscribe(bus, "org.freedesktop.portal.Desktop", "org.freedesktop.portal.Request", "Response", NULL, NULL, G_DBUS_SIGNAL_FLAGS_NONE, portal_response, NULL, NULL); + } else { + display = XOpenDisplay(NULL); + if (!display || !XFixesQueryExtension(display, &cursor_event, &(int){0})) return 1; + int major = 2, minor = 0; + if (!XFixesQueryVersion(display, &major, &minor) || major < 2) return 1; + XSetErrorHandler(x_error_handler); root = DefaultRootWindow(display); + XFixesSelectCursorInput(display, root, XFixesDisplayCursorNotifyMask); + g_timeout_add(4, x_tick, NULL); + } + login_bus = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, NULL); + if (login_bus) { + GVariant *reply = g_dbus_connection_call_sync(login_bus, "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetUser", + g_variant_new("(u)", (guint)getuid()), G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL); + if (reply) { + const char *user_path; g_variant_get(reply, "(&o)", &user_path); + GVariant *property = g_dbus_connection_call_sync(login_bus, "org.freedesktop.login1", user_path, "org.freedesktop.DBus.Properties", "Get", + g_variant_new("(ss)", "org.freedesktop.login1.User", "Display"), G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL); + g_variant_unref(reply); reply = NULL; + if (property) { + GVariant *value; g_variant_get(property, "(v)", &value); + const char *id, *path; g_variant_get(value, "(&s&o)", &id, &path); + if (*id) reply = g_variant_ref_sink(g_variant_new("(o)", path)); + g_variant_unref(value); g_variant_unref(property); + } + } + if (reply) { + const char *path; g_variant_get(reply, "(&o)", &path); + g_dbus_connection_signal_subscribe(login_bus, "org.freedesktop.login1", "org.freedesktop.DBus.Properties", "PropertiesChanged", path, NULL, G_DBUS_SIGNAL_FLAGS_NONE, login_changed, NULL, NULL); + GVariant *property = g_dbus_connection_call_sync(login_bus, "org.freedesktop.login1", path, "org.freedesktop.DBus.Properties", "GetAll", + g_variant_new("(s)", "org.freedesktop.login1.Session"), G_VARIANT_TYPE("(a{sv})"), G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL); + g_variant_unref(reply); + if (property) { GVariant *properties; g_variant_get(property, "(@a{sv})", &properties); login_state(properties); g_variant_unref(properties); g_variant_unref(property); } + } + } + g_unix_signal_add(SIGTERM, quit, NULL); g_unix_signal_add(SIGINT, quit, NULL); + g_timeout_add(1000, heartbeat, NULL); heartbeat(NULL); + g_main_loop_run(main_loop); +cleanup: + close_channel(); + if (display) { free_ximage(); XCloseDisplay(display); } + if (pw_watch) g_source_remove(pw_watch); + if (pw_stream) pw_stream_destroy(pw_stream); + if (pw_core) pw_core_disconnect(pw_core); + if (pw_context) pw_context_destroy(pw_context); + if (pw_loop) { pw_loop_leave(pw_main_loop_get_loop(pw_loop)); pw_main_loop_destroy(pw_loop); } + if (bus) { + if (session_path) g_dbus_connection_call_sync(bus, "org.freedesktop.portal.Desktop", session_path, "org.freedesktop.portal.Session", "Close", NULL, NULL, G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL); + if (session_subscription) g_dbus_connection_signal_unsubscribe(bus, session_subscription); + if (portal_subscription) g_dbus_connection_signal_unsubscribe(bus, portal_subscription); + g_object_unref(bus); + } + if (login_bus) g_object_unref(login_bus); + g_free(session_path); g_free(request_path); g_free(restore_path); free(before); + g_byte_array_unref(out); g_byte_array_unref(cursor_packet); g_main_loop_unref(main_loop); pw_deinit(); + if (restart) { + signal(SIGTERM, SIG_DFL); signal(SIGINT, SIG_DFL); + g_usleep(5000000); + execvp(argv[0], argv); + return 1; + } + return 0; +} -- 2.54.0