// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import GdkPixbuf from 'gi://GdkPixbuf'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Gtk from 'gi://Gtk'; import Plugin from '../plugin.js'; import * as URI from '../utils/uri.js'; export const Metadata = { label: _('Share'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share', description: _('Share files and URLs between devices'), incomingCapabilities: ['kdeconnect.share.request'], outgoingCapabilities: ['kdeconnect.share.request'], actions: { share: { label: _('Share'), icon_name: 'send-to-symbolic', parameter_type: null, incoming: [], outgoing: ['kdeconnect.share.request'], }, shareFile: { label: _('Share File'), icon_name: 'document-send-symbolic', parameter_type: new GLib.VariantType('(sb)'), incoming: [], outgoing: ['kdeconnect.share.request'], }, shareText: { label: _('Share Text'), icon_name: 'send-to-symbolic', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.share.request'], }, shareUri: { label: _('Share Link'), icon_name: 'send-to-symbolic', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.share.request'], }, }, }; /** * Share Plugin * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/share * * TODO: receiving 'text' TODO: Window with textview & 'Copy to Clipboard.. * https://github.com/KDE/kdeconnect-kde/commit/28f11bd5c9a717fb9fbb3f02ddd6cea62021d055 */ const SharePlugin = GObject.registerClass({ GTypeName: 'GSConnectSharePlugin', }, class SharePlugin extends Plugin { _init(device) { super._init(device, 'share'); } handlePacket(packet) { // TODO: composite jobs (lastModified, numberOfFiles, totalPayloadSize) if (packet.body.hasOwnProperty('filename')) { if (this.settings.get_boolean('receive-files')) this._handleFile(packet); else this._refuseFile(packet); } else if (packet.body.hasOwnProperty('text')) { this._handleText(packet); } else if (packet.body.hasOwnProperty('url')) { this._handleUri(packet); } } _ensureReceiveDirectory() { let receiveDir = this.settings.get_string('receive-directory'); // Ensure a directory is set if (receiveDir.length === 0) { receiveDir = GLib.get_user_special_dir( GLib.UserDirectory.DIRECTORY_DOWNLOAD ); // Fallback to ~/Downloads const homeDir = GLib.get_home_dir(); if (!receiveDir || receiveDir === homeDir) receiveDir = GLib.build_filenamev([homeDir, 'Downloads']); this.settings.set_string('receive-directory', receiveDir); } // Ensure the directory exists if (!GLib.file_test(receiveDir, GLib.FileTest.IS_DIR)) GLib.mkdir_with_parents(receiveDir, 448); return receiveDir; } _getFile(filename) { const dirpath = this._ensureReceiveDirectory(); const basepath = GLib.build_filenamev([dirpath, filename]); let filepath = basepath; let copyNum = 0; while (GLib.file_test(filepath, GLib.FileTest.EXISTS)) filepath = `${basepath} (${++copyNum})`; return Gio.File.new_for_path(filepath); } _refuseFile(packet) { try { this.device.rejectTransfer(packet); this.device.showNotification({ id: `${Date.now()}`, title: _('Transfer Failed'), // TRANSLATORS: eg. Google Pixel is not allowed to upload files body: _('%s is not allowed to upload files').format( this.device.name ), icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}), }); } catch (e) { debug(e, this.device.name); } } async _handleFile(packet) { try { const file = this._getFile(packet.body.filename); // Create the transfer const transfer = this.device.createTransfer(); transfer.addFile(packet, file); // Notify that we're about to start the transfer this.device.showNotification({ id: transfer.uuid, title: _('Transferring File'), // TRANSLATORS: eg. Receiving 'book.pdf' from Google Pixel body: _('Receiving “%s” from %s').format( packet.body.filename, this.device.name ), buttons: [{ label: _('Cancel'), action: 'cancelTransfer', parameter: new GLib.Variant('s', transfer.uuid), }], icon: new Gio.ThemedIcon({name: 'document-save-symbolic'}), }); // We'll show a notification (success or failure) let title, body, action, iconName; let buttons = []; try { await transfer.start(); title = _('Transfer Successful'); // TRANSLATORS: eg. Received 'book.pdf' from Google Pixel body = _('Received “%s” from %s').format( packet.body.filename, this.device.name ); action = { name: 'showPathInFolder', parameter: new GLib.Variant('s', file.get_uri()), }; buttons = [ { label: _('Show File Location'), action: 'showPathInFolder', parameter: new GLib.Variant('s', file.get_uri()), }, { label: _('Open File'), action: 'openPath', parameter: new GLib.Variant('s', file.get_uri()), }, ]; iconName = 'document-save-symbolic'; if (packet.body.open) { const uri = file.get_uri(); Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); } } catch (e) { debug(e, this.device.name); title = _('Transfer Failed'); // TRANSLATORS: eg. Failed to receive 'book.pdf' from Google Pixel body = _('Failed to receive “%s” from %s').format( packet.body.filename, this.device.name ); iconName = 'dialog-warning-symbolic'; // Clean up the downloaded file on failure file.delete_async(GLib.PRIORITY_DEAFAULT, null, null); } this.device.hideNotification(transfer.uuid); this.device.showNotification({ id: transfer.uuid, title: title, body: body, action: action, buttons: buttons, icon: new Gio.ThemedIcon({name: iconName}), }); } catch (e) { logError(e, this.device.name); } } _handleUri(packet) { const uri = packet.body.url; Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); } _handleText(packet) { const dialog = new Gtk.MessageDialog({ text: _('Text Shared By %s').format(this.device.name), secondary_text: URI.linkify(packet.body.text), secondary_use_markup: true, buttons: Gtk.ButtonsType.CLOSE, }); dialog.message_area.get_children()[1].selectable = true; dialog.set_keep_above(true); dialog.connect('response', (dialog) => dialog.destroy()); dialog.show(); } /** * Open the file chooser dialog for selecting a file or inputing a URI. */ share() { const dialog = new FileChooserDialog(this.device); dialog.show(); } /** * Share local file path or URI * * @param {string} path - Local file path or URI * @param {boolean} open - Whether the file should be opened after transfer */ async shareFile(path, open = false) { try { let file = null; if (path.includes('://')) file = Gio.File.new_for_uri(path); else file = Gio.File.new_for_path(path); // Create the transfer const transfer = this.device.createTransfer(); transfer.addFile({ type: 'kdeconnect.share.request', body: { filename: file.get_basename(), open: open, }, }, file); // Notify that we're about to start the transfer this.device.showNotification({ id: transfer.uuid, title: _('Transferring File'), // TRANSLATORS: eg. Sending 'book.pdf' to Google Pixel body: _('Sending “%s” to %s').format( file.get_basename(), this.device.name ), buttons: [{ label: _('Cancel'), action: 'cancelTransfer', parameter: new GLib.Variant('s', transfer.uuid), }], icon: new Gio.ThemedIcon({name: 'document-send-symbolic'}), }); // We'll show a notification (success or failure) let title, body, iconName; try { await transfer.start(); title = _('Transfer Successful'); // TRANSLATORS: eg. Sent "book.pdf" to Google Pixel body = _('Sent “%s” to %s').format( file.get_basename(), this.device.name ); iconName = 'document-send-symbolic'; } catch (e) { debug(e, this.device.name); title = _('Transfer Failed'); // TRANSLATORS: eg. Failed to send "book.pdf" to Google Pixel body = _('Failed to send “%s” to %s').format( file.get_basename(), this.device.name ); iconName = 'dialog-warning-symbolic'; } this.device.hideNotification(transfer.uuid); this.device.showNotification({ id: transfer.uuid, title: title, body: body, icon: new Gio.ThemedIcon({name: iconName}), }); } catch (e) { debug(e, this.device.name); } } /** * Share a string of text. Remote behaviour is undefined. * * @param {string} text - A string of unicode text */ shareText(text) { this.device.sendPacket({ type: 'kdeconnect.share.request', body: {text: text}, }); } /** * Share a URI. Generally the remote device opens it with the scheme default * * @param {string} uri - A URI to share */ shareUri(uri) { if (GLib.uri_parse_scheme(uri) === 'file') { this.shareFile(uri); return; } this.device.sendPacket({ type: 'kdeconnect.share.request', body: {url: uri}, }); } }); /** A simple FileChooserDialog for sharing files */ const FileChooserDialog = GObject.registerClass({ GTypeName: 'GSConnectShareFileChooserDialog', }, class FileChooserDialog extends Gtk.FileChooserDialog { _init(device) { super._init({ // TRANSLATORS: eg. Send files to Google Pixel title: _('Send files to %s').format(device.name), select_multiple: true, extra_widget: new Gtk.CheckButton({ // TRANSLATORS: Mark the file to be opened once completed label: _('Open when done'), visible: true, }), use_preview_label: false, }); this.device = device; // Align checkbox with sidebar const box = this.get_content_area().get_children()[0].get_children()[0]; const paned = box.get_children()[0]; paned.bind_property( 'position', this.extra_widget, 'margin-left', GObject.BindingFlags.SYNC_CREATE ); // Preview Widget this.preview_widget = new Gtk.Image(); this.preview_widget_active = false; this.connect('update-preview', this._onUpdatePreview); // URI entry this._uriEntry = new Gtk.Entry({ placeholder_text: 'https://', hexpand: true, visible: true, }); this._uriEntry.connect('activate', this._sendLink.bind(this)); // URI/File toggle this._uriButton = new Gtk.ToggleButton({ image: new Gtk.Image({ icon_name: 'web-browser-symbolic', pixel_size: 16, }), valign: Gtk.Align.CENTER, // TRANSLATORS: eg. Send a link to Google Pixel tooltip_text: _('Send a link to %s').format(device.name), visible: true, }); this._uriButton.connect('toggled', this._onUriButtonToggled.bind(this)); this.add_button(_('Cancel'), Gtk.ResponseType.CANCEL); const sendButton = this.add_button(_('Send'), Gtk.ResponseType.OK); sendButton.connect('clicked', this._sendLink.bind(this)); this.get_header_bar().pack_end(this._uriButton); this.set_default_response(Gtk.ResponseType.OK); } _onUpdatePreview(chooser) { try { const pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( chooser.get_preview_filename(), chooser.get_scale_factor() * 128, -1 ); chooser.preview_widget.pixbuf = pixbuf; chooser.preview_widget.visible = true; chooser.preview_widget_active = true; } catch (e) { chooser.preview_widget.visible = false; chooser.preview_widget_active = false; } } _onUriButtonToggled(button) { const header = this.get_header_bar(); // Show the URL entry if (button.active) { this.extra_widget.sensitive = false; header.set_custom_title(this._uriEntry); this.set_response_sensitive(Gtk.ResponseType.OK, true); // Hide the URL entry } else { header.set_custom_title(null); this.set_response_sensitive( Gtk.ResponseType.OK, this.get_uris().length > 1 ); this.extra_widget.sensitive = true; } } _sendLink(widget) { if (this._uriButton.active && this._uriEntry.text.length) this.response(1); } vfunc_response(response_id) { if (response_id === Gtk.ResponseType.OK) { for (const uri of this.get_uris()) { const parameter = new GLib.Variant( '(sb)', [uri, this.extra_widget.active] ); this.device.activate_action('shareFile', parameter); } } else if (response_id === 1) { const parameter = new GLib.Variant('s', this._uriEntry.text); this.device.activate_action('shareUri', parameter); } this.destroy(); } }); export default SharePlugin;