| 1 | import Gio from "gi://Gio"; |
| 2 | import GLib from "gi://GLib"; |
| 3 | import Meta from "gi://Meta"; |
| 4 | import Shell from "gi://Shell"; |
| 5 | import St from "gi://St"; |
| 6 | import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; |
| 7 | import * as Main from "resource:///org/gnome/shell/ui/main.js"; |
| 8 | import { CaptureService, isWaylandSession } from "./captureService.js"; |
| 9 | import { CaptureFeedback } from "./captureFeedback.js"; |
| 10 | |
| 11 | const NAME = "org.gnome.Shell.Extensions.T3SnapShot"; |
| 12 | const PATH = "/org/gnome/Shell/Extensions/T3SnapShot"; |
| 13 | const XML = `<node><interface name="${NAME}"> |
| 14 | <property name="Version" type="u" access="read"/> |
| 15 | <method name="Capture"> |
| 16 | <arg name="png" type="ay" direction="out"/> |
| 17 | <arg name="metadata" type="s" direction="out"/> |
| 18 | </method> |
| 19 | <method name="CaptureWithFeedback"> |
| 20 | <arg name="flash" type="b" direction="in"/> |
| 21 | <arg name="animate" type="b" direction="in"/> |
| 22 | <arg name="png" type="ay" direction="out"/> |
| 23 | <arg name="metadata" type="s" direction="out"/> |
| 24 | <arg name="animationStarted" type="b" direction="out"/> |
| 25 | </method> |
| 26 | <method name="Activate"><arg name="title" type="s" direction="in"/></method> |
| 27 | <method name="Animate"> |
| 28 | <arg name="x" type="d" direction="in"/> |
| 29 | <arg name="y" type="d" direction="in"/> |
| 30 | <arg name="width" type="d" direction="in"/> |
| 31 | <arg name="height" type="d" direction="in"/> |
| 32 | </method> |
| 33 | </interface></node>`; |
| 34 | |
| 35 | function busIdentity(method, name, resultType) { |
| 36 | return new Promise((resolve, reject) => { |
| 37 | Gio.DBus.session.call( |
| 38 | "org.freedesktop.DBus", |
| 39 | "/org/freedesktop/DBus", |
| 40 | "org.freedesktop.DBus", |
| 41 | method, |
| 42 | new GLib.Variant("(s)", [name]), |
| 43 | new GLib.VariantType(resultType), |
| 44 | Gio.DBusCallFlags.NONE, |
| 45 | 5_000, |
| 46 | null, |
| 47 | (connection, result) => { |
| 48 | try { |
| 49 | resolve(connection.call_finish(result).deepUnpack()[0]); |
| 50 | } catch (error) { |
| 51 | if (error.matches(Gio.DBusError, Gio.DBusError.NAME_HAS_NO_OWNER)) resolve(null); |
| 52 | else reject(error); |
| 53 | } |
| 54 | }, |
| 55 | ); |
| 56 | }); |
| 57 | } |
| 58 | |
| 59 | const getNameOwner = (name) => busIdentity("GetNameOwner", name, "(s)"); |
| 60 | const getProcessId = (sender) => busIdentity("GetConnectionUnixProcessID", sender, "(u)"); |
| 61 | |
| 62 | function takeSnapshot(animate) { |
| 63 | // No await between reading identity and Shell taking its snapshot of the focused actor. |
| 64 | const window = global.display.focus_window; |
| 65 | if (!window || window.minimized || !window.get_compositor_private()) { |
| 66 | throw new Error("No active window is available for capture."); |
| 67 | } |
| 68 | const app = Shell.WindowTracker.get_default().get_window_app(window); |
| 69 | const bounds = window.get_frame_rect(); |
| 70 | const bufferBounds = window.get_buffer_rect(); |
| 71 | let content; |
| 72 | if (animate && St.Settings.get().enable_animations) { |
| 73 | try { |
| 74 | content = window.get_compositor_private().paint_to_content(null); |
| 75 | } catch (error) { |
| 76 | console.warn(`T3 capture preview unavailable: ${error.message}`); |
| 77 | } |
| 78 | } |
| 79 | const metadata = JSON.stringify({ |
| 80 | title: window.get_title() ?? "", |
| 81 | appName: app?.get_name() ?? "", |
| 82 | appIdentifier: app?.get_id() ?? "", |
| 83 | processId: Math.max(0, window.get_pid()), |
| 84 | bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, |
| 85 | }); |
| 86 | const stream = Gio.MemoryOutputStream.new_resizable(); |
| 87 | const screenshot = new Shell.Screenshot(); |
| 88 | return new Promise((resolve, reject) => { |
| 89 | screenshot.screenshot_window(true, false, stream, (source, result) => { |
| 90 | try { |
| 91 | const [success] = source.screenshot_window_finish(result); |
| 92 | if (!success) throw new Error("GNOME could not capture the active window."); |
| 93 | stream.close(null); |
| 94 | if (stream.get_data_size() > 32 * 1024 * 1024) |
| 95 | throw new Error("The screenshot is too large."); |
| 96 | resolve({ |
| 97 | png: stream.steal_as_bytes().get_data(), |
| 98 | metadata, |
| 99 | content, |
| 100 | bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, |
| 101 | bufferBounds: { |
| 102 | x: bufferBounds.x, |
| 103 | y: bufferBounds.y, |
| 104 | width: bufferBounds.width, |
| 105 | height: bufferBounds.height, |
| 106 | }, |
| 107 | }); |
| 108 | } catch (error) { |
| 109 | reject(error); |
| 110 | } finally { |
| 111 | if (!stream.is_closed()) stream.close(null); |
| 112 | } |
| 113 | }); |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | export default class T3SnapShotExtension extends Extension { |
| 118 | enable() { |
| 119 | this._feedback = new CaptureFeedback(); |
| 120 | this._sessionChanged = Main.sessionMode.connect("updated", () => this._feedback.dispose()); |
| 121 | this._monitorsChanged = Main.layoutManager.connect("monitors-changed", () => |
| 122 | this._feedback.dispose(), |
| 123 | ); |
| 124 | this._service = new CaptureService({ |
| 125 | getNameOwner, |
| 126 | getProcessId, |
| 127 | beginFeedback: (...args) => this._feedback.begin(...args), |
| 128 | isAvailable: () => |
| 129 | isWaylandSession(Meta) && !Main.sessionMode.isLocked && !Main.sessionMode.isGreeter, |
| 130 | takeSnapshot, |
| 131 | }); |
| 132 | this._object = Gio.DBusExportedObject.wrapJSObject(XML, this); |
| 133 | this._object.export(Gio.DBus.session, PATH); |
| 134 | this._owner = Gio.bus_own_name_on_connection( |
| 135 | Gio.DBus.session, |
| 136 | NAME, |
| 137 | Gio.BusNameOwnerFlags.NONE, |
| 138 | null, |
| 139 | null, |
| 140 | ); |
| 141 | } |
| 142 | |
| 143 | get Version() { |
| 144 | return 2; |
| 145 | } |
| 146 | |
| 147 | CaptureAsync(_params, invocation) { |
| 148 | void this._service |
| 149 | .capture(invocation.get_sender()) |
| 150 | .then(({ png, metadata }) => |
| 151 | invocation.return_value(new GLib.Variant("(ays)", [png, metadata])), |
| 152 | ) |
| 153 | .catch((error) => invocation.return_dbus_error(`${NAME}.Failed`, error.message)); |
| 154 | } |
| 155 | |
| 156 | CaptureWithFeedbackAsync([flash, animate], invocation) { |
| 157 | void this._service |
| 158 | .capture(invocation.get_sender(), { flash, animate }) |
| 159 | .then(({ png, metadata, animationStarted }) => |
| 160 | invocation.return_value(new GLib.Variant("(aysb)", [png, metadata, animationStarted])), |
| 161 | ) |
| 162 | .catch((error) => invocation.return_dbus_error(`${NAME}.Failed`, error.message)); |
| 163 | } |
| 164 | |
| 165 | ActivateAsync([title], invocation) { |
| 166 | void this._feedback |
| 167 | .activate(invocation.get_sender(), title) |
| 168 | .then(() => invocation.return_value(null)) |
| 169 | .catch((error) => invocation.return_dbus_error(`${NAME}.Failed`, error.message)); |
| 170 | } |
| 171 | |
| 172 | AnimateAsync([x, y, width, height], invocation) { |
| 173 | void this._feedback |
| 174 | .animate(invocation.get_sender(), { x, y, width, height }) |
| 175 | .then(() => invocation.return_value(null)) |
| 176 | .catch((error) => invocation.return_dbus_error(`${NAME}.Failed`, error.message)); |
| 177 | } |
| 178 | |
| 179 | disable() { |
| 180 | this._service?.disable(); |
| 181 | this._feedback?.dispose(); |
| 182 | if (this._sessionChanged) Main.sessionMode.disconnect(this._sessionChanged); |
| 183 | if (this._monitorsChanged) Main.layoutManager.disconnect(this._monitorsChanged); |
| 184 | this._sessionChanged = 0; |
| 185 | this._monitorsChanged = 0; |
| 186 | this._object?.unexport(); |
| 187 | if (this._owner) Gio.bus_unown_name(this._owner); |
| 188 | this._service = null; |
| 189 | this._object = null; |
| 190 | this._owner = null; |
| 191 | } |
| 192 | } |