share.js 16 KB

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