Linux 全局菜單

ayatana 是 Canonical 发起的一个 Linux 端桌面统一体验的项目,规定了桌面托盘、通知消息、全局菜单等功能的实现,Unity 桌面也是这个项目的一部分,在2017年的时候 Canonical 宣布转向使用 Gnome,而 Unity 桌面环境将停止开发。虽然 Unity桌面环境停止开发,但是部分使用 DBus 通讯的接口却保留了下来。

Gnome 是大多数发行版官方默认的桌面的环境,但是原版不安装插件用起来的却是各种难受,包括缺少任务栏/系统托盘,非常占用空间的标题栏。早期使用插件Gnome-Global-AppMenu实现全局菜单,这个仅限于Gtk2/Gtk3,到了Gtk4不支持加载外置模块,导致Gtk4开发的软件的作者除非手动兼容,不然没有全局菜单。所以Gnome-Global-AppMenu 的开发者宣布停止开发这个插件。另外一个是Fildem,目前还在积极的维护。xfce/mate/budgie 桌面环境使用vala-panel-appmenu,效果未知。KDE 从 5.9 版本开始,官方提供了支持,到了现在非常的稳定,属于开箱即用。

Unity 之所以有全局菜单是因为 Canonical 这公司针对常见的软件包做了修改,将本来在软件内展示的菜单,展示在状态栏上面,这种操作只是一种代码外挂,并不是软件本身主动调用很接口生成菜单,有时候还会出现软件本身菜单和全局菜单同时展示的情况。常见的 UI库 GTK2/GTK3/QT都通过这种方式获得了支持,部分软件比如Firefox则采用官方定制版本才能支持全局菜单,总之实现方面并不是很完美。

全局菜单实现需要两个部分组成,一个是桌面环境(具体点是状态条)提供一个窗口菜单注册接口,并且检测运行软件运行情况,绘制菜单项目等功能。软件端则是侦测桌面环境是否支持全局菜单,侦测到支持全局菜单,就需要注册一个会话,并且将全局菜单发送到接口。后续内容将全局菜单展示的组件称为服务端,运行的软件作为客户端,服务端到客户端的通讯方式用的是 dbus。

dbus

dbus 基本是 Linux系统的必备组件,dbus 主要功能是提供一个总线服务,供不同进程进行通讯。dbus 通过资源资源、接口这个二元组定位操作,资源作为操作目标的描述,而接口则是对于目标功能特征的描述,对于目标上的操作分为方法调用和发送信号,区别就是信号不需要响应,无论成功与否。

dbus还有一个address参数,指明通过哪个socket交换数据,总的分为系统总线和用户总线,用户总线在文件系统的/run/user/[uid]/bus这个位置。

接口描述资源拥有的一些操作的合集,实现某个接口就表示资源拥有某些特征,
比如这个接口:org.freedesktop.DBus.Introspectable
拥有一个方法

org.freedesktop.DBus.Introspectable.Introspect (out STRING xml_data)

调用这个方法能获得这个资源的描述信息。还有org.freedesktop.DBus.Properties是一个类似字典的接口,描述的资源拥有的属性。

dbus 在 Linux 桌面端用的非常多,桌面的通知、桌面任务托盘这些都是使用 dbus 通讯,不过目前并没有一个大一统的趋势,对于桌面端的公共区域,并没有一个明确的公式。

开发的时候,有命令行工具dbus-ctl,GUI工具有d-feet/qdbusviewer,这些工具使用前提是实现上面介绍到的两个接口,如果程序没有实现的话,并不会影响正常运行,但是不能在调试工具里面展示。

工作流程

菜单注册

首先需要一个会话管理组件,在 dbus 总线中提供资源/com/canonical/AppMenu/Registrar并且实现接口com.canonical.AppMenu.Registrar,接口有三个方法,分别是:RegisterWindow/UnregisterWindow/GetMenuForWindow。这个接口的主要功能是提供一个 windowID <=> menuObjectPath 的映射关系存储和读取服务,每个窗口都对应一个菜单资源,后续的全局菜单数据交换则通过这个对象资源完成。注册和注销窗口都是由客户端发起,获取菜单则有服务端发起。

菜单项获取

菜单资源实现了com.canonical.dbusmenu接口,并且由每个软件单独维护,维护这个单独的代码编写实际上并不一定由开发者承担,appmenu-gtk2-module/appmenu-gtk3-module/appmenu-qt这几个模块提供了相应的图形库的支持,可以把软件内的菜单暴露出来,electron 封装的软件也有开箱即用全局菜单支持。这个接口主要由下面的方法和信号组成,通过这些方法就能获取菜单项和完成软件交互功能。

用到的方法

  • GetLayout:获取整个/部分菜单内容。
  • Event: 菜单事件出发发送给对应窗口,比如划过菜单或是点击菜单
  • AboutToShow: 滑到某个菜单下面,调用这个接口,询问程序是否需要更新菜单,根据返回作出对于动作(直接展示或是刷新再展示)
  • GetProperty/GetGroupProperties:单个/批量查询对象属性,做局部更新的时候需要用到

监听信号

  • ItemsPropertiesUpdated:菜单项属性更新
  • LayoutUpdated:菜单布局更新
  • ItemActivationRequested:菜单项激活(在软件内按快捷键,通知全局菜单组件弹出对应的菜单子菜单)

菜单展示

这又是另外一个组件,通常是根据桌面环境不同开发的插件,用于在状态栏展示菜单,就前面提到的KDE桌面环境,全局菜单只是一个 kde applet。主要工作流程是监听窗口切换,然后拿当前聚焦的窗口ID询问注册器,获取到菜单资源路径 menuObjectPaht,然后调用这个资源上的接口,获取菜单并且负责展示,用户点击或是鼠标划过,都需要发送相应的事件给窗口,窗口对应的软件在运行的时候也会动态的更新一部分菜单项,这时候会发送更新信号,所以展示组件也需要监听信号。在实现功能的时候展示组件和窗口注册组件并不多一定得分开编写,也可能合在一起一个进程完成,这时候就省略GetMenuForWindow这个方法的调用,其次软件有可能直接退出而不会主动调用取消注册的功能,这时候就需要手动回收过期的会话。

全局菜单实现的另一个比较麻烦的点是这些菜单项目并不是静态的,复杂的软件伴随着菜单项的动态更新等等,还有菜单有勾选、禁用等状态。

窗口切换监听

实现全局菜单的最后一步就是检测窗口切换事件,在 Linux 桌面环境窗口切换是由窗口管理器维护的,但是 XDG 目前并没有一个关于窗口切换管理相关信息获取的接口,每个桌面环境都有自己的一套标准。我翻了很多全局菜单实现的插件的代码,基本都是调用 libbamf3 这个库实现的,具体底层原理我也不是很清楚。

菜单展示组件只有一个进程,但是需要管理所有窗口的菜单,展示当前活跃窗口的菜单,所以需要精确的监听窗口切换的信号,然后回头去询问窗口对应的菜单资源,再展示出来。libbamf3 这个库也是ayatana项目的一部分,提供 daemon 在后台运行,切换窗口的时候会发送信号到org.ayatana.bamf.matcher这个接口,所以只需要通过 dbus 和对应的资源进行通讯,而不需要直接调用这个库。下面是方法可以捕获窗口切换的动作。

  • ViewClosed: 窗口关闭,若关闭程序还会接收到一次程序关闭信号,总共两次信号,关闭一个窗口还存有其他窗口的时候会发送一次信号。
  • ViewOpened: 窗口开启,程序第一次开启的时候会触发程序开启和窗口开启两次信号。
  • ActiveWindowChanged:切换窗口信号
  • ActiveApplicationChanged:切换程序信号
  • StackingOrderChanged:
  • RunningApplicationsChanged:切换程序

下面是调试命令,在终端运行这个指令,来回切换窗口,就可以看到窗口的一些信息。

dbus-monitor "type='signal',path='/org/ayatana/bamf/matcher',interface='org.ayatana.bamf.matcher',member='ActiveWindowChanged'"

其他

全局菜单的实现过程并不复杂,难的是在软件端的实现参差不齐,没有一个稳定的库供软件端调用,不过流行的 GUI 库比如 GTK/QT/Java Swing 基本都能覆盖到。其次全局菜单实现并不是一个 XDG 标准规范,Linux 的两大桌面之一 KDE 已经开始支持,如果 Gnome 能支持这项标准,那么很快便能在其他桌面环境也覆盖开来吧,但是 Gnome 这个其他连状态托盘都隐藏了,这个功能是不大可能实现。

参考资料

  1. Why is global menu difficult to maintain?
  2. com.canonical.dbusmenu.xml
  3. com.canonical.AppMenu.Registrar.xml