remote.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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. const SERVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect';
  8. const SERVICE_PATH = '/org/gnome/Shell/Extensions/GSConnect';
  9. const DEVICE_NAME = 'org.gnome.Shell.Extensions.GSConnect.Device';
  10. const _PROPERTIES = {
  11. 'Connected': 'connected',
  12. 'EncryptionInfo': 'encryption-info',
  13. 'IconName': 'icon-name',
  14. 'Id': 'id',
  15. 'Name': 'name',
  16. 'Paired': 'paired',
  17. 'Type': 'type',
  18. };
  19. /**
  20. * Initialize a Gio.DBusProxy in an awaitable manner
  21. *
  22. * @param {Gio.DBusProxy} proxy - The proxy object to initialize
  23. * @param {Gio.Cancellable} [cancellable] - An optional cancellable object
  24. * @returns {Promise} An awaitable Promise
  25. */
  26. function _proxyInit(proxy, cancellable = null) {
  27. if (proxy.__initialized !== undefined)
  28. return Promise.resolve();
  29. return new Promise((resolve, reject) => {
  30. proxy.init_async(
  31. GLib.PRIORITY_DEFAULT,
  32. cancellable,
  33. (proxy, res) => {
  34. try {
  35. proxy.init_finish(res);
  36. proxy.__initialized = true;
  37. resolve();
  38. } catch (e) {
  39. Gio.DBusError.strip_remote_error(e);
  40. reject(e);
  41. }
  42. }
  43. );
  44. });
  45. }
  46. /**
  47. * A simple proxy wrapper for devices exported over DBus.
  48. */
  49. export const Device = GObject.registerClass({
  50. GTypeName: 'GSConnectRemoteDevice',
  51. Implements: [Gio.DBusInterface],
  52. Properties: {
  53. 'connected': GObject.ParamSpec.boolean(
  54. 'connected',
  55. 'Connected',
  56. 'Whether the device is connected',
  57. GObject.ParamFlags.READABLE,
  58. null
  59. ),
  60. 'encryption-info': GObject.ParamSpec.string(
  61. 'encryption-info',
  62. 'Encryption Info',
  63. 'A formatted string with the local and remote fingerprints',
  64. GObject.ParamFlags.READABLE,
  65. null
  66. ),
  67. 'icon-name': GObject.ParamSpec.string(
  68. 'icon-name',
  69. 'Icon Name',
  70. 'Icon name representing the device',
  71. GObject.ParamFlags.READABLE,
  72. null
  73. ),
  74. 'id': GObject.ParamSpec.string(
  75. 'id',
  76. 'deviceId',
  77. 'The device hostname or other unique id',
  78. GObject.ParamFlags.READABLE,
  79. ''
  80. ),
  81. 'name': GObject.ParamSpec.string(
  82. 'name',
  83. 'deviceName',
  84. 'The device name',
  85. GObject.ParamFlags.READABLE,
  86. null
  87. ),
  88. 'paired': GObject.ParamSpec.boolean(
  89. 'paired',
  90. 'Paired',
  91. 'Whether the device is paired',
  92. GObject.ParamFlags.READABLE,
  93. null
  94. ),
  95. 'type': GObject.ParamSpec.string(
  96. 'type',
  97. 'deviceType',
  98. 'The device type',
  99. GObject.ParamFlags.READABLE,
  100. null
  101. ),
  102. },
  103. }, class Device extends Gio.DBusProxy {
  104. _init(service, object_path) {
  105. this._service = service;
  106. super._init({
  107. g_connection: service.g_connection,
  108. g_name: SERVICE_NAME,
  109. g_object_path: object_path,
  110. g_interface_name: DEVICE_NAME,
  111. });
  112. }
  113. vfunc_g_properties_changed(changed, invalidated) {
  114. try {
  115. for (const name in changed.deepUnpack())
  116. this.notify(_PROPERTIES[name]);
  117. } catch (e) {
  118. logError(e);
  119. }
  120. }
  121. _get(name, fallback = null) {
  122. try {
  123. return this.get_cached_property(name).unpack();
  124. } catch {
  125. return fallback;
  126. }
  127. }
  128. get connected() {
  129. return this._get('Connected', false);
  130. }
  131. get encryption_info() {
  132. return this._get('EncryptionInfo', '');
  133. }
  134. get icon_name() {
  135. return this._get('IconName', 'computer');
  136. }
  137. get id() {
  138. return this._get('Id', '0');
  139. }
  140. get name() {
  141. return this._get('Name', 'Unknown');
  142. }
  143. get paired() {
  144. return this._get('Paired', false);
  145. }
  146. get service() {
  147. return this._service;
  148. }
  149. get type() {
  150. return this._get('Type', 'desktop');
  151. }
  152. async start() {
  153. try {
  154. await _proxyInit(this);
  155. // For GActions & GMenu we pass the service's name owner to avoid
  156. // any mixup with instances.
  157. this.action_group = Gio.DBusActionGroup.get(
  158. this.g_connection,
  159. this.service.g_name_owner,
  160. this.g_object_path
  161. );
  162. this.menu = Gio.DBusMenuModel.get(
  163. this.g_connection,
  164. this.service.g_name_owner,
  165. this.g_object_path
  166. );
  167. // Poke the GMenu to ensure it's ready for us
  168. await new Promise((resolve, reject) => {
  169. this.g_connection.call(
  170. SERVICE_NAME,
  171. this.g_object_path,
  172. 'org.gtk.Menus',
  173. 'Start',
  174. new GLib.Variant('(au)', [[0]]),
  175. null,
  176. Gio.DBusCallFlags.NONE,
  177. -1,
  178. null,
  179. (proxy, res) => {
  180. try {
  181. resolve(proxy.call_finish(res));
  182. } catch (e) {
  183. Gio.DBusError.strip_remote_error(e);
  184. reject(e);
  185. }
  186. }
  187. );
  188. });
  189. } catch (e) {
  190. this.destroy();
  191. throw e;
  192. }
  193. }
  194. destroy() {
  195. GObject.signal_handlers_destroy(this);
  196. }
  197. });
  198. /**
  199. * A simple proxy wrapper for the GSConnect service.
  200. */
  201. export const Service = GObject.registerClass({
  202. GTypeName: 'GSConnectRemoteService',
  203. Implements: [Gio.DBusInterface],
  204. Properties: {
  205. 'active': GObject.ParamSpec.boolean(
  206. 'active',
  207. 'Active',
  208. 'Whether the service is active',
  209. GObject.ParamFlags.READABLE,
  210. false
  211. ),
  212. },
  213. Signals: {
  214. 'device-added': {
  215. flags: GObject.SignalFlags.RUN_FIRST,
  216. param_types: [Device.$gtype],
  217. },
  218. 'device-removed': {
  219. flags: GObject.SignalFlags.RUN_FIRST,
  220. param_types: [Device.$gtype],
  221. },
  222. },
  223. }, class Service extends Gio.DBusProxy {
  224. _init() {
  225. super._init({
  226. g_bus_type: Gio.BusType.SESSION,
  227. g_name: SERVICE_NAME,
  228. g_object_path: SERVICE_PATH,
  229. g_interface_name: 'org.freedesktop.DBus.ObjectManager',
  230. g_flags: Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION,
  231. });
  232. this._active = false;
  233. this._devices = new Map();
  234. this._starting = false;
  235. // Watch the service
  236. this._nameOwnerChangedId = this.connect(
  237. 'notify::g-name-owner',
  238. this._onNameOwnerChanged.bind(this)
  239. );
  240. }
  241. get active() {
  242. return this._active;
  243. }
  244. get devices() {
  245. return Array.from(this._devices.values());
  246. }
  247. vfunc_g_signal(sender_name, signal_name, parameters) {
  248. try {
  249. // Don't emit signals until the ObjectManager has started
  250. if (!this.active)
  251. return;
  252. parameters = parameters.deepUnpack();
  253. switch (true) {
  254. case (signal_name === 'InterfacesAdded'):
  255. this._onInterfacesAdded(...parameters);
  256. break;
  257. case (signal_name === 'InterfacesRemoved'):
  258. this._onInterfacesRemoved(...parameters);
  259. break;
  260. }
  261. } catch (e) {
  262. logError(e);
  263. }
  264. }
  265. /**
  266. * org.freedesktop.DBus.ObjectManager.InterfacesAdded
  267. *
  268. * @param {string} object_path - Path interfaces have been added to
  269. * @param {object} interfaces - A dictionary of interface objects
  270. */
  271. async _onInterfacesAdded(object_path, interfaces) {
  272. try {
  273. // An empty list means only the object has been added
  274. if (Object.values(interfaces).length === 0)
  275. return;
  276. // Skip existing proxies
  277. if (this._devices.has(object_path))
  278. return;
  279. // Create a proxy
  280. const device = new Device(this, object_path);
  281. await device.start();
  282. // Hold the proxy and emit ::device-added
  283. this._devices.set(object_path, device);
  284. this.emit('device-added', device);
  285. } catch (e) {
  286. logError(e, object_path);
  287. }
  288. }
  289. /**
  290. * org.freedesktop.DBus.ObjectManager.InterfacesRemoved
  291. *
  292. * @param {string} object_path - Path interfaces have been removed from
  293. * @param {string[]} interfaces - List of interface names removed
  294. */
  295. _onInterfacesRemoved(object_path, interfaces) {
  296. try {
  297. // An empty interface list means the object is being removed
  298. if (interfaces.length === 0)
  299. return;
  300. // Get the proxy
  301. const device = this._devices.get(object_path);
  302. if (device === undefined)
  303. return;
  304. // Release the proxy and emit ::device-removed
  305. this._devices.delete(object_path);
  306. this.emit('device-removed', device);
  307. // Destroy the device and force disposal
  308. device.destroy();
  309. } catch (e) {
  310. logError(e, object_path);
  311. }
  312. }
  313. async _addDevices() {
  314. const objects = await new Promise((resolve, reject) => {
  315. this.call(
  316. 'GetManagedObjects',
  317. null,
  318. Gio.DBusCallFlags.NONE,
  319. -1,
  320. null,
  321. (proxy, res) => {
  322. try {
  323. const variant = proxy.call_finish(res);
  324. resolve(variant.deepUnpack()[0]);
  325. } catch (e) {
  326. Gio.DBusError.strip_remote_error(e);
  327. reject(e);
  328. }
  329. }
  330. );
  331. });
  332. for (const [object_path, object] of Object.entries(objects))
  333. await this._onInterfacesAdded(object_path, object);
  334. }
  335. _clearDevices() {
  336. for (const [object_path, device] of this._devices) {
  337. this._devices.delete(object_path);
  338. this.emit('device-removed', device);
  339. device.destroy();
  340. }
  341. }
  342. async _onNameOwnerChanged() {
  343. try {
  344. // If the service stopped, remove each device and mark it inactive
  345. if (this.g_name_owner === null) {
  346. this._clearDevices();
  347. this._active = false;
  348. this.notify('active');
  349. // If the service started, mark it active and add each device
  350. } else {
  351. this._active = true;
  352. this.notify('active');
  353. await this._addDevices();
  354. }
  355. } catch (e) {
  356. logError(e);
  357. }
  358. }
  359. /**
  360. * Reload all devices without affecting the remote service. This amounts to
  361. * removing and adding each device while emitting the appropriate signals.
  362. */
  363. async reload() {
  364. try {
  365. if (this._starting === false) {
  366. this._starting = true;
  367. this._clearDevices();
  368. await _proxyInit(this);
  369. await this._onNameOwnerChanged();
  370. this._starting = false;
  371. }
  372. } catch (e) {
  373. this._starting = false;
  374. throw e;
  375. }
  376. }
  377. /**
  378. * Start the service
  379. */
  380. async start() {
  381. try {
  382. if (this._starting === false && this.active === false) {
  383. this._starting = true;
  384. await _proxyInit(this);
  385. await this._onNameOwnerChanged();
  386. // Activate the service if it's not already running
  387. if (!this.active) {
  388. await new Promise((resolve, reject) => {
  389. this.g_connection.call(
  390. SERVICE_NAME,
  391. SERVICE_PATH,
  392. 'org.freedesktop.Application',
  393. 'Activate',
  394. GLib.Variant.new('(a{sv})', [{}]),
  395. null,
  396. Gio.DBusCallFlags.NONE,
  397. -1,
  398. null,
  399. (proxy, res) => {
  400. try {
  401. resolve(proxy.call_finish(res));
  402. } catch (e) {
  403. Gio.DBusError.strip_remote_error(e);
  404. reject(e);
  405. }
  406. }
  407. );
  408. });
  409. }
  410. this._starting = false;
  411. }
  412. } catch (e) {
  413. this._starting = false;
  414. throw e;
  415. }
  416. }
  417. /**
  418. * Stop the service
  419. */
  420. stop() {
  421. if (this.active)
  422. this.activate_action('quit');
  423. }
  424. activate_action(name, parameter = null) {
  425. try {
  426. const paramArray = [];
  427. if (parameter instanceof GLib.Variant)
  428. paramArray[0] = parameter;
  429. const connection = this.g_connection || Gio.DBus.session;
  430. connection.call(
  431. SERVICE_NAME,
  432. SERVICE_PATH,
  433. 'org.freedesktop.Application',
  434. 'ActivateAction',
  435. GLib.Variant.new('(sava{sv})', [name, paramArray, {}]),
  436. null,
  437. Gio.DBusCallFlags.NONE,
  438. -1,
  439. null,
  440. null
  441. );
  442. } catch (e) {
  443. logError(e);
  444. }
  445. }
  446. destroy() {
  447. if (this._nameOwnerChangedId > 0) {
  448. this.disconnect(this._nameOwnerChangedId);
  449. this._nameOwnerChangedId = 0;
  450. this._clearDevices();
  451. this._active = false;
  452. GObject.signal_handlers_destroy(this);
  453. }
  454. }
  455. });