extension.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import Clutter from 'gi://Clutter';
  2. import Gio from 'gi://Gio';
  3. import GLib from 'gi://GLib';
  4. import GObject from 'gi://GObject';
  5. import St from 'gi://St'
  6. import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
  7. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  8. import * as Main from 'resource:///org/gnome/shell/ui/main.js';
  9. import * as Util from 'resource:///org/gnome/shell/misc/util.js';
  10. import * as Sensors from './sensors.js';
  11. import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
  12. import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
  13. import * as Values from './values.js';
  14. import * as Config from 'resource:///org/gnome/shell/misc/config.js';
  15. import * as MenuItem from './menuItem.js';
  16. let vitalsMenu;
  17. var VitalsMenuButton = GObject.registerClass({
  18. GTypeName: 'VitalsMenuButton',
  19. }, class VitalsMenuButton extends PanelMenu.Button {
  20. _init(extensionObject) {
  21. super._init(Clutter.ActorAlign.FILL);
  22. this._extensionObject = extensionObject;
  23. this._settings = extensionObject.getSettings();
  24. this._sensorIcons = {
  25. 'temperature' : { 'icon': 'temperature-symbolic.svg' },
  26. 'voltage' : { 'icon': 'voltage-symbolic.svg' },
  27. 'fan' : { 'icon': 'fan-symbolic.svg' },
  28. 'memory' : { 'icon': 'memory-symbolic.svg' },
  29. 'processor' : { 'icon': 'cpu-symbolic.svg' },
  30. 'system' : { 'icon': 'system-symbolic.svg' },
  31. 'network' : { 'icon': 'network-symbolic.svg',
  32. 'icon-rx': 'network-download-symbolic.svg',
  33. 'icon-tx': 'network-upload-symbolic.svg' },
  34. 'storage' : { 'icon': 'storage-symbolic.svg' },
  35. 'battery' : { 'icon': 'battery-symbolic.svg' }
  36. }
  37. this._warnings = [];
  38. this._sensorMenuItems = {};
  39. this._hotLabels = {};
  40. this._hotIcons = {};
  41. this._groups = {};
  42. this._widths = {};
  43. this._last_query = new Date().getTime();
  44. this._sensors = new Sensors.Sensors(this._settings, this._sensorIcons);
  45. this._values = new Values.Values(this._settings, this._sensorIcons);
  46. this._menuLayout = new St.BoxLayout({
  47. vertical: false,
  48. clip_to_allocation: true,
  49. x_align: Clutter.ActorAlign.START,
  50. y_align: Clutter.ActorAlign.CENTER,
  51. reactive: true,
  52. x_expand: true,
  53. pack_start: false
  54. });
  55. this._drawMenu();
  56. this.add_actor(this._menuLayout);
  57. this._settingChangedSignals = [];
  58. this._refreshTimeoutId = null;
  59. this._addSettingChangedSignal('update-time', this._updateTimeChanged.bind(this));
  60. this._addSettingChangedSignal('position-in-panel', this._positionInPanelChanged.bind(this));
  61. this._addSettingChangedSignal('menu-centered', this._positionInPanelChanged.bind(this));
  62. let settings = [ 'use-higher-precision', 'alphabetize', 'hide-zeros', 'fixed-widths', 'hide-icons', 'unit', 'memory-measurement', 'include-public-ip', 'network-speed-format', 'storage-measurement', 'include-static-info' ];
  63. for (let setting of Object.values(settings))
  64. this._addSettingChangedSignal(setting, this._redrawMenu.bind(this));
  65. // add signals for show- preference based categories
  66. for (let sensor in this._sensorIcons)
  67. this._addSettingChangedSignal('show-' + sensor, this._showHideSensorsChanged.bind(this));
  68. this._initializeMenu();
  69. // start off with fresh sensors
  70. this._querySensors();
  71. // start monitoring sensors
  72. this._initializeTimer();
  73. }
  74. _initializeMenu() {
  75. // display sensor categories
  76. for (let sensor in this._sensorIcons) {
  77. // groups associated sensors under accordion menu
  78. if (sensor in this._groups) continue;
  79. this._groups[sensor] = new PopupMenu.PopupSubMenuMenuItem(_(this._ucFirst(sensor)), true);
  80. this._groups[sensor].icon.gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons[sensor]['icon']);
  81. // hide menu items that user has requested to not include
  82. if (!this._settings.get_boolean('show-' + sensor))
  83. this._groups[sensor].actor.hide();
  84. if (!this._groups[sensor].status) {
  85. this._groups[sensor].status = this._defaultLabel();
  86. this._groups[sensor].actor.insert_child_at_index(this._groups[sensor].status, 4);
  87. this._groups[sensor].status.text = _('No Data');
  88. }
  89. this.menu.addMenuItem(this._groups[sensor]);
  90. }
  91. // add separator
  92. this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
  93. let item = new PopupMenu.PopupBaseMenuItem({
  94. reactive: false,
  95. style_class: 'vitals-menu-button-container'
  96. });
  97. let customButtonBox = new St.BoxLayout({
  98. style_class: 'vitals-button-box',
  99. vertical: false,
  100. clip_to_allocation: true,
  101. x_align: Clutter.ActorAlign.CENTER,
  102. y_align: Clutter.ActorAlign.CENTER,
  103. reactive: true,
  104. x_expand: true,
  105. pack_start: false
  106. });
  107. // custom round refresh button
  108. let refreshButton = this._createRoundButton('view-refresh-symbolic', _('Refresh'));
  109. refreshButton.connect('clicked', (self) => {
  110. // force refresh by clearing history
  111. this._sensors.resetHistory();
  112. this._values.resetHistory();
  113. // make sure timer fires at next full interval
  114. this._updateTimeChanged();
  115. // refresh sensors now
  116. this._querySensors();
  117. });
  118. customButtonBox.add_actor(refreshButton);
  119. // custom round monitor button
  120. let monitorButton = this._createRoundButton('org.gnome.SystemMonitor-symbolic', _('System Monitor'));
  121. monitorButton.connect('clicked', (self) => {
  122. this.menu._getTopMenu().close();
  123. Util.spawn(this._settings.get_string('monitor-cmd').split(" "));
  124. });
  125. customButtonBox.add_actor(monitorButton);
  126. // custom round preferences button
  127. let prefsButton = this._createRoundButton('preferences-system-symbolic', _('Preferences'));
  128. prefsButton.connect('clicked', (self) => {
  129. this.menu._getTopMenu().close();
  130. this._extensionObject.openPreferences();
  131. });
  132. customButtonBox.add_actor(prefsButton);
  133. // now add the buttons to the top bar
  134. item.actor.add_actor(customButtonBox);
  135. // add buttons
  136. this.menu.addMenuItem(item);
  137. // query sensors on menu open
  138. this._menuStateChangeId = this.menu.connect('open-state-changed', (self, isMenuOpen) => {
  139. if (isMenuOpen) {
  140. // make sure timer fires at next full interval
  141. this._updateTimeChanged();
  142. // refresh sensors now
  143. this._querySensors();
  144. }
  145. });
  146. }
  147. _createRoundButton(iconName) {
  148. let button = new St.Button({
  149. style_class: 'message-list-clear-button button vitals-button-action'
  150. });
  151. button.child = new St.Icon({
  152. icon_name: iconName
  153. });
  154. return button;
  155. }
  156. _removeMissingHotSensors(hotSensors) {
  157. for (let i = hotSensors.length - 1; i >= 0; i--) {
  158. let sensor = hotSensors[i];
  159. // make sure default icon (if any) stays visible
  160. if (sensor == '_default_icon_') continue;
  161. // removes sensors that are no longer available
  162. if (!this._sensorMenuItems[sensor]) {
  163. hotSensors.splice(i, 1);
  164. this._removeHotLabel(sensor);
  165. this._removeHotIcon(sensor);
  166. }
  167. }
  168. return hotSensors;
  169. }
  170. _saveHotSensors(hotSensors) {
  171. // removes any sensors that may not currently be available
  172. hotSensors = this._removeMissingHotSensors(hotSensors);
  173. this._settings.set_strv('hot-sensors', hotSensors.filter(
  174. function(item, pos) {
  175. return hotSensors.indexOf(item) == pos;
  176. }
  177. ));
  178. }
  179. _initializeTimer() {
  180. // used to query sensors and update display
  181. let update_time = this._settings.get_int('update-time');
  182. this._refreshTimeoutId = GLib.timeout_add_seconds(
  183. GLib.PRIORITY_DEFAULT,
  184. update_time,
  185. (self) => {
  186. // only update menu if we have hot sensors
  187. if (Object.values(this._hotLabels).length > 0)
  188. this._querySensors();
  189. // keep the timer running
  190. return GLib.SOURCE_CONTINUE;
  191. }
  192. );
  193. }
  194. _createHotItem(key, value) {
  195. let icon = this._defaultIcon(key);
  196. this._hotIcons[key] = icon;
  197. this._menuLayout.add_actor(icon)
  198. // don't add a label when no sensors are in the panel
  199. if (key == '_default_icon_') return;
  200. let label = new St.Label({
  201. style_class: 'vitals-panel-label',
  202. text: (value)?value:'\u2026', // ...
  203. y_expand: true,
  204. y_align: Clutter.ActorAlign.START
  205. });
  206. // attempt to prevent ellipsizes
  207. label.get_clutter_text().ellipsize = 0;
  208. // keep track of label for removal later
  209. this._hotLabels[key] = label;
  210. // prevent "called on the widget" "which is not in the stage" errors by adding before width below
  211. this._menuLayout.add_actor(label);
  212. // support for fixed widths #55, save label (text) width
  213. this._widths[key] = label.width;
  214. }
  215. _showHideSensorsChanged(self, sensor) {
  216. this._sensors.resetHistory();
  217. this._groups[sensor.substr(5)].visible = this._settings.get_boolean(sensor);
  218. }
  219. _positionInPanelChanged() {
  220. this.container.get_parent().remove_actor(this.container);
  221. let position = this._positionInPanel();
  222. // allows easily addressable boxes
  223. let boxes = {
  224. left: Main.panel._leftBox,
  225. center: Main.panel._centerBox,
  226. right: Main.panel._rightBox
  227. };
  228. // update position when changed from preferences
  229. boxes[position[0]].insert_child_at_index(this.container, position[1]);
  230. }
  231. _removeHotLabel(key) {
  232. if (key in this._hotLabels) {
  233. let label = this._hotLabels[key];
  234. delete this._hotLabels[key];
  235. // make sure set_label is not called on non existent actor
  236. label.destroy();
  237. }
  238. }
  239. _removeHotLabels() {
  240. for (let key in this._hotLabels)
  241. this._removeHotLabel(key);
  242. }
  243. _removeHotIcon(key) {
  244. if (key in this._hotIcons) {
  245. this._hotIcons[key].destroy();
  246. delete this._hotIcons[key];
  247. }
  248. }
  249. _removeHotIcons() {
  250. for (let key in this._hotIcons)
  251. this._removeHotIcon(key);
  252. }
  253. _redrawMenu() {
  254. this._removeHotIcons();
  255. this._removeHotLabels();
  256. for (let key in this._sensorMenuItems) {
  257. if (key.includes('-group')) continue;
  258. this._sensorMenuItems[key].destroy();
  259. delete this._sensorMenuItems[key];
  260. }
  261. this._drawMenu();
  262. this._sensors.resetHistory();
  263. this._values.resetHistory();
  264. this._querySensors();
  265. }
  266. _drawMenu() {
  267. // grab list of selected menubar icons
  268. let hotSensors = this._settings.get_strv('hot-sensors');
  269. for (let key of Object.values(hotSensors)) {
  270. // fixes issue #225 which started when _max_ was moved to the end
  271. if (key == '__max_network-download__') key = '__network-rx_max__';
  272. if (key == '__max_network-upload__') key = '__network-tx_max__';
  273. this._createHotItem(key);
  274. }
  275. }
  276. _destroyTimer() {
  277. // invalidate and reinitialize timer
  278. if (this._refreshTimeoutId != null) {
  279. GLib.Source.remove(this._refreshTimeoutId);
  280. this._refreshTimeoutId = null;
  281. }
  282. }
  283. _updateTimeChanged() {
  284. this._destroyTimer();
  285. this._initializeTimer();
  286. }
  287. _addSettingChangedSignal(key, callback) {
  288. this._settingChangedSignals.push(this._settings.connect('changed::' + key, callback));
  289. }
  290. _updateDisplay(label, value, type, key) {
  291. // update sensor value in menubar
  292. if (this._hotLabels[key]) {
  293. this._hotLabels[key].set_text(value);
  294. // support for fixed widths #55
  295. if (this._settings.get_boolean('fixed-widths')) {
  296. // grab text box width and see if new text is wider than old text
  297. let width2 = this._hotLabels[key].get_clutter_text().width;
  298. if (width2 > this._widths[key]) {
  299. this._hotLabels[key].set_width(width2);
  300. this._widths[key] = width2;
  301. }
  302. }
  303. }
  304. // have we added this sensor before?
  305. let item = this._sensorMenuItems[key];
  306. if (item) {
  307. // update sensor value in the group
  308. item.value = value;
  309. } else if (type.includes('-group')) {
  310. // update text next to group header
  311. let group = type.split('-')[0];
  312. if (this._groups[group]) {
  313. this._groups[group].status.text = value;
  314. this._sensorMenuItems[type] = this._groups[group];
  315. }
  316. } else {
  317. // add item to group for the first time
  318. let sensor = { 'label': label, 'value': value, 'type': type }
  319. this._appendMenuItem(sensor, key);
  320. }
  321. }
  322. _appendMenuItem(sensor, key) {
  323. let split = sensor.type.split('-');
  324. let type = split[0];
  325. let icon = (split.length == 2)?'icon-' + split[1]:'icon';
  326. let gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons[type][icon]);
  327. let item = new MenuItem.MenuItem(gicon, key, sensor.label, sensor.value, this._hotLabels[key]);
  328. item.connect('toggle', (self) => {
  329. let hotSensors = this._settings.get_strv('hot-sensors');
  330. if (self.checked) {
  331. // add selected sensor to panel
  332. hotSensors.push(self.key);
  333. this._createHotItem(self.key, self.value);
  334. } else {
  335. // remove selected sensor from panel
  336. hotSensors.splice(hotSensors.indexOf(self.key), 1);
  337. this._removeHotLabel(self.key);
  338. this._removeHotIcon(self.key);
  339. }
  340. if (hotSensors.length <= 0) {
  341. // add generic icon to panel when no sensors are selected
  342. hotSensors.push('_default_icon_');
  343. this._createHotItem('_default_icon_');
  344. } else {
  345. let defIconPos = hotSensors.indexOf('_default_icon_');
  346. if (defIconPos >= 0) {
  347. // remove generic icon from panel when sensors are selected
  348. hotSensors.splice(defIconPos, 1);
  349. this._removeHotIcon('_default_icon_');
  350. }
  351. }
  352. // this code is called asynchronously - make sure to save it for next round
  353. this._saveHotSensors(hotSensors);
  354. });
  355. this._sensorMenuItems[key] = item;
  356. let i = Object.keys(this._sensorMenuItems[key]).length;
  357. // alphabetize the sensors for these categories
  358. if (this._settings.get_boolean('alphabetize')) {
  359. let menuItems = this._groups[type].menu._getMenuItems();
  360. for (i = 0; i < menuItems.length; i++)
  361. // use natural sort order for system load, etc
  362. if (menuItems[i].label.localeCompare(item.label, undefined, { numeric: true, sensitivity: 'base' }) > 0)
  363. break;
  364. }
  365. this._groups[type].menu.addMenuItem(item, i);
  366. }
  367. _defaultLabel() {
  368. return new St.Label({
  369. y_expand: true,
  370. y_align: Clutter.ActorAlign.CENTER
  371. });
  372. }
  373. _defaultIcon(key) {
  374. let split = key.replaceAll('_', ' ').trim().split(' ')[0].split('-');
  375. let type = split[0];
  376. let icon = new St.Icon({
  377. style_class: 'system-status-icon vitals-panel-icon-' + type,
  378. reactive: true
  379. });
  380. // second condition prevents crash due to issue #225, which started when _max_ was moved to the end
  381. if (type == 'default' || !(type in this._sensorIcons)) {
  382. icon.gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons['system']['icon']);
  383. } else if (!this._settings.get_boolean('hide-icons')) { // support for hide icons #80
  384. let iconObj = (split.length == 2)?'icon-' + split[1]:'icon';
  385. icon.gicon = Gio.icon_new_for_string(this._extensionObject.path + '/icons/' + this._sensorIcons[type][iconObj]);
  386. }
  387. return icon;
  388. }
  389. _ucFirst(string) {
  390. return string.charAt(0).toUpperCase() + string.slice(1);
  391. }
  392. _positionInPanel() {
  393. let alignment = '';
  394. let gravity = 0;
  395. let arrow_pos = 0;
  396. switch (this._settings.get_int('position-in-panel')) {
  397. case 0: // left
  398. alignment = 'left';
  399. gravity = -1;
  400. arrow_pos = 1;
  401. break;
  402. case 1: // center
  403. alignment = 'center';
  404. gravity = -1;
  405. arrow_pos = 0.5;
  406. break;
  407. case 2: // right
  408. alignment = 'right';
  409. gravity = 0;
  410. arrow_pos = 0;
  411. break;
  412. case 3: // far left
  413. alignment = 'left';
  414. gravity = 0;
  415. arrow_pos = 1;
  416. break;
  417. case 4: // far right
  418. alignment = 'right';
  419. gravity = -1;
  420. arrow_pos = 0;
  421. break;
  422. }
  423. let centered = this._settings.get_boolean('menu-centered')
  424. if (centered) arrow_pos = 0.5;
  425. // set arrow position when initializing and moving vitals
  426. this.menu._arrowAlignment = arrow_pos;
  427. return [alignment, gravity];
  428. }
  429. _querySensors() {
  430. // figure out last run time
  431. let now = new Date().getTime();
  432. let dwell = (now - this._last_query) / 1000;
  433. this._last_query = now;
  434. this._sensors.query((label, value, type, format) => {
  435. let key = '_' + type.replace('-group', '') + '_' + label.replace(' ', '_').toLowerCase() + '_';
  436. // if a sensor is disabled, gray it out
  437. if (key in this._sensorMenuItems) {
  438. this._sensorMenuItems[key].setSensitive((value!='disabled'));
  439. // don't continue below, last known value is shown
  440. if (value == 'disabled') return;
  441. }
  442. let items = this._values.returnIfDifferent(dwell, label, value, type, format, key);
  443. for (let item of Object.values(items))
  444. this._updateDisplay(_(item[0]), item[1], item[2], item[3]);
  445. }, dwell);
  446. if (this._warnings.length > 0) {
  447. this._notify('Vitals', this._warnings.join("\n"), 'folder-symbolic');
  448. this._warnings = [];
  449. }
  450. }
  451. _notify(msg, details, icon) {
  452. let source = new MessageTray.Source('MyApp Information', icon);
  453. Main.messageTray.add(source);
  454. let notification = new MessageTray.Notification(source, msg, details);
  455. notification.setTransient(true);
  456. source.notify(notification);
  457. }
  458. destroy() {
  459. this._destroyTimer();
  460. for (let signal of Object.values(this._settingChangedSignals))
  461. this._settings.disconnect(signal);
  462. super.destroy();
  463. }
  464. });
  465. export default class VitalsExtension extends Extension {
  466. enable() {
  467. vitalsMenu = new VitalsMenuButton(this);
  468. let position = vitalsMenu._positionInPanel();
  469. Main.panel.addToStatusArea('vitalsMenu', vitalsMenu, position[1], position[0]);
  470. }
  471. disable() {
  472. vitalsMenu.destroy();
  473. vitalsMenu = null;
  474. }
  475. }