From 8ba0fe1e1766d5104e4ea1fdc6d7ae5d573401db Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Wed, 24 Apr 2024 23:38:14 +0100 Subject: [PATCH] Screenshot portal (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Danielle Foré --- data/gresource.xml | 12 ++ data/icons/grab-area-symbolic.svg | 64 ++++++ data/icons/grab-screen-symbolic-dark.svg | 93 +++++++++ data/icons/grab-screen-symbolic.svg | 74 +++++++ data/icons/grab-window-symbolic.svg | 71 +++++++ data/meson.build | 5 + data/pantheon.portal | 2 +- meson.build | 1 + po/POTFILES | 1 + src/Screenshot/Portal.vala | 245 +++++++++++++++++++++++ src/Screenshot/SetupDialog.vala | 212 ++++++++++++++++++++ src/XdgDesktopPortalPantheon.vala | 6 + src/meson.build | 3 + 13 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 data/gresource.xml create mode 100644 data/icons/grab-area-symbolic.svg create mode 100644 data/icons/grab-screen-symbolic-dark.svg create mode 100644 data/icons/grab-screen-symbolic.svg create mode 100644 data/icons/grab-window-symbolic.svg create mode 100644 src/Screenshot/Portal.vala create mode 100644 src/Screenshot/SetupDialog.vala diff --git a/data/gresource.xml b/data/gresource.xml new file mode 100644 index 00000000..a0172123 --- /dev/null +++ b/data/gresource.xml @@ -0,0 +1,12 @@ + + + + icons/grab-area-symbolic.svg + icons/grab-area-symbolic.svg + icons/grab-screen-symbolic.svg + icons/grab-screen-symbolic.svg + icons/grab-screen-symbolic-dark.svg + icons/grab-screen-symbolic-dark.svg + icons/grab-window-symbolic.svg + + \ No newline at end of file diff --git a/data/icons/grab-area-symbolic.svg b/data/icons/grab-area-symbolic.svg new file mode 100644 index 00000000..34ebd926 --- /dev/null +++ b/data/icons/grab-area-symbolic.svg @@ -0,0 +1,64 @@ + + + + + + + + + + image/svg+xml + + + + + + + diff --git a/data/icons/grab-screen-symbolic-dark.svg b/data/icons/grab-screen-symbolic-dark.svg new file mode 100644 index 00000000..7bc80c11 --- /dev/null +++ b/data/icons/grab-screen-symbolic-dark.svg @@ -0,0 +1,93 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/data/icons/grab-screen-symbolic.svg b/data/icons/grab-screen-symbolic.svg new file mode 100644 index 00000000..0b24c471 --- /dev/null +++ b/data/icons/grab-screen-symbolic.svg @@ -0,0 +1,74 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/data/icons/grab-window-symbolic.svg b/data/icons/grab-window-symbolic.svg new file mode 100644 index 00000000..ca1f6eac --- /dev/null +++ b/data/icons/grab-window-symbolic.svg @@ -0,0 +1,71 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/data/meson.build b/data/meson.build index 85490b18..e596191d 100644 --- a/data/meson.build +++ b/data/meson.build @@ -32,3 +32,8 @@ i18n.merge_file( install: true, install_dir: datadir / 'metainfo' ) + +icon_res = gnome.compile_resources( + 'screenshot-icon-resources', + 'gresource.xml' +) diff --git a/data/pantheon.portal b/data/pantheon.portal index 10642131..ad472038 100644 --- a/data/pantheon.portal +++ b/data/pantheon.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.pantheon -Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.AppChooser;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Wallpaper +Interfaces=org.freedesktop.impl.portal.Access;org.freedesktop.impl.portal.AppChooser;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper UseIn=pantheon diff --git a/meson.build b/meson.build index 2d09a4f4..65dc7a87 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,6 @@ project('xdg-desktop-portal-pantheon', 'c', 'vala', version: '7.1.1', meson_version: '>=0.58') +gnome = import('gnome') i18n = import('i18n') prefix = get_option('prefix') diff --git a/po/POTFILES b/po/POTFILES index bc051e4e..434edde7 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -6,3 +6,4 @@ src/AppChooser/Portal.vala src/AppChooser/Dialog.vala src/AppChooser/AppButton.vala src/Background/NotificationRequest.vala +src/Screenshot/SetupDialog.vala diff --git a/src/Screenshot/Portal.vala b/src/Screenshot/Portal.vala new file mode 100644 index 00000000..b1cd9416 --- /dev/null +++ b/src/Screenshot/Portal.vala @@ -0,0 +1,245 @@ +/* + * SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +[DBus (name = "org.gnome.Shell.Screenshot")] +public interface Gala.ScreenshotProxy : Object { + public const string NAME = "org.gnome.Shell.Screenshot"; + public const string PATH = "/org/gnome/Shell/Screenshot"; + + public abstract async void conceal_text () throws GLib.Error; + public abstract async void screenshot (bool include_cursor, bool flash, string filename, out bool success, out string filename_used) throws GLib.Error; + public abstract async void screenshot_window (bool include_frame, bool include_cursor, bool flash, string filename, out bool success, out string filename_used) throws GLib.Error; + public abstract async void screenshot_area (int x, int y, int width, int height, bool flash, string filename, out bool success, out string filename_used) throws GLib.Error; + public abstract async void screenshot_area_with_cursor (int x, int y, int width, int height, bool include_cursor, bool flash, string filename, out bool success, out string filename_used) throws GLib.Error; + public abstract async void select_area (out int x, out int y, out int width, out int height) throws GLib.Error; + public abstract async void pick_color (out HashTable result) throws GLib.Error; +} + +[DBus (name = "org.freedesktop.impl.portal.Screenshot")] +public class Screenshot.Portal : Object { + private Gala.ScreenshotProxy screenshot_proxy; + private DBusConnection connection; + + // Force the property name to be "version" instead of "Version" + [DBus (name = "version")] + public uint32 version { get; default = 2; } + + public Portal (DBusConnection connection) { + this.connection = connection; + + connection.get_proxy.begin ( + Gala.ScreenshotProxy.NAME, + Gala.ScreenshotProxy.PATH, + NONE, null, (obj, res) => { + try { + screenshot_proxy = connection.get_proxy.end (res); + } catch (GLib.Error e) { + warning ("Failed to get screenshot proxy, portal working with reduced functionality: %s", e.message); + } + } + ); + } + + private async void do_delay (int seconds) { + if (seconds > 0) { + GLib.Timeout.add_seconds (seconds, () => { + do_delay.callback (); + return false; + }); + + yield; + } + } + + private async string do_screenshot ( + SetupDialog.ScreenshotType screenshot_type, + bool grab_pointer, + bool redact, + int delay + ) throws GLib.Error { + string filename_used = ""; + var tmp_filename = get_tmp_filename (); + + switch (screenshot_type) { + case SetupDialog.ScreenshotType.ALL: + var success = false; + + yield do_delay (delay); + if (redact) { + yield screenshot_proxy.conceal_text (); + yield do_delay (1); + } + yield screenshot_proxy.screenshot (grab_pointer, true, tmp_filename, out success, out filename_used); + + if (!success) { + throw new GLib.IOError.FAILED ("Failed to take screenshot"); + } + + break; + case SetupDialog.ScreenshotType.WINDOW: + var success = false; + + yield do_delay (delay); + if (redact) { + yield screenshot_proxy.conceal_text (); + yield do_delay (1); + } + yield screenshot_proxy.screenshot_window (false, grab_pointer, true, tmp_filename, out success, out filename_used); + + if (!success) { + throw new GLib.IOError.FAILED ("Failed to take screenshot"); + } + + break; + case SetupDialog.ScreenshotType.AREA: + var success = false; + + int x, y, width, height; + yield screenshot_proxy.select_area (out x, out y, out width, out height); + + yield do_delay (delay); + if (redact) { + yield screenshot_proxy.conceal_text (); + yield do_delay (1); + } + yield screenshot_proxy.screenshot_area_with_cursor (x, y, width, height, grab_pointer, true, tmp_filename, out success, out filename_used); + + if (!success) { + throw new GLib.IOError.FAILED ("Failed to take screenshot"); + } + + break; + } + + return GLib.Filename.to_uri (filename_used, null); + } + + public async void screenshot ( + ObjectPath handle, + string app_id, + string parent_window, + HashTable options, + out uint response, + out HashTable results + ) throws DBusError, IOError { + var modal = true; + var interactive = false; + var permission_store_checked = false; + + results = new HashTable (str_hash, str_equal); + + if ("modal" in options && options["modal"].get_type_string () == "b") { + modal = options["modal"].get_boolean (); + } + + if ("interactive" in options && options["interactive"].get_type_string () == "b") { + interactive = options["interactive"].get_boolean (); + } + + if ("permission_store_checked" in options && options["permission_store_checked"].get_type_string () == "b") { + permission_store_checked = options["permission_store_checked"].get_boolean (); + } + + debug ("screenshot: modal=%b, interactive=%b, permission_store_checked=%b", modal, interactive, permission_store_checked); + + // Non-interactive screenshots for a pre-approved app, just take a fullscreen screenshot and send it + if (!interactive && permission_store_checked) { + var uri = ""; + + try { + uri = yield do_screenshot (SetupDialog.ScreenshotType.ALL, false, false, 0); + } catch (Error e) { + warning ("Couldn't call screenshot: %s\n", e.message); + response = 1; + return; + } + + response = 0; + results["uri"] = uri; + return; + } + + if (interactive) { + var dialog = new SetupDialog (parent_window, modal); + + bool cancelled = true; + dialog.response.connect ((response_id) => { + if (response_id == Gtk.ResponseType.OK) { + cancelled = false; + } + + screenshot.callback (); + }); + + dialog.show (); + yield; + + dialog.destroy (); + + if (cancelled) { + response = 1; + return; + } + + var uri = ""; + try { + uri = yield do_screenshot (dialog.screenshot_type, dialog.grab_pointer, dialog.redact_text, dialog.delay); + } catch (Error e) { + warning ("Couldn't call screenshot: %s\n", e.message); + response = 2; + return; + } + + // The user has already approved this app to take screenshots, so we send the screenshot without prompting + if (permission_store_checked) { + response = 0; + results["uri"] = uri; + return; + } + } + + warning ("Unimplemented screenshot code path, this should not be reached"); + response = 2; + results = new HashTable (str_hash, str_equal); + } + + public async void pick_color ( + ObjectPath handle, + string app_id, + string parent_window, + HashTable options, + out uint response, + out HashTable results + ) throws DBusError, IOError { + var _result = new HashTable (str_hash, str_equal); + + try { + yield screenshot_proxy.pick_color (out _result); + } catch (Error e) { + warning ("Couldn't call pick_color: %s\n", e.message); + response = 1; + results = new HashTable (str_hash, str_equal); + results["color"] = new Variant.array (new GLib.VariantType ("d"), { 0.0, 0.0, 0.0 }); + return; + } + + var color = _result["color"]; + if (color == null || color.get_type_string () != "(ddd)") { + response = 2; + results = new HashTable (str_hash, str_equal); + results["color"] = new Variant.array (new GLib.VariantType ("d"), { 0.0, 0.0, 0.0 }); + return; + } + + response = 0; + results = _result; + } + + private string get_tmp_filename () { + var dir = Environment.get_user_cache_dir (); + var name = "io.elementary.portals.screenshot-%lu.png".printf (Random.next_int ()); + return Path.build_filename (dir, name); + } +} diff --git a/src/Screenshot/SetupDialog.vala b/src/Screenshot/SetupDialog.vala new file mode 100644 index 00000000..50ba76cd --- /dev/null +++ b/src/Screenshot/SetupDialog.vala @@ -0,0 +1,212 @@ +/* + * SPDX-FileCopyrightText: 2023 elementary, Inc. (https://elementary.io) + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +public class Screenshot.SetupDialog : Gtk.Window { + public enum ScreenshotType { + ALL, + WINDOW, + AREA + } + + public signal void response (Gtk.ResponseType response_type); + + public string parent_window { get; construct; } + + public ScreenshotType screenshot_type { get; private set; default = ScreenshotType.ALL; } + public bool grab_pointer { get; private set; default = false; } + public bool redact_text { get; private set; default = false; } + public int delay { get; private set; default = 0; } + + public SetupDialog (string parent_window, bool modal) { + Object ( + resizable: false, + parent_window: parent_window, + modal: modal + ); + } + + private Gtk.Image all_image; + + construct { + if (parent_window != "") { + ((Gtk.Widget) this).realize.connect (() => { + try { + ExternalWindow.from_handle (parent_window).set_parent_of (get_surface ()); + } catch (Error e) { + warning ("Failed to associate portal window with parent %s: %s", parent_window, e.message); + } + }); + } + + all_image = new Gtk.Image.from_icon_name ("grab-screen-symbolic") { + icon_size = LARGE + }; + + var all = new Gtk.CheckButton () { + active = true, + tooltip_text = _("Grab the whole screen") + }; + all.add_css_class ("image-button"); + all_image.set_parent (all); + + all.toggled.connect (() => { + if (all.active) { + screenshot_type = ScreenshotType.ALL; + } + }); + + var curr_image = new Gtk.Image.from_icon_name ("grab-window-symbolic") { + icon_size = LARGE + }; + + var curr_window = new Gtk.CheckButton () { + group = all, + tooltip_text = _("Grab the current window") + }; + curr_window.add_css_class ("image-button"); + curr_image.set_parent (curr_window); + + curr_window.toggled.connect (() => { + if (curr_window.active) { + screenshot_type = ScreenshotType.WINDOW; + } + }); + + var selection_image = new Gtk.Image.from_icon_name ("grab-area-symbolic") { + icon_size = LARGE + }; + + var selection = new Gtk.CheckButton () { + group = all, + tooltip_text = _("Select area to grab") + }; + selection.add_css_class ("image-button"); + selection_image.set_parent (selection); + + selection.toggled.connect (() => { + if (selection.active) { + screenshot_type = ScreenshotType.AREA; + } + }); + + var pointer_switch = new Gtk.Switch () { + halign = START + }; + + pointer_switch.state_set.connect (() => { + grab_pointer = pointer_switch.active; + }); + + var pointer_label = new Gtk.Label (_("Grab pointer:")) { + halign = END, + mnemonic_widget = pointer_switch + }; + + var redact_switch = new Gtk.Switch () { + halign = START + }; + + redact_switch.state_set.connect (() => { + redact_text = redact_switch.active; + }); + + var redact_label = new Gtk.Label (_("Conceal text:")) { + halign = END, + mnemonic_widget = redact_switch + }; + + var delay_spin = new Gtk.SpinButton.with_range (0, 15, 1); + + delay_spin.value_changed.connect (() => { + delay = (int) delay_spin.value; + }); + + var delay_label = new Gtk.Label (_("Delay in seconds:")) { + halign = END, + mnemonic_widget = delay_spin + }; + + var take_btn = new Gtk.Button.with_label (_("Take Screenshot")) { + receives_default = true + }; + take_btn.add_css_class (Granite.STYLE_CLASS_SUGGESTED_ACTION); + + take_btn.clicked.connect (() => { + response (Gtk.ResponseType.OK); + }); + + var close_btn = new Gtk.Button.with_label (_("Close")); + + var radio_box = new Gtk.Box (HORIZONTAL, 18) { + halign = CENTER + }; + radio_box.append (all); + radio_box.append (curr_window); + radio_box.append (selection); + + var option_grid = new Gtk.Grid () { + column_spacing = 12, + row_spacing = 6 + }; + option_grid.attach (pointer_label, 0, 0); + option_grid.attach (pointer_switch, 1, 0); + + option_grid.attach (redact_label, 0, 1); + option_grid.attach (redact_switch, 1, 1); + + option_grid.attach (delay_label, 0, 2); + option_grid.attach (delay_spin, 1, 2); + + var actions = new Gtk.Box (HORIZONTAL, 6) { + halign = END, + homogeneous = true + }; + actions.append (close_btn); + actions.append (take_btn); + + var box = new Gtk.Box (VERTICAL, 24) { + margin_top = 24, + margin_end = 12, + margin_bottom = 12, + margin_start = 12 + }; + box.append (radio_box); + box.append (option_grid); + box.append (actions); + + var window_handle = new Gtk.WindowHandle () { + child = box + }; + + child = window_handle; + + // We need to hide the title area + titlebar = new Gtk.Grid () { + visible = false + }; + + add_css_class ("dialog"); + add_css_class (Granite.STYLE_CLASS_MESSAGE_DIALOG); + + close_btn.clicked.connect (() => { + response (Gtk.ResponseType.CLOSE); + }); + + var gtk_settings = Gtk.Settings.get_default (); + gtk_settings.notify["gtk-application-prefer-dark-theme"].connect (() => { + update_icons (gtk_settings.gtk_application_prefer_dark_theme); + }); + + update_icons (gtk_settings.gtk_application_prefer_dark_theme); + } + + private void update_icons (bool prefers_dark) { + if (prefers_dark) { + all_image.icon_name = "grab-screen-symbolic-dark"; + } else { + all_image.icon_name = "grab-screen-symbolic"; + } + } +} diff --git a/src/XdgDesktopPortalPantheon.vala b/src/XdgDesktopPortalPantheon.vala index 8e3cecc5..f413ee79 100644 --- a/src/XdgDesktopPortalPantheon.vala +++ b/src/XdgDesktopPortalPantheon.vala @@ -41,6 +41,9 @@ private void on_bus_acquired (DBusConnection connection, string name) { connection.register_object ("/org/freedesktop/portal/desktop", new Background.Portal (connection)); debug ("Background Portal registered!"); + connection.register_object ("/org/freedesktop/portal/desktop", new Screenshot.Portal (connection)); + debug ("Screenshot Portal registered!"); + connection.register_object ("/org/freedesktop/portal/desktop", new Wallpaper.Portal (connection)); debug ("Wallpaper Portal registered!"); } catch (Error e) { @@ -83,6 +86,9 @@ int main (string[] args) { Gtk.init (); + weak Gtk.IconTheme default_theme = Gtk.IconTheme.get_for_display (Gdk.Display.get_default ()); + default_theme.add_resource_path ("/io/elementary/xdg-desktop-portal-pantheon"); + try { var opt_context = new OptionContext ("- portal backends"); opt_context.set_summary ("A backend implementation for xdg-desktop-portal."); diff --git a/src/meson.build b/src/meson.build index 64c35a42..90ffcddc 100644 --- a/src/meson.build +++ b/src/meson.build @@ -8,10 +8,13 @@ executable( 'AppChooser/Portal.vala', 'Background/NotificationRequest.vala', 'Background/Portal.vala', + 'Screenshot/Portal.vala', + 'Screenshot/SetupDialog.vala', 'Wallpaper/Portal.vala', configure_file(input: 'Config.vala.in', output: '@BASENAME@', configuration: conf_data), 'ExternalWindow.vala', 'XdgDesktopPortalPantheon.vala', + icon_res, dependencies: [ glib_dep, gobject_dep,