daemon.js 19 KB

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