device.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gio = imports.gi.Gio;
  6. const GLib = imports.gi.GLib;
  7. const GObject = imports.gi.GObject;
  8. const Config = imports.config;
  9. const Components = imports.service.components;
  10. const Core = imports.service.core;
  11. /**
  12. * An object representing a remote device.
  13. *
  14. * Device class is subclassed from Gio.SimpleActionGroup so it implements the
  15. * GActionGroup and GActionMap interfaces, like Gio.Application.
  16. *
  17. */
  18. var Device = GObject.registerClass({
  19. GTypeName: 'GSConnectDevice',
  20. Properties: {
  21. 'connected': GObject.ParamSpec.boolean(
  22. 'connected',
  23. 'Connected',
  24. 'Whether the device is connected',
  25. GObject.ParamFlags.READABLE,
  26. false
  27. ),
  28. 'contacts': GObject.ParamSpec.object(
  29. 'contacts',
  30. 'Contacts',
  31. 'The contacts store for this device',
  32. GObject.ParamFlags.READABLE,
  33. GObject.Object
  34. ),
  35. 'encryption-info': GObject.ParamSpec.string(
  36. 'encryption-info',
  37. 'Encryption Info',
  38. 'A formatted string with the local and remote fingerprints',
  39. GObject.ParamFlags.READABLE,
  40. null
  41. ),
  42. 'icon-name': GObject.ParamSpec.string(
  43. 'icon-name',
  44. 'Icon Name',
  45. 'Icon name representing the device',
  46. GObject.ParamFlags.READABLE,
  47. null
  48. ),
  49. 'id': GObject.ParamSpec.string(
  50. 'id',
  51. 'Id',
  52. 'The device hostname or other network unique id',
  53. GObject.ParamFlags.READABLE,
  54. ''
  55. ),
  56. 'name': GObject.ParamSpec.string(
  57. 'name',
  58. 'Name',
  59. 'The device name',
  60. GObject.ParamFlags.READABLE,
  61. null
  62. ),
  63. 'paired': GObject.ParamSpec.boolean(
  64. 'paired',
  65. 'Paired',
  66. 'Whether the device is paired',
  67. GObject.ParamFlags.READABLE,
  68. false
  69. ),
  70. 'type': GObject.ParamSpec.string(
  71. 'type',
  72. 'Type',
  73. 'The device type',
  74. GObject.ParamFlags.READABLE,
  75. null
  76. ),
  77. },
  78. }, class Device extends Gio.SimpleActionGroup {
  79. _init(identity) {
  80. super._init();
  81. this._id = identity.body.deviceId;
  82. // GLib.Source timeout id's for pairing requests
  83. this._incomingPairRequest = 0;
  84. this._outgoingPairRequest = 0;
  85. // Maps of name->Plugin, packet->Plugin, uuid->Transfer
  86. this._plugins = new Map();
  87. this._handlers = new Map();
  88. this._procs = new Set();
  89. this._transfers = new Map();
  90. this._outputLock = false;
  91. this._outputQueue = [];
  92. // GSettings
  93. this.settings = new Gio.Settings({
  94. settings_schema: Config.GSCHEMA.lookup(
  95. 'org.gnome.Shell.Extensions.GSConnect.Device',
  96. true
  97. ),
  98. path: `/org/gnome/shell/extensions/gsconnect/device/${this.id}/`,
  99. });
  100. this._migratePlugins();
  101. // Watch for changes to supported and disabled plugins
  102. this._disabledPluginsChangedId = this.settings.connect(
  103. 'changed::disabled-plugins',
  104. this._onAllowedPluginsChanged.bind(this)
  105. );
  106. this._supportedPluginsChangedId = this.settings.connect(
  107. 'changed::supported-plugins',
  108. this._onAllowedPluginsChanged.bind(this)
  109. );
  110. this._registerActions();
  111. this.menu = new Gio.Menu();
  112. // Parse identity if initialized with a proper packet, otherwise load
  113. if (identity.id !== undefined)
  114. this._handleIdentity(identity);
  115. else
  116. this._loadPlugins();
  117. }
  118. get channel() {
  119. if (this._channel === undefined)
  120. this._channel = null;
  121. return this._channel;
  122. }
  123. get connected() {
  124. if (this._connected === undefined)
  125. this._connected = false;
  126. return this._connected;
  127. }
  128. get connection_type() {
  129. const lastConnection = this.settings.get_string('last-connection');
  130. return lastConnection.split('://')[0];
  131. }
  132. get contacts() {
  133. const contacts = this._plugins.get('contacts');
  134. if (contacts && contacts.settings.get_boolean('contacts-source'))
  135. return contacts._store;
  136. if (this._contacts === undefined)
  137. this._contacts = Components.acquire('contacts');
  138. return this._contacts;
  139. }
  140. // FIXME: backend should do this stuff
  141. get encryption_info() {
  142. let localCert = null;
  143. let remoteCert = null;
  144. // Bluetooth connections have no certificate so we use the host address
  145. if (this.connection_type === 'bluetooth') {
  146. // TRANSLATORS: Bluetooth address for remote device
  147. return _('Bluetooth device at %s').format('???');
  148. // If the device is connected use the certificate from the connection
  149. } else if (this.connected) {
  150. remoteCert = this.channel.peer_certificate;
  151. // Otherwise pull it out of the settings
  152. } else if (this.paired) {
  153. remoteCert = Gio.TlsCertificate.new_from_pem(
  154. this.settings.get_string('certificate-pem'),
  155. -1
  156. );
  157. }
  158. // FIXME: another ugly reach-around
  159. let lanBackend;
  160. if (this.service !== null)
  161. lanBackend = this.service.manager.backends.get('lan');
  162. if (lanBackend && lanBackend.certificate)
  163. localCert = lanBackend.certificate;
  164. let verificationKey = '';
  165. if (localCert && remoteCert) {
  166. let a = localCert.pubkey_der();
  167. let b = remoteCert.pubkey_der();
  168. if (a.compare(b) < 0)
  169. [a, b] = [b, a]; // swap
  170. const checksum = new GLib.Checksum(GLib.ChecksumType.SHA256);
  171. checksum.update(a.toArray());
  172. checksum.update(b.toArray());
  173. verificationKey = checksum.get_string();
  174. }
  175. // TRANSLATORS: Label for TLS connection verification key
  176. //
  177. // Example:
  178. //
  179. // Verification key: 0123456789abcdef000000000000000000000000
  180. return _('Verification key: %s').format(verificationKey);
  181. }
  182. get id() {
  183. return this._id;
  184. }
  185. get name() {
  186. return this.settings.get_string('name');
  187. }
  188. get paired() {
  189. return this.settings.get_boolean('paired');
  190. }
  191. get icon_name() {
  192. switch (this.type) {
  193. case 'laptop':
  194. return 'laptop-symbolic';
  195. case 'phone':
  196. return 'smartphone-symbolic';
  197. case 'tablet':
  198. return 'tablet-symbolic';
  199. case 'tv':
  200. return 'tv-symbolic';
  201. case 'desktop':
  202. default:
  203. return 'computer-symbolic';
  204. }
  205. }
  206. get service() {
  207. if (this._service === undefined)
  208. this._service = Gio.Application.get_default();
  209. return this._service;
  210. }
  211. get type() {
  212. return this.settings.get_string('type');
  213. }
  214. _migratePlugins() {
  215. const deprecated = ['photo'];
  216. const supported = this.settings
  217. .get_strv('supported-plugins')
  218. .filter(name => !deprecated.includes(name));
  219. this.settings.set_strv('supported-plugins', supported);
  220. }
  221. _handleIdentity(packet) {
  222. this.freeze_notify();
  223. // If we're connected, record the reconnect URI
  224. if (this.channel !== null)
  225. this.settings.set_string('last-connection', this.channel.address);
  226. // The type won't change, but it might not be properly set yet
  227. if (this.type !== packet.body.deviceType) {
  228. this.settings.set_string('type', packet.body.deviceType);
  229. this.notify('type');
  230. this.notify('icon-name');
  231. }
  232. // The name may change so we check and notify if so
  233. if (this.name !== packet.body.deviceName) {
  234. this.settings.set_string('name', packet.body.deviceName);
  235. this.notify('name');
  236. }
  237. // Packets
  238. const incoming = packet.body.incomingCapabilities.sort();
  239. const outgoing = packet.body.outgoingCapabilities.sort();
  240. const inc = this.settings.get_strv('incoming-capabilities');
  241. const out = this.settings.get_strv('outgoing-capabilities');
  242. // Only write GSettings if something has changed
  243. if (incoming.join('') !== inc.join('') || outgoing.join('') !== out.join('')) {
  244. this.settings.set_strv('incoming-capabilities', incoming);
  245. this.settings.set_strv('outgoing-capabilities', outgoing);
  246. }
  247. // Determine supported plugins by matching incoming to outgoing types
  248. const supported = [];
  249. for (const name in imports.service.plugins) {
  250. // Exclude mousepad/presenter plugins in unsupported sessions
  251. if (!HAVE_REMOTEINPUT && ['mousepad', 'presenter'].includes(name))
  252. continue;
  253. const meta = imports.service.plugins[name].Metadata;
  254. if (meta === undefined)
  255. continue;
  256. // If we can handle packets it sends or send packets it can handle
  257. if (meta.incomingCapabilities.some(t => outgoing.includes(t)) ||
  258. meta.outgoingCapabilities.some(t => incoming.includes(t)))
  259. supported.push(name);
  260. }
  261. // Only write GSettings if something has changed
  262. const currentSupported = this.settings.get_strv('supported-plugins');
  263. if (currentSupported.join('') !== supported.sort().join(''))
  264. this.settings.set_strv('supported-plugins', supported);
  265. this.thaw_notify();
  266. }
  267. /**
  268. * Set the channel and start sending/receiving packets. If %null is passed
  269. * the device becomes disconnected.
  270. *
  271. * @param {Core.Channel} [channel] - The new channel
  272. */
  273. setChannel(channel = null) {
  274. if (this.channel === channel)
  275. return;
  276. if (this.channel !== null)
  277. this.channel.close();
  278. this._channel = channel;
  279. // If we've disconnected empty the queue, otherwise restart the read
  280. // loop and update the device metadata
  281. if (this.channel === null) {
  282. this._outputQueue.length = 0;
  283. } else {
  284. this._handleIdentity(this.channel.identity);
  285. this._readLoop(channel);
  286. }
  287. // The connected state didn't change
  288. if (this.connected === !!this.channel)
  289. return;
  290. // Notify and trigger plugins
  291. this._connected = !!this.channel;
  292. this.notify('connected');
  293. this._triggerPlugins();
  294. }
  295. async _readLoop(channel) {
  296. try {
  297. let packet = null;
  298. while ((packet = await this.channel.readPacket())) {
  299. debug(packet, this.name);
  300. this.handlePacket(packet);
  301. }
  302. } catch (e) {
  303. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  304. debug(e, this.name);
  305. if (this.channel === channel)
  306. this.setChannel(null);
  307. }
  308. }
  309. _processExit(proc, result) {
  310. try {
  311. proc.wait_check_finish(result);
  312. } catch (e) {
  313. debug(e);
  314. }
  315. this.delete(proc);
  316. }
  317. /**
  318. * Launch a subprocess for the device. If the device becomes unpaired, it is
  319. * assumed the device is no longer trusted and all subprocesses will be
  320. * killed.
  321. *
  322. * @param {string[]} args - process arguments
  323. * @param {Gio.Cancellable} [cancellable] - optional cancellable
  324. * @return {Gio.Subprocess} The subprocess
  325. */
  326. launchProcess(args, cancellable = null) {
  327. if (this._launcher === undefined) {
  328. const application = GLib.build_filenamev([
  329. Config.PACKAGE_DATADIR,
  330. 'service',
  331. 'daemon.js',
  332. ]);
  333. this._launcher = new Gio.SubprocessLauncher();
  334. this._launcher.setenv('GSCONNECT', application, false);
  335. this._launcher.setenv('GSCONNECT_DEVICE_ID', this.id, false);
  336. this._launcher.setenv('GSCONNECT_DEVICE_NAME', this.name, false);
  337. this._launcher.setenv('GSCONNECT_DEVICE_ICON', this.icon_name, false);
  338. this._launcher.setenv(
  339. 'GSCONNECT_DEVICE_DBUS',
  340. `${Config.APP_PATH}/Device/${this.id.replace(/\W+/g, '_')}`,
  341. false
  342. );
  343. }
  344. // Create and track the process
  345. const proc = this._launcher.spawnv(args);
  346. proc.wait_check_async(cancellable, this._processExit.bind(this._procs));
  347. this._procs.add(proc);
  348. return proc;
  349. }
  350. /**
  351. * Handle a packet and pass it to the appropriate plugin.
  352. *
  353. * @param {Core.Packet} packet - The incoming packet object
  354. * @return {undefined} no return value
  355. */
  356. handlePacket(packet) {
  357. try {
  358. if (packet.type === 'kdeconnect.pair')
  359. return this._handlePair(packet);
  360. // The device must think we're paired; inform it we are not
  361. if (!this.paired)
  362. return this.unpair();
  363. const handler = this._handlers.get(packet.type);
  364. if (handler !== undefined)
  365. handler.handlePacket(packet);
  366. else
  367. debug(`Unsupported packet type (${packet.type})`, this.name);
  368. } catch (e) {
  369. debug(e, this.name);
  370. }
  371. }
  372. /**
  373. * Send a packet to the device.
  374. *
  375. * @param {Object} packet - An object of packet data...
  376. */
  377. async sendPacket(packet) {
  378. try {
  379. if (!this.connected)
  380. return;
  381. if (!this.paired && packet.type !== 'kdeconnect.pair')
  382. return;
  383. this._outputQueue.push(new Core.Packet(packet));
  384. if (this._outputLock)
  385. return;
  386. this._outputLock = true;
  387. let next;
  388. while ((next = this._outputQueue.shift())) {
  389. await this.channel.sendPacket(next);
  390. debug(next, this.name);
  391. }
  392. this._outputLock = false;
  393. } catch (e) {
  394. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  395. debug(e, this.name);
  396. this._outputLock = false;
  397. }
  398. }
  399. /**
  400. * Actions
  401. */
  402. _registerActions() {
  403. // Pairing notification actions
  404. const acceptPair = new Gio.SimpleAction({name: 'pair'});
  405. acceptPair.connect('activate', this.pair.bind(this));
  406. this.add_action(acceptPair);
  407. const rejectPair = new Gio.SimpleAction({name: 'unpair'});
  408. rejectPair.connect('activate', this.unpair.bind(this));
  409. this.add_action(rejectPair);
  410. // Transfer notification actions
  411. const cancelTransfer = new Gio.SimpleAction({
  412. name: 'cancelTransfer',
  413. parameter_type: new GLib.VariantType('s'),
  414. });
  415. cancelTransfer.connect('activate', this.cancelTransfer.bind(this));
  416. this.add_action(cancelTransfer);
  417. const openPath = new Gio.SimpleAction({
  418. name: 'openPath',
  419. parameter_type: new GLib.VariantType('s'),
  420. });
  421. openPath.connect('activate', this.openPath);
  422. this.add_action(openPath);
  423. const showPathInFolder = new Gio.SimpleAction({
  424. name: 'showPathInFolder',
  425. parameter_type: new GLib.VariantType('s'),
  426. });
  427. showPathInFolder.connect('activate', this.showPathInFolder);
  428. this.add_action(showPathInFolder);
  429. // Preference helpers
  430. const clearCache = new Gio.SimpleAction({
  431. name: 'clearCache',
  432. parameter_type: null,
  433. });
  434. clearCache.connect('activate', this._clearCache.bind(this));
  435. this.add_action(clearCache);
  436. }
  437. /**
  438. * Get the position of a GMenuItem with @actionName in the top level of the
  439. * device menu.
  440. *
  441. * @param {string} actionName - An action name with scope (eg. device.foo)
  442. * @return {number} An 0-based index or -1 if not found
  443. */
  444. getMenuAction(actionName) {
  445. for (let i = 0, len = this.menu.get_n_items(); i < len; i++) {
  446. try {
  447. const val = this.menu.get_item_attribute_value(i, 'action', null);
  448. if (val.unpack() === actionName)
  449. return i;
  450. } catch (e) {
  451. continue;
  452. }
  453. }
  454. return -1;
  455. }
  456. /**
  457. * Add a GMenuItem to the top level of the device menu
  458. *
  459. * @param {Gio.MenuItem} menuItem - A GMenuItem
  460. * @param {number} [index] - The position to place the item
  461. * @return {number} The position the item was placed
  462. */
  463. addMenuItem(menuItem, index = -1) {
  464. try {
  465. if (index > -1) {
  466. this.menu.insert_item(index, menuItem);
  467. return index;
  468. }
  469. this.menu.append_item(menuItem);
  470. return this.menu.get_n_items();
  471. } catch (e) {
  472. debug(e, this.name);
  473. return -1;
  474. }
  475. }
  476. /**
  477. * Add a Device GAction to the top level of the device menu
  478. *
  479. * @param {Gio.Action} action - A GAction
  480. * @param {number} [index] - The position to place the item
  481. * @param {string} label - A label for the item
  482. * @param {string} icon_name - A themed icon name for the item
  483. * @return {number} The position the item was placed
  484. */
  485. addMenuAction(action, index = -1, label, icon_name) {
  486. try {
  487. const item = new Gio.MenuItem();
  488. if (label)
  489. item.set_label(label);
  490. if (icon_name)
  491. item.set_icon(new Gio.ThemedIcon({name: icon_name}));
  492. item.set_attribute_value(
  493. 'hidden-when',
  494. new GLib.Variant('s', 'action-disabled')
  495. );
  496. item.set_detailed_action(`device.${action.name}`);
  497. return this.addMenuItem(item, index);
  498. } catch (e) {
  499. debug(e, this.name);
  500. return -1;
  501. }
  502. }
  503. /**
  504. * Remove a GAction from the top level of the device menu by action name
  505. *
  506. * @param {string} actionName - A GAction name, including scope
  507. * @return {number} The position the item was removed from or -1
  508. */
  509. removeMenuAction(actionName) {
  510. try {
  511. const index = this.getMenuAction(actionName);
  512. if (index > -1)
  513. this.menu.remove(index);
  514. return index;
  515. } catch (e) {
  516. debug(e, this.name);
  517. return -1;
  518. }
  519. }
  520. /**
  521. * Withdraw a device notification.
  522. *
  523. * @param {string} id - Id for the notification to withdraw
  524. */
  525. hideNotification(id) {
  526. if (this.service === null)
  527. return;
  528. this.service.withdraw_notification(`${this.id}|${id}`);
  529. }
  530. /**
  531. * Show a device notification.
  532. *
  533. * @param {Object} params - A dictionary of notification parameters
  534. * @param {number} [params.id] - A UNIX epoch timestamp (ms)
  535. * @param {string} [params.title] - A title
  536. * @param {string} [params.body] - A body
  537. * @param {Gio.Icon} [params.icon] - An icon
  538. * @param {Gio.NotificationPriority} [params.priority] - The priority
  539. * @param {Array} [params.actions] - A dictionary of action parameters
  540. * @param {Array} [params.buttons] - An Array of buttons
  541. */
  542. showNotification(params) {
  543. if (this.service === null)
  544. return;
  545. // KDE Connect on Android can sometimes give an undefined for params.body
  546. Object.keys(params)
  547. .forEach(key => params[key] === undefined && delete params[key]);
  548. params = Object.assign({
  549. id: Date.now(),
  550. title: this.name,
  551. body: '',
  552. icon: new Gio.ThemedIcon({name: this.icon_name}),
  553. priority: Gio.NotificationPriority.NORMAL,
  554. action: null,
  555. buttons: [],
  556. }, params);
  557. const notif = new Gio.Notification();
  558. notif.set_title(params.title);
  559. notif.set_body(params.body);
  560. notif.set_icon(params.icon);
  561. notif.set_priority(params.priority);
  562. // Default Action
  563. if (params.action) {
  564. const hasParameter = (params.action.parameter !== null);
  565. if (!hasParameter)
  566. params.action.parameter = new GLib.Variant('s', '');
  567. notif.set_default_action_and_target(
  568. 'app.device',
  569. new GLib.Variant('(ssbv)', [
  570. this.id,
  571. params.action.name,
  572. hasParameter,
  573. params.action.parameter,
  574. ])
  575. );
  576. }
  577. // Buttons
  578. for (const button of params.buttons) {
  579. const hasParameter = (button.parameter !== null);
  580. if (!hasParameter)
  581. button.parameter = new GLib.Variant('s', '');
  582. notif.add_button_with_target(
  583. button.label,
  584. 'app.device',
  585. new GLib.Variant('(ssbv)', [
  586. this.id,
  587. button.action,
  588. hasParameter,
  589. button.parameter,
  590. ])
  591. );
  592. }
  593. this.service.send_notification(`${this.id}|${params.id}`, notif);
  594. }
  595. /**
  596. * Cancel an ongoing file transfer.
  597. *
  598. * @param {Gio.Action} action - The GAction
  599. * @param {GLib.Variant} parameter - The activation parameter
  600. */
  601. cancelTransfer(action, parameter) {
  602. try {
  603. const uuid = parameter.unpack();
  604. const transfer = this._transfers.get(uuid);
  605. if (transfer === undefined)
  606. return;
  607. this._transfers.delete(uuid);
  608. transfer.cancel();
  609. } catch (e) {
  610. logError(e, this.name);
  611. }
  612. }
  613. /**
  614. * Create a transfer object.
  615. *
  616. * @return {Core.Transfer} A new transfer
  617. */
  618. createTransfer() {
  619. const transfer = new Core.Transfer({device: this});
  620. // Track the transfer
  621. this._transfers.set(transfer.uuid, transfer);
  622. transfer.connect('notify::completed', (transfer) => {
  623. this._transfers.delete(transfer.uuid);
  624. });
  625. return transfer;
  626. }
  627. /**
  628. * Reject the transfer payload described by @packet.
  629. *
  630. * @param {Core.Packet} packet - A packet
  631. * @return {Promise} A promise for the operation
  632. */
  633. rejectTransfer(packet) {
  634. if (!packet || !packet.hasPayload())
  635. return;
  636. return this.channel.rejectTransfer(packet);
  637. }
  638. openPath(action, parameter) {
  639. const path = parameter.unpack();
  640. // Normalize paths to URIs, assuming local file
  641. const uri = path.includes('://') ? path : `file://${path}`;
  642. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  643. }
  644. showPathInFolder(action, parameter) {
  645. const path = parameter.unpack();
  646. const uri = path.includes('://') ? path : `file://${path}`;
  647. const connection = Gio.DBus.session;
  648. connection.call(
  649. 'org.freedesktop.FileManager1',
  650. '/org/freedesktop/FileManager1',
  651. 'org.freedesktop.FileManager1',
  652. 'ShowItems',
  653. new GLib.Variant('(ass)', [[uri], 's']),
  654. null,
  655. Gio.DBusCallFlags.NONE,
  656. -1,
  657. null,
  658. (connection, res) => {
  659. try {
  660. connection.call_finish(res);
  661. } catch (e) {
  662. Gio.DBusError.strip_remote_error(e);
  663. logError(e);
  664. }
  665. }
  666. );
  667. }
  668. _clearCache(action, parameter) {
  669. for (const plugin of this._plugins.values()) {
  670. try {
  671. plugin.clearCache();
  672. } catch (e) {
  673. debug(e, this.name);
  674. }
  675. }
  676. }
  677. /**
  678. * Pair request handler
  679. *
  680. * @param {Core.Packet} packet - A complete kdeconnect.pair packet
  681. */
  682. _handlePair(packet) {
  683. // A pair has been requested/confirmed
  684. if (packet.body.pair) {
  685. // The device is accepting our request
  686. if (this._outgoingPairRequest) {
  687. this._setPaired(true);
  688. this._loadPlugins();
  689. // The device thinks we're unpaired
  690. } else if (this.paired) {
  691. this._setPaired(true);
  692. this.sendPacket({
  693. type: 'kdeconnect.pair',
  694. body: {pair: true},
  695. });
  696. this._triggerPlugins();
  697. // The device is requesting pairing
  698. } else {
  699. this._notifyPairRequest();
  700. }
  701. // Device is requesting unpairing/rejecting our request
  702. } else {
  703. this._setPaired(false);
  704. this._unloadPlugins();
  705. }
  706. }
  707. /**
  708. * Notify the user of an incoming pair request and set a 30s timeout
  709. */
  710. _notifyPairRequest() {
  711. // Reset any active request
  712. this._resetPairRequest();
  713. this.showNotification({
  714. id: 'pair-request',
  715. // TRANSLATORS: eg. Pair Request from Google Pixel
  716. title: _('Pair Request from %s').format(this.name),
  717. body: this.encryption_info,
  718. icon: new Gio.ThemedIcon({name: 'channel-insecure-symbolic'}),
  719. priority: Gio.NotificationPriority.URGENT,
  720. buttons: [
  721. {
  722. action: 'unpair',
  723. label: _('Reject'),
  724. parameter: null,
  725. },
  726. {
  727. action: 'pair',
  728. label: _('Accept'),
  729. parameter: null,
  730. },
  731. ],
  732. });
  733. // Start a 30s countdown
  734. this._incomingPairRequest = GLib.timeout_add_seconds(
  735. GLib.PRIORITY_DEFAULT,
  736. 30,
  737. this._setPaired.bind(this, false)
  738. );
  739. }
  740. /**
  741. * Reset pair request timeouts and withdraw any notifications
  742. */
  743. _resetPairRequest() {
  744. this.hideNotification('pair-request');
  745. if (this._incomingPairRequest) {
  746. GLib.source_remove(this._incomingPairRequest);
  747. this._incomingPairRequest = 0;
  748. }
  749. if (this._outgoingPairRequest) {
  750. GLib.source_remove(this._outgoingPairRequest);
  751. this._outgoingPairRequest = 0;
  752. }
  753. }
  754. /**
  755. * Set the internal paired state of the device and emit ::notify
  756. *
  757. * @param {boolean} paired - The paired state to set
  758. */
  759. _setPaired(paired) {
  760. this._resetPairRequest();
  761. // For TCP connections we store or reset the TLS Certificate
  762. if (this.connection_type === 'lan') {
  763. if (paired) {
  764. this.settings.set_string(
  765. 'certificate-pem',
  766. this.channel.peer_certificate.certificate_pem
  767. );
  768. } else {
  769. this.settings.reset('certificate-pem');
  770. }
  771. }
  772. // If we've become unpaired, stop all subprocesses and transfers
  773. if (!paired) {
  774. for (const proc of this._procs)
  775. proc.force_exit();
  776. this._procs.clear();
  777. for (const transfer of this._transfers.values())
  778. transfer.close();
  779. this._transfers.clear();
  780. }
  781. this.settings.set_boolean('paired', paired);
  782. this.notify('paired');
  783. }
  784. /**
  785. * Send or accept an incoming pair request; also exported as a GAction
  786. */
  787. pair() {
  788. try {
  789. // If we're accepting an incoming pair request, set the internal
  790. // paired state and send the confirmation before loading plugins.
  791. if (this._incomingPairRequest) {
  792. this._setPaired(true);
  793. this.sendPacket({
  794. type: 'kdeconnect.pair',
  795. body: {pair: true},
  796. });
  797. this._loadPlugins();
  798. // If we're initiating an outgoing pair request, be sure the timer
  799. // is reset before sending the request and setting a 30s timeout.
  800. } else if (!this.paired) {
  801. this._resetPairRequest();
  802. this.sendPacket({
  803. type: 'kdeconnect.pair',
  804. body: {pair: true},
  805. });
  806. this._outgoingPairRequest = GLib.timeout_add_seconds(
  807. GLib.PRIORITY_DEFAULT,
  808. 30,
  809. this._setPaired.bind(this, false)
  810. );
  811. }
  812. } catch (e) {
  813. logError(e, this.name);
  814. }
  815. }
  816. /**
  817. * Unpair or reject an incoming pair request; also exported as a GAction
  818. */
  819. unpair() {
  820. try {
  821. if (this.connected) {
  822. this.sendPacket({
  823. type: 'kdeconnect.pair',
  824. body: {pair: false},
  825. });
  826. }
  827. this._setPaired(false);
  828. this._unloadPlugins();
  829. } catch (e) {
  830. logError(e, this.name);
  831. }
  832. }
  833. /*
  834. * Plugin Functions
  835. */
  836. _onAllowedPluginsChanged(settings) {
  837. const disabled = this.settings.get_strv('disabled-plugins');
  838. const supported = this.settings.get_strv('supported-plugins');
  839. const allowed = supported.filter(name => !disabled.includes(name));
  840. // Unload any plugins that are disabled or unsupported
  841. this._plugins.forEach(plugin => {
  842. if (!allowed.includes(plugin.name))
  843. this._unloadPlugin(plugin.name);
  844. });
  845. // Make sure we change the contacts store if the plugin was disabled
  846. if (!allowed.includes('contacts'))
  847. this.notify('contacts');
  848. // Load allowed plugins
  849. for (const name of allowed)
  850. this._loadPlugin(name);
  851. }
  852. _loadPlugin(name) {
  853. let handler, plugin;
  854. try {
  855. if (this.paired && !this._plugins.has(name)) {
  856. // Instantiate the handler
  857. handler = imports.service.plugins[name];
  858. plugin = new handler.Plugin(this);
  859. // Register packet handlers
  860. for (const packetType of handler.Metadata.incomingCapabilities)
  861. this._handlers.set(packetType, plugin);
  862. // Register plugin
  863. this._plugins.set(name, plugin);
  864. // Run the connected()/disconnected() handler
  865. if (this.connected)
  866. plugin.connected();
  867. else
  868. plugin.disconnected();
  869. }
  870. } catch (e) {
  871. if (plugin !== undefined)
  872. plugin.destroy();
  873. if (this.service !== null)
  874. this.service.notify_error(e);
  875. else
  876. logError(e, this.name);
  877. }
  878. }
  879. async _loadPlugins() {
  880. const disabled = this.settings.get_strv('disabled-plugins');
  881. for (const name of this.settings.get_strv('supported-plugins')) {
  882. if (!disabled.includes(name))
  883. await this._loadPlugin(name);
  884. }
  885. }
  886. _unloadPlugin(name) {
  887. let handler, plugin;
  888. try {
  889. if (this._plugins.has(name)) {
  890. // Unregister packet handlers
  891. handler = imports.service.plugins[name];
  892. for (const type of handler.Metadata.incomingCapabilities)
  893. this._handlers.delete(type);
  894. // Unregister plugin
  895. plugin = this._plugins.get(name);
  896. this._plugins.delete(name);
  897. plugin.destroy();
  898. }
  899. } catch (e) {
  900. logError(e, this.name);
  901. }
  902. }
  903. async _unloadPlugins() {
  904. for (const name of this._plugins.keys())
  905. await this._unloadPlugin(name);
  906. }
  907. _triggerPlugins() {
  908. for (const plugin of this._plugins.values()) {
  909. if (this.connected)
  910. plugin.connected();
  911. else
  912. plugin.disconnected();
  913. }
  914. }
  915. destroy() {
  916. // Drop the default contacts store if we were using it
  917. if (this._contacts !== undefined)
  918. this._contacts = Components.release('contacts');
  919. // Close the channel if still connected
  920. if (this.channel !== null)
  921. this.channel.close();
  922. // Synchronously destroy plugins
  923. this._plugins.forEach(plugin => plugin.destroy());
  924. this._plugins.clear();
  925. // Dispose GSettings
  926. this.settings.disconnect(this._disabledPluginsChangedId);
  927. this.settings.disconnect(this._supportedPluginsChangedId);
  928. this.settings.run_dispose();
  929. GObject.signal_handlers_destroy(this);
  930. }
  931. });