Началось все с переезда на новую версию одного дистрибутива Linux, а там — скандально известный GNOME Shell (GH для краткости), на Javascript. Ну ок, на JS так на JS, работает — и ладно.
Одновременно с этим темп моей работы давно уже требовал найти нормальный почтовик, вместо тормозящей и жрущей тонны мегабайт вкладки outlook.office.com
в браузере. И вот нашел, в наше время есть несколько почти прекрасных кандидатур, одна беда — почтовик стал доставать меня уведомлениями о новых письмах — и звуком, и всплывающими надписями.
Что делать? Решение написать расширение "Не беспокоить" пришло не сразу, очень не хотелось писать велосипед и/или увязнуть в разработке/коде/тоннах ошибок, но решился, и вот хочу поделиться с Хабром своим опытом.1
Технические требования
Хочется иметь одну большую кнопку, чтобы выключить уведомления и звуки на время по выбору: 20 минут, 40 минут, 1 час, 2 часа, 4, 8 и 24 часа.2 Ага, тайминг как в Slack.
На просторах extensions.gnome.org нашлось расширение "Do Not Disturb Button", которое послужило моделью к написанию своего расширения Do Not Disturb Time.
Конечный результат: Do Not Disturb Time
Установить с сайта extensions.gnome.org.
Исходники на github: ставим звездочки, форкаем, предлагаем улучшения.
Как установить расширение GH: инструкция
- Устанавливаем пакет chrome-gnome-shell, коннектор к браузеру, на примере Ubuntu:
sudo apt install chrome-gnome-shell
- По ссылке устанавливаем браузерное расширение:
- переходим по ссылке Click here to install browser extension
- в Ubuntu 18.04 у меня заработало в браузере Chrome/Chromium, в Fedora 28/29 — и в Firefox, и в Chromium-е
- Ищем нужное расширение в списке https://extensions.gnome.org: включаем, выключаем, меняем настройки расширения.
- PROFIT!
Начало
Создадим расширение с нуля:
$ gnome-shell-extension-tool --create-extension
Name: Do Not Disturb Time
Description: Disables notifications and sound for a period
Uuid: dnd@catbo.net
Created extension in '~/.local/share/gnome-shell/extensions/dnd@catbo.net'
# перегружаем gnome-shell
Alt+F2, r, Enter
# включаем расширение в https://extensions.gnome.org/local/ в браузере и видим результат
# смотреть логи Gnome Shell - очевидно, gnome-shell процесс управляется systemd, пользовательский режим
journalctl -f /usr/bin/gnome-shell
# чтобы подсветить свои ошибки, но выводить все равно все ошибки gnome-shell
journalctl -f /usr/bin/gnome-shell | grep -E 'dnd|$'
Файл extension.js
в соответствующей директории является входной точкой в нашем приложении, в минимальном исполнении он выглядит так:
function enable() {} // вызывается при включении; создаем все здесь
function disable() {} // --||-- выключении; удаляем все созданное в enable()
Первый код
Для начала мы хотим добавить кнопку в Status Menu
справа сверху, как на скриншоте выше.
Итак, с чего бы начать? О, начнем с документации. У нас же есть официальная документация, все дела. А вот нет, официальная документация очень невелика и разрозненна, однако благодаря julio641742
и его неофициальной документации мы получаем то, что нужно:
// 1 - выравнивание меню относительно кнопки(1 - слева, 0 - справа, 0.5 - по центру)
// true, если автоматически создавать меню
let dndButton = new PanelMenu.Button(1, "DoNotDisturb", false);
// `right` - где мы хотим увидеть кнопку (left/center/right)
Main.panel.addToStatusArea("DoNotDisturbRole", dndButton, 0, "right");
let box = new St.BoxLayout();
dndButton.actor.add_child(box);
let icon = new St.Icon({ style_class: "system-status-icon" });
icon.set_gicon(Gio.icon_new_for_string("/tmp/bell_normal.svg"));
box.add(icon);
Данный код создает ключевой объект dndButton
класса PanelMenu.Button
— это кнопка, специально предназначенная для панели Status Menu. И мы ее вставляем в эту панель с помощью функции Main.panel.addToStatusArea().3
Вставляем пункты меню с прикрученными к ним обработчиками, пример:
let menuItem = new PopupMenu.PopupMenuItem("hello, world!");
menuItem.connect("activate", (menuItem, event) => {
log("hello, world!");
});
dndButton.menu.addMenuItem(menuItem);
Спасибо тебе, julio641742, за документацию! Ссылка:
https://github.com/julio641742/gnome-shell-extension-reference
Итоговый работающий код — по ссылке.
Особенности работы GNOME Shell и Javascript
На дворе конец 2018-го, и Node.js/V8 — основной инструмент для запуска Javascript-кода. Вся современная web-разработка держится на "ноде".
Но GNOME Shell и вся инфраструктура вокруг него использует другой Javascript-движок, SpiderMonkey от Mozilla, и отсюда следует много важных различий в работе.
Импорт модулей
В отличие от Node.js, здесь нет require(), и модного ES6-import-а — тоже. Вместо этого есть специальный объект imports, обращение к атрибутам которого приводит к загрузке модуля:
//const PanelMenu = require("ui/panelMenu");
const PanelMenu = imports.ui.panelMenu;
В данном случае мы загрузили модуль js/ui/panelMenu.js из библиотеки пакета GNOME Shell, в котором реализован функционал кнопки с всплывающим меню.
Да-да, все кнопки в панели современного десктопа Linux, использующего GNOME, написаны на базе panelMenu.js. В том числе: та самая правая кнопка с индикаторами батареи, Wi-fi, громкостью звука; переключалка языка ввода en-ru.
Далее, есть особый атрибут imports.searchPath
— это список путей (строк), где будут искаться наши JS-модули. К примеру, мы выделили в отдельный модуль timeUtils.js функционал работы с таймером и положили его рядом с входной точкой нашего расширения, extension.js. Импортим timeUtils.js следующим образом:
// получаем путь до нашего расширения, где-то в ~/.local/share/gnome-shell/extensions/<your-extension>/
const Me = imports.misc.extensionUtils.getCurrentExtension();
// вставляем новый путь в начало списка
imports.searchPath.unshift(Me.path);
// собственно импорт
const timeUtils = imports.timeUtils;
Логирование, отладка Javascript
Ну раз у нас не Node.js, то и логирование у нас свое. Вместо console.log() в коде доступны несколько функций логирования, см. gjs/../global.cpp, static_funcs:
- "log" = g_message("JS LOG: " + message) — логирование в stderr, пример:
$ cat helloWorld.js
log("hello, world");
$ gjs helloWorld.js
Gjs-Message: 17:20:21.048: JS LOG: hello, world
- "logError" — логирует стек исключения:
- первый обязательный аргумент — исключение, затем через запятую — что хочешь
- пример, если нужно распечатать стек в нужном месте:
try {
throw new Error('bum!');
} catch(e) {
logError(e, "what a fuck");
}
и это нарисует в stderr в стиле:
(gjs:28674): Gjs-WARNING **: 13:39:46.951: JS ERROR: what a fuck: Error: bum!
ggg@./gtk.js:5:15
ddd@./gtk.js:12:5
@./gtk.js:15:1
- "print" = g_print("%s\n", txt); — только текст + "\n" в stdout, без префиксов и окраски, в отличие от log()
- "printerr" = g_printerr("%s\n", txt) — отличие от print в том, что в stderr
А вот отладчика для SpiderMonkey из коробки нет (не зря же я кропотливо выписал выше все доступные инструменты для логирования, пользуйтесь!). При желании можно попробовать JSRDbg: раз, два.
А есть ли жизнь для JS-кода вне GNOME Shell?
Есть. Полнофункциональные приложения, включая графический интерфейс (GUI), можно писать на Javascript! Запускать их нужно с помощью бинаря gjs, пускальщика JS-GTK-кода, пример создания GUI-окошка:
$ which gjs
/usr/bin/gjs
$ dpkg --search /usr/bin/gjs
gjs: /usr/bin/gjs
$ cat gtk.js
const Gtk = imports.gi.Gtk;
Gtk.init(null);
let win = new Gtk.Window();
win.connect("delete-event", () => {
Gtk.main_quit();
});
win.show_all();
Gtk.main();
$ gjs gtk.js
Выше я упомянул про разбиение кода на модули и загрузку их из Javascript. Возникает вопрос, а как в самом модуле определить, запущен ли он как "main"-модуль, или загружен из другого модуля?
В Python-е есть аутентичная конструкция:
if __name__ == "__main__":
main()
В Node.js — аналогично:
if (require.main === module) {
main();
}
Официального ответа на этот вопрос для Gjs/GH я не нашел, но придумал такой прием, которым спешу поделиться с читателем (а че, кто-то дочитал "досюдова"? респект!).
Итак, хитрый прием основан на анализе текущего стека вызовов, если он состоит из 2х и более строк — значит мы не в main()-модуле:
if (
new Error().stack.split(/\r\n|\r|\n/g).filter(line => line.length > 0)
.length == 1
) {
main();
}
Уборка за собой
Каждое расширение GNOME Shell имеет доступ ко всем объектам всего GNOME Shell. К примеру, чтобы отобразить кол-во непрочитанных еще уведомлений, доберемся до контейнера с ними в Notification Area
, расположенного по центру сверху, номер 4 на картинке (нажмите на надпись с текущим временем, она кликабельна в реале, не здесь):
let unseenlist =
Main.panel.statusArea.dateMenu._messageList._notificationSection._list;
Можно узнать, сколько их, непрочитанных уведомлений, подписаться на события добавления и удаления уведомлений:
let number = unseenlist.get_n_children();
unseenlist.connect("actor-added", () => {
log("added!");
});
unseenlist.connect("actor-removed", () => {
log("removed!");
});
Это прекрасно, но пользователь, бывает, может решить, что расширение X ему больше не нужно, и нажмет кнопку отключить расширение. Для расширения это равносильно вызову функции disable(), и нужно предпринять все усилия, чтобы выключенное расширение не поломало работающий GH:
function disable() {
dndButton.destroy();
}
В данном случае, помимо того, что удаляем саму кнопку, нужно отписаться от событий "actor-added"/"actor-removed", пример:
var signal = unseenlist.connect("actor-added", () => {
log("added!");
});
function disable() {
dndButton.destroy();
unseenlist.disconnect(signal);
}
Если этого не сделать, то код обработчиков будет продолжать вызываться на соответствующего события, пытаться обновлять состояние несуществующей уже кнопки с менюшкой и… GNOME Shell начнет глючить. Ну да, напакостим мы, ругаться будут пользователи, камни полетят в разрабов GNOME Shell и GNOME в целом. Реальная картина, че.
Итак, GNOME Shell/Gjs представляет собой симбиоз двух систем, Glib/GTK и Javascript, и у них разный подход к управлению ресурсами. Glib/GTK требует явного освобождения своих ресурсов (кнопок, таймеров и прочего). Если же объект создан движком Javascript-а, то действуем как обычно (ничего не освобождаем).
В итоге, как только наше расширение готово, и не "течет", можно смело публиковать его на https://extensions.gnome.org.
Режим GnomeSession.PresenceStatus.BUSY и DBus.
Если вы еще не забыли, мы делаем расширение "Do Not Disturb", которое выключает показ уведомлений пользователю.
В GNOME уже есть флаг, отвечающий за это состояние. После логина пользователя создается процесс gnome-session, в котором этот флаг и находится: это атрибут GsmPresencePrivate.status, см. исходники gnome-session, gnome-session/gsm-presence.c. Доступ к этому флагу получаем через DBus-интерфейс (такое межпроцессное взаимодействие).
Не только нам, но и самому GH нужна информация об этом флаге, чтобы не показывать уведомления. Это достаточно легко ищется в исходниках GH:
this._presence = new GnomeSession.Presence((proxy, error) => {
this._onStatusChanged(proxy.status);
});
...
this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => {
this._onStatusChanged(status);
});
В данном случае метод _onStatusChanged есть обработчик, реагирующий на смену состояния. Копируем этот код к себе, адаптируем — с уведомлениями разобрались4, остался звук.
Выключение/включение звука
Большинство современных Linux-десктопов управляется PulseAudio, небезызвестное поделие программа за авторством небезызвестного Lennart Poettering. До сих пор у меня не доходили руки пошерстить код PulseAudio, и я был рад представившейся возможности разобраться в PulseAudio на некотором уровне.
В итоге оказалось, что для mute/unmute достаточно одной утилиты pactl
, а точнее трех команд на ее основе:
- "pactl info": узнать
default sink
— на какой звуковой выход, если их несколько, подается звук по умолчанию - "pactl list sinks": узнать состояние mute/unmute соответствующего устройства
- "pactl set-sink-mute %(defaultSink)s %(isMute)s": для собственно mute/unmute
Итак, наша задача состоит в запуске команд/процессов, чтении их вывода stdout и поиске нужных значений по регулярке. Короче, стандартная задача.
В GNOME за создание процессов отвечает базовая библиотека glib, и есть отличная документация по ней. И конечно она на C. А у нас JS. Известно, что пакет Gjs сделал умную, "интуитивно-понятную" прослойку между С-API и Javascript. Но все равно понимаешь, что нужны примеры и без гугления не обойтись.
В итоге, благодаря прекрасному gist-у получаем работающий код:
let resList = GLib.spawn_command_line_sync(cmd);
// res = true/false, успех/провал запуска процесса
// status = int, код выхода процесса
// out/err = строки, содержащие stdout/stderr процесса
let [res, out, err, status] = resList;
if (res != true || status != 0) {
print("not ok!");
} else {
// do something useful
}
Сохранение настроек в реестре
Не, конечно реестра в Linux-е нет. Тут вам не Windows. Есть лучше, называется GSettings (это API), за ним скрывается несколько вариантов реализации, по умолчанию в GNOME используется Dconf. Вот так выглядит GUI-шка для него:
— Чем это лучше хранения настроек в обычных текстовых файлах? — спросят олдовые и бородатые пользователи Linux-а. Основная фишка GSettings в том, что можно легко подписаться на изменения в настройке, пример:
const Gio = imports.gi.Gio;
settings = new Gio.Settings({ settings_schema: schemaObj });
settings.connect("changed::mute-audio", function() {
log("I see, you changed it!");
});
Единственная пока настройка в нашем "Do Not Disturb" — это опция "mute-audio", которая позволяет по желанию пользователя выключать или нет звук на время "тихого часа".
И немного классики, GUI на GTK
Чтобы красиво показать пользователю настройки нашего расширения (а не лезть грязными лапами в реестр), GH предлагает нам написать GUI-код и положить его в функцию buildPrefsWidget() файла prefs.js. В этом случае напротив нашего расширения в списке "Installed Extensions" здесь мы увидим дополнительную кнопку "Configure this extension", по нажатию которой наша красота и появится.
Давайте создадим отдельную вкладку About, ведь известно, что без "Эбаута", пардон, программа не является полноценной.
Вообще говоря, для построения классического графического интерфейса у GTK есть весь ассортимент строительных блоков, виджетов
, заценить кол-во и качество которых можно здесь.
Мы же воспользуемся лишь несколькими из них:
- Gtk.Notebook — это вкладки, примерно как в браузере
- Gtk.VBox — это контейнер для вертикального структурирования списка виджетов
- Gtk.Label — это базовый элемент, надпись, с возможностью HTML-форматирования
function buildPrefsWidget() {
// собственно набор вкладок Gtk.Notebook составляет все наше GUI
let notebook = new Gtk.Notebook();
...
// вкладка About, содержимым вкладки будет VBox c отступом в 10 пикселей,
// прям как margin/padding на фронте
let aboutBox = new Gtk.VBox({ border_width: 10 });
// добавляем вкладку с титулом About
notebook.append_page(
aboutBox,
new Gtk.Label({label: "About"}),
);
// первым делом вставляем название нашего расширения жирным шрифтом,
// и чтоб занял все свободное место, если таковое будет (expand)
aboutBox.pack_start(
new Gtk.Label({
label: "<b>Do Not Disturb Time</b>",
use_markup: true,
}),
true, // expand
true, // fill
0,
);
...
notebook.show_all();
return notebook;
}
Итоговый скриншот:
Дополнительно
Работа программиста предполагает 2 режима в моем случае:
1) в режиме поддержки, когда нужно быстро реагировать на события,- почта, Slack, Skype и прочее
2) в режиме работы, когда жизненно необходимо вырубить уведомления хотя бы на 20 минут, иначе фокус теряется и итоговая производительность труда — ничтожна. Именно для этого полезен режим "Не беспокоить".
Может показаться, что полное выключение звука, mute, это слишком. Действительно, ведь в идеале хочется, чтобы в режиме "Не беспокоить" звонки Slack/Skype были слышны, а вот остальные звуки (реальных уведомлений) — нет. Но для этого их нужно как-то различать. Можно, конечно, сделать звуковое API специально для уведомлений (и такое уже есть), только вот всегда найдется программа/программист, который не задействует такой функционал. Пример — почтовик Mailspring: звуки он просто проигрывает через тег audio
, и их никак не отличишь, допустим, от речи в звонке Slack-а.
PanelMenu.Button — это собственно кнопка в панели + всплывающее меню, и можно самому разобраться и создать с нуля, и то, и другое, пацаны на раене оценят! У меня же была нацеленность на быстрый результат и поэтому я копипастнул код из неофициальной документации.
Собственно инициировать смену режима нужно с помощью SetStatusRemote().