From 457328410811fc5853fbfe34405437321852f3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Sun, 20 Jul 2025 14:06:22 +0200 Subject: [PATCH 1/2] Add screenshot CLI tool Resolves: https://issues.redhat.com/browse/RHEL-96293 --- gnome-shell.spec | 5 + screenshot-tool.patch | 483 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 screenshot-tool.patch diff --git a/gnome-shell.spec b/gnome-shell.spec index 1416573..39fa7da 100644 --- a/gnome-shell.spec +++ b/gnome-shell.spec @@ -45,6 +45,7 @@ Patch: 0001-st-texture-cache-purge-on-resume.patch Patch: fix-some-js-warnings.patch Patch: 0001-data-Update-generated-stylesheets.patch Patch: 0001-theme-Welcome-Illustration.patch +Patch: screenshot-tool.patch %define eds_version 3.45.1 %define gnome_desktop_version 44.0-7 @@ -86,6 +87,7 @@ BuildRequires: pkgconfig(libpipewire-0.3) >= %{pipewire_version} BuildRequires: pkgconfig(gtk4) >= %{gtk4_version} BuildRequires: gettext >= 0.19.6 BuildRequires: python3 +BuildRequires: python3-argcomplete # for rst2man BuildRequires: python3-docutils @@ -230,6 +232,7 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/org.gnome.Shell.Porta %doc NEWS README.md %{_bindir}/gnome-shell %{_bindir}/gnome-extensions +%{_bindir}/gnome-screenshot-tool %{_bindir}/gnome-shell-extension-tool %{_bindir}/gnome-shell-test-tool %{_datadir}/glib-2.0/schemas/*.xml @@ -237,6 +240,7 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/org.gnome.Shell.Porta %{_datadir}/applications/org.gnome.Shell.Extensions.desktop %{_datadir}/applications/org.gnome.Shell.desktop %{_datadir}/bash-completion/completions/gnome-extensions +%{_datadir}/bash-completion/completions/gnome-screenshot-tool %{_datadir}/gnome-control-center/keybindings/50-gnome-shell-launchers.xml %{_datadir}/gnome-control-center/keybindings/50-gnome-shell-screenshots.xml %{_datadir}/gnome-control-center/keybindings/50-gnome-shell-system.xml @@ -265,6 +269,7 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/org.gnome.Shell.Porta %{_libexecdir}/gnome-shell-perf-helper %{_libexecdir}/gnome-shell-hotplug-sniffer %{_mandir}/man1/gnome-extensions.1* +%{_mandir}/man1/gnome-screenshot-tool.1* %{_mandir}/man1/gnome-shell.1* %if %{portal_helper} diff --git a/screenshot-tool.patch b/screenshot-tool.patch new file mode 100644 index 0000000..fc5ec14 --- /dev/null +++ b/screenshot-tool.patch @@ -0,0 +1,483 @@ +From c51ee10056e148e7eec74f3b67f3e5168373b563 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Florian=20M=C3=BCllner?= +Date: Sat, 19 Jul 2025 14:12:46 +0200 +Subject: [PATCH 1/2] screenshot: Fix taking interactive screenshots via D-Bus + +The D-Bus method relies on the `screenshot-taken` and `closed` signals +to differentiate successful operations from canceled/failed ones. + +However as the `screenshot-taken` signal is emitted asynchronously, +it may end up being emitted after the `closed` signal even in case +of success. Await the result to ensure correct ordering. + +Closes: https://gitlab.gnome.org/GNOME/gnome-shell/-/issues/8499 +Part-of: +--- + js/ui/screenshot.js | 12 ++++++++---- + 1 file changed, 8 insertions(+), 4 deletions(-) + +diff --git a/js/ui/screenshot.js b/js/ui/screenshot.js +index 762848c287..962459214f 100644 +--- a/js/ui/screenshot.js ++++ b/js/ui/screenshot.js +@@ -1350,7 +1350,7 @@ export const ScreenshotUI = GObject.registerClass({ + visible: false, + })); + this._captureButton.connect('clicked', +- this._onCaptureButtonClicked.bind(this)); ++ () => this._onCaptureButtonClicked().catch(logError)); + this._bottomRowContainer.add_child(this._captureButton); + + this._showPointerButtonContainer = new St.BoxLayout({ +@@ -1872,9 +1872,13 @@ export const ScreenshotUI = GObject.registerClass({ + return [x, y, w, h]; + } + +- _onCaptureButtonClicked() { ++ async _onCaptureButtonClicked() { + if (this._shotButton.checked) { +- this._saveScreenshot().catch(logError); ++ try { ++ await this._saveScreenshot(); ++ } catch (e) { ++ logError(e); ++ } + this.close(); + } else { + // Screencast closes the UI on its own. +@@ -2139,7 +2143,7 @@ export const ScreenshotUI = GObject.registerClass({ + symbol === Clutter.KEY_KP_Enter || symbol === Clutter.KEY_ISO_Enter || + ((event.get_state() & Clutter.ModifierType.CONTROL_MASK) && + (symbol === Clutter.KEY_c || symbol === Clutter.KEY_C))) { +- this._onCaptureButtonClicked(); ++ this._onCaptureButtonClicked().catch(logError); + return Clutter.EVENT_STOP; + } + +-- +2.50.1 + + +From f61170d09c728144ecf9c44b4b0a0392237fadd6 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Florian=20M=C3=BCllner?= +Date: Fri, 18 Jul 2025 21:34:36 +0200 +Subject: [PATCH 2/2] Add screenshot CLI tool + +The tool provides the same API as gnome-screenshot, but leverages +GNOME's built-in screenshot UI instead of implementing its own +interactive dialog. +--- + man/gnome-screenshot-tool.rst | 49 +++++++ + man/meson.build | 23 ++- + meson.build | 2 + + src/gnome-screenshot-tool | 266 ++++++++++++++++++++++++++++++++++ + src/meson.build | 26 ++++ + 5 files changed, 358 insertions(+), 8 deletions(-) + create mode 100644 man/gnome-screenshot-tool.rst + create mode 100755 src/gnome-screenshot-tool + +diff --git a/man/gnome-screenshot-tool.rst b/man/gnome-screenshot-tool.rst +new file mode 100644 +index 0000000000..a471738394 +--- /dev/null ++++ b/man/gnome-screenshot-tool.rst +@@ -0,0 +1,49 @@ ++===================== ++gnome-screenshot-tool ++===================== ++ ++----------------------------------------- ++Screenshot CLI tool for the GNOME desktop ++----------------------------------------- ++ ++:Manual section: 1 ++:Manual group: User Commands ++ ++SYNOPSIS ++-------- ++**gnome-screenshot-tool** [*OPTION*...] ++ ++DESCRIPTION ++----------- ++**gnome-screenshot-tool** is a GNOME utility for taking screenshots of ++the entire screen, a window or a user-defined area of the screen. ++ ++OPTIONS ++------- ++``-w``, ``--window`` ++ ++ Grab the currently active window instead of the entire screen ++ ++``-a``, ``--area`` ++ ++ Grab an area of the screen instead of the entire screen ++ ++``-i``, ``--interactive`` ++ ++ Bring up GNOME's native screenshot dialog ++ ++``-p``, ``--include-pointer`` ++ ++ Include the pointer with the screenshot ++ ++``-d``, ``--delay``\ =\ *SECONDS* ++ ++ Take the screenshot after the specified delay [in seconds] ++ ++``-f``, ``--file``\ =\ *FILENAME* ++ ++ Save the screenshot directly to this file ++ ++``-h``, ``--help`` ++ ++ Show a summary of the available options +diff --git a/man/meson.build b/man/meson.build +index c61bf51513..cfb9c8e07f 100644 +--- a/man/meson.build ++++ b/man/meson.build +@@ -1,8 +1,15 @@ +-custom_target('man page', +- input: 'gnome-shell.rst', +- output: 'gnome-shell.1', +- command: [rst2man, '--syntax-highlight=none', '@INPUT@'], +- capture: true, +- install_dir: mandir + '/man1', +- install: true +-) ++man_pages = [ ++ 'gnome-shell.rst', ++ 'gnome-screenshot-tool.rst', ++] ++ ++foreach page : man_pages ++ custom_target(f'@page@ man page', ++ input: page, ++ output: '@BASENAME@.1', ++ command: [rst2man, '--syntax-highlight=none', '@INPUT@'], ++ capture: true, ++ install_dir: mandir + '/man1', ++ install: true ++ ) ++endforeach +diff --git a/meson.build b/meson.build +index 6f10a366bf..14b793db6a 100644 +--- a/meson.build ++++ b/meson.build +@@ -134,6 +134,8 @@ if get_option('man') + subdir('man') + endif + ++bash_completion = dependency('bash-completion', required: false) ++ + mutter_typelibdir = mutter_dep.get_variable('typelibdir') + python = find_program('python3') + gjs = find_program('gjs') +diff --git a/src/gnome-screenshot-tool b/src/gnome-screenshot-tool +new file mode 100755 +index 0000000000..e29650abfe +--- /dev/null ++++ b/src/gnome-screenshot-tool +@@ -0,0 +1,266 @@ ++#!/usr/bin/env python3 ++ ++import argparse ++import os ++import gi ++ ++gi.require_version('Gdk', '4.0') ++gi.require_version('Gtk', '4.0') ++gi.require_version('GdkPixbuf', '2.0') ++from gi.repository import GLib, Gio, GdkPixbuf, Gdk, Gtk # type: ignore ++ ++try: ++ import argcomplete ++except ModuleNotFoundError: ++ pass ++ ++NAME = "org.gnome.Shell.Screenshot" ++OBJECT_PATH = "/org/gnome/Shell/Screenshot" ++INTERFACE = "org.gnome.Shell.Screenshot" ++ ++class Screenshot: ++ def __init__(self): ++ self._proxy = Gio.DBusProxy.new_for_bus_sync( ++ bus_type = Gio.BusType.SESSION, ++ flags = Gio.DBusProxyFlags.NONE, ++ info = None, ++ name = NAME, ++ object_path = OBJECT_PATH, ++ interface_name = INTERFACE, ++ cancellable = None, ++ ) ++ ++ def screenshot(self, include_cursor) -> GdkPixbuf.Pixbuf: ++ variant = self._proxy.call_sync( ++ method_name = "Screenshot", ++ parameters = GLib.Variant( ++ "(bbs)", ++ ( ++ include_cursor, ++ True, ++ self._get_tmp_filename(), ++ ), ++ ), ++ flags = Gio.DBusCallFlags.NO_AUTO_START, ++ timeout_msec = -1, ++ cancellable = None, ++ ) ++ return self._get_screenshot_from_result(variant) ++ ++ def screenshot_window(self, include_cursor) -> GdkPixbuf.Pixbuf: ++ variant = self._proxy.call_sync( ++ method_name = "ScreenshotWindow", ++ parameters = GLib.Variant( ++ "(bbbs)", ++ ( ++ True, ++ include_cursor, ++ True, ++ self._get_tmp_filename(), ++ ), ++ ), ++ flags = Gio.DBusCallFlags.NO_AUTO_START, ++ timeout_msec = -1, ++ cancellable = None, ++ ) ++ return self._get_screenshot_from_result(variant) ++ ++ def screenshot_area(self, x, y, width, height) -> GdkPixbuf.Pixbuf: ++ variant = self._proxy.call_sync( ++ method_name = "ScreenshotArea", ++ parameters = GLib.Variant( ++ "(iiiibs)", ++ ( ++ x, ++ y, ++ width, ++ height, ++ True, ++ self._get_tmp_filename(), ++ ), ++ ), ++ flags = Gio.DBusCallFlags.NO_AUTO_START, ++ timeout_msec = -1, ++ cancellable = None, ++ ) ++ return self._get_screenshot_from_result(variant) ++ ++ def interactive_screenshot(self) -> GdkPixbuf.Pixbuf: ++ variant = self._proxy.call_sync( ++ method_name = "InteractiveScreenshot", ++ parameters = None, ++ flags = Gio.DBusCallFlags.NO_AUTO_START, ++ timeout_msec = GLib.MAXINT, ++ cancellable = None, ++ ) ++ [success, uri] = variant ++ file = Gio.File.new_for_uri(uri) ++ return self._get_screenshot_from_result((success, file.get_path())) ++ ++ def select_area(self) -> tuple[int, int, int, int]: ++ variant = self._proxy.call_sync( ++ method_name = "SelectArea", ++ parameters = None, ++ flags = Gio.DBusCallFlags.NO_AUTO_START, ++ timeout_msec = -1, ++ cancellable = None, ++ ) ++ [x, y, width, height] = variant ++ return (x, y, width, height) ++ ++ def _get_screenshot_from_result(self, variant): ++ [success, filename] = variant ++ assert success ++ screenshot = GdkPixbuf.Pixbuf.new_from_file(filename) ++ GLib.unlink(filename) ++ return screenshot ++ ++ def _get_tmp_filename(self): ++ path = GLib.build_filenamev([ ++ GLib.get_user_cache_dir(), ++ "gnome-screenshot-tool", ++ ]) ++ GLib.mkdir_with_parents(path, 0o700) ++ return GLib.build_filenamev([path, f'scr-{GLib.random_int()}']) ++ ++class ScreenshotApp(Gtk.Application): ++ def __init__(self, config): ++ super().__init__(application_id="org.gnome.Screenshot") ++ ++ self._config = config ++ ++ def do_startup(self): ++ Gtk.Application.do_startup(self) ++ self._proxy = Screenshot() ++ ++ def do_activate(self): ++ self.hold() ++ if self._config.area: ++ self._selected_area = self._proxy.select_area() ++ self._start_screenshot_timeout() ++ ++ def _start_screenshot_timeout(self): ++ GLib.timeout_add_seconds(self._config.delay, self._take_screenshot) ++ ++ def _take_screenshot(self): ++ include_pointer = self._config.include_pointer ++ ++ try: ++ if self._config.interactive: ++ screenshot = self._proxy.interactive_screenshot() ++ elif self._config.area: ++ [x, y, width, height] = self._selected_area ++ screenshot = self._proxy.screenshot_area(x, y, width, height) ++ elif self._config.window: ++ screenshot = self._proxy.screenshot_window(include_pointer) ++ else: ++ screenshot = self._proxy.screenshot(include_pointer) ++ ++ # if self._config.clipboard: ++ # self._save_screenshot_to_clipboard(screenshot) ++ ++ if self._config.file: ++ target_file = Gio.File.new_for_commandline_arg(self._config.file) ++ override = True ++ else: ++ target_file = self._get_default_target_file() ++ override = False ++ ++ self._save_screenshot_to_file(screenshot, target_file, override) ++ except Exception as e: ++ print(f'Failed to take screenshot: {e}') ++ finally: ++ self.release() ++ ++ return GLib.SOURCE_REMOVE ++ ++ def _save_screenshot_to_clipboard(self, screenshot): ++ dpy = Gdk.Display.get_default() ++ clipboard = dpy.get_clipboard() ++ clipboard.set(screenshot) ++ ++ def _save_screenshot_to_file(self, screenshot, file, override): ++ if override: ++ ostream = file.replace(None, False, Gio.FileCreateFlags.NONE, None) ++ else: ++ ostream = file.create(Gio.FileCreateFlags.NONE, None) ++ ++ [_, ext] = os.path.splitext(file.get_basename()) ++ if not ext: ++ ext = 'png' ++ else: ++ ext = ext[1:] ++ ++ def find_writable_format(formats, ext): ++ for f in formats: ++ for e in f.get_extensions(): ++ if e == ext and f.is_writable(): ++ return f.get_name() ++ return None ++ ++ formats = GdkPixbuf.Pixbuf.get_formats() ++ format = find_writable_format(formats, ext) ++ ++ if format == 'png': ++ keys = ["tEXt::Software"] ++ values = ["gnome-screenshot-tool"] ++ else: ++ keys = None ++ values = None ++ ++ screenshot.save_to_streamv(ostream, format, keys, values, None) ++ ++ def _get_default_target_file(self): ++ path = GLib.build_filenamev([ ++ GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES) or GLib.get_home_dir(), ++ "Screenshots", ++ ]) ++ dir = Gio.File.new_for_path(path) ++ try: ++ dir.make_directory_with_parents(None) ++ except GLib.Error as e: ++ if not e.matches(Gio.io_error_quark(), Gio.IOErrorEnum.EXISTS): ++ raise e ++ ++ timestamp = GLib.DateTime.new_now_local().format('%Y-%m-%d %H-%M-%S') ++ name = "Screenshot From %s" % (timestamp) ++ ++ def suffixes() -> str: ++ yield '' ++ ++ i = 1 ++ while True: ++ yield f'-{i}' ++ i = i + 1 ++ ++ for suffix in suffixes(): ++ file = dir.get_child(f'{name}{suffix}.png') ++ if not file.query_exists(None): ++ return file ++ ++def main(): ++ parser = argparse.ArgumentParser() ++ ++ group = parser.add_mutually_exclusive_group() ++ group.add_argument("-w", "--window", action="store_true", help="Grab a window instead of the entire screen") ++ group.add_argument("-a", "--area", action="store_true", help="Grab an area of the screen instead of the entire screen") ++ group.add_argument("-i", "--interactive", action="store_true", help="Interactively set options") ++ ++ # parser.add_argument("-c", "--clipboard", action="store_true", help="Send the grab directly to the clipboard") ++ parser.add_argument("-p", "--include-pointer", action="store_true", help="Include the pointer with the screenshot") ++ parser.add_argument("-d", "--delay", type=int, default=0, help="Take a screenshot after the specified delay (in seconds)") ++ parser.add_argument("-f", "--file", type=str, help="Save screenshot directly to this file") ++ ++ if argcomplete: ++ argcomplete.autocomplete(parser) ++ args = parser.parse_args() ++ ++ if args.interactive: ++ if args.include_pointer: ++ print("Option --include-pointer is ignored in interactive mode.") ++ ++ app = ScreenshotApp(args) ++ app.run(None) ++ ++if __name__ == "__main__": ++ main() +diff --git a/src/meson.build b/src/meson.build +index 23d23b4f38..4ccdd3b055 100644 +--- a/src/meson.build ++++ b/src/meson.build +@@ -310,3 +310,29 @@ run_test = executable('run-js-test', 'run-js-test.c', + include_directories: [conf_inc], + build_rpath: mutter_typelibdir, + ) ++ ++install_data( ++ 'gnome-screenshot-tool', ++ install_dir: bindir ++) ++ ++if bash_completion.found() ++ bash_completion_dir = bash_completion.get_variable( ++ pkgconfig: 'completionsdir', ++ pkgconfig_define: ['datadir', datadir], ++ ) ++ ++ register_python_argcomplete = find_program('register-python-argcomplete') ++ ++ custom_target( ++ 'screenshot-tool-bash-completion', ++ output: 'gnome-screenshot-tool', ++ command: [ ++ register_python_argcomplete, ++ 'gnome-screenshot-tool', ++ ], ++ capture: true, ++ install_dir: bash_completion_dir, ++ install: true, ++ ) ++endif +-- +2.50.1 + From de36415cbc026a230876286b0653e0489db29eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Tue, 22 Jul 2025 12:53:28 +0200 Subject: [PATCH 2/2] Bump release to force rebuild Related: https://issues.redhat.com/browse/RHEL-96293