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