dbusMenu.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967
  1. // This file is part of the AppIndicator/KStatusNotifierItem GNOME Shell extension
  2. //
  3. // This program is free software; you can redistribute it and/or
  4. // modify it under the terms of the GNU General Public License
  5. // as published by the Free Software Foundation; either version 2
  6. // of the License, or (at your option) any later version.
  7. //
  8. // This program is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with this program; if not, write to the Free Software
  15. // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  16. import Clutter from 'gi://Clutter';
  17. import GLib from 'gi://GLib';
  18. import GObject from 'gi://GObject';
  19. import GdkPixbuf from 'gi://GdkPixbuf';
  20. import Gio from 'gi://Gio';
  21. import St from 'gi://St';
  22. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  23. import * as Signals from 'resource:///org/gnome/shell/misc/signals.js';
  24. import * as DBusInterfaces from './interfaces.js';
  25. import * as PromiseUtils from './promiseUtils.js';
  26. import * as Util from './util.js';
  27. import {DBusProxy} from './dbusProxy.js';
  28. Gio._promisify(GdkPixbuf.Pixbuf, 'new_from_stream_async');
  29. // ////////////////////////////////////////////////////////////////////////
  30. // PART ONE: "ViewModel" backend implementation.
  31. // Both code and design are inspired by libdbusmenu
  32. // ////////////////////////////////////////////////////////////////////////
  33. /**
  34. * Saves menu property values and handles type checking and defaults
  35. */
  36. export class PropertyStore {
  37. constructor(initialProperties) {
  38. this._props = new Map();
  39. if (initialProperties) {
  40. for (const [prop, value] of Object.entries(initialProperties))
  41. this.set(prop, value);
  42. }
  43. }
  44. set(name, value) {
  45. if (name in PropertyStore.MandatedTypes && value &&
  46. !value.is_of_type(PropertyStore.MandatedTypes[name]))
  47. Util.Logger.warn(`Cannot set property ${name}: type mismatch!`);
  48. else if (value)
  49. this._props.set(name, value);
  50. else
  51. this._props.delete(name);
  52. }
  53. get(name) {
  54. const prop = this._props.get(name);
  55. if (prop)
  56. return prop;
  57. else if (name in PropertyStore.DefaultValues)
  58. return PropertyStore.DefaultValues[name];
  59. else
  60. return null;
  61. }
  62. }
  63. // we list all the properties we know and use here, so we won' have to deal with unexpected type mismatches
  64. PropertyStore.MandatedTypes = {
  65. 'visible': GLib.VariantType.new('b'),
  66. 'enabled': GLib.VariantType.new('b'),
  67. 'label': GLib.VariantType.new('s'),
  68. 'type': GLib.VariantType.new('s'),
  69. 'children-display': GLib.VariantType.new('s'),
  70. 'icon-name': GLib.VariantType.new('s'),
  71. 'icon-data': GLib.VariantType.new('ay'),
  72. 'toggle-type': GLib.VariantType.new('s'),
  73. 'toggle-state': GLib.VariantType.new('i'),
  74. };
  75. PropertyStore.DefaultValues = {
  76. 'visible': GLib.Variant.new_boolean(true),
  77. 'enabled': GLib.Variant.new_boolean(true),
  78. 'label': GLib.Variant.new_string(''),
  79. 'type': GLib.Variant.new_string('standard'),
  80. // elements not in here must return null
  81. };
  82. /**
  83. * Represents a single menu item
  84. */
  85. export class DbusMenuItem extends Signals.EventEmitter {
  86. // will steal the properties object
  87. constructor(client, id, properties, childrenIds) {
  88. super();
  89. this._client = client;
  90. this._id = id;
  91. this._propStore = new PropertyStore(properties);
  92. this._children_ids = childrenIds;
  93. }
  94. propertyGet(propName) {
  95. const prop = this.propertyGetVariant(propName);
  96. return prop ? prop.get_string()[0] : null;
  97. }
  98. propertyGetVariant(propName) {
  99. return this._propStore.get(propName);
  100. }
  101. propertyGetBool(propName) {
  102. const prop = this.propertyGetVariant(propName);
  103. return prop ? prop.get_boolean() : false;
  104. }
  105. propertyGetInt(propName) {
  106. const prop = this.propertyGetVariant(propName);
  107. return prop ? prop.get_int32() : 0;
  108. }
  109. propertySet(prop, value) {
  110. this._propStore.set(prop, value);
  111. this.emit('property-changed', prop, this.propertyGetVariant(prop));
  112. }
  113. resetProperties() {
  114. Object.entries(PropertyStore.DefaultValues).forEach(([prop, value]) =>
  115. this.propertySet(prop, value));
  116. }
  117. getChildrenIds() {
  118. return this._children_ids.concat(); // clone it!
  119. }
  120. addChild(pos, childId) {
  121. this._children_ids.splice(pos, 0, childId);
  122. this.emit('child-added', this._client.getItem(childId), pos);
  123. }
  124. removeChild(childId) {
  125. // find it
  126. let pos = -1;
  127. for (let i = 0; i < this._children_ids.length; ++i) {
  128. if (this._children_ids[i] === childId) {
  129. pos = i;
  130. break;
  131. }
  132. }
  133. if (pos < 0) {
  134. Util.Logger.critical("Trying to remove child which doesn't exist");
  135. } else {
  136. this._children_ids.splice(pos, 1);
  137. this.emit('child-removed', this._client.getItem(childId));
  138. }
  139. }
  140. moveChild(childId, newPos) {
  141. // find the old position
  142. let oldPos = -1;
  143. for (let i = 0; i < this._children_ids.length; ++i) {
  144. if (this._children_ids[i] === childId) {
  145. oldPos = i;
  146. break;
  147. }
  148. }
  149. if (oldPos < 0) {
  150. Util.Logger.critical("tried to move child which wasn't in the list");
  151. return;
  152. }
  153. if (oldPos !== newPos) {
  154. this._children_ids.splice(oldPos, 1);
  155. this._children_ids.splice(newPos, 0, childId);
  156. this.emit('child-moved', oldPos, newPos, this._client.getItem(childId));
  157. }
  158. }
  159. getChildren() {
  160. return this._children_ids.map(el => this._client.getItem(el));
  161. }
  162. handleEvent(event, data, timestamp) {
  163. if (!data)
  164. data = GLib.Variant.new_int32(0);
  165. this._client.sendEvent(this._id, event, data, timestamp);
  166. }
  167. getId() {
  168. return this._id;
  169. }
  170. sendAboutToShow() {
  171. this._client.sendAboutToShow(this._id);
  172. }
  173. }
  174. /**
  175. * The client does the heavy lifting of actually reading layouts and distributing events
  176. */
  177. export const DBusClient = GObject.registerClass({
  178. Signals: {'ready-changed': {}},
  179. }, class AppIndicatorsDBusClient extends DBusProxy {
  180. static get interfaceInfo() {
  181. if (!this._interfaceInfo) {
  182. this._interfaceInfo = Gio.DBusInterfaceInfo.new_for_xml(
  183. DBusInterfaces.DBusMenu);
  184. }
  185. return this._interfaceInfo;
  186. }
  187. static get baseItems() {
  188. if (!this._baseItems) {
  189. this._baseItems = {
  190. 'children-display': GLib.Variant.new_string('submenu'),
  191. };
  192. }
  193. return this._baseItems;
  194. }
  195. static destroy() {
  196. delete this._interfaceInfo;
  197. }
  198. _init(busName, objectPath) {
  199. const {interfaceInfo} = AppIndicatorsDBusClient;
  200. super._init(busName, objectPath, interfaceInfo,
  201. Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES);
  202. this._items = new Map();
  203. this._items.set(0, new DbusMenuItem(this, 0, DBusClient.baseItems, []));
  204. this._flagItemsUpdateRequired = false;
  205. // will be set to true if a layout update is needed once active
  206. this._flagLayoutUpdateRequired = false;
  207. // property requests are queued
  208. this._propertiesRequestedFor = new Set(/* ids */);
  209. this._layoutUpdated = false;
  210. this._active = false;
  211. }
  212. async initAsync(cancellable) {
  213. await super.initAsync(cancellable);
  214. this._requestLayoutUpdate();
  215. }
  216. _onNameOwnerChanged() {
  217. if (this.isReady)
  218. this._requestLayoutUpdate();
  219. }
  220. get isReady() {
  221. return this._layoutUpdated && !!this.gNameOwner;
  222. }
  223. get cancellable() {
  224. return this._cancellable;
  225. }
  226. getRoot() {
  227. return this._items.get(0);
  228. }
  229. _requestLayoutUpdate() {
  230. const cancellable = new Util.CancellableChild(this._cancellable);
  231. this._beginLayoutUpdate(cancellable);
  232. }
  233. async _requestProperties(propertyId, cancellable) {
  234. this._propertiesRequestedFor.add(propertyId);
  235. if (this._propertiesRequest && this._propertiesRequest.pending())
  236. return;
  237. // if we don't have any requests queued, we'll need to add one
  238. this._propertiesRequest = new PromiseUtils.IdlePromise(
  239. GLib.PRIORITY_DEFAULT_IDLE, cancellable);
  240. await this._propertiesRequest;
  241. const requestedProperties = Array.from(this._propertiesRequestedFor);
  242. this._propertiesRequestedFor.clear();
  243. const [result] = await this.GetGroupPropertiesAsync(requestedProperties,
  244. [], cancellable);
  245. result.forEach(([id, properties]) => {
  246. const item = this._items.get(id);
  247. if (!item)
  248. return;
  249. item.resetProperties();
  250. for (const [prop, value] of Object.entries(properties))
  251. item.propertySet(prop, value);
  252. });
  253. }
  254. // Traverses the list of cached menu items and removes everyone that is not in the list
  255. // so we don't keep alive unused items
  256. _gcItems() {
  257. const tag = new Date().getTime();
  258. const toTraverse = [0];
  259. while (toTraverse.length > 0) {
  260. const item = this.getItem(toTraverse.shift());
  261. item._dbusClientGcTag = tag;
  262. Array.prototype.push.apply(toTraverse, item.getChildrenIds());
  263. }
  264. this._items.forEach((i, id) => {
  265. if (i._dbusClientGcTag !== tag)
  266. this._items.delete(id);
  267. });
  268. }
  269. // the original implementation will only request partial layouts if somehow possible
  270. // we try to save us from multiple kinds of race conditions by always requesting a full layout
  271. _beginLayoutUpdate(cancellable) {
  272. this._layoutUpdateUpdateAsync(cancellable).catch(e => {
  273. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  274. logError(e);
  275. });
  276. }
  277. // the original implementation will only request partial layouts if somehow possible
  278. // we try to save us from multiple kinds of race conditions by always requesting a full layout
  279. async _layoutUpdateUpdateAsync(cancellable) {
  280. // we only read the type property, because if the type changes after reading all properties,
  281. // the view would have to replace the item completely which we try to avoid
  282. if (this._layoutUpdateCancellable)
  283. this._layoutUpdateCancellable.cancel();
  284. this._layoutUpdateCancellable = cancellable;
  285. try {
  286. const [revision_, root] = await this.GetLayoutAsync(0, -1,
  287. ['type', 'children-display'], cancellable);
  288. this._updateLayoutState(true);
  289. this._doLayoutUpdate(root, cancellable);
  290. this._gcItems();
  291. this._flagLayoutUpdateRequired = false;
  292. this._flagItemsUpdateRequired = false;
  293. } catch (e) {
  294. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  295. this._updateLayoutState(false);
  296. throw e;
  297. } finally {
  298. if (this._layoutUpdateCancellable === cancellable)
  299. this._layoutUpdateCancellable = null;
  300. }
  301. }
  302. _updateLayoutState(state) {
  303. const wasReady = this.isReady;
  304. this._layoutUpdated = state;
  305. if (this.isReady !== wasReady)
  306. this.emit('ready-changed');
  307. }
  308. _doLayoutUpdate(item, cancellable) {
  309. const [id, properties, children] = item;
  310. const childrenUnpacked = children.map(c => c.deep_unpack());
  311. const childrenIds = childrenUnpacked.map(([c]) => c);
  312. // make sure all our children exist
  313. childrenUnpacked.forEach(c => this._doLayoutUpdate(c, cancellable));
  314. // make sure we exist
  315. const menuItem = this._items.get(id);
  316. if (menuItem) {
  317. // we do, update our properties if necessary
  318. for (const [prop, value] of Object.entries(properties))
  319. menuItem.propertySet(prop, value);
  320. // make sure our children are all at the right place, and exist
  321. const oldChildrenIds = menuItem.getChildrenIds();
  322. for (let i = 0; i < childrenIds.length; ++i) {
  323. // try to recycle an old child
  324. let oldChild = -1;
  325. for (let j = 0; j < oldChildrenIds.length; ++j) {
  326. if (oldChildrenIds[j] === childrenIds[i]) {
  327. [oldChild] = oldChildrenIds.splice(j, 1);
  328. break;
  329. }
  330. }
  331. if (oldChild < 0) {
  332. // no old child found, so create a new one!
  333. menuItem.addChild(i, childrenIds[i]);
  334. } else {
  335. // old child found, reuse it!
  336. menuItem.moveChild(childrenIds[i], i);
  337. }
  338. }
  339. // remove any old children that weren't reused
  340. oldChildrenIds.forEach(c => menuItem.removeChild(c));
  341. if (!this._flagItemsUpdateRequired)
  342. return id;
  343. }
  344. // we don't, so let's create us
  345. let newMenuItem = menuItem;
  346. if (!newMenuItem) {
  347. newMenuItem = new DbusMenuItem(this, id, properties, childrenIds);
  348. this._items.set(id, newMenuItem);
  349. }
  350. this._requestProperties(id, cancellable).catch(e => {
  351. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  352. Util.Logger.warn(`Could not get menu properties menu proxy: ${e}`);
  353. });
  354. return id;
  355. }
  356. async _doPropertiesUpdateAsync(cancellable) {
  357. if (this._propertiesUpdateCancellable)
  358. this._propertiesUpdateCancellable.cancel();
  359. this._propertiesUpdateCancellable = cancellable;
  360. try {
  361. const requests = [];
  362. this._items.forEach((_, id) =>
  363. requests.push(this._requestProperties(id, cancellable)));
  364. await Promise.all(requests);
  365. } finally {
  366. if (this._propertiesUpdateCancellable === cancellable)
  367. this._propertiesUpdateCancellable = null;
  368. }
  369. }
  370. _doPropertiesUpdate() {
  371. const cancellable = new Util.CancellableChild(this._cancellable);
  372. this._doPropertiesUpdateAsync(cancellable).catch(e => {
  373. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  374. Util.Logger.warn(`Could not get menu properties menu proxy: ${e}`);
  375. });
  376. }
  377. set active(active) {
  378. const wasActive = this._active;
  379. this._active = active;
  380. if (active && wasActive !== active) {
  381. if (this._flagLayoutUpdateRequired) {
  382. this._requestLayoutUpdate();
  383. } else if (this._flagItemsUpdateRequired) {
  384. this._doPropertiesUpdate();
  385. this._flagItemsUpdateRequired = false;
  386. }
  387. }
  388. }
  389. _onSignal(_sender, signal, params) {
  390. if (signal === 'LayoutUpdated') {
  391. if (!this._active) {
  392. this._flagLayoutUpdateRequired = true;
  393. return;
  394. }
  395. this._requestLayoutUpdate();
  396. } else if (signal === 'ItemsPropertiesUpdated') {
  397. if (!this._active) {
  398. this._flagItemsUpdateRequired = true;
  399. return;
  400. }
  401. this._onPropertiesUpdated(params.deep_unpack());
  402. }
  403. }
  404. getItem(id) {
  405. const item = this._items.get(id);
  406. if (!item)
  407. Util.Logger.warn(`trying to retrieve item for non-existing id ${id} !?`);
  408. return item || null;
  409. }
  410. // we don't need to cache and burst-send that since it will not happen that frequently
  411. async sendAboutToShow(id) {
  412. if (this._hasAboutToShow === false)
  413. return;
  414. /* Some indicators (you, dropbox!) don't use the right signature
  415. * and don't return a boolean, so we need to support both cases */
  416. try {
  417. const ret = await this.gConnection.call(this.gName, this.gObjectPath,
  418. this.gInterfaceName, 'AboutToShow', new GLib.Variant('(i)', [id]),
  419. null, Gio.DBusCallFlags.NONE, -1, this._cancellable);
  420. if ((ret.is_of_type(new GLib.VariantType('(b)')) &&
  421. ret.get_child_value(0).get_boolean()) ||
  422. ret.is_of_type(new GLib.VariantType('()')))
  423. this._requestLayoutUpdate();
  424. } catch (e) {
  425. Util.Logger.debug('Error when calling \'AboutToShow()\' in ' +
  426. `${this.gName}, ${this.gObjectPath}, ${this.gInterfaceName}`);
  427. if (e.matches(Gio.DBusError, Gio.DBusError.UNKNOWN_METHOD) ||
  428. e.matches(Gio.DBusError, Gio.DBusError.FAILED)) {
  429. this._hasAboutToShow = false;
  430. return;
  431. }
  432. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  433. logError(e);
  434. }
  435. }
  436. sendEvent(id, event, params, timestamp) {
  437. if (!this.gNameOwner)
  438. return;
  439. this.EventAsync(id, event, params, timestamp, this._cancellable).catch(e => {
  440. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  441. logError(e);
  442. });
  443. }
  444. _onPropertiesUpdated([changed, removed]) {
  445. changed.forEach(([id, props]) => {
  446. const item = this._items.get(id);
  447. if (!item)
  448. return;
  449. for (const [prop, value] of Object.entries(props))
  450. item.propertySet(prop, value);
  451. });
  452. removed.forEach(([id, propNames]) => {
  453. const item = this._items.get(id);
  454. if (!item)
  455. return;
  456. propNames.forEach(propName => item.propertySet(propName, null));
  457. });
  458. }
  459. });
  460. // ////////////////////////////////////////////////////////////////////////
  461. // PART TWO: "View" frontend implementation.
  462. // ////////////////////////////////////////////////////////////////////////
  463. // https://bugzilla.gnome.org/show_bug.cgi?id=731514
  464. // GNOME 3.10 and 3.12 can't open a nested submenu.
  465. // Patches have been written, but it's not clear when (if?) they will be applied.
  466. // We also don't know whether they will be backported to 3.10, so we will work around
  467. // it in the meantime. Offending versions can be clearly identified:
  468. const NEED_NESTED_SUBMENU_FIX = '_setOpenedSubMenu' in PopupMenu.PopupMenu.prototype;
  469. /**
  470. * Creates new wrapper menu items and injects methods for managing them at runtime.
  471. *
  472. * Many functions in this object will be bound to the created item and executed as event
  473. * handlers, so any `this` will refer to a menu item create in createItem
  474. */
  475. const MenuItemFactory = {
  476. createItem(client, dbusItem) {
  477. // first, decide whether it's a submenu or not
  478. let shellItem;
  479. if (dbusItem.propertyGet('children-display') === 'submenu')
  480. shellItem = new PopupMenu.PopupSubMenuMenuItem('FIXME');
  481. else if (dbusItem.propertyGet('type') === 'separator')
  482. shellItem = new PopupMenu.PopupSeparatorMenuItem('');
  483. else
  484. shellItem = new PopupMenu.PopupMenuItem('FIXME');
  485. shellItem._dbusItem = dbusItem;
  486. shellItem._dbusClient = client;
  487. if (shellItem instanceof PopupMenu.PopupMenuItem) {
  488. shellItem._icon = new St.Icon({
  489. style_class: 'popup-menu-icon',
  490. xAlign: Clutter.ActorAlign.END,
  491. });
  492. shellItem.add_child(shellItem._icon);
  493. shellItem.label.x_expand = true;
  494. }
  495. // initialize our state
  496. MenuItemFactory._updateLabel.call(shellItem);
  497. MenuItemFactory._updateOrnament.call(shellItem);
  498. MenuItemFactory._updateImage.call(shellItem);
  499. MenuItemFactory._updateVisible.call(shellItem);
  500. MenuItemFactory._updateSensitive.call(shellItem);
  501. // initially create children
  502. if (shellItem instanceof PopupMenu.PopupSubMenuMenuItem) {
  503. dbusItem.getChildren().forEach(c =>
  504. shellItem.menu.addMenuItem(MenuItemFactory.createItem(client, c)));
  505. }
  506. // now, connect various events
  507. Util.connectSmart(dbusItem, 'property-changed',
  508. shellItem, MenuItemFactory._onPropertyChanged);
  509. Util.connectSmart(dbusItem, 'child-added',
  510. shellItem, MenuItemFactory._onChildAdded);
  511. Util.connectSmart(dbusItem, 'child-removed',
  512. shellItem, MenuItemFactory._onChildRemoved);
  513. Util.connectSmart(dbusItem, 'child-moved',
  514. shellItem, MenuItemFactory._onChildMoved);
  515. Util.connectSmart(shellItem, 'activate',
  516. shellItem, MenuItemFactory._onActivate);
  517. shellItem.connect('destroy', () => {
  518. shellItem._dbusItem = null;
  519. shellItem._dbusClient = null;
  520. shellItem._icon = null;
  521. });
  522. if (shellItem.menu) {
  523. Util.connectSmart(shellItem.menu, 'open-state-changed',
  524. shellItem, MenuItemFactory._onOpenStateChanged);
  525. }
  526. return shellItem;
  527. },
  528. _onOpenStateChanged(menu, open) {
  529. if (open) {
  530. if (NEED_NESTED_SUBMENU_FIX) {
  531. // close our own submenus
  532. if (menu._openedSubMenu)
  533. menu._openedSubMenu.close(false);
  534. // register ourselves and close sibling submenus
  535. if (menu._parent._openedSubMenu && menu._parent._openedSubMenu !== menu)
  536. menu._parent._openedSubMenu.close(true);
  537. menu._parent._openedSubMenu = menu;
  538. }
  539. this._dbusItem.handleEvent('opened', null, 0);
  540. this._dbusItem.sendAboutToShow();
  541. } else {
  542. if (NEED_NESTED_SUBMENU_FIX) {
  543. // close our own submenus
  544. if (menu._openedSubMenu)
  545. menu._openedSubMenu.close(false);
  546. }
  547. this._dbusItem.handleEvent('closed', null, 0);
  548. }
  549. },
  550. _onActivate(_item, event) {
  551. const timestamp = event.get_time();
  552. if (timestamp && this._dbusClient.indicator)
  553. this._dbusClient.indicator.provideActivationToken(timestamp);
  554. this._dbusItem.handleEvent('clicked', GLib.Variant.new('i', 0),
  555. timestamp);
  556. },
  557. _onPropertyChanged(dbusItem, prop, _value) {
  558. if (prop === 'toggle-type' || prop === 'toggle-state')
  559. MenuItemFactory._updateOrnament.call(this);
  560. else if (prop === 'label')
  561. MenuItemFactory._updateLabel.call(this);
  562. else if (prop === 'enabled')
  563. MenuItemFactory._updateSensitive.call(this);
  564. else if (prop === 'visible')
  565. MenuItemFactory._updateVisible.call(this);
  566. else if (prop === 'icon-name' || prop === 'icon-data')
  567. MenuItemFactory._updateImage.call(this);
  568. else if (prop === 'type' || prop === 'children-display')
  569. MenuItemFactory._replaceSelf.call(this);
  570. else
  571. Util.Logger.debug(`Unhandled property change: ${prop}`);
  572. },
  573. _onChildAdded(dbusItem, child, position) {
  574. if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
  575. Util.Logger.warn('Tried to add a child to non-submenu item. Better recreate it as whole');
  576. MenuItemFactory._replaceSelf.call(this);
  577. } else {
  578. this.menu.addMenuItem(MenuItemFactory.createItem(this._dbusClient, child), position);
  579. }
  580. },
  581. _onChildRemoved(dbusItem, child) {
  582. if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
  583. Util.Logger.warn('Tried to remove a child from non-submenu item. Better recreate it as whole');
  584. MenuItemFactory._replaceSelf.call(this);
  585. } else {
  586. // find it!
  587. this.menu._getMenuItems().forEach(item => {
  588. if (item._dbusItem === child)
  589. item.destroy();
  590. });
  591. }
  592. },
  593. _onChildMoved(dbusItem, child, oldpos, newpos) {
  594. if (!(this instanceof PopupMenu.PopupSubMenuMenuItem)) {
  595. Util.Logger.warn('Tried to move a child in non-submenu item. Better recreate it as whole');
  596. MenuItemFactory._replaceSelf.call(this);
  597. } else {
  598. MenuUtils.moveItemInMenu(this.menu, child, newpos);
  599. }
  600. },
  601. _updateLabel() {
  602. const label = this._dbusItem.propertyGet('label').replace(/_([^_])/, '$1');
  603. if (this.label) // especially on GS3.8, the separator item might not even have a hidden label
  604. this.label.set_text(label);
  605. },
  606. _updateOrnament() {
  607. if (!this.setOrnament)
  608. return; // separators and alike might not have gotten the polyfill
  609. if (this._dbusItem.propertyGet('toggle-type') === 'checkmark' &&
  610. this._dbusItem.propertyGetInt('toggle-state'))
  611. this.setOrnament(PopupMenu.Ornament.CHECK);
  612. else if (this._dbusItem.propertyGet('toggle-type') === 'radio' &&
  613. this._dbusItem.propertyGetInt('toggle-state'))
  614. this.setOrnament(PopupMenu.Ornament.DOT);
  615. else
  616. this.setOrnament(PopupMenu.Ornament.NONE);
  617. },
  618. async _updateImage() {
  619. if (!this._icon)
  620. return; // might be missing on submenus / separators
  621. const iconName = this._dbusItem.propertyGet('icon-name');
  622. const iconData = this._dbusItem.propertyGetVariant('icon-data');
  623. if (iconName) {
  624. this._icon.icon_name = iconName;
  625. } else if (iconData) {
  626. try {
  627. const inputStream = Gio.MemoryInputStream.new_from_bytes(
  628. iconData.get_data_as_bytes());
  629. this._icon.gicon = await GdkPixbuf.Pixbuf.new_from_stream_async(
  630. inputStream, this._dbusClient.cancellable);
  631. } catch (e) {
  632. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  633. logError(e);
  634. }
  635. }
  636. },
  637. _updateVisible() {
  638. this.visible = this._dbusItem.propertyGetBool('visible');
  639. },
  640. _updateSensitive() {
  641. this.setSensitive(this._dbusItem.propertyGetBool('enabled'));
  642. },
  643. _replaceSelf(newSelf) {
  644. // create our new self if needed
  645. if (!newSelf)
  646. newSelf = MenuItemFactory.createItem(this._dbusClient, this._dbusItem);
  647. // first, we need to find our old position
  648. let pos = -1;
  649. const family = this._parent._getMenuItems();
  650. for (let i = 0; i < family.length; ++i) {
  651. if (family[i] === this)
  652. pos = i;
  653. }
  654. if (pos < 0)
  655. throw new Error("DBusMenu: can't replace non existing menu item");
  656. // add our new self while we're still alive
  657. this._parent.addMenuItem(newSelf, pos);
  658. // now destroy our old self
  659. this.destroy();
  660. },
  661. };
  662. /**
  663. * Utility functions not necessarily belonging into the item factory
  664. */
  665. const MenuUtils = {
  666. moveItemInMenu(menu, dbusItem, newpos) {
  667. // HACK: we're really getting into the internals of the PopupMenu implementation
  668. // First, find our wrapper. Children tend to lie. We do not trust the old positioning.
  669. const family = menu._getMenuItems();
  670. for (let i = 0; i < family.length; ++i) {
  671. if (family[i]._dbusItem === dbusItem) {
  672. // now, remove it
  673. menu.box.remove_child(family[i]);
  674. // and add it again somewhere else
  675. if (newpos < family.length && family[newpos] !== family[i])
  676. menu.box.insert_child_below(family[i], family[newpos]);
  677. else
  678. menu.box.add(family[i]);
  679. // skip the rest
  680. return;
  681. }
  682. }
  683. },
  684. };
  685. /**
  686. * Processes DBus events, creates the menu items and handles the actions
  687. *
  688. * Something like a mini-god-object
  689. */
  690. export class Client extends Signals.EventEmitter {
  691. constructor(busName, path, indicator) {
  692. super();
  693. this._busName = busName;
  694. this._busPath = path;
  695. this._client = new DBusClient(busName, path);
  696. this._rootMenu = null; // the shell menu
  697. this._rootItem = null; // the DbusMenuItem for the root
  698. this.indicator = indicator;
  699. this.cancellable = new Util.CancellableChild(this.indicator.cancellable);
  700. this._client.initAsync(this.cancellable).catch(e => {
  701. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  702. logError(e);
  703. });
  704. Util.connectSmart(this._client, 'ready-changed', this,
  705. () => this.emit('ready-changed'));
  706. }
  707. get isReady() {
  708. return this._client.isReady;
  709. }
  710. // this will attach the client to an already existing menu that will be used as the root menu.
  711. // it will also connect the client to be automatically destroyed when the menu dies.
  712. attachToMenu(menu) {
  713. this._rootMenu = menu;
  714. this._rootItem = this._client.getRoot();
  715. this._itemsBeingAdded = new Set();
  716. // cleanup: remove existing children (just in case)
  717. this._rootMenu.removeAll();
  718. if (NEED_NESTED_SUBMENU_FIX)
  719. menu._setOpenedSubMenu = this._setOpenedSubmenu.bind(this);
  720. // connect handlers
  721. Util.connectSmart(menu, 'open-state-changed', this, this._onMenuOpened);
  722. Util.connectSmart(menu, 'destroy', this, this.destroy);
  723. Util.connectSmart(this._rootItem, 'child-added', this, this._onRootChildAdded);
  724. Util.connectSmart(this._rootItem, 'child-removed', this, this._onRootChildRemoved);
  725. Util.connectSmart(this._rootItem, 'child-moved', this, this._onRootChildMoved);
  726. // Dropbox requires us to call AboutToShow(0) first
  727. this._rootItem.sendAboutToShow();
  728. // fill the menu for the first time
  729. const children = this._rootItem.getChildren();
  730. children.forEach(child =>
  731. this._onRootChildAdded(this._rootItem, child));
  732. }
  733. _setOpenedSubmenu(submenu) {
  734. if (!submenu)
  735. return;
  736. if (submenu._parent !== this._rootMenu)
  737. return;
  738. if (submenu === this._openedSubMenu)
  739. return;
  740. if (this._openedSubMenu && this._openedSubMenu.isOpen)
  741. this._openedSubMenu.close(true);
  742. this._openedSubMenu = submenu;
  743. }
  744. _onRootChildAdded(dbusItem, child, position) {
  745. // Menu additions can be expensive, so let's do it in different chunks
  746. const basePriority = this.isOpen ? GLib.PRIORITY_DEFAULT : GLib.PRIORITY_LOW;
  747. const idlePromise = new PromiseUtils.IdlePromise(
  748. basePriority + this._itemsBeingAdded.size, this.cancellable);
  749. this._itemsBeingAdded.add(child);
  750. idlePromise.then(() => {
  751. if (!this._itemsBeingAdded.has(child))
  752. return;
  753. this._rootMenu.addMenuItem(
  754. MenuItemFactory.createItem(this, child), position);
  755. }).catch(e => {
  756. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  757. logError(e);
  758. }).finally(() => this._itemsBeingAdded.delete(child));
  759. }
  760. _onRootChildRemoved(dbusItem, child) {
  761. // children like to play hide and seek
  762. // but we know how to find it for sure!
  763. const item = this._rootMenu._getMenuItems().find(it =>
  764. it._dbusItem === child);
  765. if (item)
  766. item.destroy();
  767. else
  768. this._itemsBeingAdded.delete(child);
  769. }
  770. _onRootChildMoved(dbusItem, child, oldpos, newpos) {
  771. MenuUtils.moveItemInMenu(this._rootMenu, dbusItem, newpos);
  772. }
  773. _onMenuOpened(menu, state) {
  774. if (!this._rootItem)
  775. return;
  776. this._client.active = state;
  777. if (state) {
  778. if (this._openedSubMenu && this._openedSubMenu.isOpen)
  779. this._openedSubMenu.close();
  780. this._rootItem.handleEvent('opened', null, 0);
  781. this._rootItem.sendAboutToShow();
  782. } else {
  783. this._rootItem.handleEvent('closed', null, 0);
  784. }
  785. }
  786. destroy() {
  787. this.emit('destroy');
  788. if (this._client)
  789. this._client.destroy();
  790. this._client = null;
  791. this._rootItem = null;
  792. this._rootMenu = null;
  793. this.indicator = null;
  794. this._itemsBeingAdded = null;
  795. }
  796. }