manager.js 15 KB

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