在 MacOS 中置顶浮动窗口

缘起

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

image
image

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

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

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

初步解决

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

在阅读了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是目标进程的进程 IDlevellevel属性的值越大窗口越靠前通常NSNormalWindowLevelNSNormalWindowLevel的值为00设置为1010可以比较安全地实现置顶功能2

封装

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

由于只需要传递一个参数WebSocket 的实现非常简单但由于单次打开 kitty 对应了多个进程 (kittykitty 本体工具集kittenkitten 以及一些桌面渲染的进程)需要通过锁来保证只有一个进程在监听 WebSocket 套接字否则会出现端口冲突的问题进而导致无法正常通信3

这个代码展示了基本的逻辑省略了错误处理和信号处理等细节


            
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 的快捷键即可实现快速置顶窗口

image
image

总结

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

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

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

Back to all articles

Loading comments...