daemon.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. #!/usr/bin/env -S gjs -m
  2. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  3. //
  4. // SPDX-License-Identifier: GPL-2.0-or-later
  5. import Gdk from 'gi://Gdk?version=3.0';
  6. import 'gi://GdkPixbuf?version=2.0';
  7. import Gio from 'gi://Gio?version=2.0';
  8. import 'gi://GIRepository?version=2.0';
  9. import GLib from 'gi://GLib?version=2.0';
  10. import GObject from 'gi://GObject?version=2.0';
  11. import Gtk from 'gi://Gtk?version=3.0';
  12. import 'gi://Pango?version=1.0';
  13. import system from 'system';
  14. import './init.js';
  15. import Config from '../config.js';
  16. import Device from './device.js';
  17. import Manager from './manager.js';
  18. import * as ServiceUI from './ui/service.js';
  19. import('gi://GioUnix?version=2.0').catch(() => {}); // Set version for optional dependency
  20. /**
  21. * Class representing the GSConnect service daemon.
  22. */
  23. const Service = GObject.registerClass({
  24. GTypeName: 'GSConnectService',
  25. }, class Service extends Gtk.Application {
  26. _init() {
  27. super._init({
  28. application_id: 'org.gnome.Shell.Extensions.GSConnect',
  29. flags: Gio.ApplicationFlags.HANDLES_OPEN,
  30. resource_base_path: '/org/gnome/Shell/Extensions/GSConnect',
  31. });
  32. GLib.set_prgname('gsconnect');
  33. GLib.set_application_name('GSConnect');
  34. // Command-line
  35. this._initOptions();
  36. }
  37. _migrateConfiguration() {
  38. if (!Device.validateName(this.settings.get_string('name')))
  39. this.settings.set('name', GLib.get_host_name().slice(0, 32));
  40. const [certPath, keyPath] = [
  41. GLib.build_filenamev([Config.CONFIGDIR, 'certificate.pem']),
  42. GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
  43. ];
  44. const certificate = Gio.TlsCertificate.new_for_paths(certPath, keyPath,
  45. null);
  46. if (Device.validateId(certificate.common_name))
  47. return;
  48. // Remove the old certificate, serving as the single source of truth
  49. // for the device ID
  50. try {
  51. Gio.File.new_for_path(certPath).delete(null);
  52. Gio.File.new_for_path(keyPath).delete(null);
  53. } catch {
  54. // Silence errors
  55. }
  56. // For each device, remove it entirely if it violates the device ID
  57. // constraints, otherwise mark it unpaired to preserve the settings.
  58. const deviceList = this.settings.get_strv('devices').filter(id => {
  59. const settingsPath = `/org/gnome/shell/extensions/gsconnect/device/${id}/`;
  60. if (!Device.validateId(id)) {
  61. GLib.spawn_command_line_async(`dconf reset -f ${settingsPath}`);
  62. Gio.File.rm_rf(GLib.build_filenamev([Config.CACHEDIR, id]));
  63. debug(`Invalid device ID ${id} removed.`);
  64. return false;
  65. }
  66. const settings = new Gio.Settings({
  67. settings_schema: Config.GSCHEMA.lookup(
  68. 'org.gnome.Shell.Extensions.GSConnect.Device', true),
  69. path: settingsPath,
  70. });
  71. settings.set_boolean('paired', false);
  72. return true;
  73. });
  74. this.settings.set_strv('devices', deviceList);
  75. // Notify the user
  76. const notification = Gio.Notification.new(_('Settings Migrated'));
  77. notification.set_body(_('GSConnect has updated to support changes to the KDE Connect protocol. Some devices may need to be repaired.'));
  78. notification.set_icon(new Gio.ThemedIcon({name: 'dialog-warning'}));
  79. notification.set_priority(Gio.NotificationPriority.HIGH);
  80. this.send_notification('settings-migrated', notification);
  81. // Finally, reset the service ID to trigger re-generation.
  82. this.settings.reset('id');
  83. }
  84. get settings() {
  85. if (this._settings === undefined) {
  86. this._settings = new Gio.Settings({
  87. settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
  88. });
  89. }
  90. return this._settings;
  91. }
  92. /*
  93. * GActions
  94. */
  95. _initActions() {
  96. const actions = [
  97. ['connect', this._identify.bind(this), new GLib.VariantType('s')],
  98. ['device', this._device.bind(this), new GLib.VariantType('(ssbv)')],
  99. ['error', this._error.bind(this), new GLib.VariantType('a{ss}')],
  100. ['preferences', this._preferences, null],
  101. ['quit', () => this.quit(), null],
  102. ['refresh', this._identify.bind(this), null],
  103. ];
  104. for (const [name, callback, type] of actions) {
  105. const action = new Gio.SimpleAction({
  106. name: name,
  107. parameter_type: type,
  108. });
  109. action.connect('activate', callback);
  110. this.add_action(action);
  111. }
  112. }
  113. /**
  114. * A wrapper for Device GActions. This is used to route device notification
  115. * actions to their device, since GNotifications need an 'app' level action.
  116. *
  117. * @param {Gio.Action} action - The GAction
  118. * @param {GLib.Variant} parameter - The activation parameter
  119. */
  120. _device(action, parameter) {
  121. try {
  122. parameter = parameter.unpack();
  123. // Select the appropriate device(s)
  124. let devices;
  125. const id = parameter[0].unpack();
  126. if (id === '*')
  127. devices = this.manager.devices.values();
  128. else
  129. devices = [this.manager.devices.get(id)];
  130. // Unpack the action data and activate the action
  131. const name = parameter[1].unpack();
  132. const target = parameter[2].unpack() ? parameter[3].unpack() : null;
  133. for (const device of devices)
  134. device.activate_action(name, target);
  135. } catch (e) {
  136. logError(e);
  137. }
  138. }
  139. _error(action, parameter) {
  140. try {
  141. const error = parameter.deepUnpack();
  142. // If there's a URL, we have better information in the Wiki
  143. if (error.url !== undefined) {
  144. Gio.AppInfo.launch_default_for_uri_async(
  145. error.url,
  146. null,
  147. null,
  148. null
  149. );
  150. return;
  151. }
  152. const dialog = new ServiceUI.ErrorDialog(error);
  153. dialog.present();
  154. } catch (e) {
  155. logError(e);
  156. }
  157. }
  158. _identify(action, parameter) {
  159. try {
  160. let uri = null;
  161. if (parameter instanceof GLib.Variant)
  162. uri = parameter.unpack();
  163. this.manager.identify(uri);
  164. } catch (e) {
  165. logError(e);
  166. }
  167. }
  168. _preferences() {
  169. Gio.Subprocess.new(
  170. [`${Config.PACKAGE_DATADIR}/gsconnect-preferences`],
  171. Gio.SubprocessFlags.NONE
  172. );
  173. }
  174. /**
  175. * Report a service-level error
  176. *
  177. * @param {object} error - An Error or object with name, message and stack
  178. */
  179. notify_error(error) {
  180. try {
  181. // Always log the error
  182. logError(error);
  183. // Create an new notification
  184. let id, body, priority;
  185. const notif = new Gio.Notification();
  186. const icon = new Gio.ThemedIcon({name: 'dialog-error'});
  187. let target = null;
  188. if (error.name === undefined)
  189. error.name = 'Error';
  190. if (error.url !== undefined) {
  191. id = error.url;
  192. body = _('Click for help troubleshooting');
  193. priority = Gio.NotificationPriority.URGENT;
  194. target = new GLib.Variant('a{ss}', {
  195. name: error.name.trim(),
  196. message: error.message.trim(),
  197. stack: error.stack.trim(),
  198. url: error.url,
  199. });
  200. } else {
  201. id = error.message.trim();
  202. body = _('Click for more information');
  203. priority = Gio.NotificationPriority.HIGH;
  204. target = new GLib.Variant('a{ss}', {
  205. name: error.name.trim(),
  206. message: error.message.trim(),
  207. stack: error.stack.trim(),
  208. });
  209. }
  210. notif.set_title(`GSConnect: ${error.name.trim()}`);
  211. notif.set_body(body);
  212. notif.set_icon(icon);
  213. notif.set_priority(priority);
  214. notif.set_default_action_and_target('app.error', target);
  215. this.send_notification(id, notif);
  216. } catch (e) {
  217. logError(e);
  218. }
  219. }
  220. vfunc_activate() {
  221. super.vfunc_activate();
  222. }
  223. vfunc_startup() {
  224. super.vfunc_startup();
  225. this.hold();
  226. // Watch *this* file and stop the service if it's updated/uninstalled
  227. this._serviceMonitor = Gio.File.new_for_path(
  228. `${Config.PACKAGE_DATADIR}/service/daemon.js`
  229. ).monitor(Gio.FileMonitorFlags.WATCH_MOVES, null);
  230. this._serviceMonitor.connect('changed', () => this.quit());
  231. // Init some resources
  232. const provider = new Gtk.CssProvider();
  233. provider.load_from_resource(`${Config.APP_PATH}/application.css`);
  234. Gtk.StyleContext.add_provider_for_screen(
  235. Gdk.Screen.get_default(),
  236. provider,
  237. Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
  238. );
  239. // Ensure our handlers are registered
  240. try {
  241. const appInfo = Gio.DesktopAppInfo.new(`${Config.APP_ID}.desktop`);
  242. appInfo.add_supports_type('x-scheme-handler/sms');
  243. appInfo.add_supports_type('x-scheme-handler/tel');
  244. } catch (e) {
  245. debug(e);
  246. }
  247. // GActions & GSettings
  248. this._initActions();
  249. // TODO: remove after a reasonable period of time
  250. this._migrateConfiguration();
  251. this.manager.start();
  252. }
  253. vfunc_dbus_register(connection, object_path) {
  254. if (!super.vfunc_dbus_register(connection, object_path))
  255. return false;
  256. this.manager = new Manager({
  257. connection: connection,
  258. object_path: object_path,
  259. });
  260. return true;
  261. }
  262. vfunc_dbus_unregister(connection, object_path) {
  263. this.manager.destroy();
  264. super.vfunc_dbus_unregister(connection, object_path);
  265. }
  266. vfunc_open(files, hint) {
  267. super.vfunc_open(files, hint);
  268. for (const file of files) {
  269. let action, parameter, title;
  270. try {
  271. switch (file.get_uri_scheme()) {
  272. case 'sms':
  273. title = _('Send SMS');
  274. action = 'uriSms';
  275. parameter = new GLib.Variant('s', file.get_uri());
  276. break;
  277. case 'tel':
  278. title = _('Dial Number');
  279. action = 'shareUri';
  280. parameter = new GLib.Variant('s', file.get_uri());
  281. break;
  282. case 'file':
  283. title = _('Share File');
  284. action = 'shareFile';
  285. parameter = new GLib.Variant('(sb)', [file.get_uri(), false]);
  286. break;
  287. default:
  288. throw new Error(`Unsupported URI: ${file.get_uri()}`);
  289. }
  290. // Show chooser dialog
  291. new ServiceUI.DeviceChooser({
  292. title: title,
  293. action_name: action,
  294. action_target: parameter,
  295. });
  296. } catch (e) {
  297. logError(e, `GSConnect: Opening ${file.get_uri()}`);
  298. }
  299. }
  300. }
  301. vfunc_shutdown() {
  302. // Dispose GSettings
  303. if (this._settings !== undefined)
  304. this.settings.run_dispose();
  305. this.manager.stop();
  306. // Exhaust the event loop to ensure any pending operations complete
  307. const context = GLib.MainContext.default();
  308. while (context.iteration(false))
  309. continue;
  310. // Force a GC to prevent any more calls back into JS, then chain-up
  311. system.gc();
  312. super.vfunc_shutdown();
  313. }
  314. /*
  315. * CLI
  316. */
  317. _initOptions() {
  318. /*
  319. * Device Listings
  320. */
  321. this.add_main_option(
  322. 'list-devices',
  323. 'l'.charCodeAt(0),
  324. GLib.OptionFlags.NONE,
  325. GLib.OptionArg.NONE,
  326. _('List available devices'),
  327. null
  328. );
  329. this.add_main_option(
  330. 'list-all',
  331. 'a'.charCodeAt(0),
  332. GLib.OptionFlags.NONE,
  333. GLib.OptionArg.NONE,
  334. _('List all devices'),
  335. null
  336. );
  337. this.add_main_option(
  338. 'device',
  339. 'd'.charCodeAt(0),
  340. GLib.OptionFlags.NONE,
  341. GLib.OptionArg.STRING,
  342. _('Target Device'),
  343. '<device-id>'
  344. );
  345. /**
  346. * Pairing
  347. */
  348. this.add_main_option(
  349. 'pair',
  350. 0,
  351. GLib.OptionFlags.NONE,
  352. GLib.OptionArg.NONE,
  353. _('Pair'),
  354. null
  355. );
  356. this.add_main_option(
  357. 'unpair',
  358. 0,
  359. GLib.OptionFlags.NONE,
  360. GLib.OptionArg.NONE,
  361. _('Unpair'),
  362. null
  363. );
  364. /*
  365. * Messaging
  366. */
  367. this.add_main_option(
  368. 'message',
  369. 0,
  370. GLib.OptionFlags.NONE,
  371. GLib.OptionArg.STRING_ARRAY,
  372. _('Send SMS'),
  373. '<phone-number>'
  374. );
  375. this.add_main_option(
  376. 'message-body',
  377. 0,
  378. GLib.OptionFlags.NONE,
  379. GLib.OptionArg.STRING,
  380. _('Message Body'),
  381. '<text>'
  382. );
  383. /*
  384. * Notifications
  385. */
  386. this.add_main_option(
  387. 'notification',
  388. 0,
  389. GLib.OptionFlags.NONE,
  390. GLib.OptionArg.STRING,
  391. _('Send Notification'),
  392. '<title>'
  393. );
  394. this.add_main_option(
  395. 'notification-appname',
  396. 0,
  397. GLib.OptionFlags.NONE,
  398. GLib.OptionArg.STRING,
  399. _('Notification App Name'),
  400. '<name>'
  401. );
  402. this.add_main_option(
  403. 'notification-body',
  404. 0,
  405. GLib.OptionFlags.NONE,
  406. GLib.OptionArg.STRING,
  407. _('Notification Body'),
  408. '<text>'
  409. );
  410. this.add_main_option(
  411. 'notification-icon',
  412. 0,
  413. GLib.OptionFlags.NONE,
  414. GLib.OptionArg.STRING,
  415. _('Notification Icon'),
  416. '<icon-name>'
  417. );
  418. this.add_main_option(
  419. 'notification-id',
  420. 0,
  421. GLib.OptionFlags.NONE,
  422. GLib.OptionArg.STRING,
  423. _('Notification ID'),
  424. '<id>'
  425. );
  426. this.add_main_option(
  427. 'ping',
  428. 0,
  429. GLib.OptionFlags.NONE,
  430. GLib.OptionArg.NONE,
  431. _('Ping'),
  432. null
  433. );
  434. this.add_main_option(
  435. 'ring',
  436. 0,
  437. GLib.OptionFlags.NONE,
  438. GLib.OptionArg.NONE,
  439. _('Ring'),
  440. null
  441. );
  442. /*
  443. * Sharing
  444. */
  445. this.add_main_option(
  446. 'share-file',
  447. 0,
  448. GLib.OptionFlags.NONE,
  449. GLib.OptionArg.FILENAME_ARRAY,
  450. _('Share File'),
  451. '<filepath|URI>'
  452. );
  453. this.add_main_option(
  454. 'share-link',
  455. 0,
  456. GLib.OptionFlags.NONE,
  457. GLib.OptionArg.STRING_ARRAY,
  458. _('Share Link'),
  459. '<URL>'
  460. );
  461. this.add_main_option(
  462. 'share-text',
  463. 0,
  464. GLib.OptionFlags.NONE,
  465. GLib.OptionArg.STRING,
  466. _('Share Text'),
  467. '<text>'
  468. );
  469. /*
  470. * Misc
  471. */
  472. this.add_main_option(
  473. 'version',
  474. 'v'.charCodeAt(0),
  475. GLib.OptionFlags.NONE,
  476. GLib.OptionArg.NONE,
  477. _('Show release version'),
  478. null
  479. );
  480. }
  481. _cliAction(id, name, parameter = null) {
  482. const parameters = [];
  483. if (parameter instanceof GLib.Variant)
  484. parameters[0] = parameter;
  485. id = id.replace(/\W+/g, '_');
  486. Gio.DBus.session.call_sync(
  487. 'org.gnome.Shell.Extensions.GSConnect',
  488. `/org/gnome/Shell/Extensions/GSConnect/Device/${id}`,
  489. 'org.gtk.Actions',
  490. 'Activate',
  491. GLib.Variant.new('(sava{sv})', [name, parameters, {}]),
  492. null,
  493. Gio.DBusCallFlags.NONE,
  494. -1,
  495. null
  496. );
  497. }
  498. _cliListDevices(full = true) {
  499. const result = Gio.DBus.session.call_sync(
  500. 'org.gnome.Shell.Extensions.GSConnect',
  501. '/org/gnome/Shell/Extensions/GSConnect',
  502. 'org.freedesktop.DBus.ObjectManager',
  503. 'GetManagedObjects',
  504. null,
  505. null,
  506. Gio.DBusCallFlags.NONE,
  507. -1,
  508. null
  509. );
  510. const variant = result.unpack()[0].unpack();
  511. let device;
  512. for (let object of Object.values(variant)) {
  513. object = object.recursiveUnpack();
  514. device = object['org.gnome.Shell.Extensions.GSConnect.Device'];
  515. if (full)
  516. print(`${device.Id}\t${device.Name}\t${device.Connected}\t${device.Paired}`);
  517. else if (device.Connected && device.Paired)
  518. print(device.Id);
  519. }
  520. }
  521. _cliMessage(id, options) {
  522. if (!options.contains('message-body'))
  523. throw new TypeError('missing --message-body option');
  524. // TODO: currently we only support single-recipient messaging
  525. const addresses = options.lookup_value('message', null).deepUnpack();
  526. const body = options.lookup_value('message-body', null).deepUnpack();
  527. this._cliAction(
  528. id,
  529. 'sendSms',
  530. GLib.Variant.new('(ss)', [addresses[0], body])
  531. );
  532. }
  533. _cliNotify(id, options) {
  534. const title = options.lookup_value('notification', null).unpack();
  535. let body = '';
  536. let icon = null;
  537. let nid = `${Date.now()}`;
  538. let appName = 'GSConnect CLI';
  539. if (options.contains('notification-id'))
  540. nid = options.lookup_value('notification-id', null).unpack();
  541. if (options.contains('notification-body'))
  542. body = options.lookup_value('notification-body', null).unpack();
  543. if (options.contains('notification-appname'))
  544. appName = options.lookup_value('notification-appname', null).unpack();
  545. if (options.contains('notification-icon')) {
  546. icon = options.lookup_value('notification-icon', null).unpack();
  547. icon = Gio.Icon.new_for_string(icon);
  548. } else {
  549. icon = new Gio.ThemedIcon({
  550. name: 'org.gnome.Shell.Extensions.GSConnect',
  551. });
  552. }
  553. const notification = new GLib.Variant('a{sv}', {
  554. appName: GLib.Variant.new_string(appName),
  555. id: GLib.Variant.new_string(nid),
  556. title: GLib.Variant.new_string(title),
  557. text: GLib.Variant.new_string(body),
  558. ticker: GLib.Variant.new_string(`${title}: ${body}`),
  559. time: GLib.Variant.new_string(`${Date.now()}`),
  560. isClearable: GLib.Variant.new_boolean(true),
  561. icon: icon.serialize(),
  562. });
  563. this._cliAction(id, 'sendNotification', notification);
  564. }
  565. _cliShareFile(device, options) {
  566. const files = options.lookup_value('share-file', null).deepUnpack();
  567. for (let file of files) {
  568. file = new TextDecoder().decode(file);
  569. this._cliAction(device, 'shareFile', GLib.Variant.new('(sb)', [file, false]));
  570. }
  571. }
  572. _cliShareLink(device, options) {
  573. const uris = options.lookup_value('share-link', null).unpack();
  574. for (const uri of uris)
  575. this._cliAction(device, 'shareUri', uri);
  576. }
  577. _cliShareText(device, options) {
  578. const text = options.lookup_value('share-text', null).unpack();
  579. this._cliAction(device, 'shareText', GLib.Variant.new_string(text));
  580. }
  581. vfunc_handle_local_options(options) {
  582. try {
  583. if (options.contains('version')) {
  584. print(`GSConnect ${Config.PACKAGE_VERSION}`);
  585. return 0;
  586. }
  587. this.register(null);
  588. if (options.contains('list-devices')) {
  589. this._cliListDevices(false);
  590. return 0;
  591. }
  592. if (options.contains('list-all')) {
  593. this._cliListDevices(true);
  594. return 0;
  595. }
  596. // We need a device for anything else; exit since this is probably
  597. // the daemon being started.
  598. if (!options.contains('device'))
  599. return -1;
  600. const id = options.lookup_value('device', null).unpack();
  601. // Pairing
  602. if (options.contains('pair')) {
  603. this._cliAction(id, 'pair');
  604. return 0;
  605. }
  606. if (options.contains('unpair')) {
  607. this._cliAction(id, 'unpair');
  608. return 0;
  609. }
  610. // Plugins
  611. if (options.contains('message'))
  612. this._cliMessage(id, options);
  613. if (options.contains('notification'))
  614. this._cliNotify(id, options);
  615. if (options.contains('ping'))
  616. this._cliAction(id, 'ping', GLib.Variant.new_string(''));
  617. if (options.contains('ring'))
  618. this._cliAction(id, 'ring');
  619. if (options.contains('share-file'))
  620. this._cliShareFile(id, options);
  621. if (options.contains('share-link'))
  622. this._cliShareLink(id, options);
  623. if (options.contains('share-text'))
  624. this._cliShareText(id, options);
  625. return 0;
  626. } catch (e) {
  627. logError(e);
  628. return 1;
  629. }
  630. }
  631. });
  632. await (new Service()).runAsync([system.programInvocationName].concat(ARGV));