device.js 34 KB

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