manager.js 15 KB

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