authorclover caruso <git@paperclover.net> 2026-10-06 23:11:12-07:00
committerclover caruso <git@paperclover.net> 2026-10-07 01:25:16-07:00
logf851910ec518002265217fc9cf84a45089dd89af
tree4f733677ee314731cee51986228155da065ad725
parent2f1eb7876ce087844fe59c21ca41f1eb139de473
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

7 files changed, 900 insertions(+), 4 deletions(-)

guest/DESIGN.md+12-2
......@@ -1,6 +1,6 @@
11# Guest display stream
22
3The 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.
3The 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.
44
55Build 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.
66
......@@ -8,6 +8,16 @@ Windows 10 build 19041 and later use Windows Graphics Capture for the primary mo
88
99`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.
1010
11## Linux cursor backend
12
13`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.
14
15The 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.
16
17X11 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.
18
19Desktop 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.
20
1121## Wire format
1222
1323`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
3545
3646## Later backends
3747
38Keep 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.
48Keep 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.
guest/build.py+17-2
......@@ -1,13 +1,16 @@
11#!/usr/bin/env python3
22import argparse
33import json
4import os
45from pathlib import Path
56import re
7import shlex
68import subprocess
79
810
911parser = argparse.ArgumentParser()
10parser.add_argument("--arch", choices=["x86", "x86_64", "aarch64"], default="x86_64")
12parser.add_argument("--platform", choices=["windows", "linux"], default="windows")
13parser.add_argument("--arch", choices=["x86", "x86_64", "aarch64"])
1114parser.add_argument("--output", type=Path, default=Path(__file__).parent / "build")
1215args = parser.parse_args()
1316source = Path(__file__).resolve().parent
......@@ -21,8 +24,20 @@ for name, packet in schema["packets"].items():
2124 header += [f"#define SG_{name.upper()}_{field.upper()} {index}u" for index, field in enumerate(packet["fields"])]
2225header += [f"#define SG_WINDOW_WORDS {len(schema['window_fields'])}u"]
2326header += [f"#define SG_WINDOW_{field.upper()} {index}u" for index, field in enumerate(schema["window_fields"])]
24header += [f'#define SG_MAGIC "{schema["magic"]}"', f'#define SG_CHANNEL L"{schema["channel"]}"', "#endif"]
27header += [f'#define SG_MAGIC "{schema["magic"]}"', f'#define SG_CHANNEL_NAME "{schema["channel"]}"', f'#define SG_CHANNEL L"{schema["channel"]}"', "#endif"]
2528(args.output / "protocol.h").write_text("\n".join(header) + "\n")
29if args.platform == "linux":
30 if args.arch:
31 parser.error("Linux builds use the native compiler; omit --arch.")
32 packages = ["gio-unix-2.0", "libpipewire-0.3", "x11", "xfixes", "xext"]
33 flags = shlex.split(subprocess.check_output(["pkg-config", "--cflags", "--libs", *packages], text=True))
34 subprocess.run([
35 *shlex.split(os.environ.get("CC", "cc")), "-std=gnu99", "-O2", "-Wall", "-Wextra", "-Werror",
36 "-I", str(args.output), str(source / "linux.c"), str(source / "cursor.c"),
37 *flags, "-o", str(args.output / "snowglobe-guest"),
38 ], check=True)
39 raise SystemExit(0)
40args.arch = args.arch or "x86_64"
2641subsystem = "10.0" if args.arch == "aarch64" else "6.1"
2742environment = subprocess.check_output(["zig", "env"], text=True)
2843match = re.search(r'\.lib_dir\s*=\s*"([^"]+)"', environment)
guest/cursor.c+2
......@@ -54,6 +54,7 @@ int sg_rgba(const uint8_t *black, const uint8_t *white, uint8_t *rgba, uint8_t *
5454 return 1;
5555}
5656
57#if !__STDC_HOSTED__
5758void *memcpy(void *destination, const void *source, size_t length) {
5859 uint8_t *out = destination;
5960 const uint8_t *in = source;
......@@ -68,3 +69,4 @@ void *memset(void *destination, int value, size_t length) {
6869 for (i = 0; i < length; ++i) out[i] = (uint8_t)value;
6970 return destination;
7071}
72#endif
guest/install.sh created+37
......@@ -0,0 +1,37 @@
1#!/bin/sh
2set -eu
3if [ "$(id -u)" != 0 ]; then
4 printf '%s\n' 'Run this installer with sudo to install the guest agent.' >&2
5 exit 1
6fi
7if [ "${1-}" = --remove ]; then
8 rm -f /etc/xdg/autostart/snowglobe-guest.desktop /etc/udev/rules.d/70-snowglobe-guest.rules /usr/local/bin/snowglobe-guest
9 udevadm control --reload-rules
10 udevadm trigger --subsystem-match=virtio-ports
11 udevadm settle
12 port=$(python3 -c 'import json, sys; print("/dev/virtio-ports/" + json.load(open(sys.argv[1]))["channel"])' "$(dirname "$0")/protocol.json")
13 if [ -e "$port" ]; then chmod 0600 "$port"; fi
14 exit 0
15fi
16if [ "$#" != 1 ] || [ ! -f "$1" ]; then
17 printf '%s\n' 'Pass the path to the Linux guest executable.' >&2
18 exit 1
19fi
20install -D -m 0755 "$1" /usr/local/bin/snowglobe-guest
21mkdir -p /etc/udev/rules.d /etc/xdg/autostart
22python3 - "$(dirname "$0")/protocol.json" > /etc/udev/rules.d/70-snowglobe-guest.rules <<'PYTHON'
23import json, sys
24channel = json.load(open(sys.argv[1]))['channel']
25print('SUBSYSTEM=="virtio-ports", ATTR{name}==' + json.dumps(channel) + ', TAG+="uaccess"')
26PYTHON
27cat > /etc/xdg/autostart/snowglobe-guest.desktop <<'DESKTOP'
28[Desktop Entry]
29Type=Application
30Name=Snowglobe Guest
31Exec=/usr/local/bin/snowglobe-guest
32NoDisplay=true
33DESKTOP
34udevadm control --reload-rules
35udevadm trigger --subsystem-match=virtio-ports
36udevadm settle
37printf '%s\n' 'Installed. Sign out and back in to start the guest agent.'
guest/linux-test.py created+88
......@@ -0,0 +1,88 @@
1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import pty
6import select
7import shlex
8import struct
9import subprocess
10import tempfile
11import time
12import tty
13
14source = Path(__file__).resolve().parent
15subprocess.run(['python3', str(source / 'build.py'), '--platform', 'linux'], check=True)
16schema = json.loads((source / 'protocol.json').read_text())
17ids = {key: value['id'] for key, value in schema['packets'].items()}
18flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'gio-unix-2.0', 'libpipewire-0.3', 'x11', 'xfixes', 'xext'], text=True))
19with tempfile.TemporaryDirectory() as temporary:
20 binary = Path(temporary) / 'linux-tests'
21 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)
22 subprocess.run([str(binary)], check=True)
23 fixture = Path(temporary) / 'fixture.c'
24 fixture.write_text('''#include <X11/Xlib.h>
25#include <X11/cursorfont.h>
26#include <unistd.h>
27int main(void) {
28Display *d = XOpenDisplay(0); if (!d) return 1;
29Window root = DefaultRootWindow(d);
30XSetWindowBackground(d, root, 0x314159); XClearWindow(d, root);
31XDefineCursor(d, root, XCreateFontCursor(d, XC_left_ptr));
32XWarpPointer(d, None, root, 0, 0, 0, 0, 40, 40); XSync(d, 0);
33sleep(2); XDefineCursor(d, root, XCreateFontCursor(d, XC_xterm)); XSync(d, 0);
34sleep(10); XCloseDisplay(d); return 0;
35}''')
36 helper = Path(temporary) / 'fixture'
37 subprocess.run(['cc', str(fixture), '-lX11', '-o', str(helper)], check=True)
38 xvfb = subprocess.Popen(['Xvfb', '-displayfd', '1', '-screen', '0', '160x100x24', '-nolisten', 'tcp'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
39 display = ':' + xvfb.stdout.readline().strip()
40 env = {key: value for key, value in os.environ.items() if key not in ('WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS')}
41 env['DISPLAY'] = display
42 fixture_process = subprocess.Popen([str(helper)], env=env)
43 master, slave = pty.openpty(); tty.setraw(slave)
44 agent = subprocess.Popen([str(source / 'build/snowglobe-guest'), os.ttyname(slave)], env=env, stderr=subprocess.PIPE)
45 pending = bytearray()
46 def send(kind, data=b''):
47 os.write(master, struct.pack('!4sII', schema['magic'].encode(), ids[kind], len(data)) + data)
48 def packet(timeout=5):
49 deadline = time.monotonic() + timeout
50 while True:
51 if len(pending) >= 12:
52 magic, kind, length = struct.unpack('!4sII', pending[:12])
53 assert magic == schema['magic'].encode()
54 if len(pending) >= 12 + length:
55 payload = bytes(pending[12:12 + length]); del pending[:12 + length]
56 return kind, payload
57 remaining = deadline - time.monotonic()
58 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''))
59 pending.extend(os.read(master, 65536))
60 try:
61 kind, payload = packet(); assert kind == ids['capabilities'] and struct.unpack('!II', payload) == (0, 1)
62 send('stream', struct.pack('!I', 1)); send('refresh')
63 cursors = set(); first_frame = None
64 deadline = time.monotonic() + 6
65 while time.monotonic() < deadline and (len(cursors) < 2 or first_frame is None):
66 kind, payload = packet()
67 if kind == ids['cursor']:
68 cursor_id, w, h, hot_x, hot_y, frames = struct.unpack('!6I', payload[:24])
69 assert w and h and hot_x < w and hot_y < h and frames == 1
70 cursors.add(payload[28:28 + w * h * 4])
71 if kind == ids['screen']:
72 w, h, x, y, rw, rh = struct.unpack('!6I', payload[:24]); assert (w, h, x, y, rw, rh) == (160, 100, 0, 0, 160, 100)
73 first_frame = payload[24:]
74 assert all(first_frame[i:i + 3] == bytes.fromhex('594131') for i in range(0, len(first_frame), 4)), 'Cursor leaked into screen pixels'
75 assert kind not in (ids['windows'], ids['surface'])
76 assert len(cursors) >= 2 and first_frame is not None
77 send('stop')
78 while packet()[0] != ids['stop']: pass
79 send('refresh'); send('stream', struct.pack('!I', 1))
80 seen = set()
81 deadline = time.monotonic() + 5
82 while time.monotonic() < deadline and not {ids['cursor'], ids['screen']} <= seen: seen.add(packet()[0])
83 assert {ids['cursor'], ids['screen']} <= seen
84 finally:
85 for process in (agent, fixture_process, xvfb):
86 process.terminate(); process.wait(timeout=5)
87 os.close(master); os.close(slave)
88print('Linux cursor metadata bounds, alpha, dirty frames, fragmented stop, X11 shape changes, cursor-free video, and reconnect passed')
guest/linux-tests.c created+64
......@@ -0,0 +1,64 @@
1#define main guest_main
2#include "linux.c"
3#undef main
4#include <assert.h>
5#include <sys/socket.h>
6
7static 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]; }
8
9int main(void) {
10 out = g_byte_array_new(); cursor_packet = g_byte_array_new();
11 int sockets[2]; assert(!socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sockets));
12 channel = sockets[0];
13 uint8_t metadata[sizeof(struct spa_meta_cursor) + sizeof(struct spa_meta_bitmap) + 8] = {0};
14 struct spa_meta_cursor *cursor = (void *)metadata;
15 struct spa_meta_bitmap *bitmap = (void *)(metadata + sizeof(*cursor));
16 cursor->id = 7; cursor->position.x = -17; cursor->position.y = 25;
17 cursor->bitmap_offset = sizeof(*cursor); cursor->hotspot.x = 1;
18 bitmap->format = SPA_VIDEO_FORMAT_BGRA; bitmap->size = SPA_RECTANGLE(2, 1);
19 bitmap->stride = 8; bitmap->offset = sizeof(*bitmap);
20 uint8_t *pixels = metadata + sizeof(*cursor) + sizeof(*bitmap);
21 memcpy(pixels, (uint8_t[]){16, 32, 64, 128, 0, 0, 0, 0}, 8);
22 struct spa_meta meta = {SPA_META_Cursor, sizeof(metadata), metadata};
23 struct spa_buffer buffer = {.n_metas = 1, .metas = &meta};
24 pw_cursor(&buffer);
25 assert(cursor_packet->len == SG_CURSOR_WORDS * 4 + 4 + 10);
26 const uint8_t *rgba = cursor_packet->data + SG_CURSOR_WORDS * 4 + 4;
27 assert(rgba[0] == 128 && rgba[1] == 64 && rgba[2] == 32 && rgba[3] == 128);
28 assert(!rgba[4] && !rgba[5] && !rgba[6] && !rgba[7]);
29 assert(word(cursor_packet->data + SG_CURSOR_HOT_X * 4) == 1);
30 assert((int32_t)pointer[SG_POINTER_X] == -17 && pointer[SG_POINTER_VISIBLE]);
31 size_t length = cursor_packet->len;
32 cursor->bitmap_offset = UINT32_MAX;
33 pw_cursor(&buffer); assert(cursor_packet->len == length);
34 cursor->bitmap_offset = sizeof(*cursor) + 1; pw_cursor(&buffer);
35 cursor->bitmap_offset = sizeof(*cursor); bitmap->stride = -8;
36 pw_cursor(&buffer); assert(cursor_packet->len == SG_CURSOR_WORDS * 4);
37 bitmap->stride = 8; bitmap->offset = UINT32_MAX;
38 pw_cursor(&buffer); assert(cursor_packet->len == SG_CURSOR_WORDS * 4);
39 bitmap->offset = sizeof(*bitmap); cursor->hotspot.x = 2;
40 pw_cursor(&buffer); assert(cursor_packet->len == SG_CURSOR_WORDS * 4);
41 cursor->hotspot.x = 1; bitmap->offset = sizeof(*bitmap); pw_cursor(&buffer);
42 assert(pointer[SG_POINTER_VISIBLE]);
43 cursor->id = 0; pw_cursor(&buffer); assert(pointer[SG_POINTER_VISIBLE]);
44 cursor->id = 7; bitmap->offset = 0; pw_cursor(&buffer); assert(!pointer[SG_POINTER_VISIBLE]);
45 cursor->bitmap_offset = 0; pw_cursor(&buffer); assert(!pointer[SG_POINTER_VISIBLE]);
46 cursor->bitmap_offset = sizeof(*cursor); bitmap->format = 0; pw_cursor(&buffer); assert(!pointer[SG_POINTER_VISIBLE]);
47 bitmap->format = SPA_VIDEO_FORMAT_BGRA; bitmap->offset = sizeof(*bitmap); pw_cursor(&buffer); assert(pointer[SG_POINTER_VISIBLE]);
48 uint8_t drain[4096]; while (read(sockets[1], drain, sizeof(drain)) > 0) {}
49 g_byte_array_set_size(cursor_packet, 0);
50 capturing = 1; uint8_t screen[16] = {0, 0, 255, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0};
51 screen_frame(screen, 2, 2);
52 ssize_t count = read(sockets[1], drain, sizeof(drain));
53 assert(count == 12 + SG_SCREEN_WORDS * 4 + 16 && word(drain + 4) == SG_SCREEN);
54 screen_frame(screen, 2, 2); assert(read(sockets[1], drain, sizeof(drain)) < 0 && errno == EAGAIN);
55 screen[4] = 128; screen_frame(screen, 2, 2);
56 count = read(sockets[1], drain, sizeof(drain));
57 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);
58 uint8_t stop[12] = SG_MAGIC; sg_word(stop + 4, SG_STOP); sg_word(stop + 8, 0);
59 assert(write(sockets[1], stop, 3) == 3); assert(read_requests(channel, G_IO_IN, NULL) == G_SOURCE_CONTINUE);
60 assert(write(sockets[1], stop + 3, 9) == 9); assert(read_requests(channel, G_IO_IN, NULL) == G_SOURCE_REMOVE);
61 count = read(sockets[1], drain, sizeof(drain)); assert(count == 12 && word(drain + 4) == SG_STOP && channel == -1);
62 close(sockets[1]); free(before); g_byte_array_unref(out); g_byte_array_unref(cursor_packet);
63 return 0;
64}
guest/linux.c created+680
......@@ -0,0 +1,680 @@
1#define _GNU_SOURCE
2#include <errno.h>
3#include <fcntl.h>
4#include <signal.h>
5#include <stdlib.h>
6#include <string.h>
7#include <sys/file.h>
8#include <sys/ipc.h>
9#include <sys/shm.h>
10#include <unistd.h>
11#include <gio/gunixfdlist.h>
12#include <glib-unix.h>
13#include <X11/Xlib.h>
14#include <X11/Xutil.h>
15#include <X11/extensions/Xfixes.h>
16#include <X11/extensions/XShm.h>
17#include <pipewire/pipewire.h>
18#include <spa/param/video/format-utils.h>
19#include <spa/buffer/meta.h>
20#include "cursor.h"
21
22static GMainLoop *main_loop;
23static int channel = -1, capturing, initial = 1, stopping, suspended;
24static guint read_watch, write_watch;
25static GByteArray *out, *cursor_packet;
26static size_t sent;
27static gint64 queued_at;
28static uint8_t request[16];
29static size_t received, request_size = 12;
30static uint8_t *before;
31static uint32_t width, height, cursor_id, pointer[SG_POINTER_WORDS];
32static const char *channel_path;
33static uint32_t stats[SG_CAPTURE_STATS_WORDS];
34static gint64 stats_at;
35static Display *display;
36static Window root;
37static XImage *ximage;
38static XShmSegmentInfo shared = {.shmid = -1};
39static int shm_attached, x_error, cursor_event, cursor_changed = 1;
40static unsigned long cursor_serial;
41static gint64 next_capture;
42static GDBusConnection *bus, *login_bus;
43static char *session_path, *request_path;
44static int portal_step, session_active = 1, session_locked, restart;
45static struct pw_main_loop *pw_loop;
46static struct pw_context *pw_context;
47static struct pw_core *pw_core;
48static struct pw_stream *pw_stream;
49static struct spa_hook stream_listener;
50static struct spa_video_info_raw video_info;
51static char *restore_path;
52static guint portal_subscription, session_subscription, pw_watch;
53
54static void close_channel(void) {
55 if (read_watch) { g_source_remove(read_watch); read_watch = 0; }
56 if (write_watch) { g_source_remove(write_watch); write_watch = 0; }
57 if (channel >= 0) close(channel);
58 channel = -1; capturing = 0; stopping = 0; initial = 1;
59 received = 0; request_size = 12; sent = 0;
60 g_byte_array_set_size(out, 0);
61 if (pw_stream) pw_stream_set_active(pw_stream, false);
62}
63
64static gboolean flush_output(gint fd, GIOCondition condition, gpointer unused) {
65 (void)fd; (void)unused;
66 write_watch = 0;
67 if (condition & (G_IO_HUP | G_IO_ERR)) { close_channel(); return G_SOURCE_REMOVE; }
68 while (sent < out->len) {
69 size_t amount = out->len - sent;
70 if (amount > 65536) amount = 65536;
71 ssize_t count = write(channel, out->data + sent, amount);
72 if (count < 0 && errno == EINTR) continue;
73 if (count < 0 && errno == EAGAIN) {
74 write_watch = g_unix_fd_add(channel, G_IO_OUT | G_IO_HUP | G_IO_ERR, flush_output, NULL);
75 return G_SOURCE_REMOVE;
76 }
77 if (count <= 0) { close_channel(); return G_SOURCE_REMOVE; }
78 sent += (size_t)count;
79 }
80 g_byte_array_set_size(out, 0); sent = 0;
81 if (stopping) close_channel();
82 return G_SOURCE_REMOVE;
83}
84
85static int packet(uint32_t type, const uint32_t *words, uint32_t count, const uint8_t *data, size_t length) {
86 if (channel < 0 || stopping) return 0;
87 size_t payload = count * 4 + length;
88 if (payload > SG_MAX_PACKET_BYTES || out->len - sent + payload + 12 > SG_MAX_PACKET_BYTES + 131072u) {
89 close_channel(); return 0;
90 }
91 uint8_t header[12 + SG_SCREEN_WORDS * 4];
92 memcpy(header, SG_MAGIC, 4); sg_word(header + 4, type); sg_word(header + 8, (uint32_t)payload);
93 for (uint32_t i = 0; i < count; ++i) sg_word(header + 12 + i * 4, words[i]);
94 if (sent) { g_byte_array_remove_range(out, 0, (guint)sent); sent = 0; }
95 if (!out->len) queued_at = g_get_monotonic_time();
96 g_byte_array_append(out, header, 12 + count * 4);
97 if (length) g_byte_array_append(out, data, (guint)length);
98 if (!write_watch) flush_output(channel, 0, NULL);
99 return channel >= 0;
100}
101
102static void capabilities(void) {
103 const uint32_t words[SG_CAPABILITIES_WORDS] = {0, 1};
104 packet(SG_CAPABILITIES, words, SG_CAPABILITIES_WORDS, NULL, 0);
105}
106
107static void replay_cursor(void) {
108 if (cursor_packet->len) {
109 uint32_t words[SG_CURSOR_WORDS];
110 for (uint32_t i = 0; i < SG_CURSOR_WORDS; ++i) {
111 const uint8_t *p = cursor_packet->data + i * 4;
112 words[i] = ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3];
113 }
114 packet(SG_CURSOR, words, SG_CURSOR_WORDS, cursor_packet->data + sizeof(words), cursor_packet->len - sizeof(words));
115 packet(SG_POINTER, pointer, SG_POINTER_WORDS, NULL, 0);
116 }
117}
118
119static void cursor_shape(uint32_t w, uint32_t h, int hot_x, int hot_y, const uint8_t *rgba) {
120 uint32_t words[SG_CURSOR_WORDS] = {0};
121 words[SG_CURSOR_ID] = ++cursor_id;
122 size_t pixels = (size_t)w * h;
123 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) {
124 words[SG_CURSOR_WIDTH] = w; words[SG_CURSOR_HEIGHT] = h;
125 words[SG_CURSOR_HOT_X] = (uint32_t)hot_x; words[SG_CURSOR_HOT_Y] = (uint32_t)hot_y; words[SG_CURSOR_FRAMES] = 1;
126 } else pixels = 0;
127 g_byte_array_set_size(cursor_packet, sizeof(words) + (pixels ? 4 + pixels * 5 : 0));
128 for (uint32_t i = 0; i < SG_CURSOR_WORDS; ++i) sg_word(cursor_packet->data + i * 4, words[i]);
129 if (pixels) {
130 sg_word(cursor_packet->data + sizeof(words), 1000);
131 memcpy(cursor_packet->data + sizeof(words) + 4, rgba, pixels * 4);
132 memset(cursor_packet->data + sizeof(words) + 4 + pixels * 4, 0, pixels);
133 }
134 pointer[SG_POINTER_ID] = cursor_id;
135 replay_cursor();
136}
137
138static void position(int x, int y, int visible) {
139 uint32_t words[SG_POINTER_WORDS] = {cursor_id, (uint32_t)x, (uint32_t)y, (uint32_t)visible};
140 if (memcmp(pointer, words, sizeof(words))) {
141 memcpy(pointer, words, sizeof(words));
142 packet(SG_POINTER, words, SG_POINTER_WORDS, NULL, 0);
143 }
144}
145
146static void screen_frame(const uint8_t *pixels, uint32_t w, uint32_t h) {
147 if (!w || !h || w > SG_MAX_DIMENSION || h > SG_MAX_DIMENSION || w * h > SG_MAX_PIXELS) return;
148 if (out->len - sent > 65536 || channel < 0 || !capturing || suspended) return;
149 gint64 start = g_get_monotonic_time();
150 if (w != width || h != height) {
151 free(before); before = malloc((size_t)w * h * 4);
152 if (!before) { close_channel(); return; }
153 width = w; height = h; initial = 1;
154 }
155 struct sg_rect rect = {0, 0, w, h};
156 if (!initial && !sg_difference(before, pixels, w, h, &rect)) return;
157 stats[SG_CAPTURE_STATS_SCAN_US] += (uint32_t)(g_get_monotonic_time() - start);
158 start = g_get_monotonic_time();
159 uint32_t words[SG_SCREEN_WORDS] = {w, h, rect.x, rect.y, rect.width, rect.height};
160 size_t length = (size_t)rect.width * rect.height * 4;
161 uint8_t *packed = malloc(length);
162 if (!packed) { close_channel(); return; }
163 for (uint32_t y = 0; y < rect.height; ++y) {
164 size_t offset = ((size_t)(rect.y + y) * w + rect.x) * 4;
165 memcpy(packed + (size_t)y * rect.width * 4, pixels + offset, rect.width * 4);
166 memcpy(before + offset, pixels + offset, rect.width * 4);
167 }
168 if (initial) replay_cursor();
169 if (packet(SG_SCREEN, words, SG_SCREEN_WORDS, packed, length)) initial = 0;
170 free(packed);
171 stats[SG_CAPTURE_STATS_WRITE_US] += (uint32_t)(g_get_monotonic_time() - start);
172}
173
174static gboolean read_requests(gint fd, GIOCondition condition, gpointer unused) {
175 (void)unused;
176 if (condition & (G_IO_HUP | G_IO_ERR)) { read_watch = 0; close_channel(); return G_SOURCE_REMOVE; }
177 for (;;) {
178 ssize_t count = read(fd, request + received, request_size - received);
179 if (count < 0 && errno == EINTR) continue;
180 if (count < 0 && errno == EAGAIN) return G_SOURCE_CONTINUE;
181 if (count <= 0) break;
182 received += (size_t)count;
183 if (received < request_size) continue;
184 if (request_size == 12) {
185 if (memcmp(request, SG_MAGIC, 4) || request[4] || request[5] || request[6] || request[8] || request[9] || request[10]) break;
186 if ((request[7] == SG_STREAM || request[7] == SG_EXPERIMENT) && request[11] == 4) { request_size = 16; continue; }
187 if (request[11]) break;
188 if (request[7] == SG_STOP) {
189 read_watch = 0;
190 capturing = 0;
191 if (pw_stream) pw_stream_set_active(pw_stream, false);
192 packet(SG_STOP, NULL, 0, NULL, 0);
193 stopping = 1;
194 if (!out->len) close_channel();
195 return G_SOURCE_REMOVE;
196 }
197 if (request[7] != SG_REFRESH) break;
198 initial = 1; cursor_changed = 1; capabilities(); replay_cursor();
199 } else {
200 if (request[12] || request[13] || request[14] || request[15] > 1) break;
201 if (request[7] == SG_STREAM) {
202 int enabled = request[15];
203 if (capturing != enabled) {
204 capturing = enabled; initial = 1; cursor_changed = 1;
205 if (enabled) replay_cursor();
206 if (pw_stream) pw_stream_set_active(pw_stream, capturing && !suspended);
207 }
208 }
209 }
210 received = 0; request_size = 12;
211 if (channel < 0) { read_watch = 0; return G_SOURCE_REMOVE; }
212 }
213 read_watch = 0; close_channel(); return G_SOURCE_REMOVE;
214}
215
216static int x_error_handler(Display *d, XErrorEvent *e) { (void)d; (void)e; x_error = 1; return 0; }
217
218static void free_ximage(void) {
219 if (shm_attached) { XShmDetach(display, &shared); XSync(display, False); shm_attached = 0; }
220 if (ximage) {
221 if (shared.shmid >= 0) ximage->data = NULL;
222 XDestroyImage(ximage); ximage = NULL;
223 }
224 if (shared.shmid >= 0) { shmdt(shared.shmaddr); shmctl(shared.shmid, IPC_RMID, NULL); shared.shmid = -1; }
225}
226
227static void x_cursor(void) {
228 XFixesCursorImage *image = XFixesGetCursorImage(display);
229 if (!image) return;
230 if (cursor_changed || image->cursor_serial != cursor_serial) {
231 uint8_t rgba[SG_MAX_CURSOR_SIZE * SG_MAX_CURSOR_SIZE * 4];
232 uint32_t w = image->width, h = image->height;
233 int visible = 0;
234 if (w <= SG_MAX_CURSOR_SIZE && h <= SG_MAX_CURSOR_SIZE) {
235 for (size_t i = 0; i < (size_t)w * h; ++i) {
236 uint32_t pixel = (uint32_t)image->pixels[i];
237 unsigned alpha = pixel >> 24;
238 visible |= alpha != 0;
239 rgba[i * 4 + 3] = (uint8_t)alpha;
240 for (unsigned c = 0; c < 3; ++c) {
241 unsigned value = (pixel >> (16 - c * 8)) & 255;
242 value = alpha ? (value * 255 + alpha / 2) / alpha : 0;
243 rgba[i * 4 + c] = (uint8_t)(value > 255 ? 255 : value);
244 }
245 }
246 position(image->x, image->y, visible);
247 cursor_shape(w, h, image->xhot, image->yhot, rgba);
248 } else { position(image->x, image->y, 1); cursor_shape(0, 0, 0, 0, NULL); }
249 cursor_serial = image->cursor_serial; cursor_changed = 0;
250 } else position(image->x, image->y, pointer[SG_POINTER_VISIBLE]);
251 XFree(image);
252}
253
254static gboolean x_tick(gpointer unused) {
255 (void)unused;
256 while (XPending(display)) {
257 XEvent event; XNextEvent(display, &event);
258 if (event.type == cursor_event + XFixesCursorNotify) cursor_changed = 1;
259 }
260 if (!capturing || suspended || channel < 0 || stopping) return G_SOURCE_CONTINUE;
261 x_cursor();
262 gint64 now = g_get_monotonic_time();
263 if (now < next_capture || out->len - sent > 65536) return G_SOURCE_CONTINUE;
264 next_capture = now + 16667;
265 XWindowAttributes attr;
266 x_error = 0;
267 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;
268 if (ximage && (ximage->width != attr.width || ximage->height != attr.height)) free_ximage();
269 if (!ximage && XShmQueryExtension(display)) {
270 ximage = XShmCreateImage(display, attr.visual, (unsigned)attr.depth, ZPixmap, NULL, &shared, (unsigned)attr.width, (unsigned)attr.height);
271 if (ximage) {
272 shared.shmid = shmget(IPC_PRIVATE, (size_t)ximage->bytes_per_line * ximage->height, IPC_CREAT | 0600);
273 if (shared.shmid >= 0) {
274 shared.shmaddr = shmat(shared.shmid, NULL, 0); shared.readOnly = False;
275 if (shared.shmaddr != (void *)-1) {
276 ximage->data = shared.shmaddr;
277 shm_attached = XShmAttach(display, &shared); XSync(display, False);
278 if (x_error) shm_attached = 0;
279 shmctl(shared.shmid, IPC_RMID, NULL);
280 }
281 }
282 if (!shm_attached) free_ximage();
283 }
284 }
285 int temporary = !ximage;
286 XImage *image = ximage;
287 if (image) { if (!XShmGetImage(display, root, image, 0, 0, AllPlanes)) return G_SOURCE_CONTINUE; }
288 else image = XGetImage(display, root, 0, 0, (unsigned)attr.width, (unsigned)attr.height, AllPlanes, ZPixmap);
289 if (!image || x_error) { if (temporary && image) XDestroyImage(image); return G_SOURCE_CONTINUE; }
290 ++stats[SG_CAPTURE_STATS_FRAMES];
291 stats[SG_CAPTURE_STATS_CAPTURE_US] += (uint32_t)(g_get_monotonic_time() - now);
292 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) {
293 screen_frame((uint8_t *)image->data, (uint32_t)image->width, (uint32_t)image->height);
294 } else {
295 uint8_t *pixels = malloc((size_t)image->width * image->height * 4);
296 if (pixels) {
297 unsigned shifts[3] = {0}, ranges[3];
298 unsigned long masks[3] = {image->blue_mask, image->green_mask, image->red_mask};
299 for (unsigned c = 0; c < 3; ++c) {
300 while (masks[c] && !(masks[c] & 1)) { ++shifts[c]; masks[c] >>= 1; }
301 ranges[c] = (unsigned)masks[c];
302 }
303 for (int y = 0; y < image->height; ++y) for (int x = 0; x < image->width; ++x) {
304 unsigned long pixel = XGetPixel(image, x, y);
305 size_t offset = ((size_t)y * image->width + x) * 4;
306 for (unsigned c = 0; c < 3; ++c) pixels[offset + c] = ranges[c] ? (uint8_t)(((pixel >> shifts[c]) & ranges[c]) * 255 / ranges[c]) : 0;
307 pixels[offset + 3] = 0;
308 }
309 screen_frame(pixels, (uint32_t)image->width, (uint32_t)image->height); free(pixels);
310 }
311 }
312 if (temporary) XDestroyImage(image);
313 return G_SOURCE_CONTINUE;
314}
315
316static void pw_cursor(struct spa_buffer *buffer) {
317 struct spa_meta *meta = spa_buffer_find_meta(buffer, SPA_META_Cursor);
318 if (!meta || meta->size < sizeof(struct spa_meta_cursor)) return;
319 struct spa_meta_cursor cursor; memcpy(&cursor, meta->data, sizeof(cursor));
320 if (!cursor.id) return;
321 position(cursor.position.x, cursor.position.y, pointer[SG_POINTER_VISIBLE]);
322 if (!cursor.bitmap_offset) return;
323 if (cursor.bitmap_offset < sizeof(cursor) || cursor.bitmap_offset > meta->size - sizeof(struct spa_meta_bitmap)) return;
324 struct spa_meta_bitmap bitmap; memcpy(&bitmap, (uint8_t *)meta->data + cursor.bitmap_offset, sizeof(bitmap));
325 if (!bitmap.format) return;
326 if (!bitmap.offset) { position(cursor.position.x, cursor.position.y, 0); return; }
327 uint32_t w = bitmap.size.width, h = bitmap.size.height;
328 size_t available = meta->size - cursor.bitmap_offset;
329 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) {
330 position(cursor.position.x, cursor.position.y, 1); cursor_shape(0, 0, 0, 0, NULL); return;
331 }
332 unsigned offsets[4];
333 switch (bitmap.format) {
334 case SPA_VIDEO_FORMAT_RGBA: offsets[0] = 0; offsets[1] = 1; offsets[2] = 2; offsets[3] = 3; break;
335 case SPA_VIDEO_FORMAT_BGRA: offsets[0] = 2; offsets[1] = 1; offsets[2] = 0; offsets[3] = 3; break;
336 case SPA_VIDEO_FORMAT_ARGB: offsets[0] = 1; offsets[1] = 2; offsets[2] = 3; offsets[3] = 0; break;
337 case SPA_VIDEO_FORMAT_ABGR: offsets[0] = 3; offsets[1] = 2; offsets[2] = 1; offsets[3] = 0; break;
338 default: position(cursor.position.x, cursor.position.y, 1); cursor_shape(0, 0, 0, 0, NULL); return;
339 }
340 uint8_t rgba[SG_MAX_CURSOR_SIZE * SG_MAX_CURSOR_SIZE * 4];
341 const uint8_t *pixels = (uint8_t *)meta->data + cursor.bitmap_offset + bitmap.offset;
342 int visible = 0;
343 for (uint32_t y = 0; y < h; ++y) for (uint32_t x = 0; x < w; ++x) {
344 const uint8_t *src = pixels + (size_t)y * bitmap.stride + x * 4;
345 uint8_t *dst = rgba + ((size_t)y * w + x) * 4;
346 unsigned alpha = src[offsets[3]]; dst[3] = (uint8_t)alpha; visible |= alpha != 0;
347 for (unsigned c = 0; c < 3; ++c) {
348 unsigned value = alpha ? (src[offsets[c]] * 255u + alpha / 2) / alpha : 0;
349 dst[c] = (uint8_t)(value > 255 ? 255 : value);
350 }
351 }
352 position(cursor.position.x, cursor.position.y, visible);
353 cursor_shape(w, h, cursor.hotspot.x, cursor.hotspot.y, rgba);
354}
355
356static void pw_process(void *unused) {
357 (void)unused;
358 struct pw_buffer *current = NULL, *next;
359 while ((next = pw_stream_dequeue_buffer(pw_stream))) {
360 /* Cursor updates can exist on a buffer whose video is superseded. */
361 if (capturing && !suspended) pw_cursor(next->buffer);
362 if (current) pw_stream_queue_buffer(pw_stream, current);
363 current = next;
364 }
365 if (!current) return;
366 struct spa_buffer *buffer = current->buffer;
367 uint32_t w = video_info.size.width, h = video_info.size.height;
368 if (capturing && !suspended && buffer->n_datas && w && h && w <= SG_MAX_DIMENSION && h <= SG_MAX_DIMENSION && w * h <= SG_MAX_PIXELS) {
369 struct spa_data *data = &buffer->datas[0];
370 struct spa_meta_videotransform *transform = spa_buffer_find_meta_data(buffer, SPA_META_VideoTransform, sizeof(*transform));
371 if (transform && transform->transform != SPA_META_TRANSFORMATION_None) {
372 packet(SG_SUSPEND, NULL, 0, NULL, 0); initial = 1;
373 } else if (data->data && data->chunk && !(data->chunk->flags & SPA_CHUNK_FLAG_CORRUPTED)) {
374 int32_t stride = data->chunk->stride;
375 size_t offset = data->chunk->offset;
376 size_t length = stride > 0 ? (size_t)(h - 1) * stride + w * 4 : 0;
377 if (stride >= (int32_t)w * 4 && offset <= data->maxsize && length <= data->maxsize - offset && length <= data->chunk->size) {
378 gint64 start = g_get_monotonic_time();
379 uint8_t *pixels = malloc((size_t)w * h * 4);
380 if (pixels) {
381 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);
382 ++stats[SG_CAPTURE_STATS_FRAMES];
383 stats[SG_CAPTURE_STATS_CAPTURE_US] += (uint32_t)(g_get_monotonic_time() - start);
384 screen_frame(pixels, w, h); free(pixels);
385 }
386 }
387 }
388 }
389 pw_stream_queue_buffer(pw_stream, current);
390}
391
392static void pw_format(void *unused, uint32_t id, const struct spa_pod *param) {
393 (void)unused;
394 if (id != SPA_PARAM_Format || !param) return;
395 memset(&video_info, 0, sizeof(video_info));
396 if (spa_format_video_raw_parse(param, &video_info) < 0 || video_info.format != SPA_VIDEO_FORMAT_BGRx) { close_channel(); return; }
397 uint8_t memory[1024]; struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(memory, sizeof(memory));
398 const struct spa_pod *params[3];
399 params[0] = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_ParamBuffers, SPA_PARAM_Buffers,
400 SPA_PARAM_BUFFERS_buffers, SPA_POD_CHOICE_RANGE_Int(3, 2, 8),
401 SPA_PARAM_BUFFERS_dataType, SPA_POD_CHOICE_FLAGS_Int((1 << SPA_DATA_MemPtr) | (1 << SPA_DATA_MemFd)));
402 params[1] = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta,
403 SPA_PARAM_META_type, SPA_POD_Id(SPA_META_Cursor),
404 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));
405 params[2] = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta,
406 SPA_PARAM_META_type, SPA_POD_Id(SPA_META_VideoTransform), SPA_PARAM_META_size, SPA_POD_Int(sizeof(struct spa_meta_videotransform)));
407 pw_stream_update_params(pw_stream, params, 3);
408 initial = 1;
409}
410
411static void capture_failed(void) {
412 if (!g_main_loop_is_running(main_loop)) return;
413 packet(SG_SUSPEND, NULL, 0, NULL, 0);
414 restart = 1; g_main_loop_quit(main_loop);
415}
416
417static void pw_state(void *unused, enum pw_stream_state old, enum pw_stream_state state, const char *error) {
418 (void)unused; (void)old;
419 if (state == PW_STREAM_STATE_ERROR || state == PW_STREAM_STATE_UNCONNECTED) {
420 if (error) g_printerr("PipeWire: %s\n", error);
421 capture_failed();
422 }
423}
424
425static const struct pw_stream_events stream_events = {
426 PW_VERSION_STREAM_EVENTS, .state_changed = pw_state, .param_changed = pw_format, .process = pw_process,
427};
428
429static gboolean pw_dispatch(gint fd, GIOCondition condition, gpointer unused) {
430 (void)fd; (void)unused;
431 if ((condition & (G_IO_HUP | G_IO_ERR)) || pw_loop_iterate(pw_main_loop_get_loop(pw_loop), 0) < 0) {
432 capture_failed(); pw_watch = 0; return G_SOURCE_REMOVE;
433 }
434 return G_SOURCE_CONTINUE;
435}
436
437static void portal_call(const char *method, GVariantBuilder *options);
438
439static void portal_closed(GDBusConnection *connection, const char *sender, const char *path, const char *interface, const char *signal, GVariant *parameters, gpointer unused) {
440 (void)connection; (void)sender; (void)path; (void)interface; (void)signal; (void)parameters; (void)unused;
441 if (pw_stream) pw_stream_set_active(pw_stream, false);
442 capture_failed();
443 g_clear_pointer(&session_path, g_free);
444}
445
446static void portal_response(GDBusConnection *connection, const char *sender, const char *path, const char *interface, const char *signal, GVariant *parameters, gpointer unused) {
447 (void)connection; (void)sender; (void)interface; (void)signal; (void)unused;
448 if (!request_path || strcmp(path, request_path)) return;
449 guint response; GVariant *results;
450 g_variant_get(parameters, "(u@a{sv})", &response, &results);
451 g_clear_pointer(&request_path, g_free);
452 if (response) { g_printerr("ScreenCast request ended: %u\n", response); portal_step = -1; if (suspended) capture_failed(); g_variant_unref(results); return; }
453 GVariantBuilder options; g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT);
454 if (portal_step == 0) {
455 const char *session;
456 if (!g_variant_lookup(results, "session_handle", "&s", &session)) { capture_failed(); goto done; }
457 session_path = g_strdup(session);
458 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);
459 g_variant_builder_add(&options, "{sv}", "types", g_variant_new_uint32(1));
460 g_variant_builder_add(&options, "{sv}", "multiple", g_variant_new_boolean(false));
461 g_variant_builder_add(&options, "{sv}", "cursor_mode", g_variant_new_uint32(4));
462 g_variant_builder_add(&options, "{sv}", "persist_mode", g_variant_new_uint32(2));
463 char *token = NULL;
464 if (g_file_get_contents(restore_path, &token, NULL, NULL) && *token) g_variant_builder_add(&options, "{sv}", "restore_token", g_variant_new_string(token));
465 g_free(token); ++portal_step;
466 portal_call("SelectSources", &options);
467 } else if (portal_step == 1) {
468 ++portal_step;
469 portal_call("Start", &options);
470 } else {
471 GVariant *streams = g_variant_lookup_value(results, "streams", G_VARIANT_TYPE("a(ua{sv})"));
472 if (!streams || g_variant_n_children(streams) != 1) { if (streams) g_variant_unref(streams); capture_failed(); goto done; }
473 guint node; GVariant *properties;
474 g_variant_get_child(streams, 0, "(u@a{sv})", &node, &properties);
475 guint64 serial = 0; g_variant_lookup(properties, "pipewire-serial", "t", &serial);
476 char target[32]; g_snprintf(target, sizeof(target), "%" G_GUINT64_FORMAT, serial);
477 const char *token;
478 if (g_variant_lookup(results, "restore_token", "&s", &token)) {
479 char *directory = g_path_get_dirname(restore_path);
480 if (!g_mkdir_with_parents(directory, 0700)) {
481 int fd = open(restore_path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0600);
482 if (fd >= 0) { if (write(fd, token, strlen(token)) < 0) g_printerr("Could not save ScreenCast token\n"); close(fd); }
483 }
484 g_free(directory);
485 }
486 GUnixFDList *fds = NULL; GError *error = NULL;
487 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",
488 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);
489 if (reply) {
490 int index; g_variant_get(reply, "(h)", &index);
491 int fd = g_unix_fd_list_get(fds, index, &error);
492 if (fd >= 0) {
493 pw_loop = pw_main_loop_new(NULL);
494 if (pw_loop) pw_loop_enter(pw_main_loop_get_loop(pw_loop));
495 pw_context = pw_loop ? pw_context_new(pw_main_loop_get_loop(pw_loop), NULL, 0) : NULL;
496 if (pw_context) pw_core = pw_context_connect_fd(pw_context, fd, NULL, 0);
497 else close(fd);
498 if (pw_core) {
499 struct pw_properties *settings = pw_properties_new(PW_KEY_MEDIA_TYPE, "Video", PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Screen", NULL);
500 if (serial) pw_properties_set(settings, PW_KEY_TARGET_OBJECT, target);
501 pw_stream = pw_stream_new(pw_core, "Snowglobe guest", settings);
502 }
503 if (pw_stream) {
504 pw_stream_add_listener(pw_stream, &stream_listener, &stream_events, NULL);
505 uint8_t memory[512]; struct spa_pod_builder builder = SPA_POD_BUILDER_INIT(memory, sizeof(memory));
506 const struct spa_pod *format = spa_pod_builder_add_object(&builder, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
507 SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_video), SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw),
508 SPA_FORMAT_VIDEO_format, SPA_POD_Id(SPA_VIDEO_FORMAT_BGRx),
509 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)),
510 SPA_FORMAT_VIDEO_framerate, SPA_POD_CHOICE_RANGE_Fraction(&SPA_FRACTION(60, 1), &SPA_FRACTION(0, 1), &SPA_FRACTION(60, 1)));
511 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) {
512 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);
513 pw_stream_set_active(pw_stream, capturing && !suspended);
514 }
515 }
516 }
517 g_variant_unref(reply); g_object_unref(fds);
518 }
519 if (error) { g_printerr("ScreenCast remote: %s\n", error->message); g_error_free(error); }
520 g_variant_unref(properties); g_variant_unref(streams);
521 if (!pw_watch) capture_failed();
522 }
523done:
524 g_variant_unref(results);
525}
526
527static void portal_called(GObject *object, GAsyncResult *result, gpointer unused) {
528 (void)unused;
529 GError *error = NULL;
530 GVariant *reply = g_dbus_connection_call_finish(G_DBUS_CONNECTION(object), result, &error);
531 if (reply) g_variant_unref(reply);
532 if (error) { g_printerr("ScreenCast: %s\n", error->message); g_error_free(error); g_clear_pointer(&request_path, g_free); capture_failed(); }
533}
534
535static void portal_call(const char *method, GVariantBuilder *options) {
536 static unsigned sequence;
537 char token[64]; g_snprintf(token, sizeof(token), "snowglobe_%u_%u", (unsigned)getpid(), ++sequence);
538 const char *sender = g_dbus_connection_get_unique_name(bus);
539 char *name = g_strdup(sender + 1);
540 for (char *p = name; *p; ++p) if (*p == '.') *p = '_';
541 request_path = g_strdup_printf("/org/freedesktop/portal/desktop/request/%s/%s", name, token); g_free(name);
542 g_variant_builder_add(options, "{sv}", "handle_token", g_variant_new_string(token));
543 GVariant *args;
544 if (!portal_step) args = g_variant_new("(@a{sv})", g_variant_builder_end(options));
545 else if (portal_step == 1) args = g_variant_new("(o@a{sv})", session_path, g_variant_builder_end(options));
546 else args = g_variant_new("(os@a{sv})", session_path, "", g_variant_builder_end(options));
547 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);
548}
549
550static void login_state(GVariant *properties) {
551 gboolean value;
552 if (g_variant_lookup(properties, "Active", "b", &value)) session_active = value;
553 if (g_variant_lookup(properties, "LockedHint", "b", &value)) session_locked = value;
554 int blocked = !session_active || session_locked;
555 if (blocked == suspended) return;
556 suspended = blocked; initial = 1; cursor_changed = 1;
557 if (blocked) { packet(SG_SUSPEND, NULL, 0, NULL, 0); close_channel(); }
558 else { capabilities(); replay_cursor(); }
559 if (pw_stream) pw_stream_set_active(pw_stream, capturing && !suspended);
560}
561
562static void login_changed(GDBusConnection *connection, const char *sender, const char *path, const char *interface, const char *signal, GVariant *parameters, gpointer unused) {
563 (void)connection; (void)sender; (void)path; (void)interface; (void)signal; (void)unused;
564 const char *name; GVariant *properties, *invalid;
565 g_variant_get(parameters, "(&s@a{sv}@as)", &name, &properties, &invalid);
566 if (!strcmp(name, "org.freedesktop.login1.Session")) login_state(properties);
567 g_variant_unref(properties); g_variant_unref(invalid);
568}
569
570static gboolean heartbeat(gpointer unused) {
571 (void)unused;
572 if (suspended) { stats_at = g_get_monotonic_time(); memset(stats, 0, sizeof(stats)); return G_SOURCE_CONTINUE; }
573 if (bus && !portal_step && !request_path && !session_path) {
574 GVariantBuilder options; g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT);
575 char token[64]; g_snprintf(token, sizeof(token), "snowglobe_session_%u", (unsigned)getpid());
576 g_variant_builder_add(&options, "{sv}", "session_handle_token", g_variant_new_string(token));
577 portal_call("CreateSession", &options);
578 }
579 if (out->len && g_get_monotonic_time() - queued_at >= 3000000) close_channel();
580 if (channel < 0) {
581 channel = open(channel_path, O_RDWR | O_NONBLOCK | O_CLOEXEC);
582 if (channel >= 0) {
583 if (flock(channel, LOCK_EX | LOCK_NB)) { close(channel); channel = -1; return G_SOURCE_CONTINUE; }
584 read_watch = g_unix_fd_add(channel, G_IO_IN | G_IO_ERR | G_IO_HUP, read_requests, NULL);
585 capabilities();
586 }
587 }
588 if (channel >= 0 && !stopping) {
589 gint64 now = g_get_monotonic_time();
590 stats[SG_CAPTURE_STATS_ELAPSED_MS] = (uint32_t)((now - stats_at) / 1000);
591 packet(SG_CAPTURE_STATS, stats, SG_CAPTURE_STATS_WORDS, NULL, 0);
592 memset(stats, 0, sizeof(stats)); stats_at = now;
593 packet(SG_HEARTBEAT, NULL, 0, NULL, 0);
594 } else stats_at = g_get_monotonic_time();
595 return G_SOURCE_CONTINUE;
596}
597
598static gboolean quit(gpointer unused) { (void)unused; g_main_loop_quit(main_loop); return G_SOURCE_REMOVE; }
599
600int main(int argc, char **argv) {
601 if (argc > 2) return 2;
602 channel_path = argc == 2 ? argv[1] : "/dev/virtio-ports/" SG_CHANNEL_NAME;
603 signal(SIGPIPE, SIG_IGN);
604 pw_init(NULL, NULL);
605 main_loop = g_main_loop_new(NULL, false);
606 out = g_byte_array_new(); cursor_packet = g_byte_array_new(); stats_at = g_get_monotonic_time();
607 pointer[SG_POINTER_VISIBLE] = 1;
608 if (getenv("WAYLAND_DISPLAY") || (getenv("XDG_SESSION_TYPE") && !strcmp(getenv("XDG_SESSION_TYPE"), "wayland"))) {
609 bus = g_bus_get_sync(G_BUS_TYPE_SESSION, NULL, NULL);
610 if (!bus) { restart = 1; goto cleanup; }
611 restore_path = g_build_filename(g_get_user_config_dir(), "snowglobe", "screencast-token", NULL);
612 GVariant *reply = g_dbus_connection_call_sync(bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.DBus.Properties", "Get",
613 g_variant_new("(ss)", "org.freedesktop.portal.ScreenCast", "AvailableCursorModes"), G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 5000, NULL, NULL);
614 if (!reply) { restart = 1; goto cleanup; }
615 guint modes = 0;
616 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);
617 if (!(modes & 4)) { g_printerr("ScreenCast cursor metadata is unavailable\n"); return 1; }
618 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);
619 } else {
620 display = XOpenDisplay(NULL);
621 if (!display || !XFixesQueryExtension(display, &cursor_event, &(int){0})) return 1;
622 int major = 2, minor = 0;
623 if (!XFixesQueryVersion(display, &major, &minor) || major < 2) return 1;
624 XSetErrorHandler(x_error_handler); root = DefaultRootWindow(display);
625 XFixesSelectCursorInput(display, root, XFixesDisplayCursorNotifyMask);
626 g_timeout_add(4, x_tick, NULL);
627 }
628 login_bus = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, NULL);
629 if (login_bus) {
630 GVariant *reply = g_dbus_connection_call_sync(login_bus, "org.freedesktop.login1", "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "GetUser",
631 g_variant_new("(u)", (guint)getuid()), G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL);
632 if (reply) {
633 const char *user_path; g_variant_get(reply, "(&o)", &user_path);
634 GVariant *property = g_dbus_connection_call_sync(login_bus, "org.freedesktop.login1", user_path, "org.freedesktop.DBus.Properties", "Get",
635 g_variant_new("(ss)", "org.freedesktop.login1.User", "Display"), G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL);
636 g_variant_unref(reply); reply = NULL;
637 if (property) {
638 GVariant *value; g_variant_get(property, "(v)", &value);
639 const char *id, *path; g_variant_get(value, "(&s&o)", &id, &path);
640 if (*id) reply = g_variant_ref_sink(g_variant_new("(o)", path));
641 g_variant_unref(value); g_variant_unref(property);
642 }
643 }
644 if (reply) {
645 const char *path; g_variant_get(reply, "(&o)", &path);
646 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);
647 GVariant *property = g_dbus_connection_call_sync(login_bus, "org.freedesktop.login1", path, "org.freedesktop.DBus.Properties", "GetAll",
648 g_variant_new("(s)", "org.freedesktop.login1.Session"), G_VARIANT_TYPE("(a{sv})"), G_DBUS_CALL_FLAGS_NONE, 2000, NULL, NULL);
649 g_variant_unref(reply);
650 if (property) { GVariant *properties; g_variant_get(property, "(@a{sv})", &properties); login_state(properties); g_variant_unref(properties); g_variant_unref(property); }
651 }
652 }
653 g_unix_signal_add(SIGTERM, quit, NULL); g_unix_signal_add(SIGINT, quit, NULL);
654 g_timeout_add(1000, heartbeat, NULL); heartbeat(NULL);
655 g_main_loop_run(main_loop);
656cleanup:
657 close_channel();
658 if (display) { free_ximage(); XCloseDisplay(display); }
659 if (pw_watch) g_source_remove(pw_watch);
660 if (pw_stream) pw_stream_destroy(pw_stream);
661 if (pw_core) pw_core_disconnect(pw_core);
662 if (pw_context) pw_context_destroy(pw_context);
663 if (pw_loop) { pw_loop_leave(pw_main_loop_get_loop(pw_loop)); pw_main_loop_destroy(pw_loop); }
664 if (bus) {
665 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);
666 if (session_subscription) g_dbus_connection_signal_unsubscribe(bus, session_subscription);
667 if (portal_subscription) g_dbus_connection_signal_unsubscribe(bus, portal_subscription);
668 g_object_unref(bus);
669 }
670 if (login_bus) g_object_unref(login_bus);
671 g_free(session_path); g_free(request_path); g_free(restore_path); free(before);
672 g_byte_array_unref(out); g_byte_array_unref(cursor_packet); g_main_loop_unref(main_loop); pw_deinit();
673 if (restart) {
674 signal(SIGTERM, SIG_DFL); signal(SIGINT, SIG_DFL);
675 g_usleep(5000000);
676 execvp(argv[0], argv);
677 return 1;
678 }
679 return 0;
680}