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 @@
+
+
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 @@
+
+
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 @@
+
+
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 @@
+
+
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,