Ubuntu环境下为微信设置显隐快捷键

在 Ubuntu GNOME(X11)上给微信配一个 Alt+X 快捷键:按一下,微信在前台就收起,不在前台就调出来,没运行就启动。整个方案只有两个脚本、约 90 行代码,只用 wmctrlxpropgsettings,不装 xdotool、不需要 sudo。下面是从零到能用的全部步骤。

命令返回成功,不代表窗口真的动了

Ubuntu环境下为微信设置显隐快捷键

一、背景

macOS 上”呼出/隐藏某个应用”是系统能力,Linux 桌面上则要自己拼。好在我们只需要两个 X11 工具加一个快捷键绑定,就能得到同样的体验。

环境事实:

桌面 Ubuntu GNOME,XDG_SESSION_TYPE=x11
窗管 mutter
微信 腾讯官方 deb,4.1.1.8
窗口类 wechat.wechat
依赖 wmctrlxpropgsettings

二、实现效果

一个键覆盖三种状态:

1
2
3
聚焦中        -> 最小化
最小化/在后台 -> 恢复并聚焦
没有运行 -> 启动

GNOME 的自定义快捷键最终只是执行一条命令,所以整个逻辑收敛成两个可执行文件,不依赖任何交互式 shell 环境。

三、安装依赖

1
sudo apt install wmctrl x11-utils   # x11-utils 提供 xprop

如果机器上 sudo 需要密码、xdotool 又不方便装,也没关系——本方案不用它。

四、最小化辅助工具 x-iconify

微信这种窗口用 wmctrl -b add,hidden 是收不起来的(原因见文末补充),所以我们提供一个小工具,向窗管发标准的 WM_CHANGE_STATE 消息来真正最小化。

新建 ~/.local/bin/x-iconify,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#!/usr/bin/env python3
"""Minimize (iconify) an X11 window by id.

Sends the WM_CHANGE_STATE client message to the root window -- the same
thing XIconifyWindow does internally -- which mutter honors reliably.
"""
import ctypes
import sys
from ctypes import Structure, c_char_p, c_int, c_long, c_ulong, c_void_p

CLIENT_MESSAGE = 33
ICONIC_STATE = 3
SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
SUBSTRUCTURE_REDIRECT_MASK = 1 << 20


class XClientMessageEvent(Structure):
_fields_ = [
("type", c_int), ("serial", c_ulong), ("send_event", c_int),
("display", c_void_p), ("window", c_ulong), ("message_type", c_ulong),
("format", c_int), ("data", c_long * 5),
]


class XEvent(ctypes.Union):
_fields_ = [("type", c_int), ("xclient", XClientMessageEvent),
("pad", c_long * 24)]


def main():
if len(sys.argv) != 2:
sys.exit("usage: x-iconify <window-id-hex>")

x11 = ctypes.CDLL("libX11.so.6")
x11.XOpenDisplay.restype = c_void_p
x11.XOpenDisplay.argtypes = [c_char_p]
x11.XInternAtom.restype = c_ulong
x11.XInternAtom.argtypes = [c_void_p, c_char_p, c_int]
x11.XDefaultRootWindow.restype = c_ulong
x11.XDefaultRootWindow.argtypes = [c_void_p]
x11.XSendEvent.argtypes = [c_void_p, c_ulong, c_int, c_long, c_void_p]
x11.XFlush.argtypes = [c_void_p]

dpy = x11.XOpenDisplay(None)
if not dpy:
sys.exit("cannot open X display")

ev = XEvent()
ev.type = CLIENT_MESSAGE
ev.xclient.type = CLIENT_MESSAGE
ev.xclient.window = int(sys.argv[1], 16)
ev.xclient.message_type = x11.XInternAtom(dpy, b"WM_CHANGE_STATE", False)
ev.xclient.format = 32
ev.xclient.data[0] = ICONIC_STATE

x11.XSendEvent(dpy, x11.XDefaultRootWindow(dpy), False,
SUBSTRUCTURE_NOTIFY_MASK | SUBSTRUCTURE_REDIRECT_MASK,
ctypes.byref(ev))
x11.XFlush(dpy)


if __name__ == "__main__":
main()

要点:

  • XEvent 按 64 位联合声明(pad = c_long * 24),避免 XSendEvent 读取事件结构时越界。
  • 工具只负责最小化单类窗口,逻辑简单、可单独用 x-iconify 0x<窗口id> 测试。

五、主脚本 wechat-toggle

新建 ~/.local/bin/wechat-toggle,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env bash
# Toggle WeChat with one key (bound to Alt+X):
# focused -> minimize
# minimized/behind -> restore + focus
# not running -> launch
set -uo pipefail

LAUNCH_CMD='/usr/bin/wechat'
WM_CLASS='wechat.wechat'

# gnome-settings-daemon launches keybindings with a bare PATH that excludes
# ~/.local/bin, so resolve the helper relative to this script.
ICONIFY="$(dirname "$(readlink -f "$0")")/x-iconify"

win=$(wmctrl -lx 2>/dev/null | awk -v c="$WM_CLASS" '$3 == c {print $1; exit}')

if [[ -z "$win" ]]; then
setsid "$LAUNCH_CMD" >/dev/null 2>&1 &
exit 0
fi

active_raw=$(xprop -root _NET_ACTIVE_WINDOW 2>/dev/null | grep -oE '0x[0-9a-fA-F]+' | head -1)
active_dec=$(printf '%d' "${active_raw:-0x0}" 2>/dev/null || echo 0)
win_dec=$(printf '%d' "$win")

if [[ "$win_dec" -eq "$active_dec" ]]; then
exec "$ICONIFY" "$win"
fi

wmctrl -i -a "$win"

# mutter sometimes drops the focus request when the window was iconified.
# Re-assert once if focus did not land.
sleep 0.25
now=$(xprop -root _NET_ACTIVE_WINDOW 2>/dev/null | grep -oE '0x[0-9a-fA-F]+' | head -1)
if [[ "$(printf '%d' "${now:-0x0}")" -ne "$win_dec" ]]; then
wmctrl -i -a "$win"
fi

逻辑很直白:

  1. 没找到窗口 → 启动微信。
  2. 微信在前台(窗口 id 与 _NET_ACTIVE_WINDOW 相同)→ 最小化。
  3. 否则 → 调出并聚焦(聚焦没落上就补发一次,保证按键交替稳定)。

两个 id 统一转十进制比较,避免字符串格式给自己挖坑。

六、绑定到 Alt+X

1
chmod +x ~/.local/bin/x-iconify ~/.local/bin/wechat-toggle

在 GNOME 里加一条自定义快捷键,指向主脚本:

1
2
3
4
5
6
7
8
9
10
BASE=/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings
P="org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:$BASE/custom1/"

# 注意:先读回已有的 custom0 一起写回,别覆盖
gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings \
"['$BASE/custom0/', '$BASE/custom1/']"

gsettings set "$P" name '切换微信'
gsettings set "$P" command "$HOME/.local/bin/wechat-toggle"
gsettings set "$P" binding '<Alt>x'

gsettings 立即生效,不需要注销或重启。

绑定前可以确认一下目标组合键没被占用:

1
2
3
4
for s in org.gnome.desktop.wm.keybindings org.gnome.shell.keybindings \
org.gnome.settings-daemon.plugins.media-keys org.gnome.mutter.keybindings; do
gsettings list-recursively $s 2>/dev/null | grep -i "alt>x"
done

匹配到的 <Alt>XF86AudioMute 之类是多媒体键,不是字母 X,不构成冲突。

七、验证

  1. 终端里先手动跑一遍主脚本,确认三种分支行为正确。
  2. 再用 env -i DISPLAY="$DISPLAY" XAUTHORITY="$HOME/.Xauthority" HOME="$HOME" PATH=/usr/bin:/bin ~/.local/bin/wechat-toggle 模拟快捷键的真实环境验证。
  3. 最后再绑快捷键、连按 Alt+X 交替验证。

多轮循环测试都稳定交替、窗口 id 与进程 PID 稳定后,就可以正常使用了。


附:为什么这么写——踩坑与原理补充

这套”标准答案”背后有几何深水区,遇到异常时可对照排查。

1. 为什么不用 wmctrl -b add,hidden

EWMH 规范明确 _NET_WM_STATE_HIDDEN窗管单向写、客户端只读的状态位。客户端用 wmctrl -b add,hidden 置位,mutter 直接丢弃,而且按协议它没有义务反馈失败——所以命令返回 0、窗口纹丝不动。

2. 为什么不用”关闭到托盘”来隐藏

微信收进托盘时会销毁 X 窗口对象。此时再执行 /usr/bin/wechat 只会撞上单实例检测直接退出,窗口回不来——隐藏不可逆。而最小化(iconify)保留窗口对象、永远可恢复,是更稳妥的”隐藏”语义。顺带的好处:最小化的窗口仍出现在 wmctrl -l 里,窗口发现逻辑只要一行 awk。

3. 为什么不用 XIconifyWindow(Python ctypes 版)

XIconifyWindow 本身没问题,但在这台机器上用 ctypes 直接调 libX11 时触发段错误(SIGSEGV, EXIT=139)。与其调试一个封装函数的绑定,不如把它内部那条 WM_CHANGE_STATE 客户端消息直接构造出来——字段完全可控,没有黑盒,也正是本方案 x-iconify 的原理。

4. 窗口 id 有两种格式

wmctrl -lx 把 id 补零到 8 位(0x00200026),xprop 不补零(0x200026)。字符串比较永远不相等,会导致”只能调出、从不隐藏”这种坏掉一半的假象。修法:统一转十进制比较。

5. 焦点竞态

从最小化恢复时,mutter 有时会丢弃焦点请求(防抢焦点),表现为窗口出现但焦点在别处,下一次按键误判为”未聚焦”、再次激活而非隐藏。处理:激活后短暂等待并校验,没生效就补发一次(主脚本里那 0.25s + 二次 wmctrl -a)。改用 iconify 后竞态已大幅缓解,补发逻辑作为兜底。

6. 快捷键环境 PATH 很精简

gnome-settings-daemon 执行快捷键命令用的 PATH 不包含 ~/.local/bin。主脚本据此用 $(dirname "$(readlink -f "$0")") 解析同目录的 x-iconify,并用 env -i 在绑定前复现验证。

7. 写 gsettings 数组先把原有项读回来

custom-keybindings 是数组,直接覆盖会静默清掉之前配的快捷键。正确做法是保留已有项再追加。

快速排查表

症状 成因 处理
命令返回 0 但窗口不动 置位了客户端只读状态位 改用 x-iconifyWM_CHANGE_STATE
窗口消失后按 Alt+X 起不来 关闭到托盘销毁了窗口对象 用最小化替代关闭
只能调出、从不隐藏 wmctrl/xprop 的 id 格式不一致 printf '%d' 转十进制比较
窗口出现但焦点在别处、按键失交替 焦点竞态 激活后校验,未生效补发一次
终端能跑、绑上快捷键无反应 快捷键 PATH 不含 ~/.local/bin $0 解析同目录辅助工具
之前配的快捷键突然失效 覆盖了 gsettings 数组 读回原数组后追加写入

注:文中窗口 id 为具体环境下的实际值,复现时以本机 wmctrl -lx 输出为准。

  • 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!
  • © 2019-2026 guoben

微信