在 上一篇 里,我写了 Hey Jarvis 怎样处理确认音、回声、打断和麦克风交接。
当这些能力在终端和浏览器里跑通时,我一度觉得产品最困难的部分已经完成了。
后来我才意识到,那只是证明了一条语音流程可以工作。
一个 Demo 只需要在我准备好的环境里成功一次。一个产品则必须面对第一次安装、权限拒绝、窗口切换、电脑锁屏、系统睡眠、子进程崩溃和应用退出。它不但要成功,还要在无法成功时停在一个安全、诚实、可以恢复的状态。
Demo 证明一件事能发生。产品必须决定其他所有事情发生时怎么办。
产品化不是给 Python 加一个窗口 链接到标题
最早的 Hey Jarvis 由 Python 启动。Realtime 模式还需要打开一个 Chrome 页面,让浏览器负责 WebRTC 的麦克风和语音播放。
这个结构适合开发,因为每一层都可以单独观察和调试。但如果把它直接包装成一个窗口,很多只存在于我电脑里的前提仍然没有消失:
- 系统里已经安装了正确版本的 Python 和依赖;
- 项目目录和
.env文件位于预期位置; - Chrome 页面没有被重复打开;
- API Key 可以由开发环境读取;
- 进程异常时,我知道应该去哪里看日志和怎样重新启动。
这些都不是产品应该交给使用者理解的事情。
在正式搭建 Mac App 之前,我先做了一个完全隔离的 Tauri 实验。它只回答一个问题:WKWebView 能不能在真实的 Apple Silicon Mac 上完成麦克风授权、Realtime 播放、自然打断、媒体释放,以及把麦克风还给 Python?
实验成功以后,我没有继续在这份实验代码上追加功能,而是重新建立产品自己的应用外壳。
这个选择看起来多走了一步,却帮我区分了两个很不一样的问题:
实验:这条技术路线可不可行?
产品:它能否保护秘密、正确失败、恢复运行,并在另一台电脑上重现?
三种运行环境,三种责任 链接到标题
最终的 Hey Jarvis Mac App 分成三部分:
- Rust/Tauri 负责应用身份、窗口、菜单栏、Keychain、系统权限和子进程管理;
- WKWebView 负责 Realtime 麦克风、WebRTC、语音播放和打断;
- 伴随主应用运行的 Python 后台进程(sidecar)负责本地唤醒、会话协调、工具和已经验证过的语音逻辑。
这套结构的价值不在于同时用了三种技术,而在于每一个敏感能力都有明确的主人。
浏览器最适合处理 WebRTC,但它不应该拿到长期 API Key。Python 已经拥有成熟的唤醒和对话逻辑,但 Python 运行时不适合承担 macOS 应用身份、权限和生命周期管理。原生层可以访问 Keychain、系统生命周期和应用目录,因此由它建立安全边界并监督另外两层。
原生应用每次启动时只创建一个 Python sidecar,并通过有版本的消息协议与它通信。消息有顺序号和本次启动的随机身份;未知字段、重复顺序、过大的消息和可能包含凭证的内容都会被拒绝。
这听起来有些严格,但它解决的是一个很实际的问题:当三个运行环境共同组成一个产品时,不能依赖“它们大概会按正确顺序合作”。每一边都必须知道自己可以相信什么,也必须拒绝不属于当前会话的旧消息。
API Key 应该经过哪条路 链接到标题
Hey Jarvis 使用 BYOK(Bring Your Own Key),也就是由使用者提供自己的 OpenAI API Key。
在开发版本里,把 Key 放进 .env 很方便。但 Mac App 不能假设使用者有项目目录,也不能把秘密放进网页表单、URL、启动参数或普通日志。
最终,Key 通过原生输入框写入 macOS Keychain。设置界面只能知道“已经配置”或者“尚未配置”,看不到 Key 的实际内容。
启动 Python sidecar 时,Rust 从 Keychain 读取凭证,通过继承的标准输入发送一次有大小限制的私有启动消息,然后覆盖临时缓冲。之后双方才进入普通的、明确禁止秘密内容的运行协议。
这条路径刻意绕开了很多看起来更省事的选择:
不放进命令行参数
不放进 URL
不交给 JavaScript
不写入普通配置文件
不进入诊断日志
不出现在进程列表
安全设计经常不是增加一个加密算法,而是减少秘密经过的地方。
打开 Settings,为什么会让音频进程崩溃 链接到标题
Settings 是一个看起来与语音无关的普通界面,却暴露了项目里最典型的一次生命周期问题。
早期设计中,打开 Settings 会停止正在运行的 Python sidecar。逻辑看起来很合理:用户可能要修改 API Key 或重新检查麦克风权限,重启以后配置一定是新的。
但“停止 sidecar”并不是一个瞬间动作。
当原生层开始退出 Python 时,后台对话控制器可能仍在处理一次麦克风读取。读取因为关闭而失败后,旧的恢复逻辑又把它理解成普通音频错误,尝试重新打开麦克风。于是两个方向同时发生:
主流程:关闭音频设备并退出 Python
后台流程:发现音频错误,尝试恢复麦克风
这最终暴露成可重复的 Python / PortAudio 退出竞态:解释器正在结束,后台线程却仍可能重新创建音频设备。
修复它不能只在某个异常旁边加一个判断。关闭必须成为整套运行时共享的状态:
- 先发布“系统正在关闭”;
- 控制器在每次有界读取之间检查取消;
- 关闭期间的音频错误不能再触发唤醒恢复;
- 迟到的浏览器清理不能重新打开麦克风;
- 后台控制线程必须真正结束,才能销毁服务器、检测器和 Python 解释器。
同时,我也重新审视了最开始的产品决定。查看 About、诊断信息或者普通设置,并不应该停止语音助手。现在 Settings 是一个独立的单例窗口,打开和关闭它都不会替换主界面,也不会重启 sidecar。只有真正影响运行时的操作,例如更换凭证或重新检查麦克风,才走安全关闭和明确的 Resume 流程。
这个问题让我理解到:正确的退出路径,本身就是产品的核心功能。
“正在监听”必须在锁屏和睡眠以后仍然是真的 链接到标题
语音助手与普通桌面应用还有一个区别:它的价值来自不需要主动打开窗口。
如果 Mac 锁屏、显示器熄灭或者系统经历睡眠,界面上一次留下的“正在监听”可能早已不再真实。WKWebView 的麦克风轨道、Python sidecar 和系统电源策略都可能发生变化。
我增加了一个可选的 Smart Speaker Mode。它只在 Hey Jarvis 确实拥有本地唤醒麦克风时,向 macOS 申请阻止空闲系统睡眠;进入唤醒后的 Busy 对话状态时,会沿用这次已经取得的电源声明,但 Busy 状态本身不能单独申请。它不会阻止显示器关闭,也不会阻止使用者主动让电脑睡眠或合上笔记本。
锁屏测试又暴露了另一个问题:WKWebView 在锁屏后临时获取麦克风曾经需要约 13.4 秒。对一个已经被唤醒的助手来说,这几乎等于不可用。
最后的方案不是反复请求更高权限,而是在 Smart Speaker Mode 下保留一条已经由用户授权、但尚未开放输入的麦克风轨道,并提前准备现有的播放通道。唤醒后复用它们,真实测试里的麦克风获取从约 13.4 秒降到了约 5 毫秒。
但系统的显式睡眠仍然是必须尊重的边界。睡眠前,应用释放媒体和电源声明,停止旧 sidecar;唤醒后只进行一次有时间限制的本地恢复。只有麦克风真的重新归属本地唤醒,界面才恢复“正在监听”。如果 15 秒内无法恢复,就显示一个明确的 Resume,而不是假装一切正常。
一次真实测试中,显式睡眠后的系统约 5.9 秒恢复到真实的唤醒监听状态。比这个数字更重要的是,失败路径同样有定义:恢复不了,就诚实地停下来等待使用者。
诊断问题,但不记录对话 链接到标题
当应用涉及 Rust、网页和 Python 三层时,只显示“发生错误”几乎没有帮助。但语音产品的日志又非常敏感。
最容易调试的做法,是保存用户说了什么、模型回答了什么、WebRTC 交换了什么。那也是我最不愿意采用的做法。
Hey Jarvis 的本地诊断只记录有限的生命周期信息,例如哪一个组件在什么时间进入了什么状态。API Key、原始音频、转写文本、回答内容、工具参数、网络协商内容和第三方返回数据都不进入诊断文件。
日志有大小和保留数量限制,导出的 support bundle 还会再次经过敏感内容扫描。即使 sidecar 意外退出,自动恢复也有固定次数;连续失败会进入“崩溃循环”(crash loop)保护,停在不监听的状态,而不是无限重启,更不会因为恢复进程而自动发起一次付费的 Realtime 对话。
这里的取舍是明确的:少记录一些可能让调试更快的信息,换取诊断文件本身不会变成一份隐藏的对话历史。
在我的电脑上能运行,还不等于可以交付 链接到标题
Python sidecar 依赖唤醒模型、音频库和包含原生代码的机器学习运行时。把源码复制给别人,不叫交付;要求对方安装正确的 Python、Homebrew 包和模型,也不符合我想要的使用方式。
所以 release 构建会把 Python 3.12、TFLite 唤醒运行时、必要模型、音频依赖和应用一起打包。启动时不需要项目目录、系统 Python、Homebrew 路径或在线下载模型。
这带来了一个很现实的取舍:最终安装后的应用大约 104 MiB,比完全原生的实现更大。但它保留了已经验证过的 Python 行为,也避免使用者单独管理运行环境。
为了知道这 104 MiB 里究竟有什么,构建会生成依赖与许可证清单、模型哈希、所有资源的哈希,以及原生二进制清单。最终内部版本包含 83 个 arm64 Mach-O 条目。没有被使用的 ONNX、SciPy、scikit-learn 和其他模型,只有在导入分析和完整行为测试证明不需要以后才被移除。
打包检查还真的发现过 release binary 泄露本地项目构建路径。这个问题不影响功能,却会把开发机器的信息带进交付物。修复以后,路径扫描也成为发布验证的一部分。
可复现构建不是为了追求一个漂亮的工程标签,而是为了回答:我交给别人的,是否就是我测试过的那一份东西?
公开可下载,不等于已经准备好公开分发 链接到标题
F092 验收的 v0.1.0 内部 DMG 大约 45.4 MB,只支持 Apple Silicon 和 macOS 14 及以上版本。内部测试流程覆盖安装、运行、手动更新、回滚和卸载,但它没有 Developer ID 签名,也没有经过 Apple notarization。
我现在把它作为 v0.1.0-internal GitHub Release 公开提供,方便知情的测试者下载和评估,但仍明确标记为 INTERNAL-UNSIGNED。公开可下载不等于面向普通消费者的正式版本,它也没有通过 Gatekeeper 的正常分发流程。
Release 同时提供 DMG、对应的 .sha256 文件以及中英文安装说明。下载者应该先校验 SHA-256;如果 macOS 阻止首次启动,也只应在确认信任构建产物后,通过 System Settings → Privacy & Security → Open Anyway 放行,而不是关闭 Gatekeeper 或移除 quarantine 标记。
这看起来像一句发布说明,实际上也是产品边界的一部分。
“我可以让别人下载它”与“我有能力安全地把它分发给任何普通使用者”仍然是两个不同的结论。诚实地保留这个差距,比把一个 unsigned DMG 包装成已经完成的产品更重要。
产品的复杂度,藏在失败路径里 链接到标题
把 Hey Jarvis 做成 Mac App 以后,它在演示中的使用方式反而变简单了:安装,填入 API Key,允许麦克风,然后说 “Hey Jarvis”。
但为了让这几步成立,系统必须知道:
- Keychain 不可用时怎么办;
- 麦克风被拒绝时怎么办;
- 网页媒体没有释放时怎么办;
- Settings 只是被打开时,什么都不应该重启;
- 真正需要重启时,怎样保证旧线程已经退出;
- 系统睡眠后,什么时候可以重新声称“正在监听”;
- sidecar 连续崩溃时,什么时候应该停止自动恢复;
- 构建产物缺少模型、带有错误架构或者泄露本地路径时,怎样拒绝发布。
产品化不是给 Demo 加上图标、窗口和安装包。
它是把开发者脑中所有“正常情况下应该没问题”的假设,逐个变成明确的状态、受限制的权限、可以观察的失败和能够验证的恢复路径。
下一篇,我想回到这个项目最初的问题:如果一个 AI 助手真正需要的是随时可用的入口,那么这个入口是否应该一直寄居在 Mac、手机和 Siri 规定的边界里?还是说,它最终需要一种属于自己的硬件?
Building Hey Jarvis 系列: