manager.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gio = imports.gi.Gio;
  6. const GLib = imports.gi.GLib;
  7. const GObject = imports.gi.GObject;
  8. const Config = imports.config;
  9. const DBus = imports.service.utils.dbus;
  10. const Device = imports.service.device;
  11. const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';
  12. const DEVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect/Device';
  13. const DEVICE_IFACE = Config.DBUS.lookup_interface(DEVICE_NAME);
  14. /**
  15. * A manager for devices.
  16. */
  17. var Manager = GObject.registerClass({
  18. GTypeName: 'GSConnectManager',
  19. Properties: {
  20. 'active': GObject.ParamSpec.boolean(
  21. 'active',
  22. 'Active',
  23. 'Whether the manager is active',
  24. GObject.ParamFlags.READABLE,
  25. false
  26. ),
  27. 'discoverable': GObject.ParamSpec.boolean(
  28. 'discoverable',
  29. 'Discoverable',
  30. 'Whether the service responds to discovery requests',
  31. GObject.ParamFlags.READWRITE,
  32. false
  33. ),
  34. 'id': GObject.ParamSpec.string(
  35. 'id',
  36. 'Id',
  37. 'The hostname or other network unique id',
  38. GObject.ParamFlags.READWRITE,
  39. null
  40. ),
  41. 'name': GObject.ParamSpec.string(
  42. 'name',
  43. 'Name',
  44. 'The name announced to the network',
  45. GObject.ParamFlags.READWRITE,
  46. 'GSConnect'
  47. ),
  48. },
  49. }, class Manager extends Gio.DBusObjectManagerServer {
  50. _init(params = {}) {
  51. super._init(params);
  52. this._exported = new WeakMap();
  53. this._reconnectId = 0;
  54. this._settings = new Gio.Settings({
  55. settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
  56. });
  57. this._initSettings();
  58. }
  59. get active() {
  60. if (this._active === undefined)
  61. this._active = false;
  62. return this._active;
  63. }
  64. get backends() {
  65. if (this._backends === undefined)
  66. this._backends = new Map();
  67. return this._backends;
  68. }
  69. get devices() {
  70. if (this._devices === undefined)
  71. this._devices = new Map();
  72. return this._devices;
  73. }
  74. get discoverable() {
  75. if (this._discoverable === undefined)
  76. this._discoverable = this.settings.get_boolean('discoverable');
  77. return this._discoverable;
  78. }
  79. set discoverable(value) {
  80. if (this.discoverable === value)
  81. return;
  82. this._discoverable = value;
  83. this.notify('discoverable');
  84. // FIXME: This whole thing just keeps getting uglier
  85. const application = Gio.Application.get_default();
  86. if (application === null)
  87. return;
  88. if (this.discoverable) {
  89. Gio.Application.prototype.withdraw_notification.call(
  90. application,
  91. 'discovery-warning'
  92. );
  93. } else {
  94. const notif = new Gio.Notification();
  95. notif.set_title(_('Discovery Disabled'));
  96. notif.set_body(_('Discovery has been disabled due to the number of devices on this network.'));
  97. notif.set_icon(new Gio.ThemedIcon({name: 'dialog-warning'}));
  98. notif.set_priority(Gio.NotificationPriority.HIGH);
  99. notif.set_default_action('app.preferences');
  100. Gio.Application.prototype.withdraw_notification.call(
  101. application,
  102. 'discovery-warning',
  103. notif
  104. );
  105. }
  106. }
  107. get id() {
  108. if (this._id === undefined)
  109. this._id = this.settings.get_string('id');
  110. return this._id;
  111. }
  112. set id(value) {
  113. if (this.id === value)
  114. return;
  115. this._id = value;
  116. this.notify('id');
  117. }
  118. get name() {
  119. if (this._name === undefined)
  120. this._name = this.settings.get_string('name');
  121. return this._name;
  122. }
  123. set name(value) {
  124. if (this.name === value)
  125. return;
  126. this._name = value;
  127. this.notify('name');
  128. // Broadcast changes to the network
  129. for (const backend of this.backends.values()) {
  130. backend.name = this.name;
  131. backend.buildIdentity();
  132. }
  133. this.identify();
  134. }
  135. get settings() {
  136. if (this._settings === undefined) {
  137. this._settings = new Gio.Settings({
  138. settings_schema: Config.GSCHEMA.lookup(Config.APP_ID, true),
  139. });
  140. }
  141. return this._settings;
  142. }
  143. vfunc_notify(pspec) {
  144. if (pspec.name !== 'connection')
  145. return;
  146. if (this.connection !== null)
  147. this._exportDevices();
  148. else
  149. this._unexportDevices();
  150. }
  151. /*
  152. * GSettings
  153. */
  154. _initSettings() {
  155. // Initialize the ID and name of the service
  156. if (this.settings.get_string('id').length === 0)
  157. this.settings.set_string('id', GLib.uuid_string_random());
  158. if (this.settings.get_string('name').length === 0)
  159. this.settings.set_string('name', GLib.get_host_name());
  160. // Bound Properties
  161. this.settings.bind('discoverable', this, 'discoverable', 0);
  162. this.settings.bind('id', this, 'id', 0);
  163. this.settings.bind('name', this, 'name', 0);
  164. }
  165. /*
  166. * Backends
  167. */
  168. _onChannel(backend, channel) {
  169. try {
  170. let device = this.devices.get(channel.identity.body.deviceId);
  171. switch (true) {
  172. // Proceed if this is an existing device...
  173. case (device !== undefined):
  174. break;
  175. // Or the connection is allowed...
  176. case this.discoverable || channel.allowed:
  177. device = this._ensureDevice(channel.identity);
  178. break;
  179. // ...otherwise bail
  180. default:
  181. debug(`${channel.identity.body.deviceName}: not allowed`);
  182. return false;
  183. }
  184. device.setChannel(channel);
  185. return true;
  186. } catch (e) {
  187. logError(e, backend.name);
  188. return false;
  189. }
  190. }
  191. _loadBackends() {
  192. for (const name in imports.service.backends) {
  193. try {
  194. const module = imports.service.backends[name];
  195. if (module.ChannelService === undefined)
  196. continue;
  197. // Try to create the backend and track it if successful
  198. const backend = new module.ChannelService({
  199. id: this.id,
  200. name: this.name,
  201. });
  202. this.backends.set(name, backend);
  203. // Connect to the backend
  204. backend.__channelId = backend.connect(
  205. 'channel',
  206. this._onChannel.bind(this)
  207. );
  208. // Now try to start the backend, allowing us to retry if we fail
  209. backend.start();
  210. } catch (e) {
  211. if (Gio.Application.get_default())
  212. Gio.Application.get_default().notify_error(e);
  213. }
  214. }
  215. }
  216. /*
  217. * Devices
  218. */
  219. _loadDevices() {
  220. // Load cached devices
  221. for (const id of this.settings.get_strv('devices')) {
  222. const device = new Device.Device({body: {deviceId: id}});
  223. this._exportDevice(device);
  224. this.devices.set(id, device);
  225. }
  226. }
  227. _exportDevice(device) {
  228. if (this.connection === null)
  229. return;
  230. const info = {
  231. object: null,
  232. interface: null,
  233. actions: 0,
  234. menu: 0,
  235. };
  236. const objectPath = `${DEVICE_PATH}/${device.id.replace(/\W+/g, '_')}`;
  237. // Export an object path for the device
  238. info.object = new Gio.DBusObjectSkeleton({
  239. g_object_path: objectPath,
  240. });
  241. this.export(info.object);
  242. // Export GActions & GMenu
  243. info.actions = Gio.DBus.session.export_action_group(objectPath, device);
  244. info.menu = Gio.DBus.session.export_menu_model(objectPath, device.menu);
  245. // Export the Device interface
  246. info.interface = new DBus.Interface({
  247. g_instance: device,
  248. g_interface_info: DEVICE_IFACE,
  249. });
  250. info.object.add_interface(info.interface);
  251. this._exported.set(device, info);
  252. }
  253. _exportDevices() {
  254. if (this.connection === null)
  255. return;
  256. for (const device of this.devices.values())
  257. this._exportDevice(device);
  258. }
  259. _unexportDevice(device) {
  260. const info = this._exported.get(device);
  261. if (info === undefined)
  262. return;
  263. // Unexport GActions and GMenu
  264. Gio.DBus.session.unexport_action_group(info.actions);
  265. Gio.DBus.session.unexport_menu_model(info.menu);
  266. // Unexport the Device interface and object
  267. info.interface.flush();
  268. info.object.remove_interface(info.interface);
  269. info.object.flush();
  270. this.unexport(info.object.g_object_path);
  271. this._exported.delete(device);
  272. }
  273. _unexportDevices() {
  274. for (const device of this.devices.values())
  275. this._unexportDevice(device);
  276. }
  277. /**
  278. * Return a device for @packet, creating it and adding it to the list of
  279. * of known devices if it doesn't exist.
  280. *
  281. * @param {Core.Packet} packet - An identity packet for the device
  282. * @return {Device.Device} A device object
  283. */
  284. _ensureDevice(packet) {
  285. let device = this.devices.get(packet.body.deviceId);
  286. if (device === undefined) {
  287. debug(`Adding ${packet.body.deviceName}`);
  288. // TODO: Remove when all clients support bluetooth-like discovery
  289. //
  290. // If this is the third unpaired device to connect, we disable
  291. // discovery to avoid choking on networks with many devices
  292. const unpaired = Array.from(this.devices.values()).filter(dev => {
  293. return !dev.paired;
  294. });
  295. if (unpaired.length === 3)
  296. this.discoverable = false;
  297. device = new Device.Device(packet);
  298. this._exportDevice(device);
  299. this.devices.set(device.id, device);
  300. // Notify
  301. this.settings.set_strv('devices', Array.from(this.devices.keys()));
  302. }
  303. return device;
  304. }
  305. /**
  306. * Permanently remove a device.
  307. *
  308. * Removes the device from the list of known devices, deletes all GSettings
  309. * and files.
  310. *
  311. * @param {string} id - The id of the device to delete
  312. */
  313. _removeDevice(id) {
  314. // Delete all GSettings
  315. const settings_path = `/org/gnome/shell/extensions/gsconnect/${id}/`;
  316. GLib.spawn_command_line_async(`dconf reset -f ${settings_path}`);
  317. // Delete the cache
  318. const cache = GLib.build_filenamev([Config.CACHEDIR, id]);
  319. Gio.File.rm_rf(cache);
  320. // Forget the device
  321. this.devices.delete(id);
  322. this.settings.set_strv('devices', Array.from(this.devices.keys()));
  323. }
  324. /**
  325. * A GSourceFunc that tries to reconnect to each paired device, while
  326. * pruning unpaired devices that have disconnected.
  327. *
  328. * @return {boolean} Always %true
  329. */
  330. _reconnect() {
  331. for (const [id, device] of this.devices) {
  332. if (device.connected)
  333. continue;
  334. if (device.paired) {
  335. this.identify(device.settings.get_string('last-connection'));
  336. continue;
  337. }
  338. this._unexportDevice(device);
  339. this._removeDevice(id);
  340. device.destroy();
  341. }
  342. return GLib.SOURCE_CONTINUE;
  343. }
  344. /**
  345. * Identify to an address or broadcast to the network.
  346. *
  347. * @param {string} [uri] - An address URI or %null to broadcast
  348. */
  349. identify(uri = null) {
  350. try {
  351. // If we're passed a parameter, try and find a backend for it
  352. if (uri !== null) {
  353. const [scheme, address] = uri.split('://');
  354. const backend = this.backends.get(scheme);
  355. if (backend !== undefined)
  356. backend.broadcast(address);
  357. // If we're not discoverable, only try to reconnect known devices
  358. } else if (!this.discoverable) {
  359. this._reconnect();
  360. // Otherwise have each backend broadcast to it's network
  361. } else {
  362. this.backends.forEach(backend => backend.broadcast());
  363. }
  364. } catch (e) {
  365. logError(e);
  366. }
  367. }
  368. /**
  369. * Start managing devices.
  370. */
  371. start() {
  372. if (this.active)
  373. return;
  374. this._loadDevices();
  375. this._loadBackends();
  376. if (this._reconnectId === 0) {
  377. this._reconnectId = GLib.timeout_add_seconds(
  378. GLib.PRIORITY_LOW,
  379. 5,
  380. this._reconnect.bind(this)
  381. );
  382. }
  383. this._active = true;
  384. this.notify('active');
  385. }
  386. /**
  387. * Stop managing devices.
  388. */
  389. stop() {
  390. if (!this.active)
  391. return;
  392. if (this._reconnectId > 0) {
  393. GLib.Source.remove(this._reconnectId);
  394. this._reconnectId = 0;
  395. }
  396. this._unexportDevices();
  397. this.backends.forEach(backend => backend.destroy());
  398. this.backends.clear();
  399. this.devices.forEach(device => device.destroy());
  400. this.devices.clear();
  401. this._active = false;
  402. this.notify('active');
  403. }
  404. /**
  405. * Stop managing devices and free any resources.
  406. */
  407. destroy() {
  408. this.stop();
  409. this.set_connection(null);
  410. }
  411. });