Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,10 @@ jobs:
- name: Build frontend and run frontend/contract tests
run: npm test

# Direct Cargo checks do not execute Tauri beforeBuildCommand.
- name: Build native capsule for Tauri checks
run: npm run build:native-capsule:dev

- name: Install pinned Codex CLI for sandbox contract
if: runner.os == 'macOS'
run: npm install --global @openai/codex@0.146.0
Expand Down Expand Up @@ -626,6 +630,8 @@ jobs:
openless-all/app/src-tauri/backend-tests -> target
- run: npm ci
- run: npm run build
- name: Build native capsule for Tauri checks
run: npm run build:native-capsule:dev
- name: Check Tauri backend with Rust 1.99 MSRV
run: cargo +1.99.0 check --locked --manifest-path src-tauri/Cargo.toml --timings
- name: Compile backend tests with Rust 1.99 MSRV
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/native-capsule.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Native capsule
on:
pull_request:
branches: [main, beta]
workflow_dispatch:
jobs:
native-capsule:
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: openless-all/app
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: openless-all/app
- run: cargo check --locked -p openless-native-capsule
- run: cargo test --locked -p openless-capsule --features ui,host
- run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings
309 changes: 309 additions & 0 deletions docs/desktop-low-memory-mode.md

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions docs/macos-correction-card-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# macOS 低内存模式:文字更正卡片关闭崩溃

日期:2026-10-18。作为 PR #1180 的独立修复提交。

## 问题与范围

手动修改听写结果后出现的“是否记住这个词”卡片仍由 Tauri/WebView 绘制。
它与独立 egui 录音胶囊的窗口实现不同。叉号调用 `reject_pending_correction`,
最后一条建议消失后,Coordinator 隐藏卡片,低内存策略销毁窗口及 WebView。
确认最后一条建议、建议到期和插入失败卡片关闭也使用相同的窗口生命周期。

`8918451a` 为临时卡片引入了“初始化普通窗口后执行 `object_setClass`”的做法。
AppKit 此时已经安装内部 KVO 观察者,换类会破坏原生对象的清理路径。
用户应用在 2026-10-18 21:28:43 的记录为主进程 SIGABRT。

在隔离旧实现中,首次叉号操作即在 `objc_exception_throw` 捕获:
`WKWindowVisibilityObserver` 无法为 `contentLayoutRect` 移除未注册的观察者。
堆栈依次经过 `stopObserving`、`viewWillMoveToWindow`、`removeFromSuperview`、
Wry `InnerWebView::drop`,确认异常发生在 WebView 脱离已换类窗口的销毁过程。

## 创建与释放方案

只在主线程同步创建临时卡片期间,使用作用域保护器替换 NSWindow 初始化函数。
匹配到首个尚未初始化的 `TaoWindow` 时,将其转换为兼容 NSPanel 子类,
调用真实 NSPanel 初始化函数,并从一开始设置 NonactivatingPanel 样式。
其余窗口类继续调用原初始化实现。

Tao 0.35.3 的窗口带有 `focusable` 实例字段,不能照搬无实例字段的 winit 适配:

- NSPanel 子类保留同类型、同偏移的 `focusable` 字段和 Tao 的 `sendEvent:` 实现
- 检查原类父类、字段集合、字段类型与偏移以及完整实例大小
- `canBecomeKeyWindow`、`canBecomeMainWindow` 返回 false,保持输入目标焦点
- 构建成功、返回错误或 Rust unwind 时都恢复原初始化函数
- 布局不兼容时保留普通窗口初始化路径,随后返回创建错误并销毁该窗口

初始化完成后不再换类、补装 KVO 或吞掉 Objective-C 异常。
临时卡片不进入 tauri-nspanel 的永久持有注册表。
关闭仍执行 hide + destroy,释放 WebView,保留主进程的托盘、Core 和热键。

普通模式和移动端继续使用各自原有窗口策略;本次不改变模型构建选项。

## 可重复的图形会话测试

`src-tauri/examples/card-lifecycle.rs` 使用真实的打包前端和生产窗口创建、关闭函数。
启动握手与词条数据由隔离用例提供,不启动用户后端、不写用户词库与设置。
循环从真实卡片按钮经过 Tauri IPC 关闭,或等待前端建议到期。

```sh
cd openless-all/app
npm run build
MACOSX_DEPLOYMENT_TARGET=14.0 CARGO_PROFILE_RELEASE_STRIP=none cargo build --locked --release \
--manifest-path src-tauri/Cargo.toml --example card-lifecycle
python3 scripts/test-card-lifecycle-macos.py
# 首轮等待实际鼠标点击叉号,后续轮次自动进行:
python3 scripts/test-card-lifecycle-macos.py --manual-first
```

脚本先为整个临时 `.app` 签名并严格校验,再运行测试;不会替换已安装的应用。
九轮覆盖叉号、确认、超时各三次,检查同一宿主持续运行并能反复创建窗口,
用 Objective-C 弱引用检查九个 WKWebView 均已释放,最后检查普通窗口仍为 NSWindow。
调试旧实现时可指定旧 fixture 二进制并传入 `--debug-exception`,
在 `objc_exception_throw` 停止并输出异常和堆栈。

测试的原生句柄采集遵循 Tauri 2.11.2 的具体所有权实现:`WithWebview` 对 WebView、
controller、NSWindow 分别执行 `Retained::into_raw`,用例必须接管并释放三个 +1 引用。
对这些指针再执行 retain 会由测试本身制造残留。生产卡片使用的 `window.ns_window()`
则返回 autoreleased 借用指针,二者不能混用。

## 本机验证结果

macOS arm64 图形会话中,自动九轮及首轮实际点击叉号的九轮均以状态 0 通过。
每轮结束后宿主继续创建下一张卡片;两组各九个 WKWebView 的弱引用最终全部清空,
最后的普通窗口检查也通过。旧实现首次拒绝操作即捕获上述 KVO 异常。

TypeScript/Vite、原生 egui 辅助程序及完整本地 macOS App 构建通过;
最终 `.app` 的 `codesign --verify --deep --strict` 通过。
本地 App 使用此前已采用的 MLX 可选、Whisper CPU/Accelerate 构建配置,
PR 不携带这些本机构建配置改动。
120 changes: 120 additions & 0 deletions docs/macos-native-capsule-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# macOS 低内存胶囊:Dock 闪现与退出崩溃修复

日期:2026-10-12。关联 [PR #1180](https://github.com/Open-Less/openless/pull/1180)。

## 范围和已有工作

低内存模式的总体设计见 [desktop-low-memory-mode.md](desktop-low-memory-mode.md)。
本修复保留主窗口三秒关闭宽限期、胶囊按需启动/Idle 回收和设置页运行态提示,
仅调整 macOS 辅助程序的应用身份和原生窗口初始化。

低内存模式启动独立胶囊时,macOS Dock 短暂给图标
腾出位置,随后又收回。此前的诊断还发现,胶囊处理 Shutdown 时抛出
`NSRangeException`,退出状态为 SIGABRT。单纯追加 `effectiveAppearance`
观察者的修补仍会在 `_windowLayerContext` 的内部观察者清理处崩溃。

本地 ASR 依赖、MLX/Whisper Metal 默认构建配置和模型加载/卸载策略保持原状。
完整 macOS Host 构建仍需要原有原生子模块和 MetalToolchain。

## Dock 身份必须在 AppKit 注册之前确定

只在 winit EventLoopBuilder 上设置 Accessory 策略,不能覆盖启动早期的
LaunchServices 注册。本机此前的包内路径测试曾采到短暂的 Foreground 身份。

辅助程序现在自带 `Info.plist`,由其 `build.rs` 嵌入 Mach-O 的
`__TEXT,__info_plist`。声明独立的 `com.openless.native-capsule` bundle ID
和 `LSUIElement = true`。因此,即使执行文件位于主应用的
`OpenLess.app/Contents/MacOS` 中,也有自己的 agent 身份。辅助程序在入口
设置 Accessory,winit 继续使用相同策略。

该 metadata 只链接到辅助程序,不修改主应用的 Info.plist 或 Dock 行为。
Apple 对 [LSUIElement](https://developer.apple.com/documentation/bundleresources/information-property-list/lsuielement)
的定义是可显示界面、但不出现在 Dock 的 agent application。

## NSPanel 转换必须发生在初始化和 KVO 之前

原来在 eframe 的 CreationContext 回调里,对已初始化窗口执行
`object_setClass`。此时 AppKit 内部和 winit delegate 已安装 KVO 观察者;
换类破坏了观察者对应的运行时类,之后的析构无法正常清理。

新适配器只在辅助进程创建首个窗口期间,临时替换 NSWindow 的
`initWithContentRect:styleMask:backing:defer:` 实现:

1. 只匹配 `WinitWindow`;其他窗口调用原始初始化函数。
2. 检查 WinitWindow 的实例大小等于 NSWindow,且足以容纳 NSPanel。
3. 在初始化前转换为不允许成为 key/main window 的 NSPanel 子类。
4. 调用真正的 NSPanel 初始化实现,并在创建时传入 NonactivatingPanel 样式。
5. eframe 提供 CreationContext 后,立即恢复 NSWindow 的原始初始化函数。
6. 验证最终对象属于 NSPanel,再设置窗口层级和 Space 行为。

AppKit 与 winit 后续的 KVO 注册、外观通知和析构都作用于同一个初始化后的
panel,无需手动注册观察者或吞掉 Objective-C 异常。非激活样式用于 NSPanel,
参见 Apple 的 [nonactivatingPanel](https://developer.apple.com/documentation/appkit/nswindow/stylemask-swift.struct/nonactivatingpanel)。

这仍然是对 winit 窗口分配细节的原生适配。直接依赖固定为 `winit = 0.30.13`,
其 WinitWindow 没有实例变量。升级 winit/SDK 时必须复查初始化 ABI、类布局、
主题切换、焦点和退出行为;尺寸检查无法代替这些检查。布局不兼容时保留原
初始化函数,随后明确报告创建失败,不对已安装 KVO 的对象强行换类。

## 本机验证与复现

在 `openless-all/app` 中运行:

```sh
npm run build:native-capsule
cargo fmt --check -p openless-native-capsule
cargo clippy --locked -p openless-native-capsule -- -D warnings
cargo test --locked -p openless-capsule --features ui,host
python3 scripts/test-native-capsule-macos.py \
--helper src-tauri/binaries/openless-native-capsule-aarch64-apple-darwin
npm run tauri build -- --bundles app
# 本地构建:为整个新 App 重建 ad-hoc 签名及资源清单。
codesign --force --deep --sign - src-tauri/target/release/bundle/macos/OpenLess.app
codesign --verify --deep --strict src-tauri/target/release/bundle/macos/OpenLess.app
```

脚本需要已登录的 macOS 图形会话。它构造声明为前台应用的临时 `.app`,将辅助
程序放在其 MacOS 目录,完成整个副本的本地签名及严格签名检查后才运行。
直接复制签名执行文件到未正确签名的包内可能产生“OpenLess 已损坏”系统弹窗,
因此不使用未签名的包内测试副本,也不改动 `/Applications/OpenLess.app`。

脚本覆盖 Classic、Typeless、Siri 三种样式和 Shutdown、管道 EOF、Idle 三种
结束方式,共九次独立启动。显式 Shutdown/Idle 测试保留 stdin,避免 EOF
掩盖消息处理错误。每次必须收到真实 Ready,采到 UIElement 身份,未采到
辅助程序成为前台应用,并以状态 0 在超时前退出。

| 检查 | 结果 |
| --- | --- |
| 原生辅助程序 release 构建 | 通过,macOS arm64 |
| 原生辅助程序 fmt / clippy `-D warnings` | 通过 |
| 共享胶囊 ui/host 单元测试 | 7 通过 |
| 已签名 `.app` 包内运行 | 独立 bundle ID,UIElement 身份 |
| 在 PR 最新提交上复跑三种样式、三种结束方式 | 九次启动均状态 0;242 次身份采样全为 UIElement;退出耗时 19–39 ms |
| 已签名测试包严格校验 | 每次启动前校验整个临时 `.app` 的签名及资源清单 |
| 本地 App 包内辅助程序回归 | 一次独立启动和 Shutdown 通过,31 次采样均为 UIElement |
| 完整 Tauri Host / 安装包 | 独立的本地云端 ASR 测试构建已完成;本 PR 默认 MLX/Whisper Metal 构建未复测 |

LaunchServices 采样可以检测身份回退,无法证明在所有系统版本上绝无极短瞬态。
全屏 Space、多屏、实际点击后的焦点和 Windows 真机仍待验证。

## 后台内存复核

2026-10-12,在 Apple Silicon macOS 上确认安装的主程序/胶囊构建 UUID 与包含
此修复的本地构建一致,低内存开关开启、所有窗口关闭、仅保留菜单栏后采样:

| 进程 | PID | phys_footprint |
| --- | --- | --- |
| OpenLess 主进程 | 17875 | 48 MB |
| 唯一剩余 WebKit Networking(保守计入) | 17879 | 5760 KB,约 5.6 MB |
| 合计 | | 约 54 MB |

未发现 WebContent、WebKit GPU 或原生胶囊进程。设置窗口三秒宽限期内重开保留
弹窗状态属于预期行为,不能单独作为 WebView 未回收的证据。

此快照使用云端 ASR,本地测试 Host 未启用 MLX/Whisper Metal 编译选项;该构建
策略不包含在本 PR 内。这里验证关闭窗口后的资源回收,不承诺 PR 默认构建或
启用本地模型后的具体内存值。普通模式约 310 MB 的历史快照是在主窗口和识别
状态开启时取得,窗口状态不同,不能据此声称同状态下精确节省约 250 MB。

本地 App 完成 ad-hoc 重签及严格校验后可用于测试;正式发行仍由原有流程执行
Developer ID 签名及公证。原生窗口初始化依赖当前 winit/SDK,升级后需重新验证。
3 changes: 3 additions & 0 deletions openless-all/app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ dist/
# Tauri
src-tauri/target/
src-tauri/gen/

# Generated native capsule sidecars
src-tauri/binaries/
Loading
Loading