clipboard.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gio from 'gi://Gio';
  5. import GjsPrivate from 'gi://GjsPrivate';
  6. import GLib from 'gi://GLib';
  7. import GObject from 'gi://GObject';
  8. import Meta from 'gi://Meta';
  9. /*
  10. * DBus Interface Info
  11. */
  12. const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
  13. const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
  14. const DBUS_NODE = Gio.DBusNodeInfo.new_for_xml(`
  15. <node>
  16. <interface name="org.gnome.Shell.Extensions.GSConnect.Clipboard">
  17. <!-- Methods -->
  18. <method name="GetMimetypes">
  19. <arg direction="out" type="as" name="mimetypes"/>
  20. </method>
  21. <method name="GetText">
  22. <arg direction="out" type="s" name="text"/>
  23. </method>
  24. <method name="SetText">
  25. <arg direction="in" type="s" name="text"/>
  26. </method>
  27. <method name="GetValue">
  28. <arg direction="in" type="s" name="mimetype"/>
  29. <arg direction="out" type="ay" name="value"/>
  30. </method>
  31. <method name="SetValue">
  32. <arg direction="in" type="ay" name="value"/>
  33. <arg direction="in" type="s" name="mimetype"/>
  34. </method>
  35. <!-- Signals -->
  36. <signal name="OwnerChange"/>
  37. </interface>
  38. </node>
  39. `);
  40. const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME);
  41. /*
  42. * Text Mimetypes
  43. */
  44. const TEXT_MIMETYPES = [
  45. 'text/plain;charset=utf-8',
  46. 'UTF8_STRING',
  47. 'text/plain',
  48. 'STRING',
  49. ];
  50. /* GSConnectClipboardPortal:
  51. *
  52. * A simple clipboard portal, especially useful on Wayland where GtkClipboard
  53. * doesn't work in the background.
  54. */
  55. export const Clipboard = GObject.registerClass({
  56. GTypeName: 'GSConnectShellClipboard',
  57. }, class GSConnectShellClipboard extends GjsPrivate.DBusImplementation {
  58. _init(params = {}) {
  59. super._init({
  60. g_interface_info: DBUS_INFO,
  61. });
  62. this._transferring = false;
  63. // Watch global selection
  64. this._selection = global.display.get_selection();
  65. this._ownerChangedId = this._selection.connect(
  66. 'owner-changed',
  67. this._onOwnerChanged.bind(this)
  68. );
  69. // Prepare DBus interface
  70. this._handleMethodCallId = this.connect(
  71. 'handle-method-call',
  72. this._onHandleMethodCall.bind(this)
  73. );
  74. this._nameId = Gio.DBus.own_name(
  75. Gio.BusType.SESSION,
  76. DBUS_NAME,
  77. Gio.BusNameOwnerFlags.NONE,
  78. this._onBusAcquired.bind(this),
  79. null,
  80. this._onNameLost.bind(this)
  81. );
  82. }
  83. _onOwnerChanged(selection, type, source) {
  84. /* We're only interested in the standard clipboard */
  85. if (type !== Meta.SelectionType.SELECTION_CLIPBOARD)
  86. return;
  87. /* In Wayland an intermediate GMemoryOutputStream is used which triggers
  88. * a second ::owner-changed emission, so we need to ensure we ignore
  89. * that while the transfer is resolving.
  90. */
  91. if (this._transferring)
  92. return;
  93. this._transferring = true;
  94. /* We need to put our signal emission in an idle callback to ensure that
  95. * Mutter's internal calls have finished resolving in the loop, or else
  96. * we'll end up with the previous selection's content.
  97. */
  98. GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
  99. this.emit_signal('OwnerChange', null);
  100. this._transferring = false;
  101. return GLib.SOURCE_REMOVE;
  102. });
  103. }
  104. _onBusAcquired(connection, name) {
  105. try {
  106. this.export(connection, DBUS_PATH);
  107. } catch (e) {
  108. logError(e);
  109. }
  110. }
  111. _onNameLost(connection, name) {
  112. try {
  113. this.unexport();
  114. } catch (e) {
  115. logError(e);
  116. }
  117. }
  118. async _onHandleMethodCall(iface, name, parameters, invocation) {
  119. let retval;
  120. try {
  121. const args = parameters.recursiveUnpack();
  122. retval = await this[name](...args);
  123. } catch (e) {
  124. if (e instanceof GLib.Error) {
  125. invocation.return_gerror(e);
  126. } else {
  127. if (!e.name.includes('.'))
  128. e.name = `org.gnome.gjs.JSError.${e.name}`;
  129. invocation.return_dbus_error(e.name, e.message);
  130. }
  131. return;
  132. }
  133. if (retval === undefined)
  134. retval = new GLib.Variant('()', []);
  135. try {
  136. if (!(retval instanceof GLib.Variant)) {
  137. const args = DBUS_INFO.lookup_method(name).out_args;
  138. retval = new GLib.Variant(
  139. `(${args.map(arg => arg.signature).join('')})`,
  140. (args.length === 1) ? [retval] : retval
  141. );
  142. }
  143. invocation.return_value(retval);
  144. // Without a response, the client will wait for timeout
  145. } catch (e) {
  146. invocation.return_dbus_error(
  147. 'org.gnome.gjs.JSError.ValueError',
  148. 'Service implementation returned an incorrect value type'
  149. );
  150. }
  151. }
  152. /**
  153. * Get the available mimetypes of the current clipboard content
  154. *
  155. * @return {Promise<string[]>} A list of mime-types
  156. */
  157. GetMimetypes() {
  158. return new Promise((resolve, reject) => {
  159. try {
  160. const mimetypes = this._selection.get_mimetypes(
  161. Meta.SelectionType.SELECTION_CLIPBOARD
  162. );
  163. resolve(mimetypes);
  164. } catch (e) {
  165. reject(e);
  166. }
  167. });
  168. }
  169. /**
  170. * Get the text content of the clipboard
  171. *
  172. * @return {Promise<string>} Text content of the clipboard
  173. */
  174. GetText() {
  175. return new Promise((resolve, reject) => {
  176. const mimetypes = this._selection.get_mimetypes(
  177. Meta.SelectionType.SELECTION_CLIPBOARD);
  178. const mimetype = TEXT_MIMETYPES.find(type => mimetypes.includes(type));
  179. if (mimetype !== undefined) {
  180. const stream = Gio.MemoryOutputStream.new_resizable();
  181. this._selection.transfer_async(
  182. Meta.SelectionType.SELECTION_CLIPBOARD,
  183. mimetype, -1,
  184. stream, null,
  185. (selection, res) => {
  186. try {
  187. selection.transfer_finish(res);
  188. const bytes = stream.steal_as_bytes();
  189. const bytearray = bytes.get_data();
  190. resolve(new TextDecoder().decode(bytearray));
  191. } catch (e) {
  192. reject(e);
  193. }
  194. }
  195. );
  196. } else {
  197. reject(new Error('text not available'));
  198. }
  199. });
  200. }
  201. /**
  202. * Set the text content of the clipboard
  203. *
  204. * @param {string} text - text content to set
  205. * @return {Promise} A promise for the operation
  206. */
  207. SetText(text) {
  208. return new Promise((resolve, reject) => {
  209. try {
  210. if (typeof text !== 'string') {
  211. throw new Gio.DBusError({
  212. code: Gio.DBusError.INVALID_ARGS,
  213. message: 'expected string',
  214. });
  215. }
  216. const source = Meta.SelectionSourceMemory.new(
  217. 'text/plain;charset=utf-8', GLib.Bytes.new(text));
  218. this._selection.set_owner(
  219. Meta.SelectionType.SELECTION_CLIPBOARD, source);
  220. resolve();
  221. } catch (e) {
  222. reject(e);
  223. }
  224. });
  225. }
  226. /**
  227. * Get the content of the clipboard with the type @mimetype.
  228. *
  229. * @param {string} mimetype - the mimetype to request
  230. * @return {Promise<Uint8Array>} The content of the clipboard
  231. */
  232. GetValue(mimetype) {
  233. return new Promise((resolve, reject) => {
  234. const stream = Gio.MemoryOutputStream.new_resizable();
  235. this._selection.transfer_async(
  236. Meta.SelectionType.SELECTION_CLIPBOARD,
  237. mimetype, -1,
  238. stream, null,
  239. (selection, res) => {
  240. try {
  241. selection.transfer_finish(res);
  242. const bytes = stream.steal_as_bytes();
  243. resolve(bytes.get_data());
  244. } catch (e) {
  245. reject(e);
  246. }
  247. }
  248. );
  249. });
  250. }
  251. /**
  252. * Set the content of the clipboard to @value with the type @mimetype.
  253. *
  254. * @param {Uint8Array} value - the value to set
  255. * @param {string} mimetype - the mimetype of the value
  256. * @return {Promise} - A promise for the operation
  257. */
  258. SetValue(value, mimetype) {
  259. return new Promise((resolve, reject) => {
  260. try {
  261. const source = Meta.SelectionSourceMemory.new(mimetype,
  262. GLib.Bytes.new(value));
  263. this._selection.set_owner(
  264. Meta.SelectionType.SELECTION_CLIPBOARD, source);
  265. resolve();
  266. } catch (e) {
  267. reject(e);
  268. }
  269. });
  270. }
  271. destroy() {
  272. if (this._selection && this._ownerChangedId > 0) {
  273. this._selection.disconnect(this._ownerChangedId);
  274. this._ownerChangedId = 0;
  275. }
  276. if (this._nameId > 0) {
  277. Gio.bus_unown_name(this._nameId);
  278. this._nameId = 0;
  279. }
  280. if (this._handleMethodCallId > 0) {
  281. this.disconnect(this._handleMethodCallId);
  282. this._handleMethodCallId = 0;
  283. this.unexport();
  284. }
  285. }
  286. });
  287. let _portal = null;
  288. let _portalId = 0;
  289. /**
  290. * Watch for the service to start and export the clipboard portal when it does.
  291. */
  292. export function watchService() {
  293. if (GLib.getenv('XDG_SESSION_TYPE') !== 'wayland')
  294. return;
  295. if (_portalId > 0)
  296. return;
  297. _portalId = Gio.bus_watch_name(
  298. Gio.BusType.SESSION,
  299. 'org.gnome.Shell.Extensions.GSConnect',
  300. Gio.BusNameWatcherFlags.NONE,
  301. () => {
  302. if (_portal === null)
  303. _portal = new Clipboard();
  304. },
  305. () => {
  306. if (_portal !== null) {
  307. _portal.destroy();
  308. _portal = null;
  309. }
  310. }
  311. );
  312. }
  313. /**
  314. * Stop watching the service and export the portal if currently running.
  315. */
  316. export function unwatchService() {
  317. if (_portalId > 0) {
  318. Gio.bus_unwatch_name(_portalId);
  319. _portalId = 0;
  320. }
  321. }