remote.js 14 KB

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