Skip to content

Instantly share code, notes, and snippets.

@Recursing
Last active December 7, 2025 20:29
Show Gist options
  • Select an option

  • Save Recursing/813aee5bfa27b521a720d7c1eba3cb03 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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