#!/usr/bin/env gjs 'use strict'; imports.gi.versions.Gdk = '3.0'; imports.gi.versions.GdkPixbuf = '2.0'; imports.gi.versions.Gio = '2.0'; imports.gi.versions.GIRepository = '2.0'; imports.gi.versions.GLib = '2.0'; imports.gi.versions.GObject = '2.0'; imports.gi.versions.Gtk = '3.0'; imports.gi.versions.Pango = '1.0'; imports.gi.versions.UPowerGlib = '1.0'; const Gdk = imports.gi.Gdk; const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; const GObject = imports.gi.GObject; const Gtk = imports.gi.Gtk; // Find the root datadir of the extension function get_datadir() { let m = /@(.+):\d+/.exec((new Error()).stack.split('\n')[1]); return Gio.File.new_for_path(m[1]).get_parent().get_parent().get_path(); } window.gsconnect = {extdatadir: get_datadir()}; imports.searchPath.unshift(gsconnect.extdatadir); imports._gsconnect; // Local Imports const Core = imports.service.protocol.core; const Device = imports.service.device; const ServiceUI = imports.service.ui.service; const _GITHUB = 'https://github.com/andyholmes/gnome-shell-extension-gsconnect'; const Service = GObject.registerClass({ GTypeName: 'GSConnectService', Properties: { 'discoverable': GObject.ParamSpec.boolean( 'discoverable', 'Discoverable', 'Whether the service responds to discovery requests', GObject.ParamFlags.READWRITE, false ), 'id': GObject.ParamSpec.string( 'id', 'Id', 'The service id', GObject.ParamFlags.READWRITE, null ), 'name': GObject.ParamSpec.string( 'name', 'deviceName', 'The name announced to the network', GObject.ParamFlags.READWRITE, 'GSConnect' ) } }, class Service extends Gtk.Application { _init() { super._init({ application_id: gsconnect.app_id, flags: Gio.ApplicationFlags.HANDLES_OPEN }); GLib.set_prgname('GSConnect'); GLib.set_application_name('GSConnect'); // Track devices with id as key this._devices = new Map(); // Command-line this._initOptions(); } get backends() { if (this._backends === undefined) { this._backends = new Map(); } return this._backends; } get components() { if (this._components === undefined) { this._components = new Map(); } return this._components; } get devices() { return Array.from(this._devices.values()); } get identity() { if (this._identity === undefined) { this._identity = new Core.Packet({ id: 0, type: 'kdeconnect.identity', body: { deviceId: this.id, deviceName: this.name, deviceType: this._getDeviceType(), protocolVersion: 7, incomingCapabilities: [], outgoingCapabilities: [] } }); 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; meta.incomingCapabilities.map(type => { this._identity.body.incomingCapabilities.push(type); }); meta.outgoingCapabilities.map(type => { this._identity.body.outgoingCapabilities.push(type); }); } } return this._identity; } /** * Helpers */ _getDeviceType() { try { let type = GLib.file_get_contents('/sys/class/dmi/id/chassis_type')[1]; type = Number(imports.byteArray.toString(type)); if ([8, 9, 10, 14].includes(type)) { return 'laptop'; } return 'desktop'; } catch (e) { return 'desktop'; } } /** * Return a device for @packet, creating it and adding it to the list of * of known devices if it doesn't exist. * * @param {kdeconnect.identity} packet - An identity packet for the device * @return {Device.Device} - A device object */ _ensureDevice(packet) { let device = this._devices.get(packet.body.deviceId); if (device === undefined) { debug(`Adding ${packet.body.deviceName}`); // TODO: Remove when all clients support bluetooth-like discovery // // If this is the third unpaired device to connect, we disable // discovery to avoid choking on networks with many devices let unpaired = Array.from(this._devices.values()).filter(dev => { return !dev.paired; }); if (unpaired.length === 3 && this.discoverable) { this.discoverable = false; let error = new Error(); error.name = 'DiscoveryWarning'; this.notify_error(error); } device = new Device.Device(packet); this._devices.set(device.id, device); // Notify this.settings.set_strv( 'devices', Array.from(this._devices.keys()) ); } return device; } /** * Permanently remove a device. * * Removes the device from the list of known devices, deletes all GSettings * and files. * * @param {String} id - The id of the device to delete */ _removeDevice(id) { // Delete all GSettings let settings_path = '/org/gnome/shell/extensions/gsconnect/' + id + '/'; GLib.spawn_command_line_async(`dconf reset -f ${settings_path}`); // Delete the cache let cache = GLib.build_filenamev([gsconnect.cachedir, id]); Gio.File.rm_rf(cache); // Forget the device this._devices.delete(id); this.settings.set_strv( 'devices', Array.from(this._devices.keys()) ); } /** * GSettings */ _initSettings() { this.settings = new Gio.Settings({ settings_schema: gsconnect.gschema.lookup(gsconnect.app_id, true) }); // Bound Properties this.settings.bind('discoverable', this, 'discoverable', 0); this.settings.bind('id', this, 'id', 0); this.settings.bind('name', this, 'name', 0); // Set the default name to the computer's hostname if (this.name.length === 0) { this.settings.set_string('name', GLib.get_host_name()); } // Keep identity updated and broadcast any name changes this._nameChangedId = this.settings.connect( 'changed::name', this._onNameChanged.bind(this) ); } _onNameChanged(settings, key) { this.identity.body.deviceName = this.name; this._identify(); } /** * GActions */ _initActions() { let actions = [ ['connect', this._identify.bind(this), 's'], ['device', this._device.bind(this), '(ssbv)'], ['error', this._error.bind(this), 'a{ss}'], ['preferences', this._preferences], ['quit', () => this.quit()], ['refresh', this._identify.bind(this)], ['wiki', this._wiki.bind(this), 's'] ]; for (let [name, callback, type] of actions) { let action = new Gio.SimpleAction({ name: name, parameter_type: (type) ? new GLib.VariantType(type) : null }); action.connect('activate', callback); this.add_action(action); } } /** * A wrapper for Device GActions. This is used to route device notification * actions to their device, since GNotifications need an 'app' level action. * * @param {Gio.Action} action - ... * @param {GLib.Variant(av)} parameter - ... * @param {GLib.Variant(s)} parameter[0] - Device Id or '*' for all * @param {GLib.Variant(s)} parameter[1] - GAction name * @param {GLib.Variant(b)} parameter[2] - %false if the parameter is null * @param {GLib.Variant(v)} parameter[3] - GAction parameter */ _device(action, parameter) { try { parameter = parameter.unpack(); // Select the appropriate device(s) let devices; let id = parameter[0].unpack(); if (id === '*') { devices = this._devices.values(); } else { devices = [this._devices.get(id)]; } // Unpack the action data let name = parameter[1].unpack(); let target = parameter[2].unpack() ? parameter[3].unpack() : null; // Activate the action on each available device for (let device of devices) { if (device) { device.activate_action(name, target); } } } catch (e) { logError(e); } } _error(action, parameter) { try { let error = parameter.deepUnpack(); let dialog = new Gtk.MessageDialog({ text: error.message, secondary_text: error.stack, buttons: Gtk.ButtonsType.CLOSE, message_type: Gtk.MessageType.ERROR, }); dialog.add_button(_('Report'), Gtk.ResponseType.OK); dialog.set_keep_above(true); let [message, stack] = dialog.get_message_area().get_children(); message.halign = Gtk.Align.START; message.selectable = true; stack.selectable = true; dialog.connect('response', (dialog, response_id) => { if (response_id === Gtk.ResponseType.OK) { let query = encodeURIComponent(dialog.text).replace('%20', '+'); this._github(`issues?q=is%3Aissue+"${query}"`); } else { dialog.destroy(); } }); dialog.show(); } catch (e) { logError(e); } } _identify(action, parameter) { try { // If we're passed a parameter, try and find a backend for it if (parameter instanceof GLib.Variant) { let uri = parameter.unpack(); let [scheme, address] = uri.split('://'); let backend = this.backends.get(scheme); if (backend) { backend.broadcast(address); } // If we're not discoverable, only try to reconnect known devices } else if (!this.discoverable) { this._reconnect(); // Otherwise have each backend broadcast to it's network } else { for (let backend of this.backends.values()) { backend.broadcast(); } } } catch (e) { logError(e); } } _preferences() { let proc = new Gio.Subprocess({ argv: [gsconnect.extdatadir + '/gsconnect-preferences'] }); proc.init(null); proc.wait_async(null, null); } /** * A GSourceFunc that tries to reconnect to each paired device, while * pruning unpaired devices that have disconnected. */ _reconnect() { for (let [id, device] of this._devices.entries()) { switch (true) { case device.connected: break; case device.paired: device.activate(); break; default: this._removeDevice(id); device.destroy(); } } return GLib.SOURCE_CONTINUE; } _wiki(action, parameter) { this._github(`wiki/${parameter.unpack()}`); } _github(path = []) { let uri = [_GITHUB].concat(path.split('/')).join('/'); Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null); } /** * Components */ _initComponents() { for (let name in imports.service.components) { try { let module = imports.service.components[name]; if (module.hasOwnProperty('Component')) { let component = new module.Component(); this.components.set(name, component); } } catch (e) { logError(e, `'${name}' Component`); } } } /** * Backends * * These are the implementations of Core.ChannelService that emit * Core.ChannelService::channel with objects implementing Core.Channel. */ _onChannel(backend, channel) { try { let device = this._devices.get(channel.identity.body.deviceId); switch (true) { // Proceed if this is an existing device... case (device !== undefined): break; // Or the service is discoverable... case this.discoverable: device = this._ensureDevice(channel.identity); break; // ...otherwise bail default: debug(`${channel.identity.body.deviceName}: not allowed`); return false; } channel.attach(device); return true; } catch (e) { logError(e, backend.name); return false; } } _initBackends() { let backends = [ 'lan' ]; for (let name of backends) { try { // Try to create the backend and track it if successful let module = imports.service.protocol[name]; let backend = new module.ChannelService(); this.backends.set(name, backend); // Connect to the backend backend.__channelId = backend.connect( 'channel', this._onChannel.bind(this) ); // Now try to start the backend, allowing us to retry if we fail backend.start(); } catch (e) { this.notify_error(e); } } } /** * Remove a local libnotify or Gtk notification. * * @param {String|Number} id - Gtk (string) or libnotify id (uint32) * @param {String|null} application - Application Id if Gtk or null */ remove_notification(id, application = null) { let name, path, method, variant; if (application !== null) { name = 'org.gtk.Notifications'; method = 'RemoveNotification'; path = '/org/gtk/Notifications'; variant = new GLib.Variant('(ss)', [application, id]); } else { name = 'org.freedesktop.Notifications'; path = '/org/freedesktop/Notifications'; method = 'CloseNotification'; variant = new GLib.Variant('(u)', [id]); } Gio.DBus.session.call( name, path, name, method, variant, null, Gio.DBusCallFlags.NONE, -1, null, (connection, res) => { try { connection.call_finish(res); } catch (e) { logError(e); } } ); } /** * Report a service-level error * * @param {object} error - An Error or object with name, message and stack */ notify_error(error) { try { // Always log the error logError(error); // Create an new notification let id, title, body, icon, priority; let notif = new Gio.Notification(); switch (error.name) { case 'LanError': id = error.name; title = _('Network Error'); body = _('Click for help troubleshooting'); icon = new Gio.ThemedIcon({name: 'network-error'}); priority = Gio.NotificationPriority.URGENT; notif.set_default_action(`app.wiki('Help#${error.name}')`); break; case 'DiscoveryWarning': id = 'discovery-warning'; title = _('Discovery Disabled'); body = _('Discovery has been disabled due to the number of devices on this network.'); icon = new Gio.ThemedIcon({name: 'dialog-warning'}); priority = Gio.NotificationPriority.NORMAL; notif.set_default_action('app.preferences'); break; default: id = `${Date.now()}`; title = error.name.trim(); body = _('Click for more information'); icon = new Gio.ThemedIcon({name: 'dialog-error'}); error = new GLib.Variant('a{ss}', { name: error.name.trim(), message: error.message.trim(), stack: error.stack.trim() }); notif.set_default_action_and_target('app.error', error); priority = Gio.NotificationPriority.HIGH; } // Create an urgent notification notif.set_title(`GSConnect: ${title}`); notif.set_body(body); notif.set_icon(icon); notif.set_priority(priority); // Bypass override super.send_notification(id, notif); } catch (e) { logError(e); } } vfunc_activate() { super.vfunc_activate(); } vfunc_startup() { super.vfunc_startup(); this.hold(); // Watch *this* file and stop the service if it's updated/uninstalled this._serviceMonitor = Gio.File.new_for_path( gsconnect.extdatadir + '/service/daemon.js' ).monitor(Gio.FileMonitorFlags.WATCH_MOVES, null); this._serviceMonitor.connect('changed', () => this.quit()); // Init some resources let provider = new Gtk.CssProvider(); provider.load_from_resource(gsconnect.app_path + '/application.css'); Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ); // Ensure our handlers are registered try { let appInfo = Gio.DesktopAppInfo.new(`${gsconnect.app_id}.desktop`); appInfo.add_supports_type('x-scheme-handler/sms'); appInfo.add_supports_type('x-scheme-handler/tel'); } catch (e) { logError(e); } // GActions & GSettings this._initSettings(); this._initActions(); this._initComponents(); this._initBackends(); // Load cached devices for (let id of this.settings.get_strv('devices')) { let device = new Device.Device({body: {deviceId: id}}); this._devices.set(id, device); } // Reconnect to paired devices every 5 seconds GLib.timeout_add_seconds(300, 5, this._reconnect.bind(this)); } vfunc_dbus_register(connection, object_path) { if (!super.vfunc_dbus_register(connection, object_path)) return false; this.objectManager = new Gio.DBusObjectManagerServer({ connection: connection, object_path: object_path }); return true; } vfunc_open(files, hint) { super.vfunc_open(files, hint); for (let file of files) { let action, parameter, title; try { switch (file.get_uri_scheme()) { case 'sms': title = _('Send SMS'); action = 'uriSms'; parameter = new GLib.Variant('s', file.get_uri()); break; case 'tel': title = _('Dial Number'); action = 'shareUri'; parameter = new GLib.Variant('s', file.get_uri()); break; case 'file': title = _('Share File'); action = 'shareFile'; parameter = new GLib.Variant('(sb)', [file.get_uri(), false]); break; default: throw new Error(`Unsupported URI: ${file.get_uri()}`); } // Show chooser dialog new ServiceUI.DeviceChooserDialog({ title: title, action: action, parameter: parameter }); } catch (e) { logError(e, `GSConnect: Opening ${file.get_uri()}`); } } } vfunc_shutdown() { // Dispose GSettings this.settings.disconnect(this._nameChangedId); this.settings.run_dispose(); // Destroy the backends first to avoid any further connections for (let [name, backend] of this.backends) { try { backend.destroy(); } catch (e) { logError(e, `'${name}' Backend`); } } // We must unexport the devices before ::dbus-unregister is emitted this._devices.forEach(device => device.destroy()); // Destroy the components last for (let [name, component] of this.components) { try { component.destroy(); } catch (e) { logError(e, `'${name}' Component`); } } // Chain up last (application->priv->did_shutdown) super.vfunc_shutdown(); } /* * CLI */ _initOptions() { /* * Device Listings */ this.add_main_option( 'list-devices', 'l'.charCodeAt(0), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('List available devices'), null ); this.add_main_option( 'list-all', 'a'.charCodeAt(0), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('List all devices'), null ); this.add_main_option( 'device', 'd'.charCodeAt(0), GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Target Device'), '' ); /** * Pairing */ this.add_main_option( 'pair', null, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('Pair'), null ); this.add_main_option( 'unpair', null, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('Unpair'), null ); /* * Messaging */ this.add_main_option( 'message', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING_ARRAY, _('Send SMS'), '' ); this.add_main_option( 'message-body', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Message Body'), '' ); /* * Notifications */ this.add_main_option( 'notification', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Send Notification'), '' ); this.add_main_option( 'notification-appname', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Notification App Name'), '<name>' ); this.add_main_option( 'notification-body', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Notification Body'), '<text>' ); this.add_main_option( 'notification-icon', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Notification Icon'), '<icon-name>' ); this.add_main_option( 'notification-id', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Notification ID'), '<id>' ); this.add_main_option( 'photo', null, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('Photo'), null ); this.add_main_option( 'ping', null, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('Ping'), null ); this.add_main_option( 'ring', null, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('Ring'), null ); /* * Sharing */ this.add_main_option( 'share-file', null, GLib.OptionFlags.NONE, GLib.OptionArg.FILENAME_ARRAY, _('Share File'), '<filepath|URI>' ); this.add_main_option( 'share-link', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING_ARRAY, _('Share Link'), '<URL>' ); this.add_main_option( 'share-text', null, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _('Share Text'), '<text>' ); /* * Misc */ this.add_main_option( 'version', 'v'.charCodeAt(0), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _('Show release version'), null ); } _cliAction(id, name, parameter = null) { let parameters = []; if (parameter instanceof GLib.Variant) { parameters[0] = parameter; } id = id.replace(/\W+/g, '_'); Gio.DBus.session.call_sync( 'org.gnome.Shell.Extensions.GSConnect', `/org/gnome/Shell/Extensions/GSConnect/Device/${id}`, 'org.gtk.Actions', 'Activate', GLib.Variant.new('(sava{sv})', [name, parameters, {}]), null, Gio.DBusCallFlags.NONE, -1, null ); } _cliListDevices(full = true) { let result = Gio.DBus.session.call_sync( 'org.gnome.Shell.Extensions.GSConnect', '/org/gnome/Shell/Extensions/GSConnect', 'org.freedesktop.DBus.ObjectManager', 'GetManagedObjects', null, null, Gio.DBusCallFlags.NONE, -1, null ); let variant = result.unpack()[0].unpack(); let device; for (let object of Object.values(variant)) { object = object.recursiveUnpack(); device = object['org.gnome.Shell.Extensions.GSConnect.Device']; if (full) { print(`${device.Id}\t${device.Name}\t${device.Connected}\t${device.Paired}`); } else if (device.Connected && device.Paired) { print(device.Id); } } } _cliMessage(id, options) { if (!options.contains('message-body')) { throw new TypeError('missing --message-body option'); } // TODO: currently we only support single-recipient messaging let addresses = options.lookup_value('message', null).deepUnpack(); let body = options.lookup_value('message-body', null).deepUnpack(); this._cliAction( id, 'sendSms', GLib.Variant.new('(ss)', [addresses[0], body]) ); } _cliNotify(id, options) { let title = options.lookup_value('notification', null).unpack(); let body = ''; let icon = null; let nid = `${Date.now()}`; let appName = gsconnect.settings.get_string('name'); if (options.contains('notification-id')) { nid = options.lookup_value('notification-id', null).unpack(); } if (options.contains('notification-body')) { body = options.lookup_value('notification-body', null).unpack(); } if (options.contains('notification-appname')) { appName = options.lookup_value('notification-appname', null).unpack(); } if (options.contains('notification-icon')) { icon = options.lookup_value('notification-icon', null).unpack(); icon = Gio.Icon.new_for_string(icon); } let notif = { appName: appName, id: nid, title: title, text: body, ticker: `${title}: ${body}`, time: `${Date.now()}`, isClearable: true, icon: icon }; let parameter = GLib.Variant.full_pack(notif); this._cliAction(id, 'sendNotification', parameter); } _cliShareFile(device, options) { let files = options.lookup_value('share-file', null); files = files.deepUnpack(); files.map(file => { file = imports.byteArray.toString(file); this._cliAction(device, 'shareFile', GLib.Variant.new('(sb)', [file, false])); }); } _cliShareLink(device, options) { let uris = options.lookup_value('share-link', null).deepUnpack(); uris.map(uri => { uri = imports.byteArray.toString(uri); this._cliAction(device, 'shareUri', GLib.Variant.new_string(uri)); }); } _cliShareText(device, options) { let text = options.lookup_value('share-text', null).unpack(); this._cliAction(device, 'shareText', GLib.Variant.new_string(text)); } vfunc_handle_local_options(options) { try { if (options.contains('version')) { print(`GSConnect ${gsconnect.metadata.version}`); return 0; } this.register(null); if (options.contains('list-devices')) { this._cliListDevices(false); return 0; } if (options.contains('list-all')) { this._cliListDevices(true); return 0; } // We need a device for anything else; exit since this is probably // the daemon being started. if (!options.contains('device')) { return -1; } let id = options.lookup_value('device', null).unpack(); // Pairing if (options.contains('pair')) { this._cliAction(id, 'pair'); } if (options.contains('unpair')) { this._cliAction(id, 'unpair'); return 0; } // Plugins if (options.contains('message')) { this._cliMessage(id, options); } if (options.contains('notification')) { this._cliNotify(id, options); } if (options.contains('photo')) { this._cliAction(id, 'photo'); } if (options.contains('ping')) { this._cliAction(id, 'ping', GLib.Variant.new_string('')); } if (options.contains('ring')) { this._cliAction(id, 'ring'); } if (options.contains('share-file')) { this._cliShareFile(id, options); } if (options.contains('share-link')) { this._cliShareLink(id, options); } return 0; } catch (e) { logError(e); return 1; } } }); (new Service()).run([imports.system.programInvocationName].concat(ARGV));