Як написати розширення для GNOME Shell: режим «Do Not Disturb»
Почалося все з переїзду на нову версію одного дистрибутива 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-а — теж. Замість цього є спеціальний об’єкт заяву, звернення до атрибутів якого призводить до завантаження модуля:
//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(“%sn”, txt); — тільки текст + “n” stdout, без префіксів і забарвлення, на відміну від log()
- “printerr” = g_printerr(“%sn”, 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(/rn|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 GNOME Shell і в цілому. Реальна картина, че.
Отже, 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;
}
Підсумковий скріншот:
Додатково
1. Режими роботи: підтримка і робота
Робота програміста передбачає 2 режиму в моєму випадку:
1) у режимі підтримки, коли потрібно швидко реагувати на події, пошта, Slack, Skype та інше
2) у режимі роботи, коли життєво необхідно вирубати повідомлення хоча б на 20 хвилин, інакше фокус втрачається і підсумкова продуктивність праці — низькою. Саме для цього корисний режим “Не турбувати”.
2. Як вимкнути звук
Може здатися, що повне вимикання звуку, mute, це занадто. Дійсно, адже в ідеалі хочеться, щоб в режимі “Не турбувати” дзвінки Slack/Skype було чути, а от інші звуки (реальних повідомлень) — немає. Але для цього їх потрібно розрізняти. Можна, звичайно, зробити звуковий API спеціально для повідомлень (і таке вже є), тільки от завжди знайдеться програма/програміст, який не задіює такий функціонал. Приклад — поштовик Mailspring: звуки він просто програє через тег audio
, і їх ніяк не відрізниш, припустимо, від мовлення в дзвінку Slack-а.
3. PanelMenu.Button
PanelMenu.Button — це власне кнопка в панелі + спливаюче меню, і можна самому розібратися і створити з нуля, і те, і інше, пацани на районі оцінять! У мене ж була націленість на швидкий результат і тому я копипастнул код з неофіційною документацією.
4. SetStatusRemote()
Власне ініціювати зміну режиму потрібно з допомогою SetStatusRemote().