Last active
December 7, 2025 20:29
-
-
Save Recursing/813aee5bfa27b521a720d7c1eba3cb03 to your computer and use it in GitHub Desktop.
Take a screenshot on GNOME on Wayland, there must be a better way but it works. Written by Claude Opus 4.5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.10" | |
| # dependencies = ["PyGObject", "pillow"] | |
| # /// | |
| """ | |
| Take a screenshot on Wayland using xdg-desktop-portal. | |
| Usage: | |
| uv run take_screenshot.py -f output.png | |
| The first run will prompt for screen selection. Subsequent runs reuse | |
| the saved permission token (stored in ~/.cache/portal-screenshot/). | |
| """ | |
| import os | |
| os.environ["DESKTOP_FILE_ID"] = "my-screenshot-tool" | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| from PIL import Image | |
| import gi | |
| gi.require_version("Gst", "1.0") | |
| from gi.repository import Gio, GLib, Gst # noqa: E402 | |
| Gst.init(None) | |
| PORTAL = "org.freedesktop.portal.Desktop" | |
| PORTAL_PATH = "/org/freedesktop/portal/desktop" | |
| SCREENCAST = "org.freedesktop.portal.ScreenCast" | |
| REQUEST = "org.freedesktop.portal.Request" | |
| TOKEN_PATH = Path.home() / ".cache" / "portal-screenshot" / "token.json" | |
| def load_token(): | |
| try: | |
| return ( | |
| json.loads(TOKEN_PATH.read_text()).get("restore_token") | |
| if TOKEN_PATH.exists() | |
| else None | |
| ) | |
| except (json.JSONDecodeError, OSError): | |
| return None | |
| def save_token(token): | |
| try: | |
| TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| TOKEN_PATH.write_text(json.dumps({"restore_token": token})) | |
| except OSError: | |
| pass | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Take a screenshot on Wayland") | |
| parser.add_argument( | |
| "-f", | |
| "--file", | |
| type=Path, | |
| default=Path("screenshot.png"), | |
| help="Output file path (default: screenshot.png)", | |
| ) | |
| args = parser.parse_args() | |
| output_path = args.file | |
| bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) | |
| loop = GLib.MainLoop() | |
| state = {"session": None, "token": load_token()} | |
| def call(method, signature, call_args, callback, options=None): | |
| token = f"tok_{GLib.get_monotonic_time()}" | |
| sender = bus.get_unique_name().replace(".", "_").lstrip(":") | |
| bus.signal_subscribe( | |
| PORTAL, | |
| REQUEST, | |
| "Response", | |
| f"{PORTAL_PATH}/request/{sender}/{token}", | |
| None, | |
| 0, | |
| callback, | |
| ) | |
| opts = {"handle_token": GLib.Variant("s", token)} | |
| if method == "CreateSession": | |
| opts["session_handle_token"] = GLib.Variant( | |
| "s", f"sess_{GLib.get_monotonic_time()}" | |
| ) | |
| if options: | |
| opts.update(options) | |
| bus.call_sync( | |
| PORTAL, | |
| PORTAL_PATH, | |
| SCREENCAST, | |
| method, | |
| GLib.Variant(signature, (*call_args, opts)), | |
| None, | |
| 0, | |
| -1, | |
| None, | |
| ) | |
| def on_session(*callback_args): | |
| response, results = callback_args[-1].unpack() | |
| if response != 0: | |
| print("Failed to create session", file=sys.stderr) | |
| return loop.quit() | |
| state["session"] = results["session_handle"] | |
| options = { | |
| "types": GLib.Variant("u", 3), # Monitor | Window | |
| "multiple": GLib.Variant("b", False), | |
| "persist_mode": GLib.Variant("u", 2), | |
| } | |
| if state["token"]: | |
| options["restore_token"] = GLib.Variant("s", state["token"]) | |
| call("SelectSources", "(oa{sv})", (state["session"],), on_sources, options) | |
| def on_sources(*callback_args): | |
| if callback_args[-1].unpack()[0] != 0: | |
| print("Source selection failed or was cancelled", file=sys.stderr) | |
| return loop.quit() | |
| call("Start", "(osa{sv})", (state["session"], ""), on_start) | |
| def on_start(*callback_args): | |
| response, results = callback_args[-1].unpack() | |
| if response != 0 or not results.get("streams"): | |
| print("Failed to start stream", file=sys.stderr) | |
| return loop.quit() | |
| if token := results.get("restore_token"): | |
| save_token(token) | |
| node = results["streams"][0][0] | |
| fd_result = bus.call_with_unix_fd_list_sync( | |
| PORTAL, | |
| PORTAL_PATH, | |
| SCREENCAST, | |
| "OpenPipeWireRemote", | |
| GLib.Variant("(oa{sv})", (state["session"], {})), | |
| None, | |
| 0, | |
| -1, | |
| None, | |
| None, | |
| ) | |
| fd = fd_result[1].get(fd_result[0].unpack()[0]) | |
| pipeline = Gst.parse_launch( | |
| f"pipewiresrc fd={fd} path={node} ! videoconvert ! " | |
| "video/x-raw,format=RGB ! appsink name=sink emit-signals=true max-buffers=1 drop=true" | |
| ) | |
| def on_frame(sink): | |
| sample = sink.emit("pull-sample") | |
| if not sample: | |
| return Gst.FlowReturn.OK | |
| buf = sample.get_buffer() | |
| caps = sample.get_caps() | |
| width = caps.get_structure(0).get_value("width") | |
| height = caps.get_structure(0).get_value("height") | |
| success, map_info = buf.map(Gst.MapFlags.READ) | |
| if success: | |
| Image.frombytes("RGB", (width, height), map_info.data).save(output_path) | |
| print(f"Saved: {output_path}") | |
| buf.unmap(map_info) | |
| def cleanup(): | |
| pipeline.set_state(Gst.State.NULL) | |
| loop.quit() | |
| return False | |
| GLib.idle_add(cleanup) | |
| return Gst.FlowReturn.EOS | |
| pipeline.get_by_name("sink").connect("new-sample", on_frame) | |
| pipeline.set_state(Gst.State.PLAYING) | |
| call("CreateSession", "(a{sv})", (), on_session) | |
| loop.run() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment