// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GjsPrivate from 'gi://GjsPrivate'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Meta from 'gi://Meta'; /* * DBus Interface Info */ const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard'; const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard'; const DBUS_NODE = Gio.DBusNodeInfo.new_for_xml(` `); const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME); /* * Text Mimetypes */ const TEXT_MIMETYPES = [ 'text/plain;charset=utf-8', 'UTF8_STRING', 'text/plain', 'STRING', ]; /* GSConnectClipboardPortal: * * A simple clipboard portal, especially useful on Wayland where GtkClipboard * doesn't work in the background. */ export const Clipboard = GObject.registerClass({ GTypeName: 'GSConnectShellClipboard', }, class GSConnectShellClipboard extends GjsPrivate.DBusImplementation { _init(params = {}) { super._init({ g_interface_info: DBUS_INFO, }); this._transferring = false; // Watch global selection this._selection = global.display.get_selection(); this._ownerChangedId = this._selection.connect( 'owner-changed', this._onOwnerChanged.bind(this) ); // Prepare DBus interface this._handleMethodCallId = this.connect( 'handle-method-call', this._onHandleMethodCall.bind(this) ); this._nameId = Gio.DBus.own_name( Gio.BusType.SESSION, DBUS_NAME, Gio.BusNameOwnerFlags.NONE, this._onBusAcquired.bind(this), null, this._onNameLost.bind(this) ); } _onOwnerChanged(selection, type, source) { /* We're only interested in the standard clipboard */ if (type !== Meta.SelectionType.SELECTION_CLIPBOARD) return; /* In Wayland an intermediate GMemoryOutputStream is used which triggers * a second ::owner-changed emission, so we need to ensure we ignore * that while the transfer is resolving. */ if (this._transferring) return; this._transferring = true; /* We need to put our signal emission in an idle callback to ensure that * Mutter's internal calls have finished resolving in the loop, or else * we'll end up with the previous selection's content. */ GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { this.emit_signal('OwnerChange', null); this._transferring = false; return GLib.SOURCE_REMOVE; }); } _onBusAcquired(connection, name) { try { this.export(connection, DBUS_PATH); } catch (e) { logError(e); } } _onNameLost(connection, name) { try { this.unexport(); } catch (e) { logError(e); } } async _onHandleMethodCall(iface, name, parameters, invocation) { let retval; try { const args = parameters.recursiveUnpack(); retval = await this[name](...args); } catch (e) { if (e instanceof GLib.Error) { invocation.return_gerror(e); } else { if (!e.name.includes('.')) e.name = `org.gnome.gjs.JSError.${e.name}`; invocation.return_dbus_error(e.name, e.message); } return; } if (retval === undefined) retval = new GLib.Variant('()', []); try { if (!(retval instanceof GLib.Variant)) { const args = DBUS_INFO.lookup_method(name).out_args; retval = new GLib.Variant( `(${args.map(arg => arg.signature).join('')})`, (args.length === 1) ? [retval] : retval ); } invocation.return_value(retval); // Without a response, the client will wait for timeout } catch (e) { invocation.return_dbus_error( 'org.gnome.gjs.JSError.ValueError', 'Service implementation returned an incorrect value type' ); } } /** * Get the available mimetypes of the current clipboard content * * @return {Promise} A list of mime-types */ GetMimetypes() { return new Promise((resolve, reject) => { try { const mimetypes = this._selection.get_mimetypes( Meta.SelectionType.SELECTION_CLIPBOARD ); resolve(mimetypes); } catch (e) { reject(e); } }); } /** * Get the text content of the clipboard * * @return {Promise} Text content of the clipboard */ GetText() { return new Promise((resolve, reject) => { const mimetypes = this._selection.get_mimetypes( Meta.SelectionType.SELECTION_CLIPBOARD); const mimetype = TEXT_MIMETYPES.find(type => mimetypes.includes(type)); if (mimetype !== undefined) { const stream = Gio.MemoryOutputStream.new_resizable(); this._selection.transfer_async( Meta.SelectionType.SELECTION_CLIPBOARD, mimetype, -1, stream, null, (selection, res) => { try { selection.transfer_finish(res); const bytes = stream.steal_as_bytes(); const bytearray = bytes.get_data(); resolve(new TextDecoder().decode(bytearray)); } catch (e) { reject(e); } } ); } else { reject(new Error('text not available')); } }); } /** * Set the text content of the clipboard * * @param {string} text - text content to set * @return {Promise} A promise for the operation */ SetText(text) { return new Promise((resolve, reject) => { try { if (typeof text !== 'string') { throw new Gio.DBusError({ code: Gio.DBusError.INVALID_ARGS, message: 'expected string', }); } const source = Meta.SelectionSourceMemory.new( 'text/plain;charset=utf-8', GLib.Bytes.new(text)); this._selection.set_owner( Meta.SelectionType.SELECTION_CLIPBOARD, source); resolve(); } catch (e) { reject(e); } }); } /** * Get the content of the clipboard with the type @mimetype. * * @param {string} mimetype - the mimetype to request * @return {Promise} The content of the clipboard */ GetValue(mimetype) { return new Promise((resolve, reject) => { const stream = Gio.MemoryOutputStream.new_resizable(); this._selection.transfer_async( Meta.SelectionType.SELECTION_CLIPBOARD, mimetype, -1, stream, null, (selection, res) => { try { selection.transfer_finish(res); const bytes = stream.steal_as_bytes(); resolve(bytes.get_data()); } catch (e) { reject(e); } } ); }); } /** * Set the content of the clipboard to @value with the type @mimetype. * * @param {Uint8Array} value - the value to set * @param {string} mimetype - the mimetype of the value * @return {Promise} - A promise for the operation */ SetValue(value, mimetype) { return new Promise((resolve, reject) => { try { const source = Meta.SelectionSourceMemory.new(mimetype, GLib.Bytes.new(value)); this._selection.set_owner( Meta.SelectionType.SELECTION_CLIPBOARD, source); resolve(); } catch (e) { reject(e); } }); } destroy() { if (this._selection && this._ownerChangedId > 0) { this._selection.disconnect(this._ownerChangedId); this._ownerChangedId = 0; } if (this._nameId > 0) { Gio.bus_unown_name(this._nameId); this._nameId = 0; } if (this._handleMethodCallId > 0) { this.disconnect(this._handleMethodCallId); this._handleMethodCallId = 0; this.unexport(); } } }); let _portal = null; let _portalId = 0; /** * Watch for the service to start and export the clipboard portal when it does. */ export function watchService() { if (GLib.getenv('XDG_SESSION_TYPE') !== 'wayland') return; if (_portalId > 0) return; _portalId = Gio.bus_watch_name( Gio.BusType.SESSION, 'org.gnome.Shell.Extensions.GSConnect', Gio.BusNameWatcherFlags.NONE, () => { if (_portal === null) _portal = new Clipboard(); }, () => { if (_portal !== null) { _portal.destroy(); _portal = null; } } ); } /** * Stop watching the service and export the portal if currently running. */ export function unwatchService() { if (_portalId > 0) { Gio.bus_unwatch_name(_portalId); _portalId = 0; } }