1050 lines
31 KiB
JavaScript
1050 lines
31 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
const Gio = imports.gi.Gio;
|
||
|
const GLib = imports.gi.GLib;
|
||
|
const GObject = imports.gi.GObject;
|
||
|
const Gtk = imports.gi.Gtk;
|
||
|
|
||
|
const Core = imports.service.protocol.core;
|
||
|
const DBus = imports.utils.dbus;
|
||
|
|
||
|
const UUID = 'org.gnome.Shell.Extensions.GSConnect.Device';
|
||
|
const INTERFACE_INFO = gsconnect.dbusinfo.lookup_interface(UUID);
|
||
|
|
||
|
|
||
|
/**
|
||
|
* An object representing a remote device.
|
||
|
*
|
||
|
* Device class is subclassed from Gio.SimpleActionGroup so it implements the
|
||
|
* GActionGroup and GActionMap interfaces, like Gio.Application.
|
||
|
*
|
||
|
* TODO...
|
||
|
*/
|
||
|
var Device = GObject.registerClass({
|
||
|
GTypeName: 'GSConnectDevice',
|
||
|
Properties: {
|
||
|
'connected': GObject.ParamSpec.boolean(
|
||
|
'connected',
|
||
|
'Connected',
|
||
|
'Whether the device is connected',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
null
|
||
|
),
|
||
|
'contacts': GObject.ParamSpec.object(
|
||
|
'contacts',
|
||
|
'Contacts',
|
||
|
'The contacts store for this device',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
GObject.Object
|
||
|
),
|
||
|
'encryption-info': GObject.ParamSpec.string(
|
||
|
'encryption-info',
|
||
|
'Encryption Info',
|
||
|
'A formatted string with the local and remote fingerprints',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
null
|
||
|
),
|
||
|
'icon-name': GObject.ParamSpec.string(
|
||
|
'icon-name',
|
||
|
'Icon Name',
|
||
|
'Icon name representing the device',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
null
|
||
|
),
|
||
|
'id': GObject.ParamSpec.string(
|
||
|
'id',
|
||
|
'deviceId',
|
||
|
'The device hostname or other unique id',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
''
|
||
|
),
|
||
|
'name': GObject.ParamSpec.string(
|
||
|
'name',
|
||
|
'deviceName',
|
||
|
'The device name',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
null
|
||
|
),
|
||
|
'paired': GObject.ParamSpec.boolean(
|
||
|
'paired',
|
||
|
'Paired',
|
||
|
'Whether the device is paired',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
null
|
||
|
),
|
||
|
'type': GObject.ParamSpec.string(
|
||
|
'type',
|
||
|
'deviceType',
|
||
|
'The device type',
|
||
|
GObject.ParamFlags.READABLE,
|
||
|
null
|
||
|
)
|
||
|
}
|
||
|
}, class Device extends Gio.SimpleActionGroup {
|
||
|
|
||
|
_init(identity) {
|
||
|
super._init();
|
||
|
|
||
|
this._channel = null;
|
||
|
this._id = identity.body.deviceId;
|
||
|
|
||
|
// GLib.Source timeout id's for pairing requests
|
||
|
this._incomingPairRequest = 0;
|
||
|
this._outgoingPairRequest = 0;
|
||
|
|
||
|
// Maps of name->Plugin, packet->Plugin, uuid->Transfer
|
||
|
this._plugins = new Map();
|
||
|
this._handlers = new Map();
|
||
|
this._transfers = new Map();
|
||
|
|
||
|
// GSettings
|
||
|
this.settings = new Gio.Settings({
|
||
|
settings_schema: gsconnect.gschema.lookup(UUID, true),
|
||
|
path: '/org/gnome/shell/extensions/gsconnect/device/' + this.id + '/'
|
||
|
});
|
||
|
|
||
|
// Watch for changes to supported and disabled plugins
|
||
|
this._disabledPluginsChangedId = this.settings.connect(
|
||
|
'changed::disabled-plugins',
|
||
|
this._onAllowedPluginsChanged.bind(this)
|
||
|
);
|
||
|
this._supportedPluginsChangedId = this.settings.connect(
|
||
|
'changed::supported-plugins',
|
||
|
this._onAllowedPluginsChanged.bind(this)
|
||
|
);
|
||
|
|
||
|
// Parse identity if initialized with a proper packet
|
||
|
if (identity.id !== undefined) {
|
||
|
this._handleIdentity(identity);
|
||
|
}
|
||
|
|
||
|
// Export an object path for the device
|
||
|
this._dbus_object = new Gio.DBusObjectSkeleton({
|
||
|
g_object_path: this.g_object_path
|
||
|
});
|
||
|
this.service.objectManager.export(this._dbus_object);
|
||
|
|
||
|
// Export GActions
|
||
|
this._actionsId = Gio.DBus.session.export_action_group(
|
||
|
this.g_object_path,
|
||
|
this
|
||
|
);
|
||
|
this._registerActions();
|
||
|
|
||
|
// Export GMenu
|
||
|
this.menu = new Gio.Menu();
|
||
|
this._menuId = Gio.DBus.session.export_menu_model(
|
||
|
this.g_object_path,
|
||
|
this.menu
|
||
|
);
|
||
|
|
||
|
// Export the Device interface
|
||
|
this._dbus = new DBus.Interface({
|
||
|
g_instance: this,
|
||
|
g_interface_info: INTERFACE_INFO
|
||
|
});
|
||
|
this._dbus_object.add_interface(this._dbus);
|
||
|
|
||
|
// Load plugins
|
||
|
this._loadPlugins();
|
||
|
}
|
||
|
|
||
|
get channel() {
|
||
|
if (this._channel === undefined) {
|
||
|
this._channel = null;
|
||
|
}
|
||
|
|
||
|
return this._channel;
|
||
|
}
|
||
|
|
||
|
get connected () {
|
||
|
if (this._connected === undefined) {
|
||
|
this._connected = false;
|
||
|
}
|
||
|
|
||
|
return this._connected;
|
||
|
}
|
||
|
|
||
|
get connection_type() {
|
||
|
let lastConnection = this.settings.get_string('last-connection');
|
||
|
|
||
|
return lastConnection.split('://')[0];
|
||
|
}
|
||
|
|
||
|
get contacts() {
|
||
|
let contacts = this._plugins.get('contacts');
|
||
|
|
||
|
if (contacts && contacts.settings.get_boolean('contacts-source')) {
|
||
|
return contacts._store;
|
||
|
} else {
|
||
|
return this.service.components.get('contacts');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// FIXME: backend should do this stuff
|
||
|
get encryption_info() {
|
||
|
let fingerprint = _('Not available');
|
||
|
|
||
|
// Bluetooth connections have no certificate so we use the host address
|
||
|
if (this.connection_type === 'bluetooth') {
|
||
|
// TRANSLATORS: Bluetooth address for remote device
|
||
|
return _('Bluetooth device at %s').format('???');
|
||
|
|
||
|
// If the device is connected use the certificate from the connection
|
||
|
} else if (this.connected) {
|
||
|
fingerprint = this._channel.peer_certificate.fingerprint();
|
||
|
|
||
|
// Otherwise pull it out of the settings
|
||
|
} else if (this.paired) {
|
||
|
fingerprint = Gio.TlsCertificate.new_from_pem(
|
||
|
this.settings.get_string('certificate-pem'),
|
||
|
-1
|
||
|
).fingerprint();
|
||
|
}
|
||
|
|
||
|
// TRANSLATORS: Label for TLS Certificate fingerprint
|
||
|
//
|
||
|
// Example:
|
||
|
//
|
||
|
// Google Pixel Fingerprint:
|
||
|
// 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00
|
||
|
return _('%s Fingerprint:').format(this.name) + '\n' +
|
||
|
fingerprint + '\n\n' +
|
||
|
_('%s Fingerprint:').format(this.service.name) + '\n' +
|
||
|
this.service.backends.get('lan').certificate.fingerprint();
|
||
|
}
|
||
|
|
||
|
get id() {
|
||
|
return this._id;
|
||
|
}
|
||
|
|
||
|
get name() {
|
||
|
return this.settings.get_string('name');
|
||
|
}
|
||
|
|
||
|
get paired() {
|
||
|
return this.settings.get_boolean('paired');
|
||
|
}
|
||
|
|
||
|
get icon_name() {
|
||
|
switch (this.type) {
|
||
|
case 'laptop':
|
||
|
return 'laptop-symbolic';
|
||
|
case 'phone':
|
||
|
return 'smartphone-symbolic';
|
||
|
case 'tablet':
|
||
|
return 'tablet-symbolic';
|
||
|
case 'tv':
|
||
|
return 'tv-symbolic';
|
||
|
default:
|
||
|
return 'computer-symbolic';
|
||
|
}
|
||
|
}
|
||
|
|
||
|
get service() {
|
||
|
return Gio.Application.get_default();
|
||
|
}
|
||
|
|
||
|
get type() {
|
||
|
return this.settings.get_string('type');
|
||
|
}
|
||
|
|
||
|
get g_object_path() {
|
||
|
return `${gsconnect.app_path}/Device/${this.id.replace(/\W+/g, '_')}`;
|
||
|
}
|
||
|
|
||
|
_handleIdentity(packet) {
|
||
|
// The type won't change, but it might not be properly set yet
|
||
|
if (this.type !== packet.body.deviceType) {
|
||
|
this.settings.set_string('type', packet.body.deviceType);
|
||
|
this.notify('type');
|
||
|
this.notify('icon-name');
|
||
|
}
|
||
|
|
||
|
// The name may change so we check and notify if so
|
||
|
if (this.name !== packet.body.deviceName) {
|
||
|
this.settings.set_string('name', packet.body.deviceName);
|
||
|
this.notify('name');
|
||
|
}
|
||
|
|
||
|
// Connection
|
||
|
if (this._channel) {
|
||
|
this.settings.set_string('last-connection', this._channel.address);
|
||
|
}
|
||
|
|
||
|
// Packets
|
||
|
let incoming = packet.body.incomingCapabilities.sort();
|
||
|
let outgoing = packet.body.outgoingCapabilities.sort();
|
||
|
let inc = this.settings.get_strv('incoming-capabilities');
|
||
|
let out = this.settings.get_strv('outgoing-capabilities');
|
||
|
|
||
|
// Only write GSettings if something has changed
|
||
|
if (incoming.join('') != inc.join('') || outgoing.join('') != out.join('')) {
|
||
|
this.settings.set_strv('incoming-capabilities', incoming);
|
||
|
this.settings.set_strv('outgoing-capabilities', outgoing);
|
||
|
}
|
||
|
|
||
|
// Determine supported plugins by matching incoming to outgoing types
|
||
|
let supported = [];
|
||
|
|
||
|
for (let name in imports.service.plugins) {
|
||
|
// Don't report mousepad support in Ubuntu Wayland sessions
|
||
|
if (name === 'mousepad' && !HAVE_REMOTEINPUT) continue;
|
||
|
|
||
|
let meta = imports.service.plugins[name].Metadata;
|
||
|
|
||
|
if (!meta) continue;
|
||
|
|
||
|
// If we can handle packets it sends...
|
||
|
if (meta.incomingCapabilities.some(t => outgoing.includes(t))) {
|
||
|
supported.push(name);
|
||
|
// ...or we send packets it can handle
|
||
|
} else if (meta.outgoingCapabilities.some(t => incoming.includes(t))) {
|
||
|
supported.push(name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Only write GSettings if something has changed
|
||
|
let currentSupported = this.settings.get_strv('supported-plugins');
|
||
|
supported.sort();
|
||
|
|
||
|
if (currentSupported.join('') !== supported.join('')) {
|
||
|
this.settings.set_strv('supported-plugins', supported);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* This is invoked by Core.Channel.attach() which also sets this._channel
|
||
|
*/
|
||
|
_setConnected() {
|
||
|
debug(`Connected to ${this.name} (${this.id})`);
|
||
|
|
||
|
if (!this.connected) {
|
||
|
this._connected = true;
|
||
|
this.notify('connected');
|
||
|
|
||
|
// Run the connected hook for each plugin
|
||
|
this._plugins.forEach(async (plugin) => {
|
||
|
try {
|
||
|
plugin.connected();
|
||
|
} catch (e) {
|
||
|
logError(e, `${this.name}: ${plugin.name}`);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* This is the callback for the Core.Channel's cancellable object
|
||
|
*/
|
||
|
_setDisconnected() {
|
||
|
debug(`Disconnected from ${this.name} (${this.id})`);
|
||
|
|
||
|
if (this.connected) {
|
||
|
this._channel = null;
|
||
|
this._connected = false;
|
||
|
this.notify('connected');
|
||
|
|
||
|
// Run the disconnected hook for each plugin
|
||
|
this._plugins.forEach(async (plugin) => {
|
||
|
try {
|
||
|
plugin.disconnected();
|
||
|
} catch (e) {
|
||
|
logError(e, `${this.name}: ${plugin.name}`);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_processExit(proc, result) {
|
||
|
try {
|
||
|
proc.wait_check_finish(result);
|
||
|
} catch (e) {
|
||
|
debug(e);
|
||
|
}
|
||
|
|
||
|
this.delete(proc);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Request a connection from the device
|
||
|
*/
|
||
|
activate() {
|
||
|
try {
|
||
|
let lastConnection = this.settings.get_value('last-connection');
|
||
|
this.service.activate_action('connect', lastConnection);
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Launch a subprocess for the device. If the device becomes unpaired, it is
|
||
|
* assumed the device is no longer trusted and all subprocesses will be
|
||
|
* killed.
|
||
|
*
|
||
|
* @param {string[]} args - process arguments
|
||
|
* @param {Gio.Cancellable} [cancellable] - optional cancellable
|
||
|
* @returns {Gio.Subprocess} - The subprocess
|
||
|
*/
|
||
|
launchProcess(args, cancellable = null) {
|
||
|
if (this._launcher === undefined) {
|
||
|
let application = GLib.build_filenamev([
|
||
|
gsconnect.extdatadir,
|
||
|
'service',
|
||
|
'daemon.js'
|
||
|
]);
|
||
|
|
||
|
this._launcher = new Gio.SubprocessLauncher();
|
||
|
this._launcher.setenv('GSCONNECT', application, false);
|
||
|
this._launcher.setenv('GSCONNECT_DEVICE_ID', this.id, false);
|
||
|
this._launcher.setenv('GSCONNECT_DEVICE_NAME', this.name, false);
|
||
|
this._launcher.setenv('GSCONNECT_DEVICE_ICON', this.icon_name, false);
|
||
|
this._launcher.setenv('GSCONNECT_DEVICE_DBUS', this.g_object_path, false);
|
||
|
|
||
|
this._procs = new Set();
|
||
|
}
|
||
|
|
||
|
// Create and track the process
|
||
|
let proc = this._launcher.spawnv(args);
|
||
|
proc.wait_check_async(cancellable, this._processExit.bind(this._procs));
|
||
|
this._procs.add(proc);
|
||
|
|
||
|
return proc;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Receive a packet from the attached channel and route it to its handler
|
||
|
*
|
||
|
* @param {Core.Packet} packet - The incoming packet object
|
||
|
*/
|
||
|
receivePacket(packet) {
|
||
|
try {
|
||
|
let handler = this._handlers.get(packet.type);
|
||
|
|
||
|
switch (true) {
|
||
|
// We handle pair requests
|
||
|
case (packet.type === 'kdeconnect.pair'):
|
||
|
this._handlePair(packet);
|
||
|
break;
|
||
|
|
||
|
// The device must think we're paired; inform it we are not
|
||
|
case !this.paired:
|
||
|
this.unpair();
|
||
|
break;
|
||
|
|
||
|
// This is a supported packet
|
||
|
case (handler !== undefined):
|
||
|
handler.handlePacket(packet);
|
||
|
break;
|
||
|
|
||
|
// This is an unsupported packet or disabled plugin
|
||
|
default:
|
||
|
throw new Error(`Unsupported packet type (${packet.type})`);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
debug(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Send a packet to the device
|
||
|
* @param {Object} packet - An object of packet data...
|
||
|
* @param {Gio.Stream} payload - A payload stream // TODO
|
||
|
*/
|
||
|
sendPacket(packet, payload = null) {
|
||
|
try {
|
||
|
if (this.connected && (this.paired || packet.type === 'kdeconnect.pair')) {
|
||
|
this._channel.send(packet);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Actions
|
||
|
*/
|
||
|
_registerActions() {
|
||
|
// Pairing notification actions
|
||
|
let acceptPair = new Gio.SimpleAction({name: 'pair'});
|
||
|
acceptPair.connect('activate', this.pair.bind(this));
|
||
|
this.add_action(acceptPair);
|
||
|
|
||
|
let rejectPair = new Gio.SimpleAction({name: 'unpair'});
|
||
|
rejectPair.connect('activate', this.unpair.bind(this));
|
||
|
this.add_action(rejectPair);
|
||
|
|
||
|
// Transfer notification actions
|
||
|
let cancelTransfer = new Gio.SimpleAction({
|
||
|
name: 'cancelTransfer',
|
||
|
parameter_type: new GLib.VariantType('s')
|
||
|
});
|
||
|
cancelTransfer.connect('activate', this.cancelTransfer.bind(this));
|
||
|
this.add_action(cancelTransfer);
|
||
|
|
||
|
let openPath = new Gio.SimpleAction({
|
||
|
name: 'openPath',
|
||
|
parameter_type: new GLib.VariantType('s')
|
||
|
});
|
||
|
openPath.connect('activate', this.openPath);
|
||
|
this.add_action(openPath);
|
||
|
|
||
|
// Preference helpers
|
||
|
let clearCache = new Gio.SimpleAction({
|
||
|
name: 'clearCache',
|
||
|
parameter_type: null
|
||
|
});
|
||
|
clearCache.connect('activate', this._clearCache.bind(this));
|
||
|
this.add_action(clearCache);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the position of a GMenuItem with @actionName in the top level of the
|
||
|
* device menu.
|
||
|
*
|
||
|
* @param {string} actionName - An action name with scope (eg. device.foo)
|
||
|
* @return {number} - An 0-based index or -1 if not found
|
||
|
*/
|
||
|
getMenuAction(actionName) {
|
||
|
for (let i = 0, len = this.menu.get_n_items(); i < len; i++) {
|
||
|
try {
|
||
|
let val = this.menu.get_item_attribute_value(i, 'action', null);
|
||
|
|
||
|
if (val.unpack() === actionName) {
|
||
|
return i;
|
||
|
}
|
||
|
} catch (e) {
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return -1;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add a GMenuItem to the top level of the device menu
|
||
|
*
|
||
|
* @param {Gio.MenuItem} menuItem - A GMenuItem
|
||
|
* @param {number} [index] - The position to place the item
|
||
|
* @return {number} - The position the item was placed
|
||
|
*/
|
||
|
addMenuItem(menuItem, index = -1) {
|
||
|
try {
|
||
|
if (index > -1) {
|
||
|
this.menu.insert_item(index, menuItem);
|
||
|
return index;
|
||
|
}
|
||
|
|
||
|
this.menu.append_item(menuItem);
|
||
|
return this.menu.get_n_items();
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
return -1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add a Device GAction to the top level of the device menu
|
||
|
*
|
||
|
* @param {Gio.Action} action - A GAction
|
||
|
* @param {number} [index] - The position to place the item
|
||
|
* @param {string} label - A label for the item
|
||
|
* @param {string} icon_name - A themed icon name for the item
|
||
|
* @return {number} - The position the item was placed
|
||
|
*/
|
||
|
addMenuAction(action, index = -1, label, icon_name) {
|
||
|
try {
|
||
|
// Create a GMenuItem for @action
|
||
|
let item = new Gio.MenuItem();
|
||
|
|
||
|
if (label)
|
||
|
item.set_label(label);
|
||
|
|
||
|
if (icon_name)
|
||
|
item.set_icon(new Gio.ThemedIcon({name: icon_name}));
|
||
|
|
||
|
item.set_attribute_value(
|
||
|
'hidden-when',
|
||
|
new GLib.Variant('s', 'action-disabled')
|
||
|
);
|
||
|
|
||
|
item.set_detailed_action(`device.${action.name}`);
|
||
|
|
||
|
return this.addMenuItem(item, index);
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
return -1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Remove a GAction from the top level of the device menu by action name
|
||
|
*
|
||
|
* @param {string} actionName - A GAction name, including scope
|
||
|
* @return {number} - The position the item was removed from or -1
|
||
|
*/
|
||
|
removeMenuAction(actionName) {
|
||
|
try {
|
||
|
let index = this.getMenuAction(actionName);
|
||
|
|
||
|
if (index > -1) {
|
||
|
this.menu.remove(index);
|
||
|
}
|
||
|
|
||
|
return index;
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
return -1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Replace a GAction in the top level of the device menu with the name
|
||
|
* @actionName and insert @item in its place. If @actionName is not found
|
||
|
* @item will appended to the device menu.
|
||
|
*
|
||
|
* @param {string} actionName - A GAction name, including scope
|
||
|
* @param (Gio.MenuItem} menuItem - A GMenuItem
|
||
|
* @return {number} - The position the item was placed
|
||
|
*/
|
||
|
replaceMenuAction(actionName, menuItem) {
|
||
|
try {
|
||
|
let index = this.removeMenuAction(actionName);
|
||
|
|
||
|
return this.addMenuItem(menuItem, index);
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
return -1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hide a notification, device analog for GApplication.withdraw_notification()
|
||
|
*
|
||
|
* @param {string} id - Id for the notification to withdraw
|
||
|
*/
|
||
|
hideNotification(id) {
|
||
|
this.service.withdraw_notification(`${this.id}|${id}`);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Show a notification, device analog for GApplication.send_notification()
|
||
|
*/
|
||
|
showNotification(params) {
|
||
|
params = Object.assign({
|
||
|
id: Date.now(),
|
||
|
title: this.name,
|
||
|
body: '',
|
||
|
icon: new Gio.ThemedIcon({name: this.icon_name}),
|
||
|
priority: Gio.NotificationPriority.NORMAL,
|
||
|
action: null,
|
||
|
buttons: []
|
||
|
}, params);
|
||
|
|
||
|
let notif = new Gio.Notification();
|
||
|
notif.set_title(params.title);
|
||
|
notif.set_body(params.body);
|
||
|
notif.set_icon(params.icon);
|
||
|
notif.set_priority(params.priority);
|
||
|
|
||
|
// Default Action
|
||
|
if (params.action) {
|
||
|
let hasParameter = (params.action.parameter !== null);
|
||
|
|
||
|
if (!hasParameter) {
|
||
|
params.action.parameter = new GLib.Variant('s', '');
|
||
|
}
|
||
|
|
||
|
notif.set_default_action_and_target(
|
||
|
'app.device',
|
||
|
new GLib.Variant('(ssbv)', [
|
||
|
this.id,
|
||
|
params.action.name,
|
||
|
hasParameter,
|
||
|
params.action.parameter
|
||
|
])
|
||
|
);
|
||
|
}
|
||
|
|
||
|
// Buttons
|
||
|
for (let button of params.buttons) {
|
||
|
let hasParameter = (button.parameter !== null);
|
||
|
|
||
|
if (!hasParameter) {
|
||
|
button.parameter = new GLib.Variant('s', '');
|
||
|
}
|
||
|
|
||
|
notif.add_button_with_target(
|
||
|
button.label,
|
||
|
'app.device',
|
||
|
new GLib.Variant('(ssbv)', [
|
||
|
this.id,
|
||
|
button.action,
|
||
|
hasParameter,
|
||
|
button.parameter
|
||
|
])
|
||
|
);
|
||
|
}
|
||
|
|
||
|
this.service.send_notification(`${this.id}|${params.id}`, notif);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* File Transfers
|
||
|
*/
|
||
|
cancelTransfer(action, parameter) {
|
||
|
try {
|
||
|
let uuid = parameter.unpack();
|
||
|
let transfer = this._transfers.get(uuid);
|
||
|
|
||
|
if (transfer !== undefined) {
|
||
|
transfer.close();
|
||
|
this._transfers.delete(uuid);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
createTransfer(params) {
|
||
|
try {
|
||
|
params.device = this;
|
||
|
|
||
|
return this._channel.createTransfer(params);
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
|
||
|
// Return a mock transfer that always appears to fail
|
||
|
return {
|
||
|
uuid: 'mock-transfer',
|
||
|
download: () => false,
|
||
|
upload: () => false
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Reject the transfer payload described by @packet by passing an invalid
|
||
|
* stream to Core.Transfer.
|
||
|
*
|
||
|
* @param {Core.Packet} packet - A packet
|
||
|
*/
|
||
|
async rejectTransfer(packet) {
|
||
|
if (!packet || !packet.hasPayload()) return;
|
||
|
|
||
|
try {
|
||
|
let transfer = this.createTransfer(Object.assign({
|
||
|
output_stream: null,
|
||
|
size: packet.payloadSize
|
||
|
}, packet.payloadTransferInfo));
|
||
|
|
||
|
await transfer.download();
|
||
|
} catch (e) {
|
||
|
}
|
||
|
}
|
||
|
|
||
|
openPath(action, parameter) {
|
||
|
let path = parameter.unpack();
|
||
|
|
||
|
// Normalize paths to URIs, assuming local file
|
||
|
let uri = path.includes('://') ? path : `file://${path}`;
|
||
|
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
|
||
|
}
|
||
|
|
||
|
_clearCache(action, parameter) {
|
||
|
try {
|
||
|
for (let plugin of this._plugins.values()) {
|
||
|
plugin.clearCache();
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Pair request handler
|
||
|
*
|
||
|
* @param {Core.Packet} packet - A complete kdeconnect.pair packet
|
||
|
*/
|
||
|
_handlePair(packet) {
|
||
|
// A pair has been requested/confirmed
|
||
|
if (packet.body.pair) {
|
||
|
// The device is accepting our request
|
||
|
if (this._outgoingPairRequest) {
|
||
|
this._setPaired(true);
|
||
|
this._loadPlugins();
|
||
|
|
||
|
// The device thinks we're unpaired
|
||
|
} else if (this.paired) {
|
||
|
this._setPaired(true);
|
||
|
this.pair();
|
||
|
this._loadPlugins();
|
||
|
|
||
|
// The device is requesting pairing
|
||
|
} else {
|
||
|
this._notifyPairRequest();
|
||
|
}
|
||
|
// Device is requesting unpairing/rejecting our request
|
||
|
} else {
|
||
|
this._setPaired(false);
|
||
|
this._unloadPlugins();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Notify the user of an incoming pair request and set a 30s timeout
|
||
|
*/
|
||
|
_notifyPairRequest() {
|
||
|
this.showNotification({
|
||
|
id: 'pair-request',
|
||
|
// TRANSLATORS: eg. Pair Request from Google Pixel
|
||
|
title: _('Pair Request from %s').format(this.name),
|
||
|
body: this.encryption_info,
|
||
|
icon: new Gio.ThemedIcon({name: 'channel-insecure-symbolic'}),
|
||
|
priority: Gio.NotificationPriority.URGENT,
|
||
|
buttons: [
|
||
|
{
|
||
|
action: 'unpair',
|
||
|
label: _('Reject'),
|
||
|
parameter: null
|
||
|
},
|
||
|
{
|
||
|
action: 'pair',
|
||
|
label: _('Accept'),
|
||
|
parameter: null
|
||
|
}
|
||
|
]
|
||
|
});
|
||
|
|
||
|
// Start a 30s countdown
|
||
|
this._resetPairRequest();
|
||
|
|
||
|
this._incomingPairRequest = GLib.timeout_add_seconds(
|
||
|
GLib.PRIORITY_DEFAULT,
|
||
|
30,
|
||
|
this._setPaired.bind(this, false)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Reset pair request timeouts and withdraw any notifications
|
||
|
*/
|
||
|
_resetPairRequest() {
|
||
|
if (this._incomingPairRequest) {
|
||
|
this.hideNotification('pair-request');
|
||
|
GLib.source_remove(this._incomingPairRequest);
|
||
|
this._incomingPairRequest = 0;
|
||
|
}
|
||
|
|
||
|
if (this._outgoingPairRequest) {
|
||
|
GLib.source_remove(this._outgoingPairRequest);
|
||
|
this._outgoingPairRequest = 0;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set the internal paired state of the device and emit ::notify
|
||
|
*
|
||
|
* @param {Boolean} bool - The paired state to set
|
||
|
*/
|
||
|
_setPaired(bool) {
|
||
|
this._resetPairRequest();
|
||
|
|
||
|
// For TCP connections we store or reset the TLS Certificate
|
||
|
if (this.connection_type === 'lan') {
|
||
|
if (bool) {
|
||
|
this.settings.set_string(
|
||
|
'certificate-pem',
|
||
|
this._channel.peer_certificate.certificate_pem
|
||
|
);
|
||
|
} else {
|
||
|
this.settings.reset('certificate-pem');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// If we've become unpaired, we'll kill any tracked subprocesses
|
||
|
if (!bool && this._procs !== undefined) {
|
||
|
for (let proc of this._procs) {
|
||
|
proc.force_exit();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.settings.set_boolean('paired', bool);
|
||
|
this.notify('paired');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Send or accept an incoming pair request; also exported as a GAction
|
||
|
*/
|
||
|
pair() {
|
||
|
try {
|
||
|
// We're accepting an incoming pair request...
|
||
|
if (this._incomingPairRequest) {
|
||
|
// so set the paired state to true...
|
||
|
this._setPaired(true);
|
||
|
// then loop back around to send confirmation...
|
||
|
this.pair();
|
||
|
// ...before loading plugins
|
||
|
this._loadPlugins();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Send a pair packet
|
||
|
this.sendPacket({
|
||
|
type: 'kdeconnect.pair',
|
||
|
body: {pair: true}
|
||
|
});
|
||
|
|
||
|
// We're initiating an outgoing pair request
|
||
|
if (!this.paired) {
|
||
|
this._resetPairRequest();
|
||
|
|
||
|
this._outgoingPairRequest = GLib.timeout_add_seconds(
|
||
|
GLib.PRIORITY_DEFAULT,
|
||
|
30,
|
||
|
this._setPaired.bind(this, false)
|
||
|
);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Unpair or reject an incoming pair request; also exported as a GAction
|
||
|
*/
|
||
|
unpair() {
|
||
|
try {
|
||
|
if (this.connected) {
|
||
|
this.sendPacket({
|
||
|
type: 'kdeconnect.pair',
|
||
|
body: {pair: false}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
this._setPaired(false);
|
||
|
this._unloadPlugins();
|
||
|
} catch (e) {
|
||
|
logError(e, this.name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Plugin Functions
|
||
|
*/
|
||
|
_onAllowedPluginsChanged(settings) {
|
||
|
let disabled = this.settings.get_strv('disabled-plugins');
|
||
|
let supported = this.settings.get_strv('supported-plugins');
|
||
|
let allowed = supported.filter(name => !disabled.includes(name));
|
||
|
|
||
|
// Unload any plugins that are disabled or unsupported
|
||
|
this._plugins.forEach(plugin => {
|
||
|
if (!allowed.includes(plugin.name)) {
|
||
|
this._unloadPlugin(plugin.name);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Make sure we change the contacts store if the plugin was disabled
|
||
|
if (!allowed.includes('contacts')) {
|
||
|
this.notify('contacts');
|
||
|
}
|
||
|
|
||
|
// Load allowed plugins
|
||
|
for (let name of allowed) {
|
||
|
this._loadPlugin(name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_loadPlugin(name) {
|
||
|
let handler, plugin;
|
||
|
|
||
|
try {
|
||
|
if (this.paired && !this._plugins.has(name)) {
|
||
|
// Instantiate the handler
|
||
|
handler = imports.service.plugins[name];
|
||
|
plugin = new handler.Plugin(this);
|
||
|
|
||
|
// Register packet handlers
|
||
|
for (let packetType of handler.Metadata.incomingCapabilities) {
|
||
|
this._handlers.set(packetType, plugin);
|
||
|
}
|
||
|
|
||
|
// Register plugin
|
||
|
this._plugins.set(name, plugin);
|
||
|
|
||
|
// Run the connected()/disconnected() handler
|
||
|
this.connected ? plugin.connected() : plugin.disconnected();
|
||
|
}
|
||
|
} catch (e) {
|
||
|
this.service.notify_error(e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async _loadPlugins() {
|
||
|
let disabled = this.settings.get_strv('disabled-plugins');
|
||
|
|
||
|
for (let name of this.settings.get_strv('supported-plugins')) {
|
||
|
if (!disabled.includes(name)) {
|
||
|
await this._loadPlugin(name);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
_unloadPlugin(name) {
|
||
|
let handler, plugin;
|
||
|
|
||
|
try {
|
||
|
if (this._plugins.has(name)) {
|
||
|
// Unregister packet handlers
|
||
|
handler = imports.service.plugins[name];
|
||
|
plugin = this._plugins.get(name);
|
||
|
|
||
|
for (let type of handler.Metadata.incomingCapabilities) {
|
||
|
this._handlers.delete(type);
|
||
|
}
|
||
|
|
||
|
// Unregister plugin
|
||
|
this._plugins.delete(name);
|
||
|
plugin.destroy();
|
||
|
}
|
||
|
} catch (e) {
|
||
|
logError(e, `${this.name}: unloading ${name}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async _unloadPlugins() {
|
||
|
for (let name of this._plugins.keys()) {
|
||
|
await this._unloadPlugin(name);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
destroy() {
|
||
|
// Close the channel if still connected
|
||
|
if (this._channel !== null) {
|
||
|
this._channel.close();
|
||
|
}
|
||
|
|
||
|
// Synchronously destroy plugins
|
||
|
this._plugins.forEach(plugin => plugin.destroy());
|
||
|
|
||
|
// Unexport GActions and GMenu
|
||
|
Gio.DBus.session.unexport_action_group(this._actionsId);
|
||
|
Gio.DBus.session.unexport_menu_model(this._menuId);
|
||
|
|
||
|
// Unexport the Device interface and object
|
||
|
this._dbus.flush();
|
||
|
this._dbus_object.remove_interface(this._dbus);
|
||
|
this._dbus_object.flush();
|
||
|
this.service.objectManager.unexport(this._dbus_object.g_object_path);
|
||
|
|
||
|
// Dispose GSettings
|
||
|
this.settings.disconnect(this._disabledPluginsChangedId);
|
||
|
this.settings.disconnect(this._supportedPluginsChangedId);
|
||
|
this.settings.run_dispose();
|
||
|
|
||
|
this.run_dispose();
|
||
|
}
|
||
|
});
|
||
|
|