AI 写界面很快,真正麻烦的是 Windows 和 Codex CLI(三)

32 阅读5分钟

氛围编程(Vibe Coding)实战:最难的不是界面,是和本机环境打交道

项目地址:github.com/Wolfares526…

03-cover-windows-codex-cli.png

用 AI 做项目,很容易产生一种错觉:它好像什么都会。

写 Vue 组件,几分钟。调 CSS,也像那么回事。让它拆文件、改状态、补一点交互,通常都能跑起来。于是很多人会觉得 Vibe Coding 已经可以横着走了。

但这只是幸存者偏差。

界面层是 AI 最舒服的地方:输入明确、反馈直观、错了也好修。真正难的是项目开始碰操作系统、外部进程、权限路径和本机环境。到这里,AI 经常会开始猜:猜路径、猜协议、猜 Windows 行为,甚至认真写出一个根本不存在的调用方式。

Codex LED Widget 的第三场硬仗,就是把 Codex CLI 和 Windows 本机环境打通。界面可以让 AI 写,系统边界必须由人盯住。

IPC 调用链:先画清楚,再让 AI 写

这个项目的核心不是液态球,而是稳定读到 Codex 额度。

我先给 AI 画出调用链:

03-internal-ipc-call-chain.png

Vue UI
  -> Tauri command: get_quota
  -> Rust quota adapter
  -> locate codex.exe
  -> codex app-server --listen stdio://
  -> stdin: initialize
  -> stdin: account/rateLimits/read
  -> stdout: quota response
  -> normalize QuotaSnapshot
  -> Vue UI

前端只调用 get_quota,后面的脏活全部交给 Rust。

第一步是找 codex.exe。不能假设用户装在固定目录,也不能只赌 PATH。查找顺序必须写死:

1. CODEX_CLI_PATH
2. %LOCALAPPDATA%\OpenAI\Codex\bin\codex.exe
3. %LOCALAPPDATA%\codex-led-widget\bin
4. PATH

第二步是启动 stdio app-server:

codex app-server --listen stdio://

这不是抓一段命令行输出,而是和子进程做标准流通信。Rust 要启动进程,接管 stdin/stdout,然后按协议发 JSON。

let mut child = Command::new(codex_path)
    .args(["app-server", "--listen", "stdio://"])
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()?;

send_json(&mut child.stdin, initialize_message)?;
send_json(&mut child.stdin, rate_limits_read_message)?;

let response = read_json(&mut child.stdout)?;
let snapshot = normalize_quota(response)?;

顺序也不能靠感觉:先 initialize,再 account/rateLimits/read。协议型调用最怕“差不多”。

CLI 原始响应会有多层结构,字段也不适合前端直接用。所以最后一步必须在 Rust 层清洗,输出稳定的 QuotaSnapshot

前端绝不能直接碰 CLI 原始结构。
这条线一破,Vue 里很快就会长出一堆 response?.data?.payload?.limits,后面谁维护谁头疼。

WindowsApps:看得到,不代表能用

真正的坑出在 Microsoft Store 版 Codex CLI。

如果用户装的是 Store 版本,codex.exe 可能会在 WindowsApps 受保护路径里。这个目录很别扭:路径可能能看到,但不代表能稳定读取;文件可能存在,但不代表能直接执行。

AI 一开始很容易给出朴素方案:找到路径,直接跑。

普通路径当然没问题,到了 WindowsApps 就翻车。这不是语法问题,是 Windows 权限问题。AI 没在当前机器上真正摔过,很容易把 Windows 想得过于讲理。

最后的策略是白名单搬运。

检测到 CLI 位于受保护路径时,不硬刚权限,而是复制到当前用户可控目录:

%LOCALAPPDATA%\codex-led-widget\bin

再从缓存目录执行。

流程变成:

发现 codex.exe
  -> 判断是否位于 WindowsApps
  -> 是:复制到 %LOCALAPPDATA%\codex-led-widget\bin
  -> 否:直接使用
  -> 启动 app-server

这里的重点是“必要时复制”。不乱搬、不提权、不碰系统目录。缓存目录放在当前用户空间里,问题小很多。

Diagnostic Logs:能排错,但不能泄密

系统级功能越多,日志越重要。否则用户说“连接异常”,你根本不知道是找不到 CLI、权限失败、协议失败,还是 Codex 没登录。

但日志不能贪心。

我的规则很死:

Diagnostic Logs 只记录路径、状态和错误,不记录认证信息、Token、额度响应正文。

可以记:

codex path found
using cached executable
rate limit read failed: exit code ...
quota read success

不能记:

access token
session payload
account raw response
完整 JSON 响应正文

AI 调试时很喜欢“多打点日志”。这话听着合理,但很多泄密问题就是这么来的。安全红线不能交给热情。

03-internal-windowsapps-diagnostic-logs.png

桌面应用不是网页套壳

打通 CLI 只是第一步。一个能常驻的桌面小组件,还要处理窗口生命周期。

单实例互斥锁(Mutex)

用户双击多次,不能开出一排状态灯。Windows 下用系统级 Mutex 做单实例保护:

let mutex = create_global_mutex("Global\CodexLedWidget.SingleInstance")?;

if already_exists(mutex) {
    show_message_box("Codex LED Widget 已在运行。");
    exit(0);
}

这类判断应该放在 Rust 层。前端不该关心自己是不是第几个实例。

原生托盘菜单

状态灯要常驻,但不该一直占任务栏。托盘菜单负责把它安稳地放到后台:

托盘右键:
- 显示/隐藏
- 刷新额度
- 切换置顶
- 退出

这不是装饰。用户隐藏窗口后,必须知道从哪里把它找回来。

置顶状态同步

状态灯的价值是“扫一眼就知道”。如果它被 IDE、浏览器、终端盖住,就没意义了。

所以置顶状态要和 Tauri 窗口状态同步:

用户点击置顶
  -> Tauri command
  -> window.set_always_on_top(...)
  -> emit event
  -> Vue 更新按钮状态

不能只在前端把按钮点亮,系统层也必须真的置顶。

隐藏与恢复

关闭不等于退出。这个小组件更适合 hide/show,而不是每次销毁进程。

window.hide()?;
// 托盘点击或菜单触发
window.show()?;
window.set_focus()?;

这样体验更轻,也避免反复启动 CLI 和重建窗口。

AI 能写模块,人要守边界

这次最明显的感受是:AI 很适合写边界清楚的模块,比如 Vue 组件、Tauri command、菜单项、状态封装。规格写清楚,它能很快交付。

但碰到本机黑盒环境,就不能全信它。

Windows 权限、Store 应用路径、stdio 协议、Diagnostic Logs、单实例行为,这些都不是“补全代码”能解决的。人必须当守门人,把系统限制、安全边界和复杂度集中到 Rust 后端。

前端保持干净。
Rust 负责脏活。
AI 负责执行。
人负责别让它乱执行。

下一篇进入终章:第四篇:项目发版以后,我重新看了一遍这次 AI 重构