device.js 34 KB

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