manager.js 15 KB

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