Skip to content

Commit 4a4ce34

Browse files
committed
Add XCB MIT-SHM support, and factor out the XCB setup
This only adds the support for the XCB MIT-SHM extension to mss's internal xcb libraries. The actual usage of shared memory for screenshots will be done in a future commit.
1 parent 8575606 commit 4a4ce34

File tree

9 files changed

+846
-232
lines changed

9 files changed

+846
-232
lines changed

src/mss/linux/base.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any
4+
5+
from mss.base import MSSBase
6+
from mss.exception import ScreenShotError
7+
8+
from . import xcb
9+
from .xcb import LIB
10+
11+
if TYPE_CHECKING:
12+
from mss.screenshot import ScreenShot
13+
14+
SUPPORTED_DEPTHS = {24, 32}
15+
SUPPORTED_BITS_PER_PIXEL = 32
16+
SUPPORTED_RED_MASK = 0xFF0000
17+
SUPPORTED_GREEN_MASK = 0x00FF00
18+
SUPPORTED_BLUE_MASK = 0x0000FF
19+
ALL_PLANES = 0xFFFFFFFF # XCB doesn't define AllPlanes
20+
21+
22+
class MSSXCBBase(MSSBase):
23+
"""Base class for XCB-based screenshot implementations.
24+
25+
This class provides common XCB initialization and monitor detection logic
26+
that can be shared across different XCB screenshot methods (XGetImage,
27+
XShmGetImage, XComposite, etc.).
28+
"""
29+
30+
def __init__(self, /, **kwargs: Any) -> None: # noqa: PLR0912
31+
"""Initialize an XCB connection and validate the display configuration.
32+
33+
Args:
34+
**kwargs: Keyword arguments, including optional 'display' for X11 display string.
35+
36+
Raises:
37+
ScreenShotError: If the display configuration is not supported.
38+
"""
39+
super().__init__(**kwargs)
40+
41+
display = kwargs.get("display", b"")
42+
if not display:
43+
display = None
44+
45+
self.conn: xcb.Connection | None
46+
self.conn, pref_screen_num = xcb.connect(display)
47+
48+
# Get the connection setup information that was included when we connected.
49+
xcb_setup = xcb.get_setup(self.conn)
50+
screens = xcb.setup_roots(xcb_setup)
51+
pref_screen = screens[pref_screen_num]
52+
self.root = self.drawable = pref_screen.root
53+
54+
# We don't probe the XFixes presence or version until we need it.
55+
self._xfixes_ready: bool | None = None
56+
57+
# Probe the visuals (and related information), and make sure that our drawable is in an acceptable format.
58+
# These iterations and tests don't involve any traffic with the server; it's all stuff that was included in
59+
# the connection setup. Effectively all modern setups will be acceptable, but we verify to be sure.
60+
61+
# Currently, we assume that the drawable we're capturing is the root; when we add single-window capture,
62+
# we'll have to ask the server for its depth and visual.
63+
assert self.root == self.drawable # noqa: S101
64+
self.drawable_depth = pref_screen.root_depth
65+
self.drawable_visual_id = pref_screen.root_visual.value
66+
# Server image byte order
67+
if xcb_setup.image_byte_order != xcb.ImageOrder.LSBFirst:
68+
msg = "Only X11 servers using LSB-First images are supported."
69+
raise ScreenShotError(msg)
70+
# Depth
71+
if self.drawable_depth not in SUPPORTED_DEPTHS:
72+
msg = f"Only screens of color depth 24 or 32 are supported, not {self.drawable_depth}"
73+
raise ScreenShotError(msg)
74+
# Format (i.e., bpp, padding)
75+
for format_ in xcb.setup_pixmap_formats(xcb_setup):
76+
if format_.depth == self.drawable_depth:
77+
break
78+
else:
79+
msg = f"Internal error: drawable's depth {self.drawable_depth} not found in screen's supported formats"
80+
raise ScreenShotError(msg)
81+
drawable_format = format_
82+
if drawable_format.bits_per_pixel != SUPPORTED_BITS_PER_PIXEL:
83+
msg = (
84+
f"Only screens at 32 bpp (regardless of color depth) are supported; "
85+
f"got {drawable_format.bits_per_pixel} bpp"
86+
)
87+
raise ScreenShotError(msg)
88+
if drawable_format.scanline_pad != SUPPORTED_BITS_PER_PIXEL:
89+
# To clarify the padding: the scanline_pad is the multiple that the scanline gets padded to. If there
90+
# is no padding, then it will be the same as one pixel's size.
91+
msg = "Screens with scanline padding are not supported"
92+
raise ScreenShotError(msg)
93+
# Visual, the interpretation of pixels (like indexed, grayscale, etc). (Visuals are arranged by depth, so
94+
# we iterate over the depths first.)
95+
for xcb_depth in xcb.screen_allowed_depths(pref_screen):
96+
if xcb_depth.depth == self.drawable_depth:
97+
break
98+
else:
99+
msg = "Internal error: drawable's depth not found in screen's supported depths"
100+
raise ScreenShotError(msg)
101+
for visual_info in xcb.depth_visuals(xcb_depth):
102+
if visual_info.visual_id.value == self.drawable_visual_id:
103+
break
104+
else:
105+
msg = "Internal error: drawable's visual not found in screen's supported visuals"
106+
raise ScreenShotError(msg)
107+
if visual_info.class_ not in {xcb.VisualClass.TrueColor, xcb.VisualClass.DirectColor}:
108+
msg = "Only TrueColor and DirectColor visuals are supported"
109+
raise ScreenShotError(msg)
110+
if (
111+
visual_info.red_mask != SUPPORTED_RED_MASK
112+
or visual_info.green_mask != SUPPORTED_GREEN_MASK
113+
or visual_info.blue_mask != SUPPORTED_BLUE_MASK
114+
):
115+
# There are two ways to phrase this layout: BGRx accounts for the byte order, while xRGB implies the
116+
# native word order. Since we return the data as a byte array, we use the former. By the time we get
117+
# to this point, we've already checked the endianness and depth, so this is pretty much never going to
118+
# happen anyway.
119+
msg = "Only visuals with BGRx ordering are supported"
120+
raise ScreenShotError(msg)
121+
122+
def close(self) -> None:
123+
"""Close the XCB connection."""
124+
if self.conn is not None:
125+
xcb.disconnect(self.conn)
126+
self.conn = None
127+
128+
def _monitors_impl(self) -> None:
129+
"""Get positions of monitors. It will populate self._monitors."""
130+
if self.conn is None:
131+
msg = "Cannot identify monitors while the connection is closed"
132+
raise ScreenShotError(msg)
133+
134+
# The first entry is the whole X11 screen that the root is on. That's the one that covers all the
135+
# monitors.
136+
root_geom = xcb.get_geometry(self.conn, self.root)
137+
self._monitors.append(
138+
{
139+
"left": root_geom.x,
140+
"top": root_geom.y,
141+
"width": root_geom.width,
142+
"height": root_geom.height,
143+
}
144+
)
145+
146+
# After that, we have one for each monitor on that X11 screen. For decades, that's been handled by
147+
# Xrandr. We don't presently try to work with Xinerama. So, we're going to check the different outputs,
148+
# according to Xrandr. If that fails, we'll just leave the one root covering everything.
149+
150+
# Make sure we have the Xrandr extension we need. This will query the cache that we started populating in
151+
# __init__.
152+
randr_ext_data = xcb.get_extension_data(self.conn, LIB.randr_id)
153+
if not randr_ext_data.present:
154+
return
155+
156+
# We ask the server to give us anything up to the version we support (i.e., what we expect the reply
157+
# structs to look like). If the server only supports 1.2, then that's what it'll give us, and we're ok
158+
# with that, but we also use a faster path if the server implements at least 1.3.
159+
randr_version_data = xcb.randr_query_version(self.conn, xcb.RANDR_MAJOR_VERSION, xcb.RANDR_MINOR_VERSION)
160+
randr_version = (randr_version_data.major_version, randr_version_data.minor_version)
161+
if randr_version < (1, 2):
162+
return
163+
164+
screen_resources: xcb.RandrGetScreenResourcesReply | xcb.RandrGetScreenResourcesCurrentReply
165+
# Check to see if we have the xcb_randr_get_screen_resources_current function in libxcb-randr, and that
166+
# the server supports it.
167+
if hasattr(LIB.randr, "xcb_randr_get_screen_resources_current") and randr_version >= (1, 3):
168+
screen_resources = xcb.randr_get_screen_resources_current(self.conn, self.drawable.value)
169+
crtcs = xcb.randr_get_screen_resources_current_crtcs(screen_resources)
170+
else:
171+
# Either the client or the server doesn't support the _current form. That's ok; we'll use the old
172+
# function, which forces a new query to the physical monitors.
173+
screen_resources = xcb.randr_get_screen_resources(self.conn, self.drawable)
174+
crtcs = xcb.randr_get_screen_resources_crtcs(screen_resources)
175+
176+
for crtc in crtcs:
177+
crtc_info = xcb.randr_get_crtc_info(self.conn, crtc, screen_resources.config_timestamp)
178+
if crtc_info.num_outputs == 0:
179+
continue
180+
self._monitors.append(
181+
{"left": crtc_info.x, "top": crtc_info.y, "width": crtc_info.width, "height": crtc_info.height}
182+
)
183+
184+
# Extra credit would be to enumerate the virtual desktops; see
185+
# https://specifications.freedesktop.org/wm/latest/ar01s03.html. But I don't know how widely-used that
186+
# style is.
187+
188+
def _cursor_impl_check_xfixes(self) -> bool:
189+
if self.conn is None:
190+
msg = "Cannot take screenshot while the connection is closed"
191+
raise ScreenShotError(msg)
192+
193+
xfixes_ext_data = xcb.get_extension_data(self.conn, LIB.xfixes_id)
194+
if not xfixes_ext_data.present:
195+
return False
196+
197+
reply = xcb.xfixes_query_version(self.conn, xcb.XFIXES_MAJOR_VERSION, xcb.XFIXES_MINOR_VERSION)
198+
# We can work with 2.0 and later, but not sure about the actual minimum version we can use. That's ok;
199+
# everything these days is much more modern.
200+
return (reply.major_version, reply.minor_version) >= (2, 0)
201+
202+
def _cursor_impl(self) -> ScreenShot:
203+
"""Retrieve all cursor data. Pixels have to be RGBx."""
204+
205+
if self.conn is None:
206+
msg = "Cannot take screenshot while the connection is closed"
207+
raise ScreenShotError(msg)
208+
209+
if self._xfixes_ready is None:
210+
self._xfixes_ready = self._cursor_impl_check_xfixes()
211+
if not self._xfixes_ready:
212+
msg = "Server does not have XFixes, or the version is too old."
213+
raise ScreenShotError(msg)
214+
215+
cursor_img = xcb.xfixes_get_cursor_image(self.conn)
216+
region = {
217+
"left": cursor_img.x - cursor_img.xhot,
218+
"top": cursor_img.y - cursor_img.yhot,
219+
"width": cursor_img.width,
220+
"height": cursor_img.height,
221+
}
222+
223+
data_arr = xcb.xfixes_get_cursor_image_cursor_image(cursor_img)
224+
data = bytearray(data_arr)
225+
# We don't need to do the same array slice-and-dice work as the Xlib-based implementation: Xlib has an
226+
# unfortunate historical accident that makes it have to return the cursor image in a different format.
227+
228+
return self.cls_image(data, region)

src/mss/linux/xcb.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
from __future__ import annotations
22

3-
from ctypes import Structure, c_int, c_uint8, c_uint16, c_uint32
3+
from ctypes import Structure, _Pointer, c_int, c_uint8, c_uint16, c_uint32
44

55
from . import xcbgen
66

77
# We import these just so they're re-exported to our users.
8-
# ruff: noqa: F401
8+
# ruff: noqa: F401, TC001
99
from .xcbgen import (
1010
RANDR_MAJOR_VERSION,
1111
RANDR_MINOR_VERSION,
@@ -97,7 +97,7 @@
9797
)
9898

9999
# These are also here to re-export.
100-
from .xcbhelpers import LIB, Connection, XError
100+
from .xcbhelpers import LIB, XID, Connection, QueryExtensionReply, XcbExtension, XError
101101

102102
XCB_CONN_ERROR = 1
103103
XCB_CONN_CLOSED_EXT_NOTSUPPORTED = 2
@@ -120,6 +120,53 @@
120120
}
121121

122122

123+
#### High-level XCB function wrappers
124+
125+
126+
def get_extension_data(
127+
xcb_conn: Connection | _Pointer[Connection], ext: XcbExtension | _Pointer[XcbExtension]
128+
) -> QueryExtensionReply:
129+
"""Get extension data for the given extension.
130+
131+
Returns the extension data, which includes whether the extension is present
132+
and its opcode information.
133+
"""
134+
reply_p = LIB.xcb.xcb_get_extension_data(xcb_conn, ext)
135+
return reply_p.contents
136+
137+
138+
def prefetch_extension_data(
139+
xcb_conn: Connection | _Pointer[Connection], ext: XcbExtension | _Pointer[XcbExtension]
140+
) -> None:
141+
"""Prefetch extension data for the given extension.
142+
143+
This is a performance hint to XCB to fetch the extension data
144+
asynchronously.
145+
"""
146+
LIB.xcb.xcb_prefetch_extension_data(xcb_conn, ext)
147+
148+
149+
def generate_id(xcb_conn: Connection | _Pointer[Connection]) -> XID:
150+
"""Generate a new unique X resource ID.
151+
152+
Returns an XID that can be used to create new X resources.
153+
"""
154+
return LIB.xcb.xcb_generate_id(xcb_conn)
155+
156+
157+
def get_setup(xcb_conn: Connection | _Pointer[Connection]) -> Setup:
158+
"""Get the connection setup information.
159+
160+
Returns the setup structure containing information about the X server,
161+
including available screens, pixmap formats, etc.
162+
"""
163+
setup_p = LIB.xcb.xcb_get_setup(xcb_conn)
164+
return setup_p.contents
165+
166+
167+
# Connection management
168+
169+
123170
def initialize() -> None:
124171
LIB.initialize(callbacks=[xcbgen.initialize])
125172

@@ -145,6 +192,12 @@ def connect(display: str | bytes | None = None) -> tuple[Connection, int]:
145192
msg += f"error code {conn_err}"
146193
raise XError(msg)
147194

195+
# Prefetch extension data for all extensions we support to populate XCB's internal cache.
196+
prefetch_extension_data(conn_p, LIB.randr_id)
197+
prefetch_extension_data(conn_p, LIB.render_id)
198+
prefetch_extension_data(conn_p, LIB.shm_id)
199+
prefetch_extension_data(conn_p, LIB.xfixes_id)
200+
148201
return conn_p.contents, pref_screen_num.value
149202

150203

0 commit comments

Comments
 (0)