在GNOME上恢复窗口位置和大小
和很多人一样,我也给电脑配了两个显示器:一个在中间作为主显示器,另一个在右边竖屏放置。我主要使用中间的那个,侧边的显示器则用来放一些状态监控。我这里用的是GNOME自带的系统监视器。也许有更好的选择,但对我来说这已经够用了。然而有一件事让我很烦,就是这个应用不会保存它的窗口位置,打开时不会出现在我上次放的地方,而是出现在我主显示器的正中央,于是我得手动把它一路拖到侧边的显示器上,每一天都。
最初的解决方案
因为觉得每天拖太麻烦了,于是我让Windsurf给我写了个脚本能将窗口设置成给定的位置和大小。它出色地完成了任务,写出了set_window_geometry这个脚本。
脚本源代码
#!/bin/bash
if [ $# -ne 5 ]; then
echo "Usage: $0 \"window_name\" x y width height"
echo "Example: $0 \"Firefox\" 100 100 800 600"
exit 1
fi
window_name="$1"
x=$2
y=$3
width=$4
height=$5
# Find window ID by name
window_id=$(wmctrl -l | grep "$window_name" | head -n1 | cut -d' ' -f1)
if [ -z "$window_id" ]; then
echo "Error: No window found matching '$window_name'"
exit 1
fi
# Set the window geometry
# Note: wmctrl uses gravity,x,y,width,height format
# 0 is the gravity value which means the position is relative to the top-left corner
wmctrl -i -r "$window_id" -e "0,$x,$y,$width,$height"
echo "Window geometry set successfully"
多亏了它,我了解到了wmctrl这个工具,这个脚本也很好地满足了我的需求……直到上个月我把系统升级到最新的Ubuntu 25.10,这个脚本就坏掉了。
原因是Ubuntu 25.10放弃了X11,转而使用Wayland,但wmctrl在Wayland下无法工作,所以我开始寻找替代品。
我找到了wlrctl,据说它是一个支持在Wayland上进行各种窗口操作的工具,但它在GNOME上也无法工作。这个工具依赖于Foreign Toplevel Management接口,而GNOME的合成器Mutter并没有实现这个接口。我还发现GNOME的JavaScript API可以做到这一点,并且有一个通过D-Bus暴露的API org.gnome.Shell.Eval可以在GNOME Shell上执行JavaScript代码。然而那个API也早已被限制,所以这又是另一条死胡同。
构建一个扩展
不确定是在哪里读到的,但Wayland背后的基本理念似乎是应用程序不应该能够互相干扰,而管理窗口是窗口管理器的责任。考虑到这一点,以及GNOME内部有这个API,只是应用程序无法访问,那么下一个方向就是GNOME Shell扩展。
我找到了一个名为Window State Manager的扩展,它声称可以“自动保存/恢复窗口状态”,这听起来正是我想要的,然而事实并非如此。它是用于在桌面布局变化时(如连接/断开显示器时)恢复窗口状态的。它并不会持久化状态保留到注销/重启之后。它解决的是一个完全不同的问题,但它仍然给了我一些启发,让我知道用扩展可以做些什么。
在没找到任何符合我需求的东西后,我决定自己动手做一个。
测试环境
中国有句古话:工欲善其事,必先利其器。虽然现在的大语言模型在根据描述编写代码方面已经相当不错,但我仍然需要测试和迭代结果。在开发Web应用时,这通常很简单,你只需刷新页面;开发普通桌面应用时,也只需重新运行它。但对于GNOME Shell扩展来说,情况并非如此。
GNOME很贴心地提供了一份关于调试扩展的官方指南,其中有一节是关于重载扩展的。对于Wayland,推荐的方法是运行一个嵌套的GNOME Shell;对于X11,则是通过Alt+F2对话框重启GNOME Shell。反过来则不行,也就是说,Wayland不支持在不注销的情况下重启GNOME Shell,而X11则不支持运行嵌套的GNOME Shell。
要在Ubuntu上运行嵌套的GNOME Shell,必须先通过apt安装一个单独的包mutter-dev-bin,然后就可以用dbus-run-session gnome-shell --devkit --wayland来启动一个窗口化的GNOME Shell。起初花了很长时间才出现,以至于我以为它根本不能用,但最终它还是成功启动了。
然而这种方法并不理想。让我意识到这件事的,是第二天我打开电脑,发现桌面布局一团糟的时候。文档确实提到了:
A nested instance of GNOME Shell is not fully isolated, and will not protect your system from data loss or other consequences.
但是,在我看来,它没有真的强调它根本就没有隔离! 嵌套实例和宿主实例使用相同的配置,并且在某些情况下可能会覆盖宿主的配置。我桌面布局的丢失,就是因为嵌套实例只有一个显示器,而我在里面动了它的显示设置。这在调试一个开发中的扩展时,是绝对不想遇到的事情。
所以我也不得不在隔离方面更进一步,创建了一个虚拟机来测试它。由于需要频繁地注销和登录,我做了一些微调,让这个过程更流畅:
- 使用dconf-editor将
/org/gnome/gnome-session/logout-prompt设置为false,以禁用注销时的确认对话框。 - 在
/etc/pam.d/gdm-password中添加auth sufficient pam_succeed_if.so user != root,以允许无密码登录。
开发
TypeScript
虽然GNOME Shell扩展通常使用JavaScript开发,但我最近越来越不喜欢在新项目中使用JavaScript,因为它缺少类型检查。幸运的是,似乎不只我一个人这么想。官方文档实际上有一页专门介绍使用TypeScript进行开发。它包含了不少步骤,并且需要手动创建许多文件。如果有一个与npm init或pnpm create兼容的入门套件,这个过程或许会更简单一些。但这也不是什么大问题,而且也可以对项目中的所有文件有个了解。
偏好设置
对于这个扩展,我自然不希望对每个窗口都启用。有些应用,如Firefox,已经有自己的机制来恢复之前的状态,而对于其他许多应用,我并不在乎它们在哪里打开,默认的位置和大小就可以了。这意味着需要一种方法来指定要恢复哪些窗口。
最初的想法是使用偏好设置界面来指定,里面会列出所有打开的窗口,并允许用户切换它们的开关。这就是我让LLM实现的功能,它写的代码挺好的,但问题在于,这种方法根本行不通。偏好设置显示了一些像ReferenceError: global is not defined这样的胡言乱语,我非常困惑。接着我发现文档中简要提到:
Extension preferences run in a separate process, without access to code in GNOME Shell, …
所以偏好设置没法完成我想实现的东西,我需要一个与GNOME Shell在同一进程中运行的替代方案。1
我曾考虑在指示器区域添加一个图标,但我自己的托盘区已经非常拥挤了,而且选择恢复哪些窗口这种事也不值得这么高的关注度。这种多半会是你设置一次就放在那里的事情。我最终把它放在了快速设置面板里,感觉是个不错的折衷方案。它既没有挤在一个已经拥挤的区域里,又在需要的时候可以很方便使用,最重要的是,它与GNOME Shell在同一个进程中运行,因此可以访问必要的API。
时机问题
在Linux桌面上,窗口状态管理似乎是个复杂的问题。
最初的代码连接到window-created信号来找到新打开的窗口。这听起来很简单,但实际上在这个信号发出的时候,窗口根本不稳定,它甚至还没有分配wm_class,而我在扩展中正是用wm_class来识别窗口的。我不得不(使用 GLib.idle_add)将后续的代码延迟一点才能读取wm_class以进行进一步的判断。
然后,在延迟之后,窗口可能仍然没有稳定下来,移动和调整大小的API(Window.move_resize_frame)也无法工作,它必须等到窗口的first-frame信号发出后,才能开始移动或调整窗口大小。
坦白说,我也不确定这些额外的等待到底有多可靠。我是不是应该在window-created中直接等待first-frame?每个窗口都保证会发出这个信号吗?以及,一个窗口有没有可能在第一个空闲延迟到期前就被销毁,导致所有后续处理都失效?这些问题我都回答不了,但至少目前的方法在我的使用场景下似乎工作得还可以。
发布
虽然这个扩展是为个人使用而制作的,但我感觉其他人或许也会觉得它有用,所以我把源代码放在了GitHub上,并发布到了GNOME Extensions网站。
不出所料,在扩展网站上发布需要经过审核和批准,其他用户才能安装。由于这应该是一个由志愿者运营的项目,我原本预计这个过程可能会耗时数天。然而令我惊讶的是,一位名叫JustPerfection的审核员提交审核意见的速度非常快,在我提交后的几小时内,有时甚至是几分钟内,就审核了,还会正确地指出我扩展中的许多问题,包括不必要的文件以及定时器可能没有被正确移除等。他还提供了一些有用的建议,比如可以加入Matrix频道寻求帮助,也可以为扩展添加捐赠链接。我非常感谢这位审核员如此迅速的审核和反馈。
同样令人惊讶的是,扩展首次发布仅一天后,就已经有了几十次下载,甚至还有一条五星用户评价,称赞它是“有史以来最好的GNOME扩展”。我不知道这些人是怎么找到这个扩展的,但这非常鼓舞人心。
写在最后
你可以在GNOME Extensions和GitHub上找到这个扩展“Restore Geometry”。欢迎反馈。
这是我第一次为GNOME Shell开发扩展,总的来说是一次积极的体验,下面是我对这次经历的一些感想。
Wayland
我对Wayland有很多抱怨,它搞坏了我的许多东西,让我花了不少时间才让一切重新运转起来。这个扩展的最初动机只是其中的一个例子,其他的还包括:
看到一项新技术被强行推广,而它非但没有明显解决任何实际问题,反而破坏了大量现有的使用场景,真是令人失望。4
开发体验
开发体验实在是不太行。
扩展开发的文档还算可以,但GJS API参考文档浩如烟海,没有太多经验的话,很难知道从哪里开始看起。幸运的是,现在LLM可以帮助做很多研究工作,并找到合适的API来入手。
搭建一个合适的测试环境需要费一番功夫(需要带图形界面的虚拟机)。官方指南中建议的使用嵌套Wayland会话的方法似乎性能也并没有更好,却可能对宿主环境有潜在的破坏性。
此外,迭代修改需要打包扩展,将其发送到虚拟机,在虚拟机内部安装,然后注销再重新登录。这个过程很繁琐,尽管其中一些部分可以在一定程度上自动化。如果扩展可以在同一个会话中被卸载和重新加载,那就能节省不少时间。它不必像浏览器扩展那样被隔离(因而受限),但如果相信扩展能够清理自己的副作用,然后再支持卸载扩展的资源,这可能会在很大程度上让扩展作者的日子好过很多。
测试框架好像也是不存在的。一些扩展似乎开发了适合自己需求的初步的测试机制,但这需要许多额外的工作。对于像我的这样的小扩展,就只能全部手动测试了。
GNOME社区
我和GNOME社区这次有限的互动经历非常愉快。审核员反应迅速且友善,还有一些积极参与的用户,他们愿意尝试并提供有意义的反馈。
第一条用户评价中的一个建议是,允许不一直跟踪窗口,而是记住一个特定的位置大小,并且每次都使用它,不管窗口上次最终停在什么位置。我觉得这个功能非常有用,所以很快就在扩展中实现了它。
另一位用户在GitHub上创建了一个issue,请求支持GNOME 48,并帮助进行了测试,确认扩展实际上无需任何更改就可以在那里工作。
脚注
-
在调查的过程中,我发现GSConnect并没有使用默认的偏好设置窗口,而是用了一个非常不同的界面。它用了一种有趣的方法,会立即关闭偏好设置窗口,再在另一个独立的进程中打开它自己的窗口。当然这个方法这里也不适用。 ↩
-
对于这个问题,我最终安装了Activate Window By Title并创建了一个脚本来利用它启动计算器。 ↩
-
我花了大量时间研究解决方案,看了virtual-keyboard 协议和libei,但它们都无法满足我的使用场景。我最终使用了一个libevdev的实现来直接与
/dev/uinput交互。 ↩ -
一个我非常喜欢的反例是PipeWire——它解决了我的蓝牙耳机问题,为控制音频如何传递增加了很大的灵活性,而且什么都没搞坏。 ↩