在 MacOS 中置顶浮动窗口
缘起
MacOS 系统中,部分系统原生应用可以支持置顶窗口功能 (官方名称为“浮动在最前面”),例如Stickies
Stickies
。部分非原生应用也可以实现浮动功能,例如Typora
Typora
。
将部分窗口置顶的功能在部分场景下非常有用,例如阅读文献时可以将笔记窗口置顶,方便随时记录笔记,即使切换到其他应用也不会遮挡笔记窗口,干扰笔记记录流程。
这个想法起源于大二学年量子力学数学方法结课报告的准备过程中,我就是使用类似的工作流 (但是当时没有置顶功能) 来阅读 Kontsevich 的 Deformation Quantization of Poisson Manifolds 论文并记录笔记的。
然而,对于我常用的笔记工具 kitty
kitty
+neovim
neovim
,并没有原生的置顶功能,而例如Afloat
Afloat
等第三方工具也无法在 Apple Silicon 芯片的 MacOS 上使用,并且长期未更新。因此,在很长一段时间内,我只能通过手动调整窗口位置来实现类似的功能,效率较低。
初步解决
最近因为需要频繁地在阅读文献和记录笔记之间切换,因此我决定尝试寻找一种可行的解决方案。首先出局的是hammerspoon
hammerspoon
,因为他压根就没有这个功能。yabai
yabai
(我是它的长期用户) 在尝试一阵子之后也放弃了,主要是因为在禁用 nvram
nvram
保护的情况下,我的电脑桌面会直接崩溃¹。
在阅读了kitty 仓库之后,我发现 kitty 的窗口是通过 Cocoa API 创建的,因此理论上可以通过 Objective-C 代码注入的方式来修改窗口属性,从而实现置顶功能。Stack Exchange 上也有相关的讨论,主要是基于lldb
lldb
调整窗口的level
level
属性来实现置顶功能。
lldb -p pid -o 'expr for (NSWindow *w in (NSArray *)[(NSApplication *)NSApp windows]) { [w setLevel:10]; }' -o 'detach' -o 'quit'
lldb -p pid -o 'expr for (NSWindow *w in (NSArray *)[(NSApplication *)NSApp windows]) { [w setLevel:10]; }' -o 'detach' -o 'quit'
lldb -p pid -o 'expr for (NSWindow *w in (NSArray *)[(NSApplication *)NSApp windows]) { [w setLevel:10]; }' -o 'detach' -o 'quit'
lldb -p pid -o 'expr for (NSWindow *w in (NSArray *)[(NSApplication *)NSApp windows]) { [w setLevel:10]; }' -o 'detach' -o 'quit'
其中pid
pid
是目标进程的进程 ID,level
level
属性的值越大,窗口越靠前。通常NSNormalWindowLevel
NSNormalWindowLevel
的值为0
0
,设置为10
10
可以比较安全地实现置顶功能²。
封装
每一次运行lldb
lldb
都会花费较长的时间。受到Afloat
Afloat
的启发,我认识到可以直接在目标进程 (在这里是 kitty) 中注入代码实现置顶功能,这样只需要在启动 kitty 时注入一次代码,此后通过 WebSocket 向注入的代码通信即可实现对置顶功能的调控。这样还能很方便地实现热更新,并集成到常见的Hammerspoon
Hammerspoon
等工具中,成为工作流的一部分。
由于只需要传递一个参数,WebSocket 的实现非常简单,但由于单次打开 kitty 对应了多个进程 (kitty
kitty
本体,工具集kitten
kitten
以及一些桌面渲染的进程),需要通过锁来保证只有一个进程在监听 WebSocket 套接字,否则会出现端口冲突的问题,进而导致无法正常通信³。
这个代码展示了基本的逻辑,省略了错误处理和信号处理等细节。
static void *socket_server(void *arg) {
// Acquire lock to ensure only one process listens
lock_fd = open(LOCK_PATH, O_CREAT | O_RDWR, 0600);
if (lock_fd < 0)
return NULL;
struct flock fl = {.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0,
.l_pid = getpid()};
if (fcntl(lock_fd, F_SETLK, &fl) < 0)
return NULL;
// Create socket
sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sock_fd < 0)
return NULL;
// Bind address
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
unlink(SOCKET_PATH); // ignore ENOENT
if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
return NULL;
chmod(SOCKET_PATH, 0600);
// Start listening
if (listen(sock_fd, 5) < 0)
return NULL;
// Signal handling omitted
while (1) {
int client = accept(sock_fd, NULL, NULL);
if (client < 0)
continue;
char buf[32] = {0};
ssize_t n = read(client, buf, sizeof(buf) - 1);
if (n > 0) {
int lvl = atoi(buf);
setAllWindowsLevel(lvl);
}
close(client);
}
// Cleanup omitted
return NULL;
}
static void *socket_server(void *arg) {
// Acquire lock to ensure only one process listens
lock_fd = open(LOCK_PATH, O_CREAT | O_RDWR, 0600);
if (lock_fd < 0)
return NULL;
struct flock fl = {.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0,
.l_pid = getpid()};
if (fcntl(lock_fd, F_SETLK, &fl) < 0)
return NULL;
// Create socket
sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sock_fd < 0)
return NULL;
// Bind address
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
unlink(SOCKET_PATH); // ignore ENOENT
if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
return NULL;
chmod(SOCKET_PATH, 0600);
// Start listening
if (listen(sock_fd, 5) < 0)
return NULL;
// Signal handling omitted
while (1) {
int client = accept(sock_fd, NULL, NULL);
if (client < 0)
continue;
char buf[32] = {0};
ssize_t n = read(client, buf, sizeof(buf) - 1);
if (n > 0) {
int lvl = atoi(buf);
setAllWindowsLevel(lvl);
}
close(client);
}
// Cleanup omitted
return NULL;
}
static void *socket_server(void *arg) {
// Acquire lock to ensure only one process listens
lock_fd = open(LOCK_PATH, O_CREAT | O_RDWR, 0600);
if (lock_fd < 0)
return NULL;
struct flock fl = {.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0,
.l_pid = getpid()};
if (fcntl(lock_fd, F_SETLK, &fl) < 0)
return NULL;
// Create socket
sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sock_fd < 0)
return NULL;
// Bind address
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
unlink(SOCKET_PATH); // ignore ENOENT
if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
return NULL;
chmod(SOCKET_PATH, 0600);
// Start listening
if (listen(sock_fd, 5) < 0)
return NULL;
// Signal handling omitted
while (1) {
int client = accept(sock_fd, NULL, NULL);
if (client < 0)
continue;
char buf[32] = {0};
ssize_t n = read(client, buf, sizeof(buf) - 1);
if (n > 0) {
int lvl = atoi(buf);
setAllWindowsLevel(lvl);
}
close(client);
}
// Cleanup omitted
return NULL;
}
static void *socket_server(void *arg) {
// Acquire lock to ensure only one process listens
lock_fd = open(LOCK_PATH, O_CREAT | O_RDWR, 0600);
if (lock_fd < 0)
return NULL;
struct flock fl = {.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0,
.l_pid = getpid()};
if (fcntl(lock_fd, F_SETLK, &fl) < 0)
return NULL;
// Create socket
sock_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sock_fd < 0)
return NULL;
// Bind address
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
unlink(SOCKET_PATH); // ignore ENOENT
if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
return NULL;
chmod(SOCKET_PATH, 0600);
// Start listening
if (listen(sock_fd, 5) < 0)
return NULL;
// Signal handling omitted
while (1) {
int client = accept(sock_fd, NULL, NULL);
if (client < 0)
continue;
char buf[32] = {0};
ssize_t n = read(client, buf, sizeof(buf) - 1);
if (n > 0) {
int lvl = atoi(buf);
setAllWindowsLevel(lvl);
}
close(client);
}
// Cleanup omitted
return NULL;
}
注入的窗口置顶操作则只需要将窗口的level
level
属性设置为指定的值即可,完全类似lldb
lldb
中的操作。
static void setAllWindowsLevel(NSInteger level) {
dispatch_async(dispatch_get_main_queue(), ^{
NSArray *windows = [(NSApplication *)NSApp windows];
for (NSWindow *w in windows) {
[w setLevel:level];
}
});
}
static void setAllWindowsLevel(NSInteger level) {
dispatch_async(dispatch_get_main_queue(), ^{
NSArray *windows = [(NSApplication *)NSApp windows];
for (NSWindow *w in windows) {
[w setLevel:level];
}
});
}
static void setAllWindowsLevel(NSInteger level) {
dispatch_async(dispatch_get_main_queue(), ^{
NSArray *windows = [(NSApplication *)NSApp windows];
for (NSWindow *w in windows) {
[w setLevel:level];
}
});
}
static void setAllWindowsLevel(NSInteger level) {
dispatch_async(dispatch_get_main_queue(), ^{
NSArray *windows = [(NSApplication *)NSApp windows];
for (NSWindow *w in windows) {
[w setLevel:level];
}
});
}
根据代码编译完 dylib
dylib
文件后,只要通过
DYLD_INSERT_LIBRARIES=~/window_level/libwindow_level.dylib /Applications/kitty.app/Contents/MacOS/kitty nvim -c "SideNoteMode"
DYLD_INSERT_LIBRARIES=~/window_level/libwindow_level.dylib /Applications/kitty.app/Contents/MacOS/kitty nvim -c "SideNoteMode"
DYLD_INSERT_LIBRARIES=~/window_level/libwindow_level.dylib /Applications/kitty.app/Contents/MacOS/kitty nvim -c "SideNoteMode"
DYLD_INSERT_LIBRARIES=~/window_level/libwindow_level.dylib /Applications/kitty.app/Contents/MacOS/kitty nvim -c "SideNoteMode"
即可启动一个暴露了 WebSocket 接口的 kitty 窗口,之后通过例如
echo level | ncat -U /tmp/kitty_level.sock
echo level | ncat -U /tmp/kitty_level.sock
echo level | ncat -U /tmp/kitty_level.sock
echo level | ncat -U /tmp/kitty_level.sock
即可改变当前窗口的置顶级别,level
level
为整数,值越大窗口越靠前。将这个命令封装为 Hammerspoon 的快捷键即可实现快速置顶窗口。
总结
这个方案不需要使用 SIP(对于非系统 App),好处是原生感较强,调用的是原生 API,不会出现体验问题。坏处就是相对而言较难操作,可能需要一些基础的编程知识。
在 github 上也存在基于辅助功能实现置顶功能的工具,好处是无需编程,坏处是稳定性不佳、原生感不强、并且 Apple Watch 无法在其使用时解锁,如果不想写代码的朋友可以尝试。
-
这个事情非常吓人,我当时一度以为我的电脑坏了,后来全部重启 SIP 保护后才恢复正常。
-
对于系统程序 (保存在
/System/
/System/
目录) 启用lldb
lldb
需要利用禁用debug
debug
SIP 保护。对于这种情况,我不会介绍具体的实现方案,如果你没有这样基本的信息检索能力,请不要尝试本方案。对于修改kitty
kitty
窗口 level 的工作场景,本方案不需要关闭 SIP -
在Gist中我加入了一些 Debug print,就是因为这个问题。