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 再验一遍:
- 环境变量是隐形协议。上次是 Windows 的 CLAUDE* 变量,这次是 macOS 的 locale——GUI 进程环境 ≠ 终端 shell 环境,每次换平台都要重新对一遍。
- 别替子进程做决定,但要看住它的假设。CLI 假设自己运行在有 locale 的终端里,这个假设在 GUI 容器里不成立。
- 成功退出码只证明进程走完了,不证明语义成功。写入类操作要么读回核验,要么承认自己不知道。
仓库里的修复提交:6d220cb,附带了三个 Swift 探针脚本和一组剪贴板回归测试,方便后来者复现。
评论