'use strict'; const GdkPixbuf = imports.gi.GdkPixbuf; const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; const GObject = imports.gi.GObject; const Gtk = imports.gi.Gtk; const PluginsBase = imports.service.plugins.base; const URI = imports.utils.uri; var Metadata = { label: _('Share'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Share', 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 */ var Plugin = GObject.registerClass({ GTypeName: 'GSConnectSharePlugin', }, class Plugin extends PluginsBase.Plugin { _init(device) { super._init(device, 'share'); } _ensureReceiveDirectory() { let receiveDir = this.settings.get_string('receive-directory'); // Ensure a directory is set if (!receiveDir) { receiveDir = GLib.get_user_special_dir( GLib.UserDirectory.DIRECTORY_DOWNLOAD ); // Fallback to ~/Downloads let 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) { let dirpath = this._ensureReceiveDirectory(); let basepath = GLib.build_filenamev([dirpath, filename]); let filepath = basepath; let copyNum = 0; while (GLib.file_test(filepath, GLib.FileTest.EXISTS)) { copyNum += 1; filepath = `${basepath} (${copyNum})`; } return Gio.File.new_for_path(filepath); } async _refuseFile(packet) { try { await 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) { logError(e, this.device.name); } } async _handleFile(packet) { let file, stream, success, transfer; let title, body, iconName; let buttons = []; try { file = this._getFile(packet.body.filename); stream = await new Promise((resolve, reject) => { file.replace_async(null, false, 0, 0, null, (file, res) => { try { resolve(file.replace_finish(res)); } catch (e) { reject(e); } }); }); transfer = this.device.createTransfer(Object.assign({ output_stream: stream, size: packet.payloadSize }, packet.payloadTransferInfo)); // 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'}) }); // Start transfer success = await transfer.download(); this.device.hideNotification(transfer.uuid); // We've been asked to open this directly if (success && packet.body.open) { let uri = file.get_uri(); Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); return; } // We'll show a notification (success or failure) if (success) { title = _('Transfer Successful'); // TRANSLATORS: eg. Received 'book.pdf' from Google Pixel body = _('Received “%s” from %s').format( packet.body.filename, this.device.name ); buttons = [ { label: _('Open Folder'), action: 'openPath', parameter: new GLib.Variant('s', file.get_parent().get_uri()) }, { label: _('Open File'), action: 'openPath', parameter: new GLib.Variant('s', file.get_uri()) } ]; iconName = 'document-save-symbolic'; } else { 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(null); } this.device.showNotification({ id: transfer.uuid, title: title, body: body, buttons: buttons, icon: new Gio.ThemedIcon({name: iconName}) }); } catch (e) { logError(e, this.device.name); } } _handleUri(packet) { let uri = packet.body.url; Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); } _handleText(packet) { let 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(); } /** * Packet dispatch */ handlePacket(packet) { 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); } } /** * Remote methods */ share() { let 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) { let file, stream, success, transfer; let title, body, iconName; try { if (path.includes('://')) { file = Gio.File.new_for_uri(path); } else { file = Gio.File.new_for_path(path); } stream = await new Promise((resolve, reject) => { file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => { try { resolve(file.read_finish(res)); } catch (e) { reject(e); } }); }); transfer = this.device.createTransfer({ input_stream: stream, size: file.query_info('standard::size', 0, null).get_size() }); // 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'}) }); success = await transfer.upload({ type: 'kdeconnect.share.request', body: { filename: file.get_basename(), open: open } }); if (success) { 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'; } else { 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 - Currently http(s) and tel: URIs are supported */ shareUri(uri) { switch (true) { // Currently only pass http(s)/tel URIs case uri.startsWith('http://'): case uri.startsWith('https://'): case uri.startsWith('tel:'): break; // Redirect local file URIs case uri.startsWith('file://'): return this.sendFile(uri); // Assume HTTPS default: uri = `https://${uri}`; } this.device.sendPacket({ type: 'kdeconnect.share.request', body: {url: uri} }); } }); /** A simple FileChooserDialog for sharing files */ var 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 let box = this.get_content_area().get_children()[0].get_children()[0]; let 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); let 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 { let 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) { let 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) { this.get_uris().map(uri => { let parameter = new GLib.Variant( '(sb)', [uri, this.extra_widget.active] ); this.device.activate_action('shareFile', parameter); }); } else if (response_id === 1) { let parameter = new GLib.Variant('s', this._uriEntry.text); this.device.activate_action('shareUri', parameter); } this.destroy(); } });