share.js 17 KB

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