
在写这篇文章之前先说一个我自己的真实经历。早些年我在维护一台跑着老旧图形应用的工作站时隔三差五就要去查某个软件窗口为什么自动挂掉、又为什么在屏幕上留下一个怎么点都点不掉的黑块。后来我改用 X11 事件监听的方式把所有窗口的创建、关闭、改名、移动全部记到日志里再配合自动重启脚本整个运维负担一下子降了下来。如果你也在 Linux 桌面环境下做过自动化脚本、窗口管理器扩展或者写过截图/录屏工具你迟早会遇到一个问题怎么在程序里感知某个窗口从无到有、从显示到消失、从 A 位置挪到 B 位置。这就是标题里说的“使用 X11 监听窗口”。这篇文章会从命令行工具讲到 C 语言编程把我实际用到的方案和踩过的坑全部摊开适合想快速解决问题、又愿意理解底层原理的人。1. 监听窗口到底在监听什么三个典型场景窗口监听这个名字听起来很笼统但落在实际需求里通常逃不出下面三种情况。搞清楚你是哪一种才能选对工具和接口不然很容易做了一堆无用功。1.1 场景一盯着窗口的“生死存亡”这种场景最常见。比如某些工业软件、老旧的 Java 应用、或者跑在 Wine 里的 Windows 程序经常会不明原因地退出、崩溃或者假死。你想在它挂掉之后自动把它拉起来或者在它新建了某个子窗口时立刻做对应的操作比如点确认按钮、填表单、切换输入法。这需要监听的核心事件是窗口的创建CreateNotify窗口的销毁DestroyNotify窗口首次被映射到屏幕上MapNotify窗口从屏幕取消映射UnmapNotifyX11 里“创建”和“显示”是两码事。程序调用XCreateWindow()只是创建了一个不可见的窗口对象之后必须调用XMapWindow()才会真正显示出来。很多新手监听窗口时只看CreateNotify结果发现窗口还没出现、属性还没就绪就错过了最佳操作时机。正确的做法是优先监听MapNotify对绝大多数应用而言“映射”才是肉眼可见的时刻。1.2 场景二追踪活动窗口的动态变化第二个高频需求是知道用户当前正在操作哪个窗口。典型应用包括时间记录工具想统计“刚才在写代码现在切到浏览器了”录屏软件想自动跟随当前窗口进行区域录制输入法管理想在切换到终端时自动切换中英文状态这类需求的核心是窗口管理器WM维护的“焦点窗口”InputFocus。X11 本身支持FocusIn/FocusOut事件但很多现代桌面环境GNOME、KDE不走老式焦点模型而是用 EWMH 规范里的_NET_ACTIVE_WINDOW属性来指示当前活动窗口。实际操作里最稳定的做法不是监听键盘焦点事件而是轮询或者监听根窗口Root Window的PropertyNotify事件关注_NET_ACTIVE_WINDOW这个属性变化再根据新的窗口 ID 去查询窗口名称和类名。这样做的好处是兼容性极强不管是点击任务栏切换窗口、按 AltTab还是由程序自己激活某个窗口都能正确捕获。1.3 场景三感知窗口状态与位置的增量变化第三个场景是持续监控窗口的位置、大小、标题、图标等“增量变化”。比如自动整理工作区布局某个窗口被拖到屏幕左半边时自动把它吸附到预设网格屏保和锁屏触发检测根据终端窗口标题的变化比如vim改了文件名自动更新外部状态栏这种场景需要监听的事件就复杂了ConfigureNotify表示窗口被移动/调整大小PropertyNotify表示某些属性变了ReparentNotify则表示窗口被窗口管理器重新挂载到了装饰框下。还有一类特殊变化——窗口设置了圆角、异形区域、遮罩Shape 扩展时标准事件是收不到的必须额外通过 XShape 扩展的ShapeNotify事件来监听。这个坑我后面会具体讲。2. 摸清底层机制窗口树、事件掩码与属性的优先级很多人用 X11 写工具时第一感觉是懵因为它的接口看着很老派函数名又长。但 X11 的模型其实非常清晰只要抓住“窗口树 事件掩码 属性通知”这三点大部分问题都能解决。2.1 先把窗口树的关系捋直X11 里所有窗口是一棵层级树。树的根是“根窗口”Root Window它铺满整个屏幕是所有窗口的祖先。普通应用程序创建的窗口叫“顶层窗口”Top-Level Window它通常是根窗口的直接子窗口。但注意在带窗口管理器的桌面环境里顶层窗口会被 WM 重新“挂”到一个装饰窗口上这个过程叫 Reparent重新指定父窗口。这解释了一个常见疑惑为什么你用xwininfo看到的窗口 ID 和xdotool search查到的 ID一会儿是同一个一会儿又不是同一个。监听时你首先要选对“观察点”。观察点放在目标窗口自身只能收到它自己的事件观察点放在它的父窗口上并且打开SubstructureNotifyMask掩码才能收到它的子窗口的创建、销毁、映射等事件。所以如果你想监听“整个桌面上有没有新窗口出现”正确的姿势是在根窗口上监听SubstructureNotifyMask而不是自己枚举所有窗口再逐个挂监听。2.2 事件掩码监听的前提是把“兴趣”告诉服务器X11 的事件分发机制很像订阅-发布模式Client 必须先调用函数把自己感兴趣的事件类别注册给 X Server之后 Server 才会把相关事件推过来。这个注册动作用的就是XSelectInput()或者XChangeWindowAttributes()里的事件掩码参数。我用过的最常用掩码组合如下掩码作用典型用途StructureNotifyMask监听窗口自身结构变化移动、缩放、映射、销毁监控某个窗口的尺寸/位置变化SubstructureNotifyMask监听直接子窗口的结构变化包括子窗口创建销毁在根窗口上监听所有新窗口出现PropertyChangeMask监听属性变化标题、类名、WM_STATE 等捕捉窗口改名、_NET_ACTIVE_WINDOW切换FocusChangeMask监听焦点进入/离开老式焦点追踪兼容性有限ButtonPressMask/KeyPressMask鼠标键盘事件全局热键需要 Grab 配合有一点必须强调XSelectInput()是覆盖式设置不是叠加式。如果你先监听StructureNotifyMask后来又调用XSelectInput()只传PropertyChangeMask那么之前的结构事件就收不到了。如果想同时监听多种事件必须在一次调用里用按位或把它们组合起来。这是初学者最容易踩的坑我见过太多人在循环里反复调用XSelectInput()结果前一秒还能收到事件后一秒就悄悄“失联”了。2.3 属性通知窗口的“名片”与“状态栏”窗口除了几何位置和父子关系之外还有大量的“属性”Property。这些属性是一份挂在窗口上的键值对数据由不同程序各自写入。例如WM_NAME老的窗口标题编码通常是 locale 相关_NET_WM_NAMEUTF-8 编码的窗口标题现代应用都写这个WM_CLASS窗口的实例名和类名比如xfce4-terminal,Xfce4-terminal_NET_WM_STATE窗口状态标志全屏、最大化、置顶等_NET_ACTIVE_WINDOW根窗口上的属性表示当前活动窗口_NET_CLIENT_LIST根窗口上的属性列出所有受 WM 管理的窗口为什么要特别强调属性因为很多窗口的“出现”和“消失”并不是它被创建/销毁了而是它的_NET_WM_STATE被改了。比如说最小化到任务栏窗口其实还在只是被 Unmap 了真要看“用户能不能看到它”得同时参考MapNotify和_NET_WM_STATE里的_NET_WM_STATE_HIDDEN标志。监听属性变化的方法是先XGetWindowProperty()读一次当前值然后监听PropertyNotify事件每次收到事件时从XPropertyEvent结构体中读出atom字段跟目标属性对应的 Atom 做比较。注意PropertyNotify只在属性改变时触发不会在窗口创建时主动把现有属性全推给你所以初始化时必须手动读一次基线值后面再靠增量事件更新。这个“先全量、后增量”的模式是整个监听逻辑的核心。3. 命令行快速上手用 xdotool 组合拳实现第一版监听如果你只是想在脚本里做点快速验证不急着写 C 代码那我强烈建议先用xdotool和xprop把整套逻辑跑通。它们的原理跟编程接口完全一致但能省掉大量编译调试时间。3.1 先安装工具集在 Debian/Ubuntu 系直接sudo apt install xdotool x11-utilsArch 系用sudo pacman -S xdotool xorg-xprop。x11-utils里包含xprop、xwininfo、xev这几个工具都是调试窗口监听不可或缺的。安装完先确认 DISPLAY 环境变量是否正确。如果你是 SSH 到一台机器上跑 X11 转发的任务还要检查DISPLAY是不是localhost:10.0之类并且本地有没有跑 X Server。窗口监听工具一旦连不上 X Server通常只会报一句Unable to open display特别容易让人误以为代码写错了。3.2 阻塞式搜索等窗口出现的第一道关卡xdotool 里最核心的监听命令是带--sync的searchxdotool search --sync --name MyApp这个命令会一直阻塞直到某个窗口的标题匹配MyApp然后把它的窗口 ID 打印出来。听起来简单但有几个细节值得说清楚。第一--name匹配的是窗口标题但很多程序的标题是动态变化的。比如浏览器每个标签页都会改标题你按初始标题去搜后面可能就匹配不上了。如果目标是某种固定类型的窗口更稳的办法是用--class或--classname匹配WM_CLASS属性。例如xdotool search --sync --class xfce4-terminal第二search默认会搜索整个窗口树可能匹配到非顶层窗口。为了让结果更干净通常要组合--onlyvisible过滤掉不可见窗口再用--pid精确锁定某个进程创建的窗口需要目标程序支持。等窗口出现之后想持续监听它的消失可以参考我在 §4 里讲的编程方案。命令行层面你可以写一个循环轮询while xdotool search --name MyApp /dev/null 21; do sleep 1 done echo MyApp has closed这个写法效率不高但胜在简单直白适合临时脚本。生产环境里我更推荐用下面的属性监听法。3.3 xprop -spy十秒搭一个标题变化监听器xprop有一个容易被忽略的选项-spy它的作用不是打印一次属性就退出而是持续监听指定属性的变化每变一次就打印一行。比如xprop -spy -id 0x04000007 _NET_WM_NAME输出类似_NET_WM_NAME(UTF8_STRING) Terminal - 用户localhost: ~ _NET_WM_NAME(UTF8_STRING) Terminal - 用户localhost: /tmp这正好暴露了PropertyNotify事件的本质。用-spy调试时如果发现某些字段变了一次就不再更新那多半是目标应用没有刷新对应属性。这时候你就知道该换一个属性监听了。这里再教大家一个实用技巧如果不知道窗口 ID可以先让 xdotool 查WID$(xdotool search --name Terminal | head -1) xprop -spy -id $WID _NET_WM_NAME WM_CLASS多个属性可以写在同一命令行里同时监听这在排查窗口状态异常时特别有用。比如我经常同时盯_NET_WM_STATE和_NET_ACTIVE_WINDOW就能很清楚地看出窗口从正常切换到最小化/最大化时到底发生了什么。3.4 xev用“事件翻译器”验证掩码语义xev是另一个神级调试工具。启动后它会打开一个小窗口然后把所有发送到该窗口的 X 事件翻译成人类可读的文本。比如鼠标一移动立刻打印MotionNotify窗口一拖拽立刻打印ConfigureNotify。我通常在两种情况下用xev验证我自己代码里的事件掩码设置对不对先用xev复现预期事件再对照我程序的输出。学习某个不太熟悉的掩码。比如想看ReparentNotify到底长什么样就让xev窗口开着再用别的终端执行xdotool windowreparent事件一清二楚。xev的局限在于它只能监听自己那个小测试窗口没法直接挂到其他进程的窗口上。所以你用xev验证的是掩码语义而不是真实业务的完整逻辑。业务逻辑还是得靠编程接口。4. Xlib 编程实战写一个自己的窗口监听器命令行工具够用但如果你要同时监听几十个窗口、处理重入、过滤冗余事件那还是老老实实用 Xlib 或 XCB 写个小程序。这里我以 C Xlib 为例给出一套我验证过的监听骨架。这套代码可以迅速改造成守护进程、系统服务或者嵌入到现有工具链里。4.1 工程准备与基础循环你需要链接libX11编译时加-lX11如果用了 XShape 扩展还要加-lXext。Xlib 编程的头文件是X11/Xlib.h、X11/Xutil.h、X11/Xatom.h这三个基本上每次都要 include。最外层的主循环是经典的事件泵Display *dpy XOpenDisplay(NULL); if (!dpy) { fprintf(stderr, 无法打开 X Display\n); return 1; } Window root DefaultRootWindow(dpy); int screen DefaultScreen(dpy); XEvent ev; while (1) { XNextEvent(dpy, ev); handle_event(dpy, ev); }这个循环的要点是Xlib 的事件队列是线程安全的但如果你启动多线程分别处理不同窗口的事件事情会变得很复杂。我的经验是监听器保持“单线程事件泵 线程池任务”的结构不要让多个线程同时调XNextEvent()抢事件那样会出现不可预期的乱序。4.2 一次设置监听整个桌面根窗口监听模式我们先把目光放在“监听桌面上所有新窗口”的需求上。实现方式是在根窗口上注册SubstructureNotifyMask | PropertyChangeMaskXSelectInput(dpy, root, SubstructureNotifyMask | PropertyChangeMask);SubstructureNotifyMask保证了根窗口的所有子窗口一旦创建、映射、销毁、重命名或移动你都能收到对应的XEvent。它不止包含CreateNotify还包括MapNotify、DestroyNotify、ConfigureNotify、ReparentNotify。这里面的逻辑是根窗口的所有子窗口就是桌面上的顶层窗口以及少数特殊窗口只要它们发生变化事件都会经过父窗口这条链路。事件循环里的处理函数可以这样写static void handle_event(Display *dpy, XEvent *ev) { switch (ev-type) { case CreateNotify: { XCreateWindowEvent *e ev-xcreatewindow; printf([create] wid0x%lx parent0x%lx\n, e-window, e-parent); // 对新窗口动态注册属性监听 XSelectInput(dpy, e-window, StructureNotifyMask | PropertyChangeMask); break; } case DestroyNotify: { XDestroyWindowEvent *e ev-xdestroywindow; printf([destroy] wid0x%lx\n, e-window); break; } case MapNotify: { XMapEvent *e ev-xmap; printf([map] wid0x%lx\n, e-window); break; } case PropertyNotify: { XPropertyEvent *e ev-xproperty; printf([property] wid0x%lx atom%s\n, e-window, get_atom_name(dpy, e-atom)); break; } } }请注意CreateNotify分支里的那行XSelectInput。因为新窗口创建之后SubstructureNotifyMask只负责让你“知道它被创建了”并不会替你把窗口自身的事件也推给你。要想知道这个新窗口后续的标题变化、移动缩放、销毁必须对它单独注册事件。此时如果漏了PropertyChangeMask后面就永远收不到_NET_WM_NAME的变化。这几乎是所有人都会犯的第二个坑。4.3 监听某个已知窗口按窗口 ID 绑定事件另一类需求是“我已经知道窗口 ID想监听它一个人的变化”。这就简单得多Window target 0x04000007; // 例如来自 xdotool search XSelectInput(dpy, target, StructureNotifyMask | PropertyChangeMask); while (1) { XNextEvent(dpy, ev); if (ev.xany.window target) { // 处理目标窗口事件 } }这里有件容易被忽视的事窗口被 WM reparent 以后你手里拿到的“逻辑窗口 ID”和 X Server 里的“物理窗口 ID”可能不是一个。用xdotool search拿到的一般是客户程序的逻辑 ID当焦点切换、窗口装饰变化时事件里的窗口字段有时会变成装饰窗口 ID。所以程序里判断目标窗口时不要只比对event.window还要比对event.xconfigure.event/event.xproperty.window并用XGetWindowAttributes()去验证窗口是否仍然有效。不过直接绑定单个窗口的问题是如果目标窗口在监听过程中被销毁重建很多 GTK 应用在重启 UI 时会先 Destroy 再 Create你绑定在旧 ID 上的监听就失效了。面对这种应用我会采用“按 WM_CLASS 或 PID 重新匹配”的策略先监听根窗口的CreateNotify发现新窗口 ID 后查询它的WM_CLASS如果匹配目标程序类名就自动重新绑定监听。这个模式只需要十几行代码却能解决大量实际场景下监听“掉线”的问题。4.4 属性读取从事件中抽丝剥茧光收到PropertyNotify还不够你通常需要读取属性值。比如收到_NET_ACTIVE_WINDOW变化后要读取根窗口上该属性的新值。读取属性的标准姿势是XGetWindowProperty()Atom active_win XInternAtom(dpy, _NET_ACTIVE_WINDOW, False); Atom xa_window XInternAtom(dpy, WINDOW, False); unsigned char *data NULL; Atom actual_type; int actual_format; unsigned long nitems, bytes_after; XGetWindowProperty(dpy, root, active_win, 0, 1, False, xa_window, actual_type, actual_format, nitems, bytes_after, data); if (actual_type xa_window nitems 1) { Window active *(Window*)data; printf(当前活动窗口: 0x%lx\n, active); } XFree(data);这里最需要注意的是format参数。_NET_ACTIVE_WINDOW的类型是WINDOWformat 是 32所以读到的是 4 字节的WindowID。但很多文本属性如_NET_WM_NAME的 format 是 8读取的数据是指向 UTF-8 字符串的字节流。如果你用错了读取方式拿到的数据会是一堆乱码。一个简单的判断原则是format 等于 32 的数据用(long*)访问format 等于 8 的数据当字符串处理。属性原子Atom也是一个很容易犯迷糊的概念。XInternAtom()是大小写敏感、全字符串精确匹配的。有些人记不住_NET_WM_NAME的写法写成了_NET_WM_NAME少个下划线或者多了空格调试半天发现永远匹配不上。我的建议是把所有常用 Atom 在程序启动时一次性XInternAtom成全局变量后续比较全部用全局 Atom 常量不要在收到事件后反复 Intern那样既慢又容易出错。4.5 XShape监听圆角窗口的“隐形变动”前面提过现代桌面有很多圆角窗口、不规则弹窗它们的形状由 XShape 扩展管理。普通ConfigureNotify不会告诉你“窗口的显示区域变了”实际表现就是窗口明明还挂在屏幕上但内容被裁掉了一圈或者多出一块透明区。如果你在做截图或者区域匹配这时候就会莫名产生 1~2 像素的偏差。启用 Shape 事件只需要两行#include X11/extensions/shape.h #include X11/extensions/Xext.h int shape_event_base, shape_error_base; XShapeQueryExtension(dpy, shape_event_base, shape_error_base); XShapeSelectInput(dpy, target, ShapeNotifyMask);然后在事件循环里判断if (ev-type shape_event_base ShapeNotify) { XShapeEvent *se (XShapeEvent*)ev; printf(窗口 0x%lx 形状变化: kind%d\n, se-window, se-kind); }注意XShapeEvent里的kind字段有ShapeBounding、ShapeClip两种。绝大多数应用改的是ShapeBounding也就是窗口实际的可点击/可绘制区域。这里我没有用这个做很复杂的事情但在自动吸附式布局工具里如果不处理 Shape 事件窗口明明已经缩成一条细线你的布局算法还以为它占着半屏空间。这种“隐形事件”属于常规文档里不会教你的经验建议做成日志记录遇到异常时直接翻日志比去猜原因快得多。5. 真实使用场景与疑难杂症窗口监听的内容到上面其实已经完整了但我知道大家更关心的是“代码跑起来之后会遇到哪些诡异问题”。这一节不按教程顺序讲只聊我在实际项目中踩过的和解决过的坑。5.1 场景复盘自动整理工作区布局我之前做过一个小工具当终端窗口被拖到屏幕左半区时自动把它吸附到预设网格当某个全屏游戏启动时其他窗口自动最小化。它的核心逻辑就两件事监听根窗口上的_NET_ACTIVE_WINDOW和_NET_CLIENT_LIST属性变化对每个变更窗口查询WM_CLASS和几何位置再执行XMoveResizeWindow()或XIconifyWindow()实现时最让我头疼的不是监听本身而是“谁来触发动作”的时序问题。窗口被拖拽时会连续产生多个ConfigureNotify每个事件之间只隔几毫秒。如果你不加节流throttle工具会把同一个窗口连续移动好几次屏幕上的表现就是窗口疯狂抖动。我的处理是 100ms 的 debounce 窗口收到ConfigureNotify后启动一个计时器100ms 内不再处理同一窗口的同类事件等事件平息后再统一落一次位置。这一招看起来简单但真的能解决大量生产环境下的卡顿问题。5.2 疑难杂症一为什么监听 30 分钟后事件突然“失联”有段时间我的监听程序总是运行半小时后就没输出了但窗口还在正常变化。排查了很久最终发现是我的事件循环在长时间空闲后被 X Server 断开了连接。原因是我在某个分支里调用了一个阻塞式函数导致 Xlib 的输入缓冲区长时间没有被读取X Server 以为客户端挂死直接把它踢下线了。解决办法是两条事件循环里必须持续调用XFlush()或者保证XNextEvent()一直在读取主循环里禁止出现长时间阻塞的操作如果需要等待外部资源使用非阻塞版本的检测另外X11 连接是有默认超时的XOpenDisplay(nullptr)默认连接 600 秒后空闲不活跃会被服务端断开。应对办法是在连接建立后向服务器发送XSync的 keep-alive 心跳或者在断线检测到BadIDChoice/连接错误时自动重连。后者的代码量略大但比心跳省心。5.3 疑难杂症二收到ConfigureNotify却读不到新位置这个坑我见的频率极高。你明明收到了ConfigureNotify但调用XGetWindowAttributes()读到的x、y仍旧是旧坐标。原因是ConfigureNotify只表示窗口管理器发起了配置请求真正生效还需要等待 X Server 完成合成和重绘。也就是说事件的时间戳和窗口属性的最终值之间有一个短暂窗口期。我的经验是收到配置事件后不要立刻读取属性而是像上面说的那样做 30~50ms 的延迟再读。如果你要追求绝对精确可以监听窗口的VisibilityNotify等窗口真正可见时再读位置但那会牺牲响应速度。另外一个容易忽略的是坐标系。ConfigureNotify里的x、y是相对于父窗口通常是装饰窗口的坐标不是相对于屏幕的坐标。如果你要把它换算为全局屏幕坐标必须沿窗口树向上累加偏移或者直接用XTranslateCoordinates()来换算。这个细节我最初不知道结果写出来的窗口定位逻辑在 GNOME 下全偏了几十像素排查了半天最后用xwininfo -id对照才发现是坐标基准的问题。5.4 疑难杂症三窗口 ID 不断变化绑定监听总掉线现代应用经常“页面即窗口”每次打开新标签页都会销毁旧窗口、创建新窗口。觠个窗口 ID 可能几分钟就换一次。如果你在启动时固定绑定一个窗口 ID那过不了多久就会失效。我后来总结出一套稳定的“目标识别链”监听根窗口SubstructureNotifyMask捕获所有新窗口新窗口出现后读取它的WM_CLASS和_NET_WM_PID如果WM_CLASS匹配目标程序再对比_NET_WM_PID是否属于目标进程两层都匹配才对这个窗口绑定具体事件监听这套组合拳比单纯用标题或者单纯用类名要可靠得多。为什么因为很多程序的WM_CLASS会随着主题样式变化而_NET_WM_PID有时候读不到或者读出 0。两个条件取交集误匹配率会大幅下降。5.5 别忘了 Wayland 的大背景最后浇一盆冷水以上所有 X11 监听技术都只在 X11 会话里有效。如果你登录的是 Wayland 会话GNOME 默认就是X11 窗口监听只能通过 XWayland 兼容层看到那些“兼容 X11 的应用”而原生 Wayland 应用比如 GNOME 自带的软件对 X11 客户端来说几乎是一堵黑墙。如果你的目标桌面是 Wayland可行的替代方案是通过org.freedesktop.portal.OpenURI这类 D-Bus portal 获取有限的信息使用 GNOME 特有扩展 API或者西塞罗KWin的脚本接口退回到 XWayland只监听能看到的 X11 窗口我的个人取舍是生产环境里如果对窗口监听要求苛刻我会让系统直接跑 X11 会话或者至少在 XWayland 应用上做局部监听。Wayland 的原生监听生态还不够成熟没必要为了追新而牺牲稳定性。6. 最后一小段实践经验说了这么多核心就一句话X11 窗口监听不复杂但它的成败藏在事件掩码、坐标基准、属性时机和窗口 ID 生命周期这些细节里。我建议你从命令行工具开始验证再逐步迁到 Xlib/XCB 编程每加一个事件类型就先用xev做对照实验再写进正式代码。真正跑起来之后我强烈建议给监听程序加一个简单的日志模块把每条事件的时间、窗口 ID、事件类型、关键属性都记录下来。别嫌日志臭窗口监听这种系统级调试最怕“复现不了问题”有了日志下一次排查能省一半时间。如果你只是偶尔需要监听某个窗口的生死xdotool search --sync加一个小循环足够应付如果你要做的工具是自动化、录屏、数字签名联动这类长期运行的直接上 C 或 Rust 的 XCB 方案更稳。我自己的项目现在还在跑着基于这套思路写的小守护进程平时几乎不占用 CPU但它已经在后台帮我处理了无数次应用崩溃后的自动拉起和窗口布局恢复。X11 就是这么一套又老又稳的协议只要你愿意花一下午把它的窗口树和事件模型摸透以后无论写什么桌面自动化都会顺手很多。