1106 lines
32 KiB
JavaScript
Raw Normal View History

2024-07-08 22:46:35 +02:00
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Config from '../config.js';
import * as Components from './components/index.js';
import * as Core from './core.js';
import plugins from './plugins/index.js';
/**
* An object representing a remote device.
*
* Device class is subclassed from Gio.SimpleActionGroup so it implements the
* GActionGroup and GActionMap interfaces, like Gio.Application.
*
*/
const Device = GObject.registerClass({
GTypeName: 'GSConnectDevice',
Properties: {
'connected': GObject.ParamSpec.boolean(
'connected',
'Connected',
'Whether the device is connected',
GObject.ParamFlags.READABLE,
false
),
'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',
'Id',
'The device hostname or other network unique id',
GObject.ParamFlags.READABLE,
''
),
'name': GObject.ParamSpec.string(
'name',
'Name',
'The device name',
GObject.ParamFlags.READABLE,
null
),
'paired': GObject.ParamSpec.boolean(
'paired',
'Paired',
'Whether the device is paired',
GObject.ParamFlags.READABLE,
false
),
'type': GObject.ParamSpec.string(
'type',
'Type',
'The device type',
GObject.ParamFlags.READABLE,
null
),
},
}, class Device extends Gio.SimpleActionGroup {
_init(identity) {
super._init();
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._procs = new Set();
this._transfers = new Map();
this._outputLock = false;
this._outputQueue = [];
// GSettings
this.settings = new Gio.Settings({
settings_schema: Config.GSCHEMA.lookup(
'org.gnome.Shell.Extensions.GSConnect.Device',
true
),
path: `/org/gnome/shell/extensions/gsconnect/device/${this.id}/`,
});
this._migratePlugins();
// 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)
);
this._registerActions();
this.menu = new Gio.Menu();
// Parse identity if initialized with a proper packet, otherwise load
if (identity.id !== undefined)
this._handleIdentity(identity);
else
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() {
const lastConnection = this.settings.get_string('last-connection');
return lastConnection.split('://')[0];
}
get contacts() {
const contacts = this._plugins.get('contacts');
if (contacts && contacts.settings.get_boolean('contacts-source'))
return contacts._store;
if (this._contacts === undefined)
this._contacts = Components.acquire('contacts');
return this._contacts;
}
// FIXME: backend should do this stuff
get encryption_info() {
let localCert = null;
let remoteCert = null;
// 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) {
remoteCert = this.channel.peer_certificate;
// Otherwise pull it out of the settings
} else if (this.paired) {
remoteCert = Gio.TlsCertificate.new_from_pem(
this.settings.get_string('certificate-pem'),
-1
);
}
// FIXME: another ugly reach-around
let lanBackend;
if (this.service !== null)
lanBackend = this.service.manager.backends.get('lan');
if (lanBackend && lanBackend.certificate)
localCert = lanBackend.certificate;
let verificationKey = '';
if (localCert && remoteCert) {
let a = localCert.pubkey_der();
let b = remoteCert.pubkey_der();
if (a.compare(b) < 0)
[a, b] = [b, a]; // swap
const checksum = new GLib.Checksum(GLib.ChecksumType.SHA256);
checksum.update(a.toArray());
checksum.update(b.toArray());
verificationKey = checksum.get_string();
}
// TRANSLATORS: Label for TLS connection verification key
//
// Example:
//
// Verification key: 0123456789abcdef000000000000000000000000
return _('Verification key: %s').format(verificationKey);
}
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';
case 'desktop':
default:
return 'computer-symbolic';
}
}
get service() {
if (this._service === undefined)
this._service = Gio.Application.get_default();
return this._service;
}
get type() {
return this.settings.get_string('type');
}
_migratePlugins() {
const deprecated = ['photo'];
const supported = this.settings
.get_strv('supported-plugins')
.filter(name => !deprecated.includes(name));
this.settings.set_strv('supported-plugins', supported);
}
_handleIdentity(packet) {
this.freeze_notify();
// If we're connected, record the reconnect URI
if (this.channel !== null)
this.settings.set_string('last-connection', this.channel.address);
// 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');
}
// Packets
const incoming = packet.body.incomingCapabilities.sort();
const outgoing = packet.body.outgoingCapabilities.sort();
const inc = this.settings.get_strv('incoming-capabilities');
const 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
const supported = [];
for (const name in plugins) {
const meta = plugins[name].Metadata;
if (meta === undefined)
continue;
// If we can handle packets it sends or send packets it can handle
if (meta.incomingCapabilities.some(t => outgoing.includes(t)) ||
meta.outgoingCapabilities.some(t => incoming.includes(t)))
supported.push(name);
}
// Only write GSettings if something has changed
const currentSupported = this.settings.get_strv('supported-plugins');
if (currentSupported.join('') !== supported.sort().join(''))
this.settings.set_strv('supported-plugins', supported);
this.thaw_notify();
}
/**
* Set the channel and start sending/receiving packets. If %null is passed
* the device becomes disconnected.
*
* @param {Core.Channel} [channel] - The new channel
*/
setChannel(channel = null) {
if (this.channel === channel)
return;
if (this.channel !== null)
this.channel.close();
this._channel = channel;
// If we've disconnected empty the queue, otherwise restart the read
// loop and update the device metadata
if (this.channel === null) {
this._outputQueue.length = 0;
} else {
this._handleIdentity(this.channel.identity);
this._readLoop(channel);
}
// The connected state didn't change
if (this.connected === !!this.channel)
return;
// Notify and trigger plugins
this._connected = !!this.channel;
this.notify('connected');
this._triggerPlugins();
}
async _readLoop(channel) {
try {
let packet = null;
while ((packet = await this.channel.readPacket())) {
debug(packet, this.name);
this.handlePacket(packet);
}
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
debug(e, this.name);
if (this.channel === channel)
this.setChannel(null);
}
}
_processExit(proc, result) {
try {
proc.wait_check_finish(result);
} catch (e) {
debug(e);
}
this.delete(proc);
}
/**
* 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
* @return {Gio.Subprocess} The subprocess
*/
launchProcess(args, cancellable = null) {
if (this._launcher === undefined) {
const application = GLib.build_filenamev([
Config.PACKAGE_DATADIR,
'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',
`${Config.APP_PATH}/Device/${this.id.replace(/\W+/g, '_')}`,
false
);
}
// Create and track the process
const proc = this._launcher.spawnv(args);
proc.wait_check_async(cancellable, this._processExit.bind(this._procs));
this._procs.add(proc);
return proc;
}
/**
* Handle a packet and pass it to the appropriate plugin.
*
* @param {Core.Packet} packet - The incoming packet object
* @return {undefined} no return value
*/
handlePacket(packet) {
try {
if (packet.type === 'kdeconnect.pair')
return this._handlePair(packet);
// The device must think we're paired; inform it we are not
if (!this.paired)
return this.unpair();
const handler = this._handlers.get(packet.type);
if (handler !== undefined)
handler.handlePacket(packet);
else
debug(`Unsupported packet type (${packet.type})`, this.name);
} catch (e) {
debug(e, this.name);
}
}
/**
* Send a packet to the device.
*
* @param {Object} packet - An object of packet data...
*/
async sendPacket(packet) {
try {
if (!this.connected)
return;
if (!this.paired && packet.type !== 'kdeconnect.pair')
return;
this._outputQueue.push(new Core.Packet(packet));
if (this._outputLock)
return;
this._outputLock = true;
let next;
while ((next = this._outputQueue.shift())) {
await this.channel.sendPacket(next);
debug(next, this.name);
}
this._outputLock = false;
} catch (e) {
if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
debug(e, this.name);
this._outputLock = false;
}
}
/**
* Actions
*/
_registerActions() {
// Pairing notification actions
const acceptPair = new Gio.SimpleAction({name: 'pair'});
acceptPair.connect('activate', this.pair.bind(this));
this.add_action(acceptPair);
const rejectPair = new Gio.SimpleAction({name: 'unpair'});
rejectPair.connect('activate', this.unpair.bind(this));
this.add_action(rejectPair);
// Transfer notification actions
const cancelTransfer = new Gio.SimpleAction({
name: 'cancelTransfer',
parameter_type: new GLib.VariantType('s'),
});
cancelTransfer.connect('activate', this.cancelTransfer.bind(this));
this.add_action(cancelTransfer);
const openPath = new Gio.SimpleAction({
name: 'openPath',
parameter_type: new GLib.VariantType('s'),
});
openPath.connect('activate', this.openPath);
this.add_action(openPath);
const showPathInFolder = new Gio.SimpleAction({
name: 'showPathInFolder',
parameter_type: new GLib.VariantType('s'),
});
showPathInFolder.connect('activate', this.showPathInFolder);
this.add_action(showPathInFolder);
// Preference helpers
const 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 {
const 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) {
debug(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 {
const 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) {
debug(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 {
const index = this.getMenuAction(actionName);
if (index > -1)
this.menu.remove(index);
return index;
} catch (e) {
debug(e, this.name);
return -1;
}
}
/**
* Withdraw a device notification.
*
* @param {string} id - Id for the notification to withdraw
*/
hideNotification(id) {
if (this.service === null)
return;
this.service.withdraw_notification(`${this.id}|${id}`);
}
/**
* Show a device notification.
*
* @param {Object} params - A dictionary of notification parameters
* @param {number} [params.id] - A UNIX epoch timestamp (ms)
* @param {string} [params.title] - A title
* @param {string} [params.body] - A body
* @param {Gio.Icon} [params.icon] - An icon
* @param {Gio.NotificationPriority} [params.priority] - The priority
* @param {Array} [params.actions] - A dictionary of action parameters
* @param {Array} [params.buttons] - An Array of buttons
*/
showNotification(params) {
if (this.service === null)
return;
// KDE Connect on Android can sometimes give an undefined for params.body
Object.keys(params)
.forEach(key => params[key] === undefined && delete params[key]);
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);
const 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) {
const 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 (const button of params.buttons) {
const 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);
}
/**
* Cancel an ongoing file transfer.
*
* @param {Gio.Action} action - The GAction
* @param {GLib.Variant} parameter - The activation parameter
*/
cancelTransfer(action, parameter) {
try {
const uuid = parameter.unpack();
const transfer = this._transfers.get(uuid);
if (transfer === undefined)
return;
this._transfers.delete(uuid);
transfer.cancel();
} catch (e) {
logError(e, this.name);
}
}
/**
* Create a transfer object.
*
* @return {Core.Transfer} A new transfer
*/
createTransfer() {
const transfer = new Core.Transfer({device: this});
// Track the transfer
this._transfers.set(transfer.uuid, transfer);
transfer.connect('notify::completed', (transfer) => {
this._transfers.delete(transfer.uuid);
});
return transfer;
}
/**
* Reject the transfer payload described by @packet.
*
* @param {Core.Packet} packet - A packet
* @return {Promise} A promise for the operation
*/
rejectTransfer(packet) {
if (!packet || !packet.hasPayload())
return;
return this.channel.rejectTransfer(packet);
}
openPath(action, parameter) {
const path = parameter.unpack();
// Normalize paths to URIs, assuming local file
const uri = path.includes('://') ? path : `file://${path}`;
Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
}
showPathInFolder(action, parameter) {
const path = parameter.unpack();
const uri = path.includes('://') ? path : `file://${path}`;
const connection = Gio.DBus.session;
connection.call(
'org.freedesktop.FileManager1',
'/org/freedesktop/FileManager1',
'org.freedesktop.FileManager1',
'ShowItems',
new GLib.Variant('(ass)', [[uri], 's']),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(connection, res) => {
try {
connection.call_finish(res);
} catch (e) {
Gio.DBusError.strip_remote_error(e);
logError(e);
}
}
);
}
_clearCache(action, parameter) {
for (const plugin of this._plugins.values()) {
try {
plugin.clearCache();
} catch (e) {
debug(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.sendPacket({
type: 'kdeconnect.pair',
body: {pair: true},
});
this._triggerPlugins();
// 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() {
// Reset any active request
this._resetPairRequest();
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._incomingPairRequest = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
30,
this._setPaired.bind(this, false)
);
}
/**
* Reset pair request timeouts and withdraw any notifications
*/
_resetPairRequest() {
this.hideNotification('pair-request');
if (this._incomingPairRequest) {
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} paired - The paired state to set
*/
_setPaired(paired) {
this._resetPairRequest();
// For TCP connections we store or reset the TLS Certificate
if (this.connection_type === 'lan') {
if (paired) {
this.settings.set_string(
'certificate-pem',
this.channel.peer_certificate.certificate_pem
);
} else {
this.settings.reset('certificate-pem');
}
}
// If we've become unpaired, stop all subprocesses and transfers
if (!paired) {
for (const proc of this._procs)
proc.force_exit();
this._procs.clear();
for (const transfer of this._transfers.values())
transfer.close();
this._transfers.clear();
}
this.settings.set_boolean('paired', paired);
this.notify('paired');
}
/**
* Send or accept an incoming pair request; also exported as a GAction
*/
pair() {
try {
// If we're accepting an incoming pair request, set the internal
// paired state and send the confirmation before loading plugins.
if (this._incomingPairRequest) {
this._setPaired(true);
this.sendPacket({
type: 'kdeconnect.pair',
body: {pair: true},
});
this._loadPlugins();
// If we're initiating an outgoing pair request, be sure the timer
// is reset before sending the request and setting a 30s timeout.
} else if (!this.paired) {
this._resetPairRequest();
this.sendPacket({
type: 'kdeconnect.pair',
body: {pair: true},
});
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) {
const disabled = this.settings.get_strv('disabled-plugins');
const supported = this.settings.get_strv('supported-plugins');
const 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 (const name of allowed)
this._loadPlugin(name);
}
_loadPlugin(name) {
let handler, plugin;
try {
if (this.paired && !this._plugins.has(name)) {
// Instantiate the handler
handler = plugins[name];
plugin = new handler.default(this);
// Register packet handlers
for (const packetType of handler.Metadata.incomingCapabilities)
this._handlers.set(packetType, plugin);
// Register plugin
this._plugins.set(name, plugin);
// Run the connected()/disconnected() handler
if (this.connected)
plugin.connected();
else
plugin.disconnected();
}
} catch (e) {
if (plugin !== undefined)
plugin.destroy();
if (this.service !== null)
this.service.notify_error(e);
else
logError(e, this.name);
}
}
async _loadPlugins() {
const disabled = this.settings.get_strv('disabled-plugins');
for (const 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 = plugins[name];
for (const type of handler.Metadata.incomingCapabilities)
this._handlers.delete(type);
// Unregister plugin
plugin = this._plugins.get(name);
this._plugins.delete(name);
plugin.destroy();
}
} catch (e) {
logError(e, this.name);
}
}
async _unloadPlugins() {
for (const name of this._plugins.keys())
await this._unloadPlugin(name);
}
_triggerPlugins() {
for (const plugin of this._plugins.values()) {
if (this.connected)
plugin.connected();
else
plugin.disconnected();
}
}
destroy() {
// Drop the default contacts store if we were using it
if (this._contacts !== undefined)
this._contacts = Components.release('contacts');
// Close the channel if still connected
if (this.channel !== null)
this.channel.close();
// Synchronously destroy plugins
this._plugins.forEach(plugin => plugin.destroy());
this._plugins.clear();
// Dispose GSettings
this.settings.disconnect(this._disabledPluginsChangedId);
this.settings.disconnect(this._supportedPluginsChangedId);
this.settings.run_dispose();
GObject.signal_handlers_destroy(this);
}
});
export default Device;