device.js 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gio from 'gi://Gio';
  5. import GLib from 'gi://GLib';
  6. import GObject from 'gi://GObject';
  7. import Config from '../config.js';
  8. import * as Components from './components/index.js';
  9. import * as Core from './core.js';
  10. import plugins from './plugins/index.js';
  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. const 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 plugins) {
  250. const meta = plugins[name].Metadata;
  251. if (meta === undefined)
  252. continue;
  253. // If we can handle packets it sends or send packets it can handle
  254. if (meta.incomingCapabilities.some(t => outgoing.includes(t)) ||
  255. meta.outgoingCapabilities.some(t => incoming.includes(t)))
  256. supported.push(name);
  257. }
  258. // Only write GSettings if something has changed
  259. const currentSupported = this.settings.get_strv('supported-plugins');
  260. if (currentSupported.join('') !== supported.sort().join(''))
  261. this.settings.set_strv('supported-plugins', supported);
  262. this.thaw_notify();
  263. }
  264. /**
  265. * Set the channel and start sending/receiving packets. If %null is passed
  266. * the device becomes disconnected.
  267. *
  268. * @param {Core.Channel} [channel] - The new channel
  269. */
  270. setChannel(channel = null) {
  271. if (this.channel === channel)
  272. return;
  273. if (this.channel !== null)
  274. this.channel.close();
  275. this._channel = channel;
  276. // If we've disconnected empty the queue, otherwise restart the read
  277. // loop and update the device metadata
  278. if (this.channel === null) {
  279. this._outputQueue.length = 0;
  280. } else {
  281. this._handleIdentity(this.channel.identity);
  282. this._readLoop(channel);
  283. }
  284. // The connected state didn't change
  285. if (this.connected === !!this.channel)
  286. return;
  287. // Notify and trigger plugins
  288. this._connected = !!this.channel;
  289. this.notify('connected');
  290. this._triggerPlugins();
  291. }
  292. async _readLoop(channel) {
  293. try {
  294. let packet = null;
  295. while ((packet = await this.channel.readPacket())) {
  296. debug(packet, this.name);
  297. this.handlePacket(packet);
  298. }
  299. } catch (e) {
  300. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  301. debug(e, this.name);
  302. if (this.channel === channel)
  303. this.setChannel(null);
  304. }
  305. }
  306. _processExit(proc, result) {
  307. try {
  308. proc.wait_check_finish(result);
  309. } catch (e) {
  310. debug(e);
  311. }
  312. this.delete(proc);
  313. }
  314. /**
  315. * Launch a subprocess for the device. If the device becomes unpaired, it is
  316. * assumed the device is no longer trusted and all subprocesses will be
  317. * killed.
  318. *
  319. * @param {string[]} args - process arguments
  320. * @param {Gio.Cancellable} [cancellable] - optional cancellable
  321. * @return {Gio.Subprocess} The subprocess
  322. */
  323. launchProcess(args, cancellable = null) {
  324. if (this._launcher === undefined) {
  325. const application = GLib.build_filenamev([
  326. Config.PACKAGE_DATADIR,
  327. 'service',
  328. 'daemon.js',
  329. ]);
  330. this._launcher = new Gio.SubprocessLauncher();
  331. this._launcher.setenv('GSCONNECT', application, false);
  332. this._launcher.setenv('GSCONNECT_DEVICE_ID', this.id, false);
  333. this._launcher.setenv('GSCONNECT_DEVICE_NAME', this.name, false);
  334. this._launcher.setenv('GSCONNECT_DEVICE_ICON', this.icon_name, false);
  335. this._launcher.setenv(
  336. 'GSCONNECT_DEVICE_DBUS',
  337. `${Config.APP_PATH}/Device/${this.id.replace(/\W+/g, '_')}`,
  338. false
  339. );
  340. }
  341. // Create and track the process
  342. const proc = this._launcher.spawnv(args);
  343. proc.wait_check_async(cancellable, this._processExit.bind(this._procs));
  344. this._procs.add(proc);
  345. return proc;
  346. }
  347. /**
  348. * Handle a packet and pass it to the appropriate plugin.
  349. *
  350. * @param {Core.Packet} packet - The incoming packet object
  351. * @return {undefined} no return value
  352. */
  353. handlePacket(packet) {
  354. try {
  355. if (packet.type === 'kdeconnect.pair')
  356. return this._handlePair(packet);
  357. // The device must think we're paired; inform it we are not
  358. if (!this.paired)
  359. return this.unpair();
  360. const handler = this._handlers.get(packet.type);
  361. if (handler !== undefined)
  362. handler.handlePacket(packet);
  363. else
  364. debug(`Unsupported packet type (${packet.type})`, this.name);
  365. } catch (e) {
  366. debug(e, this.name);
  367. }
  368. }
  369. /**
  370. * Send a packet to the device.
  371. *
  372. * @param {Object} packet - An object of packet data...
  373. */
  374. async sendPacket(packet) {
  375. try {
  376. if (!this.connected)
  377. return;
  378. if (!this.paired && packet.type !== 'kdeconnect.pair')
  379. return;
  380. this._outputQueue.push(new Core.Packet(packet));
  381. if (this._outputLock)
  382. return;
  383. this._outputLock = true;
  384. let next;
  385. while ((next = this._outputQueue.shift())) {
  386. await this.channel.sendPacket(next);
  387. debug(next, this.name);
  388. }
  389. this._outputLock = false;
  390. } catch (e) {
  391. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  392. debug(e, this.name);
  393. this._outputLock = false;
  394. }
  395. }
  396. /**
  397. * Actions
  398. */
  399. _registerActions() {
  400. // Pairing notification actions
  401. const acceptPair = new Gio.SimpleAction({name: 'pair'});
  402. acceptPair.connect('activate', this.pair.bind(this));
  403. this.add_action(acceptPair);
  404. const rejectPair = new Gio.SimpleAction({name: 'unpair'});
  405. rejectPair.connect('activate', this.unpair.bind(this));
  406. this.add_action(rejectPair);
  407. // Transfer notification actions
  408. const cancelTransfer = new Gio.SimpleAction({
  409. name: 'cancelTransfer',
  410. parameter_type: new GLib.VariantType('s'),
  411. });
  412. cancelTransfer.connect('activate', this.cancelTransfer.bind(this));
  413. this.add_action(cancelTransfer);
  414. const openPath = new Gio.SimpleAction({
  415. name: 'openPath',
  416. parameter_type: new GLib.VariantType('s'),
  417. });
  418. openPath.connect('activate', this.openPath);
  419. this.add_action(openPath);
  420. const showPathInFolder = new Gio.SimpleAction({
  421. name: 'showPathInFolder',
  422. parameter_type: new GLib.VariantType('s'),
  423. });
  424. showPathInFolder.connect('activate', this.showPathInFolder);
  425. this.add_action(showPathInFolder);
  426. // Preference helpers
  427. const clearCache = new Gio.SimpleAction({
  428. name: 'clearCache',
  429. parameter_type: null,
  430. });
  431. clearCache.connect('activate', this._clearCache.bind(this));
  432. this.add_action(clearCache);
  433. }
  434. /**
  435. * Get the position of a GMenuItem with @actionName in the top level of the
  436. * device menu.
  437. *
  438. * @param {string} actionName - An action name with scope (eg. device.foo)
  439. * @return {number} An 0-based index or -1 if not found
  440. */
  441. getMenuAction(actionName) {
  442. for (let i = 0, len = this.menu.get_n_items(); i < len; i++) {
  443. try {
  444. const val = this.menu.get_item_attribute_value(i, 'action', null);
  445. if (val.unpack() === actionName)
  446. return i;
  447. } catch (e) {
  448. continue;
  449. }
  450. }
  451. return -1;
  452. }
  453. /**
  454. * Add a GMenuItem to the top level of the device menu
  455. *
  456. * @param {Gio.MenuItem} menuItem - A GMenuItem
  457. * @param {number} [index] - The position to place the item
  458. * @return {number} The position the item was placed
  459. */
  460. addMenuItem(menuItem, index = -1) {
  461. try {
  462. if (index > -1) {
  463. this.menu.insert_item(index, menuItem);
  464. return index;
  465. }
  466. this.menu.append_item(menuItem);
  467. return this.menu.get_n_items();
  468. } catch (e) {
  469. debug(e, this.name);
  470. return -1;
  471. }
  472. }
  473. /**
  474. * Add a Device GAction to the top level of the device menu
  475. *
  476. * @param {Gio.Action} action - A GAction
  477. * @param {number} [index] - The position to place the item
  478. * @param {string} label - A label for the item
  479. * @param {string} icon_name - A themed icon name for the item
  480. * @return {number} The position the item was placed
  481. */
  482. addMenuAction(action, index = -1, label, icon_name) {
  483. try {
  484. const item = new Gio.MenuItem();
  485. if (label)
  486. item.set_label(label);
  487. if (icon_name)
  488. item.set_icon(new Gio.ThemedIcon({name: icon_name}));
  489. item.set_attribute_value(
  490. 'hidden-when',
  491. new GLib.Variant('s', 'action-disabled')
  492. );
  493. item.set_detailed_action(`device.${action.name}`);
  494. return this.addMenuItem(item, index);
  495. } catch (e) {
  496. debug(e, this.name);
  497. return -1;
  498. }
  499. }
  500. /**
  501. * Remove a GAction from the top level of the device menu by action name
  502. *
  503. * @param {string} actionName - A GAction name, including scope
  504. * @return {number} The position the item was removed from or -1
  505. */
  506. removeMenuAction(actionName) {
  507. try {
  508. const index = this.getMenuAction(actionName);
  509. if (index > -1)
  510. this.menu.remove(index);
  511. return index;
  512. } catch (e) {
  513. debug(e, this.name);
  514. return -1;
  515. }
  516. }
  517. /**
  518. * Withdraw a device notification.
  519. *
  520. * @param {string} id - Id for the notification to withdraw
  521. */
  522. hideNotification(id) {
  523. if (this.service === null)
  524. return;
  525. this.service.withdraw_notification(`${this.id}|${id}`);
  526. }
  527. /**
  528. * Show a device notification.
  529. *
  530. * @param {Object} params - A dictionary of notification parameters
  531. * @param {number} [params.id] - A UNIX epoch timestamp (ms)
  532. * @param {string} [params.title] - A title
  533. * @param {string} [params.body] - A body
  534. * @param {Gio.Icon} [params.icon] - An icon
  535. * @param {Gio.NotificationPriority} [params.priority] - The priority
  536. * @param {Array} [params.actions] - A dictionary of action parameters
  537. * @param {Array} [params.buttons] - An Array of buttons
  538. */
  539. showNotification(params) {
  540. if (this.service === null)
  541. return;
  542. // KDE Connect on Android can sometimes give an undefined for params.body
  543. Object.keys(params)
  544. .forEach(key => params[key] === undefined && delete params[key]);
  545. params = Object.assign({
  546. id: Date.now(),
  547. title: this.name,
  548. body: '',
  549. icon: new Gio.ThemedIcon({name: this.icon_name}),
  550. priority: Gio.NotificationPriority.NORMAL,
  551. action: null,
  552. buttons: [],
  553. }, params);
  554. const notif = new Gio.Notification();
  555. notif.set_title(params.title);
  556. notif.set_body(params.body);
  557. notif.set_icon(params.icon);
  558. notif.set_priority(params.priority);
  559. // Default Action
  560. if (params.action) {
  561. const hasParameter = (params.action.parameter !== null);
  562. if (!hasParameter)
  563. params.action.parameter = new GLib.Variant('s', '');
  564. notif.set_default_action_and_target(
  565. 'app.device',
  566. new GLib.Variant('(ssbv)', [
  567. this.id,
  568. params.action.name,
  569. hasParameter,
  570. params.action.parameter,
  571. ])
  572. );
  573. }
  574. // Buttons
  575. for (const button of params.buttons) {
  576. const hasParameter = (button.parameter !== null);
  577. if (!hasParameter)
  578. button.parameter = new GLib.Variant('s', '');
  579. notif.add_button_with_target(
  580. button.label,
  581. 'app.device',
  582. new GLib.Variant('(ssbv)', [
  583. this.id,
  584. button.action,
  585. hasParameter,
  586. button.parameter,
  587. ])
  588. );
  589. }
  590. this.service.send_notification(`${this.id}|${params.id}`, notif);
  591. }
  592. /**
  593. * Cancel an ongoing file transfer.
  594. *
  595. * @param {Gio.Action} action - The GAction
  596. * @param {GLib.Variant} parameter - The activation parameter
  597. */
  598. cancelTransfer(action, parameter) {
  599. try {
  600. const uuid = parameter.unpack();
  601. const transfer = this._transfers.get(uuid);
  602. if (transfer === undefined)
  603. return;
  604. this._transfers.delete(uuid);
  605. transfer.cancel();
  606. } catch (e) {
  607. logError(e, this.name);
  608. }
  609. }
  610. /**
  611. * Create a transfer object.
  612. *
  613. * @return {Core.Transfer} A new transfer
  614. */
  615. createTransfer() {
  616. const transfer = new Core.Transfer({device: this});
  617. // Track the transfer
  618. this._transfers.set(transfer.uuid, transfer);
  619. transfer.connect('notify::completed', (transfer) => {
  620. this._transfers.delete(transfer.uuid);
  621. });
  622. return transfer;
  623. }
  624. /**
  625. * Reject the transfer payload described by @packet.
  626. *
  627. * @param {Core.Packet} packet - A packet
  628. * @return {Promise} A promise for the operation
  629. */
  630. rejectTransfer(packet) {
  631. if (!packet || !packet.hasPayload())
  632. return;
  633. return this.channel.rejectTransfer(packet);
  634. }
  635. openPath(action, parameter) {
  636. const path = parameter.unpack();
  637. // Normalize paths to URIs, assuming local file
  638. const uri = path.includes('://') ? path : `file://${path}`;
  639. Gio.AppInfo.launch_default_for_uri_async(uri, null, null, null);
  640. }
  641. showPathInFolder(action, parameter) {
  642. const path = parameter.unpack();
  643. const uri = path.includes('://') ? path : `file://${path}`;
  644. const connection = Gio.DBus.session;
  645. connection.call(
  646. 'org.freedesktop.FileManager1',
  647. '/org/freedesktop/FileManager1',
  648. 'org.freedesktop.FileManager1',
  649. 'ShowItems',
  650. new GLib.Variant('(ass)', [[uri], 's']),
  651. null,
  652. Gio.DBusCallFlags.NONE,
  653. -1,
  654. null,
  655. (connection, res) => {
  656. try {
  657. connection.call_finish(res);
  658. } catch (e) {
  659. Gio.DBusError.strip_remote_error(e);
  660. logError(e);
  661. }
  662. }
  663. );
  664. }
  665. _clearCache(action, parameter) {
  666. for (const plugin of this._plugins.values()) {
  667. try {
  668. plugin.clearCache();
  669. } catch (e) {
  670. debug(e, this.name);
  671. }
  672. }
  673. }
  674. /**
  675. * Pair request handler
  676. *
  677. * @param {Core.Packet} packet - A complete kdeconnect.pair packet
  678. */
  679. _handlePair(packet) {
  680. // A pair has been requested/confirmed
  681. if (packet.body.pair) {
  682. // The device is accepting our request
  683. if (this._outgoingPairRequest) {
  684. this._setPaired(true);
  685. this._loadPlugins();
  686. // The device thinks we're unpaired
  687. } else if (this.paired) {
  688. this._setPaired(true);
  689. this.sendPacket({
  690. type: 'kdeconnect.pair',
  691. body: {pair: true},
  692. });
  693. this._triggerPlugins();
  694. // The device is requesting pairing
  695. } else {
  696. this._notifyPairRequest();
  697. }
  698. // Device is requesting unpairing/rejecting our request
  699. } else {
  700. this._setPaired(false);
  701. this._unloadPlugins();
  702. }
  703. }
  704. /**
  705. * Notify the user of an incoming pair request and set a 30s timeout
  706. */
  707. _notifyPairRequest() {
  708. // Reset any active request
  709. this._resetPairRequest();
  710. this.showNotification({
  711. id: 'pair-request',
  712. // TRANSLATORS: eg. Pair Request from Google Pixel
  713. title: _('Pair Request from %s').format(this.name),
  714. body: this.encryption_info,
  715. icon: new Gio.ThemedIcon({name: 'channel-insecure-symbolic'}),
  716. priority: Gio.NotificationPriority.URGENT,
  717. buttons: [
  718. {
  719. action: 'unpair',
  720. label: _('Reject'),
  721. parameter: null,
  722. },
  723. {
  724. action: 'pair',
  725. label: _('Accept'),
  726. parameter: null,
  727. },
  728. ],
  729. });
  730. // Start a 30s countdown
  731. this._incomingPairRequest = GLib.timeout_add_seconds(
  732. GLib.PRIORITY_DEFAULT,
  733. 30,
  734. this._setPaired.bind(this, false)
  735. );
  736. }
  737. /**
  738. * Reset pair request timeouts and withdraw any notifications
  739. */
  740. _resetPairRequest() {
  741. this.hideNotification('pair-request');
  742. if (this._incomingPairRequest) {
  743. GLib.source_remove(this._incomingPairRequest);
  744. this._incomingPairRequest = 0;
  745. }
  746. if (this._outgoingPairRequest) {
  747. GLib.source_remove(this._outgoingPairRequest);
  748. this._outgoingPairRequest = 0;
  749. }
  750. }
  751. /**
  752. * Set the internal paired state of the device and emit ::notify
  753. *
  754. * @param {boolean} paired - The paired state to set
  755. */
  756. _setPaired(paired) {
  757. this._resetPairRequest();
  758. // For TCP connections we store or reset the TLS Certificate
  759. if (this.connection_type === 'lan') {
  760. if (paired) {
  761. this.settings.set_string(
  762. 'certificate-pem',
  763. this.channel.peer_certificate.certificate_pem
  764. );
  765. } else {
  766. this.settings.reset('certificate-pem');
  767. }
  768. }
  769. // If we've become unpaired, stop all subprocesses and transfers
  770. if (!paired) {
  771. for (const proc of this._procs)
  772. proc.force_exit();
  773. this._procs.clear();
  774. for (const transfer of this._transfers.values())
  775. transfer.close();
  776. this._transfers.clear();
  777. }
  778. this.settings.set_boolean('paired', paired);
  779. this.notify('paired');
  780. }
  781. /**
  782. * Send or accept an incoming pair request; also exported as a GAction
  783. */
  784. pair() {
  785. try {
  786. // If we're accepting an incoming pair request, set the internal
  787. // paired state and send the confirmation before loading plugins.
  788. if (this._incomingPairRequest) {
  789. this._setPaired(true);
  790. this.sendPacket({
  791. type: 'kdeconnect.pair',
  792. body: {pair: true},
  793. });
  794. this._loadPlugins();
  795. // If we're initiating an outgoing pair request, be sure the timer
  796. // is reset before sending the request and setting a 30s timeout.
  797. } else if (!this.paired) {
  798. this._resetPairRequest();
  799. this.sendPacket({
  800. type: 'kdeconnect.pair',
  801. body: {pair: true},
  802. });
  803. this._outgoingPairRequest = GLib.timeout_add_seconds(
  804. GLib.PRIORITY_DEFAULT,
  805. 30,
  806. this._setPaired.bind(this, false)
  807. );
  808. }
  809. } catch (e) {
  810. logError(e, this.name);
  811. }
  812. }
  813. /**
  814. * Unpair or reject an incoming pair request; also exported as a GAction
  815. */
  816. unpair() {
  817. try {
  818. if (this.connected) {
  819. this.sendPacket({
  820. type: 'kdeconnect.pair',
  821. body: {pair: false},
  822. });
  823. }
  824. this._setPaired(false);
  825. this._unloadPlugins();
  826. } catch (e) {
  827. logError(e, this.name);
  828. }
  829. }
  830. /*
  831. * Plugin Functions
  832. */
  833. _onAllowedPluginsChanged(settings) {
  834. const disabled = this.settings.get_strv('disabled-plugins');
  835. const supported = this.settings.get_strv('supported-plugins');
  836. const allowed = supported.filter(name => !disabled.includes(name));
  837. // Unload any plugins that are disabled or unsupported
  838. this._plugins.forEach(plugin => {
  839. if (!allowed.includes(plugin.name))
  840. this._unloadPlugin(plugin.name);
  841. });
  842. // Make sure we change the contacts store if the plugin was disabled
  843. if (!allowed.includes('contacts'))
  844. this.notify('contacts');
  845. // Load allowed plugins
  846. for (const name of allowed)
  847. this._loadPlugin(name);
  848. }
  849. _loadPlugin(name) {
  850. let handler, plugin;
  851. try {
  852. if (this.paired && !this._plugins.has(name)) {
  853. // Instantiate the handler
  854. handler = plugins[name];
  855. plugin = new handler.default(this);
  856. // Register packet handlers
  857. for (const packetType of handler.Metadata.incomingCapabilities)
  858. this._handlers.set(packetType, plugin);
  859. // Register plugin
  860. this._plugins.set(name, plugin);
  861. // Run the connected()/disconnected() handler
  862. if (this.connected)
  863. plugin.connected();
  864. else
  865. plugin.disconnected();
  866. }
  867. } catch (e) {
  868. if (plugin !== undefined)
  869. plugin.destroy();
  870. if (this.service !== null)
  871. this.service.notify_error(e);
  872. else
  873. logError(e, this.name);
  874. }
  875. }
  876. async _loadPlugins() {
  877. const disabled = this.settings.get_strv('disabled-plugins');
  878. for (const name of this.settings.get_strv('supported-plugins')) {
  879. if (!disabled.includes(name))
  880. await this._loadPlugin(name);
  881. }
  882. }
  883. _unloadPlugin(name) {
  884. let handler, plugin;
  885. try {
  886. if (this._plugins.has(name)) {
  887. // Unregister packet handlers
  888. handler = plugins[name];
  889. for (const type of handler.Metadata.incomingCapabilities)
  890. this._handlers.delete(type);
  891. // Unregister plugin
  892. plugin = this._plugins.get(name);
  893. this._plugins.delete(name);
  894. plugin.destroy();
  895. }
  896. } catch (e) {
  897. logError(e, this.name);
  898. }
  899. }
  900. async _unloadPlugins() {
  901. for (const name of this._plugins.keys())
  902. await this._unloadPlugin(name);
  903. }
  904. _triggerPlugins() {
  905. for (const plugin of this._plugins.values()) {
  906. if (this.connected)
  907. plugin.connected();
  908. else
  909. plugin.disconnected();
  910. }
  911. }
  912. destroy() {
  913. // Drop the default contacts store if we were using it
  914. if (this._contacts !== undefined)
  915. this._contacts = Components.release('contacts');
  916. // Close the channel if still connected
  917. if (this.channel !== null)
  918. this.channel.close();
  919. // Synchronously destroy plugins
  920. this._plugins.forEach(plugin => plugin.destroy());
  921. this._plugins.clear();
  922. // Dispose GSettings
  923. this.settings.disconnect(this._disabledPluginsChangedId);
  924. this.settings.disconnect(this._supportedPluginsChangedId);
  925. this.settings.run_dispose();
  926. GObject.signal_handlers_destroy(this);
  927. }
  928. });
  929. export default Device;