daemon.js 20 KB

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