From 2e2bc1032163110993aa3433295b5d8c84978790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20M=C3=BCllner?= Date: Thu, 12 Jan 2023 19:43:52 +0100 Subject: [PATCH 5/5] Add custom-menu extension --- extensions/custom-menu/config.js | 445 ++++++++++++++++++++++++ extensions/custom-menu/extension.js | 201 +++++++++++ extensions/custom-menu/meson.build | 7 + extensions/custom-menu/metadata.json.in | 10 + meson.build | 1 + 5 files changed, 664 insertions(+) create mode 100644 extensions/custom-menu/config.js create mode 100644 extensions/custom-menu/extension.js create mode 100644 extensions/custom-menu/meson.build create mode 100644 extensions/custom-menu/metadata.json.in diff --git a/extensions/custom-menu/config.js b/extensions/custom-menu/config.js new file mode 100644 index 00000000..d08e3201 --- /dev/null +++ b/extensions/custom-menu/config.js @@ -0,0 +1,445 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Json from 'gi://Json'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +import {getLogger} from './extension.js'; + +class Entry { + constructor(prop) { + this.type = prop.type; + this.title = prop.title || ""; + this.__vars = prop.__vars || []; + this.updateEnv(prop); + } + + setTitle(text) { + this.item.label.get_clutter_text().set_text(text); + } + + updateEnv(prop) { + this.__env = {} + if (!this.__vars) return; + for (let i in this.__vars) { + let v = this.__vars[i]; + this.__env[v] = prop[v] ? String(prop[v]) : ""; + } + } + + // the pulse function should be read as "a pulse arrives" + pulse() { + } + + _try_destroy() { + try { + if (this.item && this.item.destroy) { + this.item.destroy(); + } + } catch(e) { /* Ignore all errors during destory*/ } + } +} + +class DerivedEntry { + constructor(prop) { + if (!prop.base) { + throw new Error("Base entry not specified in type definition."); + } + this.base = prop.base; + this.vars = prop.vars || []; + delete prop.base; + delete prop.vars; + this.prop = prop; + } + + createInstance(addit_prop) { + let cls = type_map[this.base]; + if (!cls) { + throw new Error("Bad base class."); + } + if (cls.createInstance) { + throw new Error("Not allowed to derive from dervied types"); + } + for (let rp in this.prop) { + addit_prop[rp] = this.prop[rp]; + } + addit_prop.__vars = this.vars; + let instance = new cls(addit_prop); + return instance; + } +} + +let __pipeOpenQueue = []; + +/* callback: function (stdout, stderr, exit_status) { } */ +function pipeOpen(cmdline, env, callback) { + if (cmdline === undefined || callback === undefined) { + return false; + } + realPipeOpen(cmdline, env, callback); + return true; +} /**/ + +function realPipeOpen(cmdline, env, callback) { + let user_cb = callback; + let proc; + + function wait_cb(_, _res) { + let stdout_pipe = proc.get_stdout_pipe(); + let stderr_pipe = proc.get_stderr_pipe(); + let stdout_content; + let stderr_content; + + // Only the first GLib.MAXINT16 characters are fetched for optimization. + stdout_pipe.read_bytes_async(GLib.MAXINT16, 0, null, function(osrc, ores) { + const decoder = new TextDecoder(); + stdout_content = decoder.decode(stdout_pipe.read_bytes_finish(ores).get_data()); + stdout_pipe.close(null); + stderr_pipe.read_bytes_async(GLib.MAXINT16, 0, null, function(esrc, eres) { + stderr_content = decoder.decode(stderr_pipe.read_bytes_finish(eres).get_data()); + stderr_pipe.close(null); + user_cb(stdout_content, stderr_content, proc.get_exit_status()); + }); + }); + } + + if (user_cb) { + let _pipedLauncher = new Gio.SubprocessLauncher({ + flags: + Gio.SubprocessFlags.STDERR_PIPE | + Gio.SubprocessFlags.STDOUT_PIPE + }); + for (let key in env) { + _pipedLauncher.setenv(key, env[key], true); + } + proc = _pipedLauncher.spawnv(['bash', '-c', cmdline]); + proc.wait_async(null, wait_cb); + } else { + // Detached launcher is used to spawn commands that we are not concerned about its result. + let _detacLauncher = new Gio.SubprocessLauncher(); + for (let key in env) { + _detacLauncher.setenv(key, env[key], true); + } + proc = _detacLauncher.spawnv(['bash', '-c', cmdline]); + } + getLogger().info("Spawned " + cmdline); + return proc.get_identifier(); +} + +function _generalSpawn(command, env, title) { + title = title || "Process"; + pipeOpen(command, env, function(stdout, stderr, exit_status) { + if (exit_status != 0) { + log + getLogger().warning(stderr); + getLogger().notify("proc", title + " exited with status " + exit_status, stderr); + } + }); +} + +// Detect menu toggle on startup +function _toggleDetect(command, env, object) { + pipeOpen(command, env, function(stdout, stderr, exit_status) { + if (exit_status == 0) { + object.item.setToggleState(true); + } + }); +} /**/ + +function quoteShellArg(arg) { + arg = arg.replace(/'/g, "'\"'\"'"); + return "'" + arg + "'"; +} + +/** + * This cache is used to reduce detector cost. + * Each time creating an item, it check if the result of this detector is cached, + * which prevent the togglers from running detector on each creation. + * This is useful especially in search mode. + */ +let _toggler_state_cache = { }; + +class TogglerEntry extends Entry { + constructor(prop) { + super(prop); + this.command_on = prop.command_on || ""; + this.command_off = prop.command_off || ""; + this.detector = prop.detector || ""; + this.auto_on = prop.auto_on || false; + this.notify_when = prop.notify_when || []; + // if the switch is manually turned off, auto_on is disabled. + this._manually_switched_off = false; + this.pulse(); // load initial state + } + + createItem() { + this._try_destroy(); + this.item = new PopupMenu.PopupSwitchMenuItem(this.title, false); + this.item.label.get_clutter_text().set_use_markup(true); + this.item.connect('toggled', this._onManuallyToggled.bind(this)); + this._loadState(); + _toggleDetect(this.detector, this.__env, this); + return this.item; + } + + _onManuallyToggled(_, state) { + // when switched on again, this flag will get cleared. + this._manually_switched_off = !state; + this._storeState(state); + this._onToggled(state); + } + + _onToggled(state) { + if (state) { + _generalSpawn(this.command_on, this.__env, this.title); + } else { + _generalSpawn(this.command_off, this.__env, this.title); + } + } + + _detect(callback) { + // abort detecting if detector is an empty string + if (!this.detector) { + return; + } + pipeOpen(this.detector, this.__env, function(out) { + out = String(out); + callback(!Boolean(out.match(/^\s*$/))); + }); + } + + // compare the new state with cached state notify when state is different + compareState(new_state) { + let old_state = _toggler_state_cache[this.detector]; + if (old_state === undefined) return; + if (old_state == new_state) return; + + if (this.notify_when.indexOf(new_state ? "on" : "off") >= 0) { + let not_str = this.title + (new_state ? " started." : " stopped."); + if (!new_state && this.auto_on) { + not_str += " Attempt to restart it now."; + } + getLogger().notify("state", not_str); + } + } + + _storeState(state) { + let hash = JSON.stringify({ env: this.__env, detector: this.detector }); + _toggler_state_cache[hash] = state; + } + + _loadState() { + let hash = JSON.stringify({ env: this.__env, detector: this.detector }); + let state = _toggler_state_cache[hash]; + if (state !== undefined) { + this.item.setToggleState(state); // doesn't emit 'toggled' + } + } + + pulse() { + this._detect(state => { + this.compareState(state); + this._storeState(state); + this._loadState(); + if (!state && !this._manually_switched_off && this.auto_on) { + // do not call setToggleState here, because command_on may fail + this._onToggled(this.item, true); + } + }); + } + + perform() { + this.item.toggle(); + } +} + +class LauncherEntry extends Entry { + constructor(prop) { + super(prop); + this.command = prop.command || ""; + } + + createItem() { + this._try_destroy(); + this.item = new PopupMenu.PopupMenuItem(this.title); + this.item.label.get_clutter_text().set_use_markup(true); + this.item.connect('activate', this._onClicked.bind(this)); + return this.item; + } + + _onClicked(_) { + _generalSpawn(this.command, this.__env, this.title); + } + + perform() { + this.item.emit('activate'); + } +} + +class SubMenuEntry extends Entry { + constructor(prop) { + super(prop); + + if (prop.entries == undefined) { + throw new Error("Expected entries provided in submenu entry."); + } + this.entries = []; + for (let i in prop.entries) { + let entry_prop = prop.entries[i]; + let entry = createEntry(entry_prop); + this.entries.push(entry); + } + } + + createItem() { + this._try_destroy(); + this.item = new PopupMenu.PopupSubMenuMenuItem(this.title); + this.item.label.get_clutter_text().set_use_markup(true); + for (let i in this.entries) { + let entry = this.entries[i]; + this.item.menu.addMenuItem(entry.createItem()); + } + return this.item; + } + + pulse() { + for (let i in this.entries) { + let entry = this.entries[i]; + entry.pulse(); + } + } +} + +class SeparatorEntry extends Entry { + createItem() { + this._try_destroy(); + this.item = new PopupMenu.PopupSeparatorMenuItem(this.title); + this.item.label.get_clutter_text().set_use_markup(true); + return this.item; + } +} + +let type_map = {}; + +//////////////////////////////////////////////////////////////////////////////// +// Config Loader loads config from JSON file. + +// convert Json Nodes (GLib based) to native javascript value. +function convertJson(node) { + if (node.get_node_type() == Json.NodeType.VALUE) { + return node.get_value(); + } + if (node.get_node_type() == Json.NodeType.OBJECT) { + let obj = {} + node.get_object().foreach_member(function(_, k, v_n) { + obj[k] = convertJson(v_n); + }); + return obj; + } + if (node.get_node_type() == Json.NodeType.ARRAY) { + let arr = [] + node.get_array().foreach_element(function(_, i, elem) { + arr.push(convertJson(elem)); + }); + return arr; + } + return null; +} + +// +function createEntry(entry_prop) { + if (!entry_prop.type) { + throw new Error("No type specified in entry."); + } + let cls = type_map[entry_prop.type]; + if (!cls) { + throw new Error("Incorrect type '" + entry_prop.type + "'"); + } else if (cls.createInstance) { + return cls.createInstance(entry_prop); + } + return new cls(entry_prop); +} + +export class Loader { + constructor(filename) { + if (filename) { + this.loadConfig(filename); + } + } + + loadConfig(filename) { + // reset type_map everytime load the config + type_map = { + launcher: LauncherEntry, + toggler: TogglerEntry, + submenu: SubMenuEntry, + separator: SeparatorEntry + }; + + type_map.systemd = new DerivedEntry({ + base: 'toggler', + vars: ['unit'], + command_on: "pkexec systemctl start ${unit}", + command_off: "pkexec systemctl stop ${unit}", + detector: "systemctl status ${unit} | grep Active:\\\\s\\*activ[ei]", + }); + + type_map.tmux = new DerivedEntry({ + base: 'toggler', + vars: ['command', 'session'], + command_on: 'tmux new -d -s ${session} bash -c "${command}"', + command_off: 'tmux kill-session -t ${session}', + detector: 'tmux has -t "${session}" 2>/dev/null && echo yes', + }); + + /* + * Refer to README file for detailed config file format. + */ + this.entries = []; // CAUTION: remove all entries. + + let config_parser = new Json.Parser(); + config_parser.load_from_file(filename); + let conf = convertJson(config_parser.get_root()); + if (conf.entries == undefined) { + throw new Error("Key 'entries' not found."); + } + if (conf.deftype) { + for (let tname in conf.deftype) { + if (type_map[tname]) { + throw new Error("Type \""+tname+"\" duplicated."); + } + type_map[tname] = new DerivedEntry(conf.deftype[tname]); + } + } + + for (let conf_i in conf.entries) { + let entry_prop = conf.entries[conf_i]; + this.entries.push(createEntry(entry_prop)); + } + } + + saveDefaultConfig(filename) { + // Write default config + const PERMISSIONS_MODE = 0o640; + const jsonString = JSON.stringify({ + "_homepage_": "https://github.com/andreabenini/gnome-plugin.custom-menu-panel", + "_examples_": "https://github.com/andreabenini/gnome-plugin.custom-menu-panel/tree/main/examples", + "entries": [ { + "type": "launcher", + "title": "Edit menu", + "command": "gedit $HOME/.entries.json" + } ] + }, null, 4); + let fileConfig = Gio.File.new_for_path(filename); + if (GLib.mkdir_with_parents(fileConfig.get_parent().get_path(), PERMISSIONS_MODE) === 0) { + fileConfig.replace_contents(jsonString, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); + } + // Try to load newly saved file + try { + this.loadConfig(filename); + } catch(e) { + Main.notify(_('Cannot create and load file: '+filename)); + } + } +} diff --git a/extensions/custom-menu/extension.js b/extensions/custom-menu/extension.js new file mode 100644 index 00000000..9edbc548 --- /dev/null +++ b/extensions/custom-menu/extension.js @@ -0,0 +1,201 @@ +/* extension.js + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * @author Ben + * @see https://github.com/andreabenini/gnome-plugin.custom-menu-panel + */ + +const CONFIGURATION_FILE = '/.entries.json'; + +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; + +import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as BackgroundMenu from 'resource:///org/gnome/shell/ui/backgroundMenu.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +import * as Config from './config.js'; + +const LOGGER_INFO = 0; +const LOGGER_WARNING = 1; +const LOGGER_ERROR = 2; + +const {BackgroundMenu: OriginalBackgroundMenu} = BackgroundMenu; + + +class CustomMenu extends PopupMenu.PopupMenu { + constructor(sourceActor) { + super(sourceActor, 0.0, St.Side.TOP, 0); + + this._loadSetup(); + } + + /** + * LOAD Program settings from .entries.json file + */ + _loadSetup() { + this.removeAll(); + // Loading configuration from file + this.configLoader = new Config.Loader(); + try { + this.configLoader.loadConfig(GLib.get_home_dir() + CONFIGURATION_FILE); // $HOME/.entries.json + } catch(e) { + this.configLoader.saveDefaultConfig(GLib.get_home_dir() + CONFIGURATION_FILE); // create default entries + } + // Build the menu + let i = 0; + for (let i in this.configLoader.entries) { + let item = this.configLoader.entries[i].createItem(); + this.addMenuItem(item); + } + } /**/ +} + +class CustomBackgroundMenu extends CustomMenu { + constructor(layoutManager) { + super(layoutManager.dummyCursor); + + this.actor.add_style_class_name('background-menu'); + + layoutManager.uiGroup.add_actor(this.actor); + this.actor.hide(); + + this.connect('open-state-changed', (menu, open) => { + if (open) + this._updateMaxHeight(); + }); + } + + _updateMaxHeight() { + const monitor = Main.layoutManager.findMonitorForActor(this.actor); + const workArea = Main.layoutManager.getWorkAreaForMonitor(monitor.index); + const {scaleFactor} = St.ThemeContext.get_for_stage(global.stage); + const vMargins = this.actor.margin_top + this.actor.margin_bottom; + const {y: offsetY} = this.sourceActor; + + const maxHeight = Math.round((monitor.height - offsetY - vMargins) / scaleFactor); + this.actor.style = `max-height: ${maxHeight}px;`; + } +} + +const Indicator = GObject.registerClass( + class Indicator extends PanelMenu.Button { + _init() { + super._init(0.0, _('Custom Menu Panel Indicator'), true); + this.add_child(new St.Icon({ + icon_name: 'view-list-bullet-symbolic', + style_class: 'system-status-icon', + })); + + this.setMenu(new CustomMenu(this)); + } + } +); /**/ + + +class Logger { + constructor(log_file) { + this._log_file = log_file; + // initailize log_backend + if (!log_file) { + this._initEmptyLog(); + } else if(log_file == "gnome-shell") { + this._initGnomeLog(); + } else { + this._initFileLog(); + } + this.level = LOGGER_WARNING; + this.info = function(t) { + if (this.level <= LOGGER_INFO) { + this.log(t); + } + }; + this.warning = function(t) { + if (this.level <= LOGGER_WARNING) { + this.log(t); + } + }; + this.error = function(t) { + if (this.level <= LOGGER_ERROR) { + this.log(t); + } + }; + } + + _initEmptyLog() { + this.log = function(_) { }; + } + + _initGnomeLog() { + this.log = function(s) { + global.log("custom-menu-panel> " + s); + }; + } + + _initFileLog() { + this.log = function(s) { + // all operations are synchronous: any needs to optimize? + if (!this._output_file || !this._output_file.query_exists(null) || !this._fstream || this._fstream.is_closed()) { + this._output_file = Gio.File.new_for_path(this._log_file); + this._fstream = this._output_file.append_to(Gio.FileCreateFlags.NONE, null); + if (!this._fstream instanceof Gio.FileIOStream) { + this._initGnomeLog(); + this.log("IOError: Failed to append to " + this._log_file + " [Gio.IOErrorEnum:" + this._fstream + "]"); + return; + } + } + this._fstream.write(String(new Date())+" "+s+"\n", null); + this._fstream.flush(null); + } + } + + notify(t, str, details) { + this.ncond = this.ncond || ['proc', 'ext', 'state']; + if (this.ncond.indexOf(t) < 0) { + return; + } + Main.notify(str, details || ""); + } +} + +// lazy-evaluation +let logger = null; +export function getLogger() { + if (logger === null) { + logger = new Logger("gnome-shell"); + } + return logger; +} /**/ + +export default class CustomMenuExtension extends Extension { + enable() { + BackgroundMenu.BackgroundMenu = CustomBackgroundMenu; + Main.layoutManager._updateBackgrounds(); + } + + disable() { + BackgroundMenu.BackgroundMenu = OriginalBackgroundMenu; + Main.layoutManager._updateBackgrounds(); + } +} /**/ diff --git a/extensions/custom-menu/meson.build b/extensions/custom-menu/meson.build new file mode 100644 index 00000000..92450963 --- /dev/null +++ b/extensions/custom-menu/meson.build @@ -0,0 +1,7 @@ +extension_data += configure_file( + input: metadata_name + '.in', + output: metadata_name, + configuration: metadata_conf +) + +extension_sources += files('config.js') diff --git a/extensions/custom-menu/metadata.json.in b/extensions/custom-menu/metadata.json.in new file mode 100644 index 00000000..054f639b --- /dev/null +++ b/extensions/custom-menu/metadata.json.in @@ -0,0 +1,10 @@ +{ +"extension-id": "@extension_id@", +"uuid": "@uuid@", +"settings-schema": "@gschemaname@", +"gettext-domain": "@gettext_domain@", +"name": "Custom menu", +"description": "Quick custom menu for launching your favorite applications", +"shell-version": [ "@shell_current@" ], +"url": "@url@" +} diff --git a/meson.build b/meson.build index b2a5d94d..bbb72889 100644 --- a/meson.build +++ b/meson.build @@ -53,6 +53,7 @@ all_extensions = default_extensions all_extensions += [ 'auto-move-windows', 'classification-banner', + 'custom-menu', 'gesture-inhibitor', 'native-window-placement', 'user-theme' -- 2.45.0