跳到内容
Yinjie 的博客
返回

pbcopy 说成功,中文却丢了:ReSession macOS 排查记

编辑此页

ReSession 的 macOS 支持(Apple Silicon)跑通之后,开始收到同一类报告:从终端复制,粘贴出来是空的。v0.7.2 和 v0.8.1 都有人撞上。这篇记录定位过程——结论先放前面:不是剪贴板权限,不是 WebView 时序,是 locale;而且修复本身比找病因简单得多。

1. 症状,以及一条转折性线索

最初的现象描述是含混的:有时复制为空,有时看起来已经复制了。前几轮修改集中在复制按钮、快捷键和原生剪贴板通道上,都没打中要害——因为始终有一个隐藏变量没被意识到。

转折来自一条用户观察:

只选英文、数字可以复制;选区里只要包含中文,就复制不出来。

内容相关性是很贵的线索。它直接把调查从”复制权限 / WebView 时序”这两个热门嫌疑上扳开,指向字符编码和子进程环境。

2. 被忽略的前提:复制有两条独立路径

顺着线索重查,才发现系统里根本不止一条复制路径:

路径触发方式实际写入方
内嵌 CLI 的自动复制全屏 TUI 普通拖选,松开鼠标即可能触发Claude CLI 自己调用 pbcopy
App 本地选区复制Option 拖选 + Cmd/C 按钮/菜单ReSession 前端 → Tauri → 原生剪贴板

此前 v0.8.1 给 ReSession 自己启动的 pbcopy 显式设置了 UTF-8——但 Claude CLI 自己启动的另一份 pbcopy,继承的是 PTY 环境。修好了 App 的复制命令,不等于修好了嵌入其中的 CLI 自己执行的复制。两条路径必须分开看。

3. 证据链

排除旧二进制:核对运行中 v0.8.1 的 SHA-256,与官方发布资产一致。

环境取证:只查目标进程和 Claude 子进程的三个 locale 字段——LANG、LC_CTYPE、LC_ALL,全部未设置。原因不难理解:从 Finder/Dock 启动的 GUI 进程,不继承你在终端 shell 里配置的环境变量。而 ReSession 的 PTY 构造只合并了登录 shell 的 PATH,没补 locale。

路径分化观测:同一台机器上,Cmd+A 全选后点复制按钮,剪贴板拿到 2,973 字节纯文本;仅普通拖选(不形成 xterm 本地选区),剪贴板纯文本变成 0 字节。检查只记录格式、长度和计数,不读用户正文。

4. 九组受控实验

用固定样本直接压测 pbcopy,三类环境 × 三类内容,写前保留、验后恢复剪贴板:

环境ASCII(7 字节)中文(12 字节)Emoji(4 字节)
三个 locale 字段均缺失正常空,0 字节空,0 字节
显式 LC_ALL=C正常空,0 字节空,0 字节
仅 LANG=en_US.UTF-8正常正常正常

最关键的一行数据不在表里:失败样本的 pbcopy 退出码仍然是 0。进程”成功”退出,目标文字却一个字节都没写进剪贴板。如果只看退出码做错误处理,这条链路上任何一层都会以为复制成功了。

5. 修复

两处改动,风格是”只补缺省,不做主”:

PTY 侧,macOS 上仅当三个 locale 字段全部缺失或为空时,补一个 LANG=en_US.UTF-8:

#[cfg(target_os = "macos")]
{
    let locales = ["LANG", "LC_CTYPE", "LC_ALL"].map(|name| std::env::var_os(name));
    if needs_utf8_locale_fallback(&locales) {
        cmd.env("LANG", "en_US.UTF-8");
    }
}

用户显式配置过的 locale 一律不覆盖——连显式的 C 也不动,那不是”缺省的 GUI 环境”,是用户的明确选择。这条边界有单测作证。

剪贴板侧,不再把外部 pbcopy 的退出成功当作写入成功:改为在 App 主线程用 NSPasteboard 写入明确的 public.utf8-plain-text 类型,然后读回核验,期望与实际字节不一致就报错。顺便也不再让它猜测 RTF/EPS 这类富文本格式。

6. 收尾

修复已合入(双平台 CI 通过),测试包经用户实测:中文、多段换行、Emoji 复制全部恢复。等下个正式版本发布。

三条心法,前两条在上一篇 ConPTY 里就写过,这次算 macOS 再验一遍:

  1. 环境变量是隐形协议。上次是 Windows 的 CLAUDE* 变量,这次是 macOS 的 locale——GUI 进程环境 ≠ 终端 shell 环境,每次换平台都要重新对一遍。
  2. 别替子进程做决定,但要看住它的假设。CLI 假设自己运行在有 locale 的终端里,这个假设在 GUI 容器里不成立。
  3. 成功退出码只证明进程走完了,不证明语义成功。写入类操作要么读回核验,要么承认自己不知道。

仓库里的修复提交:6d220cb,附带了三个 Swift 探针脚本和一组剪贴板回归测试,方便后来者复现。


编辑此页
分享这篇文章:

上一篇
Claude Code「内置工具全部消失」排查记:ENABLE_TOOL_SEARCH 与第三方网关的坑

评论