wl_clipboard.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. // SPDX-FileCopyrightText: JingMatrix https://github.com/JingMatrix
  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. // laucher for wl-clipboard
  9. const launcher = new Gio.SubprocessLauncher({
  10. flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_MERGE,
  11. });
  12. /*
  13. * DBus Interface Info
  14. */
  15. const DBUS_NAME = 'org.gnome.Shell.Extensions.GSConnect.Clipboard';
  16. const DBUS_PATH = '/org/gnome/Shell/Extensions/GSConnect/Clipboard';
  17. const DBUS_NODE = Gio.DBusNodeInfo.new_for_xml(`
  18. <node>
  19. <interface name="org.gnome.Shell.Extensions.GSConnect.Clipboard">
  20. <!-- Methods -->
  21. <method name="GetMimetypes">
  22. <arg direction="out" type="as" name="mimetypes"/>
  23. </method>
  24. <method name="GetText">
  25. <arg direction="out" type="s" name="text"/>
  26. </method>
  27. <method name="SetText">
  28. <arg direction="in" type="s" name="text"/>
  29. </method>
  30. <!-- Signals -->
  31. <signal name="OwnerChange"/>
  32. </interface>
  33. </node>
  34. `);
  35. const DBUS_INFO = DBUS_NODE.lookup_interface(DBUS_NAME);
  36. /*
  37. * Text Mimetypes
  38. */
  39. const TEXT_MIMETYPES = [
  40. 'text/plain;charset=utf-8',
  41. 'UTF8_STRING',
  42. 'text/plain',
  43. 'STRING',
  44. ];
  45. /* GSConnectClipboardPortal:
  46. *
  47. * A simple clipboard portal, especially useful on Wayland where GtkClipboard
  48. * doesn't work in the background.
  49. */
  50. export const Clipboard = GObject.registerClass(
  51. {
  52. GTypeName: 'GSConnectShellClipboard',
  53. },
  54. class GSConnectShellClipboard extends GjsPrivate.DBusImplementation {
  55. _init() {
  56. super._init({
  57. g_interface_info: DBUS_INFO,
  58. });
  59. this._transferring = false;
  60. this.watcher = Gio.Subprocess.new([
  61. 'wl-paste',
  62. '-w',
  63. 'dbus-send',
  64. DBUS_PATH,
  65. '--dest=' + DBUS_NAME,
  66. DBUS_NAME + '.OwnerChange',
  67. ], Gio.SubprocessFlags.NONE);
  68. // Prepare DBus interface
  69. this._handleMethodCallId = this.connect(
  70. 'handle-method-call',
  71. this._onHandleMethodCall.bind(this)
  72. );
  73. this._nameId = Gio.DBus.own_name(
  74. Gio.BusType.SESSION,
  75. DBUS_NAME,
  76. Gio.BusNameOwnerFlags.NONE,
  77. this._onBusAcquired.bind(this),
  78. null,
  79. this._onNameLost.bind(this)
  80. );
  81. }
  82. _onBusAcquired(connection, name) {
  83. try {
  84. this.export(connection, DBUS_PATH);
  85. } catch (e) {
  86. logError(e);
  87. }
  88. }
  89. _onNameLost(connection, name) {
  90. try {
  91. this.unexport();
  92. } catch (e) {
  93. logError(e);
  94. }
  95. }
  96. async _onHandleMethodCall(iface, name, parameters, invocation) {
  97. let retval;
  98. try {
  99. const args = parameters.recursiveUnpack();
  100. retval = await this[name](...args);
  101. } catch (e) {
  102. if (e instanceof GLib.Error) {
  103. invocation.return_gerror(e);
  104. } else {
  105. if (!e.name.includes('.'))
  106. e.name = `org.gnome.gjs.JSError.${e.name}`;
  107. invocation.return_dbus_error(e.name, e.message);
  108. }
  109. return;
  110. }
  111. if (retval === undefined)
  112. retval = new GLib.Variant('()', []);
  113. try {
  114. if (!(retval instanceof GLib.Variant)) {
  115. const args = DBUS_INFO.lookup_method(name).out_args;
  116. retval = new GLib.Variant(
  117. `(${args.map((arg) => arg.signature).join('')})`,
  118. args.length === 1 ? [retval] : retval
  119. );
  120. }
  121. invocation.return_value(retval);
  122. // Without a response, the client will wait for timeout
  123. } catch (e) {
  124. invocation.return_dbus_error(
  125. 'org.gnome.gjs.JSError.ValueError',
  126. 'Service implementation returned an incorrect value type'
  127. );
  128. }
  129. }
  130. /**
  131. * Get the available mimetypes of the current clipboard content
  132. *
  133. * @return {Promise<string[]>} A list of mime-types
  134. */
  135. GetMimetypes() {
  136. return new Promise((resolve, reject) => {
  137. const proc = launcher.spawnv([
  138. 'wl-paste',
  139. '--list-types',
  140. '-n',
  141. ]);
  142. proc.communicate_utf8_async(null, null, (proc, res) => {
  143. try {
  144. const [, stdout, stderr] =
  145. proc.communicate_utf8_finish(res);
  146. if (proc.get_successful())
  147. resolve(stdout.trim().split('\n'));
  148. else
  149. logError(stderr);
  150. } catch (e) {
  151. reject(e);
  152. }
  153. });
  154. });
  155. }
  156. /**
  157. * Get the text content of the clipboard
  158. *
  159. * @return {Promise<string>} Text content of the clipboard
  160. */
  161. GetText() {
  162. return new Promise((resolve, reject) => {
  163. this.GetMimetypes().then((mimetypes) => {
  164. const mimetype = TEXT_MIMETYPES.find((type) =>
  165. mimetypes.includes(type)
  166. );
  167. if (mimetype !== undefined) {
  168. const proc = launcher.spawnv(['wl-paste', '-n']);
  169. proc.communicate_utf8_async(null, null, (proc, res) => {
  170. try {
  171. const [, stdout, stderr] =
  172. proc.communicate_utf8_finish(res);
  173. if (proc.get_successful())
  174. resolve(stdout);
  175. else
  176. logError(stderr);
  177. } catch (e) {
  178. reject(e);
  179. }
  180. });
  181. } else {
  182. reject(new Error('text not available'));
  183. }
  184. });
  185. });
  186. }
  187. /**
  188. * Set the text content of the clipboard
  189. *
  190. * @param {string} text - text content to set
  191. * @return {Promise} A promise for the operation
  192. */
  193. SetText(text) {
  194. return new Promise((resolve, reject) => {
  195. try {
  196. if (typeof text !== 'string') {
  197. throw new Gio.DBusError({
  198. code: Gio.DBusError.INVALID_ARGS,
  199. message: 'expected string',
  200. });
  201. }
  202. launcher.spawnv(['wl-copy', text]);
  203. resolve();
  204. } catch (e) {
  205. reject(e);
  206. }
  207. });
  208. }
  209. destroy() {
  210. if (this._nameId > 0) {
  211. Gio.bus_unown_name(this._nameId);
  212. this._nameId = 0;
  213. }
  214. if (this._handleMethodCallId > 0) {
  215. this.disconnect(this._handleMethodCallId);
  216. this._handleMethodCallId = 0;
  217. this.unexport();
  218. }
  219. if (this.watcher)
  220. this.watcher.force_exit();
  221. }
  222. }
  223. );
  224. let _portal = null;
  225. let _portalId = 0;
  226. /**
  227. * Watch for the service to start and export the clipboard portal when it does.
  228. */
  229. export function watchService() {
  230. if (_portalId > 0)
  231. return;
  232. _portalId = Gio.bus_watch_name(
  233. Gio.BusType.SESSION,
  234. 'org.gnome.Shell.Extensions.GSConnect',
  235. Gio.BusNameWatcherFlags.NONE,
  236. () => {
  237. if (_portal === null)
  238. _portal = new Clipboard();
  239. },
  240. () => {
  241. if (_portal !== null) {
  242. _portal.destroy();
  243. _portal = null;
  244. }
  245. }
  246. );
  247. }
  248. /**
  249. * Stop watching the service and export the portal if currently running.
  250. */
  251. export function unwatchService() {
  252. if (_portalId > 0) {
  253. Gio.bus_unwatch_name(_portalId);
  254. _portalId = 0;
  255. }
  256. }
  257. // vim:tabstop=2:shiftwidth=2:expandtab