2024-07-08 22:46:35 +02:00

493 lines
16 KiB
JavaScript
Executable File

// 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;