dot/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/service/device.js

1050 lines
31 KiB
JavaScript
Raw Normal View History

2020-05-11 09:16:27 +00:00
'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();
}
});