在 MacOS 中置顶浮动窗口



缘起

MacOS 系统中,部分系统原生应用可以支持置顶窗口功能 (官方名称为“浮动在最前面”),例如StickiesStickies。部分非原生应用也可以实现浮动功能,例如TyporaTypora

将部分窗口置顶的功能在部分场景下非常有用,例如阅读文献时可以将笔记窗口置顶,方便随时记录笔记,即使切换到其他应用也不会遮挡笔记窗口,干扰笔记记录流程。

这个想法起源于大二学年量子力学数学方法结课报告的准备过程中,我就是使用类似的工作流 (但是当时没有置顶功能) 来阅读 Kontsevich 的 Deformation Quantization of Poisson Manifolds 论文并记录笔记的。

然而,对于我常用的笔记工具 kittykitty+neovimneovim,并没有原生的置顶功能,而例如AfloatAfloat等第三方工具也无法在 Apple Silicon 芯片的 MacOS 上使用,并且长期未更新。因此,在很长一段时间内,我只能通过手动调整窗口位置来实现类似的功能,效率较低。

初步解决

最近因为需要频繁地在阅读文献和记录笔记之间切换,因此我决定尝试寻找一种可行的解决方案。首先出局的是hammerspoonhammerspoon,因为他压根就没有这个功能。yabaiyabai (我是它的长期用户) 在尝试一阵子之后也放弃了,主要是因为在禁用 nvramnvram 保护的情况下,我的电脑桌面会直接崩溃¹

在阅读了kitty 仓库之后,我发现 kitty 的窗口是通过 Cocoa API 创建的,因此理论上可以通过 Objective-C 代码注入的方式来修改窗口属性,从而实现置顶功能。Stack Exchange 上也有相关的讨论,主要是基于lldblldb 调整窗口的levellevel属性来实现置顶功能。


            
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'

其中pidpid是目标进程的进程 ID,levellevel属性的值越大,窗口越靠前。通常NSNormalWindowLevelNSNormalWindowLevel的值为00,设置为1010可以比较安全地实现置顶功能²

封装

每一次运行lldblldb 都会花费较长的时间。受到AfloatAfloat的启发,我认识到可以直接在目标进程 (在这里是 kitty) 中注入代码实现置顶功能,这样只需要在启动 kitty 时注入一次代码,此后通过 WebSocket 向注入的代码通信即可实现对置顶功能的调控。这样还能很方便地实现热更新,并集成到常见的HammerspoonHammerspoon等工具中,成为工作流的一部分。

由于只需要传递一个参数,WebSocket 的实现非常简单,但由于单次打开 kitty 对应了多个进程 (kittykitty 本体,工具集kittenkitten 以及一些桌面渲染的进程),需要通过锁来保证只有一个进程在监听 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;

            
}

注入的窗口置顶操作则只需要将窗口的levellevel属性设置为指定的值即可,完全类似lldblldb中的操作。


            
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];

            
    }

            
  });

            
}

根据代码编译完 dylibdylib 文件后,只要通过


            
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

即可改变当前窗口的置顶级别,levellevel为整数,值越大窗口越靠前。将这个命令封装为 Hammerspoon 的快捷键即可实现快速置顶窗口。

总结

这个方案不需要使用 SIP(对于非系统 App),好处是原生感较强,调用的是原生 API,不会出现体验问题。坏处就是相对而言较难操作,可能需要一些基础的编程知识。

在 github 上也存在基于辅助功能实现置顶功能的工具,好处是无需编程,坏处是稳定性不佳、原生感不强、并且 Apple Watch 无法在其使用时解锁,如果不想写代码的朋友可以尝试。

  1. 这个事情非常吓人,我当时一度以为我的电脑坏了,后来全部重启 SIP 保护后才恢复正常。
  2. 对于系统程序 (保存在 /System//System/ 目录) 启用 lldblldb 需要利用禁用 debugdebug SIP 保护。对于这种情况,我不会介绍具体的实现方案,如果你没有这样基本的信息检索能力,请不要尝试本方案对于修改 kittykitty 窗口 level 的工作场景,本方案不需要关闭 SIP
  3. Gist中我加入了一些 Debug print,就是因为这个问题。