share.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const GdkPixbuf = imports.gi.GdkPixbuf;
  6. const Gio = imports.gi.Gio;
  7. const GLib = imports.gi.GLib;
  8. const GObject = imports.gi.GObject;
  9. const Gtk = imports.gi.Gtk;
  10. const PluginBase = imports.service.plugin;
  11. const URI = imports.service.utils.uri;
  12. var Metadata = {
  13. label: _('Share'),
  14. id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share',
  15. description: _('Share files and URLs between devices'),
  16. incomingCapabilities: ['kdeconnect.share.request'],
  17. outgoingCapabilities: ['kdeconnect.share.request'],
  18. actions: {
  19. share: {
  20. label: _('Share'),
  21. icon_name: 'send-to-symbolic',
  22. parameter_type: null,
  23. incoming: [],
  24. outgoing: ['kdeconnect.share.request'],
  25. },
  26. shareFile: {
  27. label: _('Share File'),
  28. icon_name: 'document-send-symbolic',
  29. parameter_type: new GLib.VariantType('(sb)'),
  30. incoming: [],
  31. outgoing: ['kdeconnect.share.request'],
  32. },
  33. shareText: {
  34. label: _('Share Text'),
  35. icon_name: 'send-to-symbolic',
  36. parameter_type: new GLib.VariantType('s'),
  37. incoming: [],
  38. outgoing: ['kdeconnect.share.request'],
  39. },
  40. shareUri: {
  41. label: _('Share Link'),
  42. icon_name: 'send-to-symbolic',
  43. parameter_type: new GLib.VariantType('s'),
  44. incoming: [],
  45. outgoing: ['kdeconnect.share.request'],
  46. },
  47. },
  48. };
  49. /**
  50. * Share Plugin
  51. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/share
  52. *
  53. * TODO: receiving 'text' TODO: Window with textview & 'Copy to Clipboard..
  54. * https://github.com/KDE/kdeconnect-kde/commit/28f11bd5c9a717fb9fbb3f02ddd6cea62021d055
  55. */
  56. var Plugin = GObject.registerClass({
  57. GTypeName: 'GSConnectSharePlugin',
  58. }, class Plugin extends PluginBase.Plugin {
  59. _init(device) {
  60. super._init(device, 'share');
  61. }
  62. handlePacket(packet) {
  63. // TODO: composite jobs (lastModified, numberOfFiles, totalPayloadSize)
  64. if (packet.body.hasOwnProperty('filename')) {
  65. if (this.settings.get_boolean('receive-files'))
  66. this._handleFile(packet);
  67. else
  68. this._refuseFile(packet);
  69. } else if (packet.body.hasOwnProperty('text')) {
  70. this._handleText(packet);
  71. } else if (packet.body.hasOwnProperty('url')) {
  72. this._handleUri(packet);
  73. }
  74. }
  75. _ensureReceiveDirectory() {
  76. let receiveDir = this.settings.get_string('receive-directory');
  77. // Ensure a directory is set
  78. if (receiveDir.length === 0) {
  79. receiveDir = GLib.get_user_special_dir(
  80. GLib.UserDirectory.DIRECTORY_DOWNLOAD
  81. );
  82. // Fallback to ~/Downloads
  83. const homeDir = GLib.get_home_dir();
  84. if (!receiveDir || receiveDir === homeDir)
  85. receiveDir = GLib.build_filenamev([homeDir, 'Downloads']);
  86. this.settings.set_string('receive-directory', receiveDir);
  87. }
  88. // Ensure the directory exists
  89. if (!GLib.file_test(receiveDir, GLib.FileTest.IS_DIR))
  90. GLib.mkdir_with_parents(receiveDir, 448);
  91. return receiveDir;
  92. }
  93. _getFile(filename) {
  94. const dirpath = this._ensureReceiveDirectory();
  95. const basepath = GLib.build_filenamev([dirpath, filename]);
  96. let filepath = basepath;
  97. let copyNum = 0;
  98. while (GLib.file_test(filepath, GLib.FileTest.EXISTS))
  99. filepath = `${basepath} (${++copyNum})`;
  100. return Gio.File.new_for_path(filepath);
  101. }
  102. _refuseFile(packet) {
  103. try {
  104. this.device.rejectTransfer(packet);
  105. this.device.showNotification({
  106. id: `${Date.now()}`,
  107. title: _('Transfer Failed'),
  108. // TRANSLATORS: eg. Google Pixel is not allowed to upload files
  109. body: _('%s is not allowed to upload files').format(
  110. this.device.name
  111. ),
  112. icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
  113. });
  114. } catch (e) {
  115. debug(e, this.device.name);
  116. }
  117. }
  118. async _handleFile(packet) {
  119. try {
  120. const file = this._getFile(packet.body.filename);
  121. // Create the transfer
  122. const transfer = this.device.createTransfer();
  123. transfer.addFile(packet, file);
  124. // Notify that we're about to start the transfer
  125. this.device.showNotification({
  126. id: transfer.uuid,
  127. title: _('Transferring File'),
  128. // TRANSLATORS: eg. Receiving 'book.pdf' from Google Pixel
  129. body: _('Receiving “%s” from %s').format(
  130. packet.body.filename,
  131. this.device.name
  132. ),
  133. buttons: [{
  134. label: _('Cancel'),
  135. action: 'cancelTransfer',
  136. parameter: new GLib.Variant('s', transfer.uuid),
  137. }],
  138. icon: new Gio.ThemedIcon({name: 'document-save-symbolic'}),
  139. });
  140. // We'll show a notification (success or failure)
  141. let title, body, action, iconName;
  142. let buttons = [];
  143. try {
  144. await transfer.start();
  145. title = _('Transfer Successful');
  146. // TRANSLATORS: eg. Received 'book.pdf' from Google Pixel
  147. body = _('Received “%s” from %s').format(
  148. packet.body.filename,
  149. this.device.name
  150. );
  151. action = {
  152. name: 'showPathInFolder',
  153. parameter: new GLib.Variant('s', file.get_uri()),
  154. };
  155. buttons = [
  156. {
  157. label: _('Show File Location'),
  158. action: 'showPathInFolder',
  159. parameter: new GLib.Variant('s', file.get_uri()),
  160. },
  161. {
  162. label: _('Open File'),
  163. action: 'openPath',
  164. parameter: new GLib.Variant('s', file.get_uri()),
  165. },
  166. ];
  167. iconName = 'document-save-symbolic';
  168. if (packet.body.open) {
  169. const uri = file.get_uri();
  170. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  171. }
  172. } catch (e) {
  173. debug(e, this.device.name);
  174. title = _('Transfer Failed');
  175. // TRANSLATORS: eg. Failed to receive 'book.pdf' from Google Pixel
  176. body = _('Failed to receive “%s” from %s').format(
  177. packet.body.filename,
  178. this.device.name
  179. );
  180. iconName = 'dialog-warning-symbolic';
  181. // Clean up the downloaded file on failure
  182. file.delete_async(GLib.PRIORITY_DEAFAULT, null, null);
  183. }
  184. this.device.hideNotification(transfer.uuid);
  185. this.device.showNotification({
  186. id: transfer.uuid,
  187. title: title,
  188. body: body,
  189. action: action,
  190. buttons: buttons,
  191. icon: new Gio.ThemedIcon({name: iconName}),
  192. });
  193. } catch (e) {
  194. logError(e, this.device.name);
  195. }
  196. }
  197. _handleUri(packet) {
  198. const uri = packet.body.url;
  199. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  200. }
  201. _handleText(packet) {
  202. const dialog = new Gtk.MessageDialog({
  203. text: _('Text Shared By %s').format(this.device.name),
  204. secondary_text: URI.linkify(packet.body.text),
  205. secondary_use_markup: true,
  206. buttons: Gtk.ButtonsType.CLOSE,
  207. });
  208. dialog.message_area.get_children()[1].selectable = true;
  209. dialog.set_keep_above(true);
  210. dialog.connect('response', (dialog) => dialog.destroy());
  211. dialog.show();
  212. }
  213. /**
  214. * Open the file chooser dialog for selecting a file or inputing a URI.
  215. */
  216. share() {
  217. const dialog = new FileChooserDialog(this.device);
  218. dialog.show();
  219. }
  220. /**
  221. * Share local file path or URI
  222. *
  223. * @param {string} path - Local file path or URI
  224. * @param {boolean} open - Whether the file should be opened after transfer
  225. */
  226. async shareFile(path, open = false) {
  227. try {
  228. let file = null;
  229. if (path.includes('://'))
  230. file = Gio.File.new_for_uri(path);
  231. else
  232. file = Gio.File.new_for_path(path);
  233. // Create the transfer
  234. const transfer = this.device.createTransfer();
  235. transfer.addFile({
  236. type: 'kdeconnect.share.request',
  237. body: {
  238. filename: file.get_basename(),
  239. open: open,
  240. },
  241. }, file);
  242. // Notify that we're about to start the transfer
  243. this.device.showNotification({
  244. id: transfer.uuid,
  245. title: _('Transferring File'),
  246. // TRANSLATORS: eg. Sending 'book.pdf' to Google Pixel
  247. body: _('Sending “%s” to %s').format(
  248. file.get_basename(),
  249. this.device.name
  250. ),
  251. buttons: [{
  252. label: _('Cancel'),
  253. action: 'cancelTransfer',
  254. parameter: new GLib.Variant('s', transfer.uuid),
  255. }],
  256. icon: new Gio.ThemedIcon({name: 'document-send-symbolic'}),
  257. });
  258. // We'll show a notification (success or failure)
  259. let title, body, iconName;
  260. try {
  261. await transfer.start();
  262. title = _('Transfer Successful');
  263. // TRANSLATORS: eg. Sent "book.pdf" to Google Pixel
  264. body = _('Sent “%s” to %s').format(
  265. file.get_basename(),
  266. this.device.name
  267. );
  268. iconName = 'document-send-symbolic';
  269. } catch (e) {
  270. debug(e, this.device.name);
  271. title = _('Transfer Failed');
  272. // TRANSLATORS: eg. Failed to send "book.pdf" to Google Pixel
  273. body = _('Failed to send “%s” to %s').format(
  274. file.get_basename(),
  275. this.device.name
  276. );
  277. iconName = 'dialog-warning-symbolic';
  278. }
  279. this.device.hideNotification(transfer.uuid);
  280. this.device.showNotification({
  281. id: transfer.uuid,
  282. title: title,
  283. body: body,
  284. icon: new Gio.ThemedIcon({name: iconName}),
  285. });
  286. } catch (e) {
  287. debug(e, this.device.name);
  288. }
  289. }
  290. /**
  291. * Share a string of text. Remote behaviour is undefined.
  292. *
  293. * @param {string} text - A string of unicode text
  294. */
  295. shareText(text) {
  296. this.device.sendPacket({
  297. type: 'kdeconnect.share.request',
  298. body: {text: text},
  299. });
  300. }
  301. /**
  302. * Share a URI. Generally the remote device opens it with the scheme default
  303. *
  304. * @param {string} uri - A URI to share
  305. */
  306. shareUri(uri) {
  307. if (GLib.uri_parse_scheme(uri) === 'file') {
  308. this.shareFile(uri);
  309. return;
  310. }
  311. this.device.sendPacket({
  312. type: 'kdeconnect.share.request',
  313. body: {url: uri},
  314. });
  315. }
  316. });
  317. /** A simple FileChooserDialog for sharing files */
  318. var FileChooserDialog = GObject.registerClass({
  319. GTypeName: 'GSConnectShareFileChooserDialog',
  320. }, class FileChooserDialog extends Gtk.FileChooserDialog {
  321. _init(device) {
  322. super._init({
  323. // TRANSLATORS: eg. Send files to Google Pixel
  324. title: _('Send files to %s').format(device.name),
  325. select_multiple: true,
  326. extra_widget: new Gtk.CheckButton({
  327. // TRANSLATORS: Mark the file to be opened once completed
  328. label: _('Open when done'),
  329. visible: true,
  330. }),
  331. use_preview_label: false,
  332. });
  333. this.device = device;
  334. // Align checkbox with sidebar
  335. const box = this.get_content_area().get_children()[0].get_children()[0];
  336. const paned = box.get_children()[0];
  337. paned.bind_property(
  338. 'position',
  339. this.extra_widget,
  340. 'margin-left',
  341. GObject.BindingFlags.SYNC_CREATE
  342. );
  343. // Preview Widget
  344. this.preview_widget = new Gtk.Image();
  345. this.preview_widget_active = false;
  346. this.connect('update-preview', this._onUpdatePreview);
  347. // URI entry
  348. this._uriEntry = new Gtk.Entry({
  349. placeholder_text: 'https://',
  350. hexpand: true,
  351. visible: true,
  352. });
  353. this._uriEntry.connect('activate', this._sendLink.bind(this));
  354. // URI/File toggle
  355. this._uriButton = new Gtk.ToggleButton({
  356. image: new Gtk.Image({
  357. icon_name: 'web-browser-symbolic',
  358. pixel_size: 16,
  359. }),
  360. valign: Gtk.Align.CENTER,
  361. // TRANSLATORS: eg. Send a link to Google Pixel
  362. tooltip_text: _('Send a link to %s').format(device.name),
  363. visible: true,
  364. });
  365. this._uriButton.connect('toggled', this._onUriButtonToggled.bind(this));
  366. this.add_button(_('Cancel'), Gtk.ResponseType.CANCEL);
  367. const sendButton = this.add_button(_('Send'), Gtk.ResponseType.OK);
  368. sendButton.connect('clicked', this._sendLink.bind(this));
  369. this.get_header_bar().pack_end(this._uriButton);
  370. this.set_default_response(Gtk.ResponseType.OK);
  371. }
  372. _onUpdatePreview(chooser) {
  373. try {
  374. const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
  375. chooser.get_preview_filename(),
  376. chooser.get_scale_factor() * 128,
  377. -1
  378. );
  379. chooser.preview_widget.pixbuf = pixbuf;
  380. chooser.preview_widget.visible = true;
  381. chooser.preview_widget_active = true;
  382. } catch (e) {
  383. chooser.preview_widget.visible = false;
  384. chooser.preview_widget_active = false;
  385. }
  386. }
  387. _onUriButtonToggled(button) {
  388. const header = this.get_header_bar();
  389. // Show the URL entry
  390. if (button.active) {
  391. this.extra_widget.sensitive = false;
  392. header.set_custom_title(this._uriEntry);
  393. this.set_response_sensitive(Gtk.ResponseType.OK, true);
  394. // Hide the URL entry
  395. } else {
  396. header.set_custom_title(null);
  397. this.set_response_sensitive(
  398. Gtk.ResponseType.OK,
  399. this.get_uris().length > 1
  400. );
  401. this.extra_widget.sensitive = true;
  402. }
  403. }
  404. _sendLink(widget) {
  405. if (this._uriButton.active && this._uriEntry.text.length)
  406. this.response(1);
  407. }
  408. vfunc_response(response_id) {
  409. if (response_id === Gtk.ResponseType.OK) {
  410. for (const uri of this.get_uris()) {
  411. const parameter = new GLib.Variant(
  412. '(sb)',
  413. [uri, this.extra_widget.active]
  414. );
  415. this.device.activate_action('shareFile', parameter);
  416. }
  417. } else if (response_id === 1) {
  418. const parameter = new GLib.Variant('s', this._uriEntry.text);
  419. this.device.activate_action('shareUri', parameter);
  420. }
  421. this.destroy();
  422. }
  423. });