Pull to refresh

Как написать расширение для GNOME Shell: режим «Do Not Disturb»

Reading time 10 min
Views 9.4K

Началось все с переезда на новую версию одного дистрибутива 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


Do Not Disturb Time


Установить с сайта extensions.gnome.org.
Исходники на github: ставим звездочки, форкаем, предлагаем улучшения.


Как установить расширение GH: инструкция


  1. Устанавливаем пакет chrome-gnome-shell, коннектор к браузеру, на примере Ubuntu:
    sudo apt install chrome-gnome-shell
  2. По ссылке устанавливаем браузерное расширение:
    • переходим по ссылке Click here to install browser extension
    • в Ubuntu 18.04 у меня заработало в браузере Chrome/Chromium, в Fedora 28/29 — и в Firefox, и в Chromium-е
  3. Ищем нужное расширение в списке https://extensions.gnome.org: включаем, выключаем, меняем настройки расширения.
  4. 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 на картинке (нажмите на надпись с текущим временем, она кликабельна в реале, не здесь):


Do Not Disturb Time


  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-шка для него:


dconf-editor


— Чем это лучше хранения настроек в обычных текстовых файлах? — спросят олдовые и бородатые пользователи 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().

Tags:
Hubs:
+10
Comments 5
Comments Comments 5

Articles