From d876d668947fdc7b5ce25a1f7f34ccd3081f0565 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Fri, 30 Oct 2026 22:07:17 +0800 Subject: [PATCH 1/4] refactor: remove low-memory desktop mode --- docs/desktop-low-memory-mode.md | 309 ------------------ docs/macos-native-capsule-fixes.md | 120 ------- .../src/cloud_sync_e2ee_documents/registry.rs | 7 - .../crates/openless-core/src/preferences.rs | 1 - .../crates/openless-core/src/shared_types.rs | 27 -- openless-all/app/harmony/ipc-audit.json | 7 - openless-all/app/package.json | 4 +- .../app/scripts/windows-ui-config.test.mjs | 10 - .../app/src-tauri/src/commands/settings.rs | 1 - .../app/src-tauri/src/core_adapters.rs | 3 - .../app/src-tauri/src/desktop_resources.rs | 9 +- openless-all/app/src-tauri/src/lib.rs | 27 +- .../app/src-tauri/src/native_capsule.rs | 3 +- openless-all/app/src-tauri/src/recorder.rs | 23 +- .../src-tauri/src/tauri_coordinator_host.rs | 20 -- .../app/src-tauri/tauri.macos.conf.json | 7 +- .../app/src-tauri/tauri.windows.conf.json | 7 +- openless-all/app/src/components/AudioCue.tsx | 23 +- .../app/src/components/CapsuleStyles.css | 2 + openless-all/app/src/i18n/de.ts | 10 - openless-all/app/src/i18n/en.ts | 10 - openless-all/app/src/i18n/es.ts | 10 - openless-all/app/src/i18n/fr.ts | 10 - openless-all/app/src/i18n/ja.ts | 10 - openless-all/app/src/i18n/ko.ts | 10 - openless-all/app/src/i18n/zh-CN.ts | 9 - openless-all/app/src/i18n/zh-TW.ts | 9 - openless-all/app/src/lib/ipc/index.ts | 1 - openless-all/app/src/lib/ipc/mock-data.ts | 1 - openless-all/app/src/lib/ipc/settings.ts | 11 +- openless-all/app/src/lib/types.ts | 10 - .../pages/settings/RecordingInputSection.tsx | 92 +----- 32 files changed, 25 insertions(+), 778 deletions(-) delete mode 100644 docs/desktop-low-memory-mode.md delete mode 100644 docs/macos-native-capsule-fixes.md diff --git a/docs/desktop-low-memory-mode.md b/docs/desktop-low-memory-mode.md deleted file mode 100644 index 647b1444c..000000000 --- a/docs/desktop-low-memory-mode.md +++ /dev/null @@ -1,309 +0,0 @@ -# 桌面低内存后台模式与 egui 胶囊设计 - -关联 Issue:[Open-Less/openless#1174](https://github.com/Open-Less/openless/issues/1174)。 -基线:`beta` 分支 `1ff361a89c896ae07d22d597df3f5dc69cd79b5d`。 - -本文描述为后续 PR 准备的实现分支的资源边界、实现决策、故障处理和验证方法。 -当前交付流程为先上传个人 fork,完成本地测试后再决定是否向上游提交 PR。 -涉及桌面窗口、 -系统音频和进程内存的结论必须通过 Windows/macOS 真机实验确认,编译通过不代表这些行为已验证。 - -## 1. 目标和范围 - -OpenLess 需要长期监听快捷键,但听写通常短暂、间歇发生。本次优化的目标是降低 -正常空闲时的常驻开销:允许录音、识别和界面显示期间临时增加内存,任务完成后释放临时界面资源。 - -本 PR 包含: - -- 可选的桌面低内存后台模式,默认关闭,修改后重启生效。 -- 主窗口在需要显示时创建,关闭时销毁 WebView;托盘、快捷键和 Core 继续运行。 -- 将 Linux 已有的 egui 胶囊绘制代码提取为共享模块,桌面端按需启动独立胶囊进程。 -- 胶囊结束后终止并回收其进程,使字体、纹理、图形上下文随进程释放。 -- 原生开始录音提示音、实时转写显示、会话操作和必要的异常提示适配。 -- 低内存模式下暂停麦克风常驻预热,关闭设置时清理麦克风测试流。 - -**本地模型的加载、保留时间、空闲卸载和缓存实现沿用项目原有机制。本 PR 不增加、 -删除或改变模型卸载选项,也不把关闭浮窗等同于卸载模型。** - -本次不重写完整 React 主界面,不替换 ASR/LLM,不改变历史、词典、插入逻辑和模型凭据。 -QA、Less Computer 等非听写窗口继续使用现有生命周期;曾打开这些窗口后,其隐藏 -WebView 仍可能存在。因此,本 PR 的“无常驻主窗口和录音胶囊 WebView”不能表述为 -“整个应用在所有使用路径下绝无 WebView”。其他辅助窗口的空闲释放是后续独立工作。 - -## 2. 原有实现与复用边界 - -原有桌面 `lib.rs` 在主窗口 `CloseRequested` 时调用 `prevent_close()` 和 `hide()`。 -`main`、`capsule` 以及 Windows 的 `capsule-rail` 由配置预创建。隐藏窗口不能作为 -网页运行环境已释放的证据。 - -Linux 的 egui Host 已有录音胶囊,包含 Siri、Classic、Typeless 样式、音量反馈、 -取消和确认操作。其主机适配依赖 X11、Wayland、fcitx5,不能直接作为桌面端依赖。 - -此次提取的范围是纯绘制和轻量状态 DTO: - -| 模块 | 职责 | 依赖边界 | -| --- | --- | --- | -| `crates/openless-capsule` 默认功能 | 消息、帧、版本及会话校验 | serde,Rust 1.88,不依赖 Tauri/egui | -| `openless-capsule/host` | 非阻塞进程监管、有限队列、管道读写 | Tokio,不依赖图形库 | -| `openless-capsule/ui` | 原有胶囊与 Siri 视觉绘制 | egui/resvg,与 Linux 共用 | -| `native-capsule` | 独立窗口、字体、原生行为 | eframe,Rust 1.95+ | -| `src-tauri/src/native_capsule.rs` | 从现有反馈快照生成显示帧,接收操作 | Tauri Host 适配 | -| `src-tauri/src/desktop_resources.rs` | 主窗口创建、销毁及资源策略 | 桌面生命周期 | - -Linux 保留其主机和协议,通过薄包装调用共享绘制函数,继续使用原有语言目录。 -没有将整个 Linux UI 或平台依赖搬入 Windows/macOS Host。 - -## 3. 进程和状态所有权 - -```mermaid -flowchart TD - H["Tauri Host / Core"] -->|"按需创建"| M["React 主窗口"] - H -->|"完整显示帧"| C["egui 胶囊进程"] - C -->|"取消 / 确认"| H - H -->|"必要时创建"| A["异常或词典提示卡片"] -``` - -Core 是语音会话、识别、润色和插入状态的唯一所有者。胶囊进程没有麦克风、 -快捷键、凭据、模型或业务状态;它只绘制收到的快照,并报告用户操作。 - -启动胶囊不属于开始录音的前置条件。现有反馈入口在提交会话快照后将显示帧交给 -进程监管器;监管器在异步任务中启动子进程、等待握手及写管道。音频回调不等待 -进程启动、字体加载或图形初始化。 - -| 场景 | 主窗口 | 录音胶囊 | 后台 | -| --- | --- | --- | --- | -| 静默启动并空闲 | 未创建 | 无子进程 | Core、快捷键、托盘 | -| 打开设置 | 按需创建 | 按会话需要 | 继续运行 | -| 关闭设置 | 保存当前正常尺寸后销毁 | 按会话需要 | 继续运行 | -| 听写处理中 | 不要求存在 | 创建并显示 | 录音/ASR/LLM/插入 | -| 完成提示结束,收到 Idle | 不要求存在 | 退出并等待回收 | 返回后台状态 | -| 插入失败或词典提示 | 不要求存在 | 让出显示 | 仅创建提示卡片 WebView | - -## 4. 开关和兼容性 - -新增 `lowMemoryBackgroundEnabled`,旧配置缺失时默认为 `false`。字段属于设备配置, -不是模型参数。主窗口设置页显示"重启后生效",并解释麦克风预热和本地模型策略。 -开关与当前运行模式不一致时,设置页额外显示重启提示行和一键重启按钮(复用既有 -`restart_app` 命令;该命令跳转到主线程执行重启,避免低内存模式的 -`ExitRequested { code: None }` 拦截把重启吞掉)。胶囊辅助程序初始化失败时, -设置页通过 `native_capsule_status` 显示健康告警,而不是静默降级。 - -Host 在启动时读取一次该开关,将 `main/capsule/capsule-rail` 配置的自动创建标记关闭。 -运行中不切换胶囊所有权,避免已开始的语音会话在两套界面之间迁移。 - -关闭模式后重启,恢复原有窗口保留方式。Android/iOS 的主窗口和移动端命令路径 -不使用桌面资源策略。 - -主窗口从原有平台窗口配置重建,继续使用相同标签、前端入口、权限和平台样式。 -每次创建都应用 macOS 磨砂和 Windows Mica。当前进程内保存正常尺寸、位置及最大化 -状态;不承诺保存未提交的表单草稿或临时导航状态。持久化设置仍由原有存储负责。 - -## 5. 窗口关闭、重建与退出 - -关闭主窗口时进入 `closing` 状态,避免同名窗口尚未移除时重复创建。销毁期间 -收到再次打开请求时记录 `reopen_pending`,在 `Destroyed` 之后重建。低内存模式下 -关闭主窗口改为"先隐藏、3 秒宽限期后销毁":宽限期内重新打开会取消销毁并直接 -恢复原窗口,避免误关导致未提交的表单草稿丢失;宽限期计时使用代次令牌防止 -二次关闭重置期限后被旧计时器提前销毁。 - -主窗口销毁产生的自动退出请求通过 `ExitRequested { code: None }` 拦截。 -托盘退出及显式重启带退出代码,仍沿用原有退出逻辑。退出时停止胶囊和提示音。 - -关闭设置还需要释放麦克风电平测试流。清理在阻塞工作任务中完成,避免音频线程 -停止等待阻塞桌面主线程;预热选项在低内存模式下被资源策略抑制,用户原有选择 -不会被改写。 - -必须实测的竞态包括:关闭后立即点击托盘、多次点击打开、录音中打开/关闭设置、 -显式退出和重启。不能把仅能成功调用 `destroy()` 视为生命周期改造完成。 - -## 6. 胶囊通信和监管 - -通信使用继承的 stdin/stdout JSONL 管道,不开放网络端口。stderr 仅用于诊断。 - -- 首先等待 `Ready`,校验协议版本,然后发送最新完整帧。 -- 显示帧包含会话 ID、显示序号、状态、样式、音量、转写文字、语言和目标屏幕位置。 -- 音量和文字更新用 `watch` 合并,只保留最新帧;UI 接收队列也有固定容量。 -- 单条 JSONL 上限为 1 MiB,读取时也限制分配,避免损坏的消息触发无界分配。 -- 写管道和初始化握手设有超时;挂起的渲染进程不能拖住语音管线。 -- Idle 结束当前子进程;先等待正常退出,超时后终止并回收,再允许创建下一实例。 -- 父管道 EOF 会使浮窗主动关闭;Host 侧同时使用 `kill_on_drop`。 -- 启动失败只作有限重试,同一会话连续失败后不因每个音量帧反复启动进程。 - -取消/确认事件携带其绘制帧的会话 ID 和序号。Host 先校验显示帧,再在异步操作 -开始前确认 Core 当前会话。允许同一会话稍旧的音量帧按钮操作,拒绝未来序号、 -其他会话和已结束状态。确认操作仅适用于仍在录音的听写。 - -非听写功能的胶囊继续作为显示器,其操作由已有 QA/Agent 窗口管理,不能让一个 -迟到的辅助窗口按钮取消另一个业务域的新会话。 - -## 7. 原生平台适配 - -### Windows - -辅助进程以无控制台方式启动。窗口使用无装饰、置顶、隐藏任务栏和非激活样式。 -位置由 Host 现有 `foreground_window_monitor()` 选择当前输入应用所在屏幕, -使用物理坐标传递,避免主副屏缩放不同造成的偏移。 - -Siri 样式为鼠标穿透;Classic/Typeless 保留取消和确认。必须验证按钮点击不改变 -输入焦点,以及透明区域不会遮挡底层应用。 - -### macOS - -辅助程序的独立 Info.plist 以 `LSUIElement = true` 嵌入执行文件,启动前即声明 -无 Dock 图标的 agent 身份,入口及事件循环均使用 Accessory 激活策略。 -胶囊配置为非激活 NSPanel,设置 -`CanJoinAllSpaces` 和 `FullScreenAuxiliary`,显示和点击时不能把用户切回 OpenLess。 -AppKit 操作只发生在辅助进程主线程。 - -当前 eframe/winit 没有直接创建 NSPanel 的公开入口,因此适配在 NSWindow -初始化和 KVO 注册之前转换无实例变量的 WinitWindow,并调用真正的 NSPanel -初始化函数;收到 CreationContext 后立即恢复原始初始化实现。检查原分配 -大小等于 NSWindow、且足以容纳 NSPanel,避免换类破坏已安装的观察者。 -此部分依赖固定 `winit 0.30.13` 和 SDK 行为,是需要维护者重点评审并真机验证 -的边界;尺寸检查不等于完整兼容性证明。升级后必须重新检查。修复设计及 -启动/退出回归见 [macos-native-capsule-fixes.md](macos-native-capsule-fixes.md)。 - -不能凭 `always_on_top` 或交叉编译通过就声称全屏 Space 支持已验证。 - -## 8. 提示音、文字及提示卡片 - -原来提示音由 Web Audio 播放。没有常驻 WebView 后,Host 用 CPAL 播放相同的 -880 Hz/1108.73 Hz 开始提示音,短时输出流随工作线程结束而释放。WebView 根据 -实际运行模式关闭自身提示音监听,避免主设置窗口打开时重复响铃。 - -实时文字来自现有胶囊快照,绘制在原生胶囊上方。终态清除文字,下一会话不能 -重放前一会话的转写。系统 CJK 字体仅加载到一次性的 UI 进程。 - -词典学习和插入失败卡片继续使用原有 React UI,但只在有可见内容时创建 WebView。 -前端安装监听后读取一次提示快照,补偿冷启动前已发送的第一条事件;卡片收起时 -销毁窗口。macOS 的临时卡片不进入 tauri-nspanel 的长期引用注册表,以免销毁窗口 -后仍被注册表保留。 - -这些卡片属于需要用户处理的可见状态。卡片还在屏幕上时,不能把应用当作完全 -空闲并为节省内存自动丢弃未插入的文字。 - -## 9. 打包与工具链 - -`npm run build:native-capsule` 构建原生辅助程序并准备 Tauri `externalBin` 文件。 -macOS/Windows 的平台配置在打包前运行该命令,开发模式使用 debug 构建。 -Android 不运行该构建步骤。 - -辅助程序需要 Rust 1.95+,与原有 Linux egui 路线一致。Tauri Host 只依赖共享 -协议和监管功能,保持 Rust 1.88 的依赖边界。应检查 Host 的依赖树没有 egui、 -eframe、winit 或新增图形驱动依赖。 - -macOS 发布仍需验证辅助可执行文件的签名、架构、安装路径和首次运行。 -Windows 发布需验证 NSIS/MSI 安装后能找到辅助程序,且不会出现控制台闪烁。 -新增独立 CI 检查辅助程序的 macOS/Windows 编译和共享模块测试。 - -## 10. 验证与内存实验 - -自动检查与设备证据分开记录。自动检查包括共享模块协议与绘制测试、Core 测试、 -Linux 共享模块回归、前端 TypeScript/Vite、桌面辅助程序跨目标检查,以及现有 -桌面窗口契约。完整桌面 Host 编译和运行仍依赖对应平台工具链。 - -内存实验按相同版本、配置、ASR provider 和系统进行,对比普通模式和低内存模式: - -1. 使用云端 ASR,以隔离本地模型保留时间;静默启动后等待 5 分钟。 -2. 记录主进程和全部 WebView/胶囊子进程的内存,不能只看 Tauri 主进程。 -3. 打开设置、关闭设置,再等待 5 分钟;记录窗口与子进程是否实际消失。 -4. 连续听写 30–50 次,包含取消、网络失败和插入失败;每次回到 Idle 后采样。 -5. 使用本地模型重复实验,保留项目原有卸载配置,并把模型内存另列。 -6. 同时记录快捷键到开麦、快捷键到浮窗首帧、识别完成到 Idle 的时间。 - -Windows 记录 Private Bytes/Working Set 与 WebView2 全部进程;macOS 记录 -phys_footprint 和相关 WebKit/辅助进程。系统缓存和分配器行为可能使主进程 RSS -不完全返回初始数值,不能据此直接断定模型泄漏或资源未释放。 - -| 设备场景 | 预期 | 必须取得的证据 | -| --- | --- | --- | -| 静默启动 | 无主/录音 WebView,无胶囊进程 | 进程列表与内存采样 | -| 快捷键听写 | 录音不等待 UI | 时间戳及语音开头完整性 | -| 设置关闭、立即重开 | 只有一个主窗口,后台不退出 | 操作录像/日志 | -| 完成/取消/错误 | 胶囊自动结束,无残留进程 | 进程退出及会话日志 | -| macOS 全屏与多个 Space | 原生胶囊出现,不切换焦点 | 真机录像 | -| Windows 多屏不同 DPI | 位置正确、按钮不抢焦点 | 真机截图/录像 | -| 插入失败 | 完整文字与复制按钮保留 | 失败场景复现 | -| 系统退出/父进程异常结束 | 无孤儿胶囊 | 进程与退出日志 | -| 普通模式回归 | 原有体验保持 | 对照运行 | - -当前分支先用于个人 fork 的本地测试,不直接创建 PR。完整 Host 检查、安装包测试 -和上述设备项完成后,再整理实际测试证据并向上游提交。 -不预先承诺节省多少 MB,也不把“换成 Rust UI”本身视为性能结果。 - -## 11. 后续边界 - -其他辅助 WebView、临时窗口透明点击区域、全屏 Space 和连续快速听写是后续 -验证重点。若维护者希望缩小首次合并范围,可以保留主窗口资源策略和共享绘制 -提取,将桌面原生胶囊适配单独提交;两者的 Core 接口不互相依赖。 - -本地 Whisper 的内存增长 Issue #1166 应继续单独定位。本次界面资源释放不能 -作为该模型问题已经修复的证据。 - -## 12. 分支验证记录与本地复现 - -验证日期:2026-10-12。执行环境为 Linux 构建容器,基于本文所列 beta 基线。 - -| 检查 | 结果 | 证据边界 | -| --- | --- | --- | -| Core library 单元测试 | 1145 通过,1 忽略 | 包括配置向后兼容;没有更改模型卸载实现 | -| 共享胶囊 ui/host 测试 | 7 通过 | 消息大小、会话校验、绘制、握手取消、Idle 回收及新会话重放 | -| Linux 原有胶囊测试 | 6 通过 | 确认绘制提取后的样式及按钮回归 | -| Linux Host check/clippy | 通过 | 对全部目标检查,clippy 使用 `-D warnings` | -| 原生胶囊 check/clippy | 通过 | Linux 检查及 Windows GNU/macOS ARM 交叉目标检查 | -| TypeScript/Vite 生产构建 | 通过 | 包含新的设置、语言同步和提示卡片快照读取 | -| 前端 TypeScript 测试 | 54 个测试文件通过 | 使用 Node 的 tsx loader 执行,避开容器禁止 Unix socket 的 CLI 行为 | -| 新增 Host 适配文件独立 Windows 类型检查 | 通过 | 使用真实 Tauri/Core/CPAL,既有 Coordinator 入口用 stub;不等同于完整 Host 编译 | -| 完整 Windows Host 检查 | 未完成 | 既有 Foundry SDK 构建需下载 NuGet/微软原生依赖,容器连接被拒绝 | -| macOS/Windows 真机及安装包 | macOS 基础启动/退出及后台快照已补;其余待验证 | macOS Space/点击焦点、多屏、Windows 真机及正式发行签名/公证仍缺证据 | - -现有前端 JavaScript 契约测试已执行至 TypeScript 阶段,没有观察到断言失败。 -原始完整测试入口最终受 tsx CLI 的 Unix socket 权限限制退出,因此不记录为 -“完整前端测试入口通过”;TypeScript 文件另行执行,得到上表结果。 - -在 macOS 或 Windows 的项目目录中,先初始化原有原生子模块并安装依赖: - -```sh -git submodule update --init --recursive -cd openless-all/app -npm ci -cargo test --locked -p openless-capsule --features ui,host -cargo check --locked -p openless-native-capsule -cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings -npm run build -npm run tauri dev -``` - -平台 Tauri 配置的 `beforeDevCommand` 会先构建 debug 辅助程序,打包则构建 release -辅助程序。原生辅助程序需 Rust 1.95+。首次构建仍需原有 Host 的平台 SDK、Foundry -和模型依赖;这些依赖未被本分支替换。安装包可通过 `npm run tauri build` 生成。 - -第一次启动时启用“低内存后台模式”,并选择原有“启动后最小化”,重启后进行 -静默启动实验。若没有选择启动后最小化,低内存模式仍尊重原有显示主窗口的启动 -偏好;主窗口关闭之后才进入后台资源状态。使用托盘打开/关闭设置,确认窗口关闭 -后仍能完整听写,再按第 10 节逐项记录设备结果。 - -### macOS 真机内存实测(2026-10-12,Apple Silicon,phys_footprint) - -同一台机器使用云端 ASR 的历史快照与修复后后台复核如下。各次窗口状态不同, -这些记录不能替代相同窗口状态、相同编译配置下的 A/B 实验: - -| 实际采样状态 | 总 phys_footprint | 证据边界 | -| --- | --- | --- | -| 普通模式,主窗口和识别状态开启 | ≈310 MB | 未测普通模式所有窗口关闭后的同状态对照 | -| 低内存模式,冷启动静默 | ≈45 MB | 仅主进程 | -| 低内存模式,听写胶囊开启 | ≈255 MB | 原生胶囊约 192 MB,为临时占用 | -| 低内存模式,会话后所有窗口关闭 | ≈60 MB | 主进程约 55 MB,加 Networking 约 5 MB | -| 含 macOS 胶囊修复的本地构建,所有窗口关闭 | **≈54 MB** | 主进程 48 MB,保守计入唯一 Networking 5.6 MB;无 WebContent/GPU/胶囊进程 | - -进程构成:普通模式主窗口和识别状态开启时,4 个 WebKit XPC 服务共约 263 MB, -主进程约 47 MB;低内存模式空闲时仅主进程 -(约 40–55 MB)外加一个约 5 MB 的 `com.apple.WebKit.Networking` 共享服务残留。 -胶囊进程听写期间约 192 MB,其中约 53 MB 为整体读入的 CJK 系统字体文件 -(PingFang 缺失时回退 STHeiti Medium.ttc);Idle 后进程退出、内存随进程释放。 - -已知边界:主进程会话后未完全回落到冷启动值(分配器高水位);胶囊瞬时占用 -偏高,后续可通过字体子集化改进。约 54 MB 的复核来自本地云端 ASR 测试 Host, -其 MLX/Whisper Metal 编译选项与本 PR 默认配置不同,详见修复文档。这是关闭 -窗口后资源回收的快照,不能外推为所有构建的固定值或精确节省量。Windows -真机、正式发行签名/公证和长时间连续听写仍待验证。 diff --git a/docs/macos-native-capsule-fixes.md b/docs/macos-native-capsule-fixes.md deleted file mode 100644 index 27fa8e1a4..000000000 --- a/docs/macos-native-capsule-fixes.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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,升级后需重新验证。 diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index d21e97f64..fef46cc5f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -172,13 +172,6 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Text, reason: "device_bound", }, - PreferenceField { - rust_name: "low_memory_background_enabled", - key: "lowMemoryBackgroundEnabled", - class: PreferenceClass::DeviceProfile, - shape: PreferenceShape::Boolean, - reason: "device_bound", - }, PreferenceField { rust_name: "low_latency_dictation_enabled", key: "lowLatencyDictationEnabled", diff --git a/openless-all/app/crates/openless-core/src/preferences.rs b/openless-all/app/crates/openless-core/src/preferences.rs index 49352da57..70ee31bb4 100644 --- a/openless-all/app/crates/openless-core/src/preferences.rs +++ b/openless-all/app/crates/openless-core/src/preferences.rs @@ -126,7 +126,6 @@ fn strip_local_only_preferences(value: &mut serde_json::Value) { object.remove("lowLatencyDictationEnabled"); object.remove("echoCancellationEnabled"); object.remove("audioDsp"); - object.remove("lowMemoryBackgroundEnabled"); } } diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 57b0ff7b8..e441d4505 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -440,9 +440,6 @@ pub struct UserPreferences { /// This is opt-in because the OS microphone indicator remains active while enabled. #[serde(default)] pub low_latency_dictation_enabled: bool, - /// Desktop-only UI resource policy. Applied on the next application launch. - #[serde(default)] - pub low_memory_background_enabled: bool, /// Play an instantly synthesized cue when the recording hotkey enters the /// recording state ("recording started"). Default on; can be disabled in /// the "Recording & Input" settings. The cue is synthesized by the capsule @@ -1053,8 +1050,6 @@ struct UserPreferencesWire { stable_transcription_enabled: bool, #[serde(default)] low_latency_dictation_enabled: bool, - #[serde(default)] - low_memory_background_enabled: bool, #[serde(default = "default_true")] audio_cue_on_record: bool, #[serde(default)] @@ -1344,7 +1339,6 @@ impl Default for UserPreferencesWire { audio_dsp: prefs.audio_dsp.normalized(), stable_transcription_enabled: prefs.stable_transcription_enabled, low_latency_dictation_enabled: prefs.low_latency_dictation_enabled, - low_memory_background_enabled: prefs.low_memory_background_enabled, audio_cue_on_record: prefs.audio_cue_on_record, silence_auto_stop_enabled: prefs.silence_auto_stop_enabled, silence_auto_stop_seconds: prefs.silence_auto_stop_seconds, @@ -1569,7 +1563,6 @@ impl<'de> Deserialize<'de> for UserPreferences { audio_dsp: wire.audio_dsp.normalized(), stable_transcription_enabled: wire.stable_transcription_enabled, low_latency_dictation_enabled: wire.low_latency_dictation_enabled, - low_memory_background_enabled: wire.low_memory_background_enabled, audio_cue_on_record: wire.audio_cue_on_record, silence_auto_stop_enabled: wire.silence_auto_stop_enabled, silence_auto_stop_seconds: wire.silence_auto_stop_seconds, @@ -1972,7 +1965,6 @@ impl Default for UserPreferences { audio_dsp: Default::default(), stable_transcription_enabled: false, low_latency_dictation_enabled: false, - low_memory_background_enabled: false, audio_cue_on_record: true, silence_auto_stop_enabled: false, silence_auto_stop_seconds: default_silence_auto_stop_seconds(), @@ -4057,22 +4049,3 @@ mod capsule_transcript_preferences_tests { ); } } - -#[cfg(test)] -mod low_memory_background_preferences_tests { - use super::UserPreferences; - - #[test] - fn low_memory_background_is_opt_in_and_keeps_low_latency_preference() { - let old: UserPreferences = serde_json::from_str("{}").unwrap(); - assert!(!old.low_memory_background_enabled); - let preferences: UserPreferences = serde_json::from_str( - r#"{"lowMemoryBackgroundEnabled":true,"lowLatencyDictationEnabled":true}"#, - ) - .unwrap(); - let restored: UserPreferences = - serde_json::from_value(serde_json::to_value(preferences).unwrap()).unwrap(); - assert!(restored.low_memory_background_enabled); - assert!(restored.low_latency_dictation_enabled); - } -} diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index a07c01ce3..000fb75c5 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -837,13 +837,6 @@ ], "implementation": "rust" }, - { - "name": "native_capsule_status", - "calledFrom": [ - "src/lib/ipc/settings.ts" - ], - "implementation": "unimplemented" - }, { "name": "open_android_local_migration", "calledFrom": [ diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 4af1a03e1..78c90b6e9 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -33,9 +33,7 @@ "check:android-shizuku-scaffolding": "node scripts/merge-android-shizuku-manifest.test.mjs && node scripts/patch-android-shizuku-deps.test.mjs", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", - "test:ui-motion": "playwright test", - "build:native-capsule": "node scripts/build-native-capsule.mjs", - "build:native-capsule:dev": "node scripts/build-native-capsule.mjs --debug" + "test:ui-motion": "playwright test" }, "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index be3479c27..c317cc10d 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -68,16 +68,6 @@ assertMatch( /CapsuleState::Recording[\s\S]*?CapsuleDisplayMode::ForegroundWindowScreen[\s\S]*?!self\.state\.foreground_target_matches_session\(session_id\)[\s\S]*?self\.capture_foreground_monitor\(\)[\s\S]*?begin_foreground_target_capture\(session_id, monitor\)/, 'WebView capsules must capture the shared foreground target once per recording session', ); -const nativeCapsuleRs = await readFile( - new URL('../src-tauri/src/native_capsule.rs', import.meta.url), - 'utf-8', -); -const nativeRefreshLayout = nativeCapsuleRs.match(/fn refresh_layout\b[\s\S]*?\n }/)?.[0] ?? ''; -assertMatch( - nativeRefreshLayout, - /CapsuleDisplayMode::ForegroundWindowScreen[\s\S]*?!latest\.foreground_captured[\s\S]*?frame\.state\.phase == "recording"[\s\S]*?capsule\.capture_foreground_monitor\(\)[\s\S]*?latest\.foreground_captured = true/, - 'low-memory capsules must capture the same foreground target once per recording session', -); const capsuleTsx = await readFile( new URL('../src/components/Capsule.tsx', import.meta.url), 'utf-8', diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 4ee82123e..bf1991cdc 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -244,7 +244,6 @@ pub(crate) fn sync_low_latency_capture_preferences(prefs: &UserPreferences) { warm_hub.configure( crate::recorder::warm_capture_enabled( prefs.low_latency_dictation_enabled, - crate::desktop_resources::active(), warm_hub.activation_is_active(), ), (!prefs.microphone_device_name.trim().is_empty()) diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 4e4b70861..635d60f3b 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -143,7 +143,6 @@ pub(crate) fn backend_dependencies( warm_hub.configure( crate::recorder::warm_capture_enabled( prefs.low_latency_dictation_enabled, - prefs.low_memory_background_enabled || crate::desktop_resources::active(), false, ), (!prefs.microphone_device_name.trim().is_empty()) @@ -2003,7 +2002,6 @@ impl openless_core::VoiceActivationRuntime for TauriVoiceActivationRuntime { let preferences_snapshot = preferences.get(); let fallback_warm_capture_enabled = crate::recorder::warm_capture_enabled( preferences_snapshot.low_latency_dictation_enabled, - crate::desktop_resources::active(), false, ); let audio_processing = openless_core::AudioProcessingConfig { @@ -2267,7 +2265,6 @@ impl openless_core::VoiceActivationHandle for TauriVoiceActivationHandle { warm_hub.configure( crate::recorder::warm_capture_enabled( prefs.low_latency_dictation_enabled, - crate::desktop_resources::active(), false, ), (!prefs.microphone_device_name.trim().is_empty()) diff --git a/openless-all/app/src-tauri/src/desktop_resources.rs b/openless-all/app/src-tauri/src/desktop_resources.rs index f7e04edee..3de570610 100644 --- a/openless-all/app/src-tauri/src/desktop_resources.rs +++ b/openless-all/app/src-tauri/src/desktop_resources.rs @@ -133,8 +133,8 @@ pub(crate) fn close_main(app: &tauri::AppHandle) { } } -/// How long a low-memory close keeps the hidden WebView alive before the -/// real destroy, so accidental close/reopen cycles lose nothing. +/// How long a hidden WebView remains alive before a real destroy, so accidental +/// close/reopen cycles preserve unsaved state. #[cfg(not(mobile))] pub(crate) const MAIN_CLOSE_GRACE: std::time::Duration = std::time::Duration::from_secs(3); @@ -235,8 +235,3 @@ pub(crate) fn hide_capsule_window( } Ok(()) } - -#[tauri::command] -pub(crate) fn desktop_low_memory_active() -> bool { - active() -} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index ab628ec7d..8fa83ebc1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -254,10 +254,8 @@ macro_rules! app_invoke_handler_desktop { () => { tauri::generate_handler![ commands::get_startup_snapshot, - desktop_resources::desktop_low_memory_active, native_capsule::native_capsule_set_locale, get_capsule_auxiliary_snapshot, - native_capsule_status, commands::get_capsule_snapshot, commands::get_dictation_translation_target, commands::select_dictation_translation_target, @@ -836,7 +834,7 @@ fn run_desktop() { let mut context = tauri::generate_context!(); desktop_resources::configure( &mut context, - core_backend.get_preferences().low_memory_background_enabled, + false, ); let builder = tauri::Builder::default(); // macOS: the capsule must overlay other apps' fullscreen Spaces, which requires a @@ -930,7 +928,7 @@ fn run_desktop() { // system traffic lights; Windows disables native chrome so the React // WinTitleBar takes over. // Set the native appearance before showing any window, including - // low-memory launches where the main WebView does not exist yet. + // launches where the main WebView does not exist yet. #[cfg(target_os = "macos")] app.set_theme(match coordinator.backend().get_preferences().theme_mode { types::ThemeMode::System => None, @@ -1775,9 +1773,8 @@ fn restart_app(app: AppHandle) { } /// `AppHandle::restart` from a command thread routes through -/// `ExitRequested { code: None }`, which the low-memory background policy -/// deliberately prevents; hopping to the main thread takes tauri's direct -/// spawn-and-exit path instead. +/// `ExitRequested { code: None }` can be intercepted by the desktop lifecycle; +/// hopping to the main thread takes Tauri's direct spawn-and-exit path instead. fn restart_on_main_thread(app: AppHandle) { let handle = app.clone(); let _ = app.run_on_main_thread(move || handle.restart()); @@ -4358,19 +4355,3 @@ fn get_capsule_auxiliary_snapshot( "fallback": coord.tauri_host().auxiliary_fallback_snapshot(), }) } - -/// Runtime health of the disposable native capsule, for the settings page: -/// `active` mirrors the launch-time low-memory switch, `available` is false -/// when the sidecar helper is missing or failed to initialize. -#[cfg(not(mobile))] -#[tauri::command] -fn native_capsule_status( - coord: tauri::State<'_, Arc>, -) -> serde_json::Value { - let (available, error) = coord.tauri_host().native_capsule_status(); - serde_json::json!({ - "active": crate::desktop_resources::active(), - "available": available, - "error": error, - }) -} diff --git a/openless-all/app/src-tauri/src/native_capsule.rs b/openless-all/app/src-tauri/src/native_capsule.rs index a1aa6a40c..1d77f83a2 100644 --- a/openless-all/app/src-tauri/src/native_capsule.rs +++ b/openless-all/app/src-tauri/src/native_capsule.rs @@ -502,8 +502,7 @@ pub(crate) fn native_capsule_set_locale(app: tauri::AppHandle, locale: String) { ) { return; } - // Save even in regular mode: enabling low-memory mode then restarting - // silently must keep the UI language chosen before the restart. + // Persist the helper locale so it survives app restarts. if let Ok(directory) = app.path().app_config_dir() { if std::fs::create_dir_all(&directory).is_ok() { let temporary = directory.join("native-capsule.locale.tmp"); diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index a437e4bd2..bee237884 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -145,13 +145,8 @@ pub(crate) fn voice_diagnostic( let _ = (probe_id, hypothesis_ids, event, outcome, safe); } -/// Low-memory mode suppresses optional prewarming, not an explicitly active voice listener. -pub(crate) fn warm_capture_enabled( - low_latency: bool, - low_memory: bool, - voice_activation: bool, -) -> bool { - voice_activation || (low_latency && !low_memory) +pub(crate) fn warm_capture_enabled(low_latency: bool, voice_activation: bool) -> bool { + voice_activation || low_latency } fn voice_capture_key(generation: u64) -> u64 { @@ -3888,16 +3883,10 @@ mod tests { ); } #[test] - fn low_memory_disables_only_optional_warm_capture() { - for low_latency in [false, true] { - for low_memory in [false, true] { - assert!(warm_capture_enabled(low_latency, low_memory, true)); - } - } - assert!(warm_capture_enabled(true, false, false)); - assert!(!warm_capture_enabled(true, true, false)); - assert!(!warm_capture_enabled(false, false, false)); - assert!(!warm_capture_enabled(false, true, false)); + fn low_latency_or_voice_activation_enables_warm_capture() { + assert!(warm_capture_enabled(true, false)); + assert!(warm_capture_enabled(false, true)); + assert!(!warm_capture_enabled(false, false)); } #[test] diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 4edea0869..a01b70bb0 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -1905,11 +1905,6 @@ pub(crate) struct TauriCoordinatorHost { capsule: Arc, #[cfg(not(mobile))] native: Arc>>>, - /// Last sidecar init failure, surfaced by `native_capsule_status` so the - /// settings page can explain a missing recording overlay instead of - /// failing silently. - #[cfg(not(mobile))] - native_capsule_error: Arc>>, qa_context: Arc, } @@ -1923,8 +1918,6 @@ impl TauriCoordinatorHost { capsule: Arc::new(CapsuleWindowState::default()), #[cfg(not(mobile))] native: Arc::new(Mutex::new(None)), - #[cfg(not(mobile))] - native_capsule_error: Arc::new(Mutex::new(None)), qa_context, } } @@ -1938,11 +1931,9 @@ impl TauriCoordinatorHost { match crate::native_capsule::NativeCapsule::new(app.clone()) { Ok(native) => { *self.native.lock() = Some(Arc::new(native)); - *self.native_capsule_error.lock() = None; } Err(error) => { log::error!("[native-capsule] {error}; dictation remains available"); - *self.native_capsule_error.lock() = Some(error.to_string()); } } } @@ -1971,17 +1962,6 @@ impl TauriCoordinatorHost { } } - /// `(available, last_error)` for the disposable capsule sidecar. - #[cfg(not(mobile))] - pub(crate) fn native_capsule_status(&self) -> (bool, Option) { - let native = self.native.lock(); - let error = native - .as_ref() - .and_then(|native| native.last_error()) - .or_else(|| self.native_capsule_error.lock().clone()); - (native.is_some() && error.is_none(), error) - } - #[cfg(not(mobile))] pub(crate) fn present_native_capsule( &self, diff --git a/openless-all/app/src-tauri/tauri.macos.conf.json b/openless-all/app/src-tauri/tauri.macos.conf.json index 0e34611d1..5ad713efb 100644 --- a/openless-all/app/src-tauri/tauri.macos.conf.json +++ b/openless-all/app/src-tauri/tauri.macos.conf.json @@ -21,11 +21,10 @@ "y": 253 } } - }, - "externalBin": ["binaries/openless-native-capsule"] + } }, "build": { - "beforeBuildCommand": "npm run build:native-capsule && npm run build", - "beforeDevCommand": "npm run build:native-capsule:dev && npm run dev" + "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run dev" } } diff --git a/openless-all/app/src-tauri/tauri.windows.conf.json b/openless-all/app/src-tauri/tauri.windows.conf.json index 073c949d6..e04ed9b7d 100644 --- a/openless-all/app/src-tauri/tauri.windows.conf.json +++ b/openless-all/app/src-tauri/tauri.windows.conf.json @@ -59,10 +59,7 @@ ] }, "build": { - "beforeBuildCommand": "npm run build:native-capsule && npm run build", - "beforeDevCommand": "npm run build:native-capsule:dev && npm run dev" - }, - "bundle": { - "externalBin": ["binaries/openless-native-capsule"] + "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run dev" } } diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index 57aa5ef7d..ef4bed90d 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -3,7 +3,7 @@ // Web Audio 输出会被某些桌面音频栈当作 sink-input,启动/退出时触发音量 OSD; // Android 也可能因 Web Audio 输出切换录音输入路由,因此这两个平台禁用。 -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { isAndroid, isTauri } from '../lib/ipc'; import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; import type { CapsuleState, UserPreferences } from '../lib/types'; @@ -19,26 +19,7 @@ interface CapsulePayload { export function AudioCueListener() { const audioCueEnabledRef = useRef(true); const prevStateRef = useRef('idle' as CapsuleState); - const [nativeCueOwner, setNativeCueOwner] = useState(isTauri ? null : false); - useEffect(() => { - if (!isTauri || isAndroid()) { - setNativeCueOwner(false); - return; - } - let cancelled = false; - void import('@tauri-apps/api/core') - .then(({ invoke }) => invoke('desktop_low_memory_active')) - .then((active) => { - if (!cancelled) setNativeCueOwner(active); - }) - .catch(() => { - if (!cancelled) setNativeCueOwner(false); - }); - return () => { - cancelled = true; - }; - }, []); - const audioCueRuntimeEnabled = !isAndroid() && nativeCueOwner === false; + const audioCueRuntimeEnabled = !isAndroid(); // Read settings (enabled by default) useEffect(() => { diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index 89d911690..e5a5f0345 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -443,6 +443,8 @@ height: 40px; padding: 4px 6px; border-radius: 999px; + overflow: hidden; + isolation: isolate; box-sizing: border-box; will-change: left, width; } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 4853255db..128494d14 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1745,16 +1745,6 @@ export const de: typeof zhCN = { voiceActivationTimeoutEnabled: 'Nach Zeitüberschreitung stoppen', voiceActivationTimeoutSeconds: 'Zeitüberschreitung (Sekunden)', startupGroupTitle: 'Startverhalten', - lowMemoryBackgroundLabel: 'Speichersparender Hintergrundmodus', - lowMemoryBackgroundDesc: - 'Nach einem Neustart: Hauptfenster beim Schließen freigeben, native Kapsel bei Bedarf anzeigen und Mikrofonvorwärmung im Leerlauf deaktivieren. Die Freigabeeinstellungen lokaler Modelle bleiben erhalten.', - lowMemoryRestartLabel: 'Wird nach Neustart wirksam', - lowMemoryRestartDesc: - 'Der Schalter für den speichersparenden Hintergrundmodus ist gespeichert; der aktive Modus wechselt erst nach einem Neustart der App.', - lowMemoryRestartNow: 'Jetzt neu starten', - lowMemoryCapsuleErrorLabel: 'Native Kapsel nicht verfügbar', - lowMemoryCapsuleErrorDesc: - 'Diktieren funktioniert weiterhin, aber die schwebende Kapsel wird nicht angezeigt. Das Hilfsprogramm fehlt möglicherweise oder wird blockiert; installiere die App neu.', startMinimizedLabel: 'Minimiert starten (ohne Hauptfenster)', startMinimizedDesc: 'Zeigt bei keinem Startweg das Hauptfenster an. Nur Menüleiste / Infobereich.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ea2802538..4fd54612b 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1712,16 +1712,6 @@ export const en: typeof zhCN = { voiceActivationTimeoutEnabled: 'Stop after timeout', voiceActivationTimeoutSeconds: 'Timeout (seconds)', startupGroupTitle: 'Startup', - lowMemoryBackgroundLabel: 'Low-memory background mode', - lowMemoryBackgroundDesc: - 'Applies after restart. Release the main window on close and show a native capsule on demand. Disable microphone prewarming while idle. Local models keep their existing release settings.', - lowMemoryRestartLabel: 'Applies after restart', - lowMemoryRestartDesc: - 'The low-memory background switch is saved; the running mode changes after the app restarts.', - lowMemoryRestartNow: 'Restart now', - lowMemoryCapsuleErrorLabel: 'Native capsule unavailable', - lowMemoryCapsuleErrorDesc: - 'Dictation still works, but the floating capsule will not appear. The helper may be missing or blocked; reinstall the app.', startMinimizedLabel: 'Start minimized (no main window)', startMinimizedDesc: 'No main window on any launch path — menu bar / tray only.', autoUpdateCheckLabel: 'Auto-check for updates', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 42c6dee0e..b7fe1d6a0 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1737,16 +1737,6 @@ export const es: typeof zhCN = { voiceActivationTimeoutEnabled: 'Detener al agotarse el tiempo', voiceActivationTimeoutSeconds: 'Tiempo de espera (segundos)', startupGroupTitle: 'Inicio', - lowMemoryBackgroundLabel: 'Modo de fondo con poca memoria', - lowMemoryBackgroundDesc: - 'Se aplica al reiniciar. Libera la ventana principal al cerrarla y muestra la cápsula nativa cuando se necesita. Desactiva el precalentamiento del micrófono en reposo. Conserva la configuración de descarga de los modelos locales.', - lowMemoryRestartLabel: 'Se aplica tras reiniciar', - lowMemoryRestartDesc: - 'El cambio del modo de fondo de baja memoria está guardado; el modo en ejecución cambia al reiniciar la aplicación.', - lowMemoryRestartNow: 'Reiniciar ahora', - lowMemoryCapsuleErrorLabel: 'Cápsula nativa no disponible', - lowMemoryCapsuleErrorDesc: - 'El dictado sigue funcionando, pero no se mostrará la cápsula flotante. Puede que falte el programa auxiliar o esté bloqueado; reinstala la app.', startMinimizedLabel: 'Iniciar minimizado (sin ventana principal)', startMinimizedDesc: 'Al iniciar, solo se muestra la barra de menús o la bandeja del sistema, nunca la ventana principal.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 4e557cdcd..4b325577b 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1758,16 +1758,6 @@ export const fr: typeof zhCN = { voiceActivationTimeoutEnabled: 'Arrêter après expiration', voiceActivationTimeoutSeconds: 'Délai (secondes)', startupGroupTitle: 'Démarrage', - lowMemoryBackgroundLabel: 'Mode arrière-plan économe en mémoire', - lowMemoryBackgroundDesc: - 'Après redémarrage : libère la fenêtre principale à sa fermeture et affiche la capsule native à la demande. Désactive le préchauffage du microphone au repos. Conserve les réglages de libération des modèles locaux.', - lowMemoryRestartLabel: 'Appliqué après redémarrage', - lowMemoryRestartDesc: - "Le commutateur du mode arrière à faible mémoire est enregistré ; le mode actif ne change qu'après le redémarrage de l'application.", - lowMemoryRestartNow: 'Redémarrer maintenant', - lowMemoryCapsuleErrorLabel: 'Capsule native indisponible', - lowMemoryCapsuleErrorDesc: - "La dictée fonctionne toujours, mais la capsule flottante ne s'affichera pas. Le programme auxiliaire est peut-être manquant ou bloqué ; réinstallez l'application.", startMinimizedLabel: 'Démarrer réduit (sans fenêtre principale)', startMinimizedDesc: 'Au lancement, seule la barre des menus ou la zone de notification est affichée, jamais la fenêtre principale.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index a02c1966b..c46b848f5 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1699,16 +1699,6 @@ export const ja: typeof zhCN = { voiceActivationTimeoutEnabled: 'タイムアウトで停止', voiceActivationTimeoutSeconds: 'タイムアウト(秒)', startupGroupTitle: '起動', - lowMemoryBackgroundLabel: '省メモリバックグラウンドモード', - lowMemoryBackgroundDesc: - '再起動後に適用。メイン画面を閉じると解放し、録音時のみネイティブカプセルを表示します。待機中のマイク事前起動を無効にします。ローカルモデルの解放設定は維持されます。', - lowMemoryRestartLabel: '再起動後に適用', - lowMemoryRestartDesc: - '低メモリバックグラウンドモードの設定を保存しました。実行モードの切り替えにはアプリの再起動が必要です。', - lowMemoryRestartNow: '今すぐ再起動', - lowMemoryCapsuleErrorLabel: 'ネイティブカプセル利用不可', - lowMemoryCapsuleErrorDesc: - 'ディクテーションは引き続き利用できますが、フローティングカプセルは表示されません。ヘルパーが見つからないかブロックされている可能性があります。アプリを再インストールしてください。', startMinimizedLabel: '起動時にメインウィンドウを表示しない', startMinimizedDesc: 'どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 9b3cb0416..c125c4484 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1687,16 +1687,6 @@ export const ko: typeof zhCN = { voiceActivationTimeoutEnabled: '시간 초과 후 중지', voiceActivationTimeoutSeconds: '시간 초과(초)', startupGroupTitle: '시작', - lowMemoryBackgroundLabel: '저메모리 백그라운드 모드', - lowMemoryBackgroundDesc: - '재시작 후 적용됩니다. 메인 창을 닫으면 해제하고 녹음 시에만 네이티브 캡슐을 표시합니다. 대기 중 마이크 사전 실행을 끕니다. 로컬 모델 해제 설정은 유지됩니다.', - lowMemoryRestartLabel: '재시작 후 적용', - lowMemoryRestartDesc: - '저메모리 백그라운드 모드 설정이 저장되었습니다. 실행 모드는 앱을 재시작해야 전환됩니다.', - lowMemoryRestartNow: '지금 재시작', - lowMemoryCapsuleErrorLabel: '네이티브 캡슐을 사용할 수 없음', - lowMemoryCapsuleErrorDesc: - '받아쓰기는 계속 사용할 수 있지만 플로팅 캡슐이 표시되지 않습니다. 헬퍼가 없거나 차단되었을 수 있습니다. 앱을 다시 설치해 주세요.', startMinimizedLabel: '시작 시 메인 창 숨기기', startMinimizedDesc: '모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2d255f7d2..ba64ea7e3 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1626,15 +1626,6 @@ export const zhCN = { audioRecordingMaxEntriesLabel: '原始录音保留条数', audioRecordingMaxEntriesDesc: '本地保留 wav 文件数上限,留空 = 200。', startupGroupTitle: '启动', - lowMemoryBackgroundLabel: '低内存后台模式', - lowMemoryBackgroundDesc: - '重启后生效。关闭主界面时释放窗口,录音时按需显示原生胶囊;空闲时关闭麦克风预热。本地模型仍按原有设置释放。', - lowMemoryRestartLabel: '重启后生效', - lowMemoryRestartDesc: '低内存后台模式的开关已保存,重启应用后才会切换运行模式。', - lowMemoryRestartNow: '立即重启', - lowMemoryCapsuleErrorLabel: '原生胶囊不可用', - lowMemoryCapsuleErrorDesc: - '听写仍可进行,但不会显示悬浮胶囊。辅助程序可能缺失或被系统拦截,请重新安装应用。', startMinimizedLabel: '启动时静默运行', startMinimizedDesc: '所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。', autoUpdateCheckLabel: '自动检查更新', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 53997a4c1..a9bdf2d3f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1626,15 +1626,6 @@ export const zhTW: typeof zhCN = { voiceActivationTimeoutEnabled: '逾時後停止', voiceActivationTimeoutSeconds: '逾時(秒)', startupGroupTitle: '啟動', - lowMemoryBackgroundLabel: '低記憶體背景模式', - lowMemoryBackgroundDesc: - '重新啟動後生效。關閉主介面時釋放視窗,錄音時按需顯示原生膠囊;閒置時關閉麥克風預熱。本機模型仍依原有設定釋放。', - lowMemoryRestartLabel: '重啟後生效', - lowMemoryRestartDesc: '低記憶體背景模式開關已儲存,重新啟動應用後才會切換執行模式。', - lowMemoryRestartNow: '立即重啟', - lowMemoryCapsuleErrorLabel: '原生膠囊不可用', - lowMemoryCapsuleErrorDesc: - '聽寫仍可進行,但不會顯示懸浮膠囊。輔助程式可能缺失或被系統攔截,請重新安裝應用。', startMinimizedLabel: '啟動時靜默執行', startMinimizedDesc: '所有啟動路徑都不彈主視窗,僅選單欄 / 託盤執行。', autoUpdateCheckLabel: '自動檢查更新', diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index b949d6cb4..295e84c6d 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -30,7 +30,6 @@ export { updateSettingFields, getDefaultStyleSystemPrompts, setSettings, - getNativeCapsuleStatus, } from './settings'; // asr-credentials diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 1c5014401..b3566fb4d 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -49,7 +49,6 @@ export let mockSettings: UserPreferences = { muteDuringRecording: false, stableTranscriptionEnabled: false, lowLatencyDictationEnabled: false, - lowMemoryBackgroundEnabled: false, audioEnhancementEnabled: false, echoCancellationEnabled: false, voiceActivationEnabled: false, diff --git a/openless-all/app/src/lib/ipc/settings.ts b/openless-all/app/src/lib/ipc/settings.ts index fc48e1ba1..818839850 100644 --- a/openless-all/app/src/lib/ipc/settings.ts +++ b/openless-all/app/src/lib/ipc/settings.ts @@ -1,4 +1,4 @@ -import type { NativeCapsuleStatus, StyleSystemPrompts, UserPreferences } from '../types'; +import type { StyleSystemPrompts, UserPreferences } from '../types'; export type { UpdateChannel } from '../types'; import { BACKEND_CONTRACT_VERSION, @@ -64,12 +64,3 @@ export function updateSettingFields(edits: Record): Promise { - return invokeOrMock('native_capsule_status', undefined, () => ({ - active: false, - available: true, - error: null, - })); -} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 32e2b5618..7c5393edf 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -370,15 +370,6 @@ export interface AudioDspConfig { processingOrder: AudioDspStage[]; } -/** Runtime health of the disposable native capsule sidecar (desktop only). */ -export interface NativeCapsuleStatus { - /** Whether the launch-time low-memory background switch is active. */ - active: boolean; - /** False when the sidecar helper is missing or failed to initialize. */ - available: boolean; - error: string | null; -} - export interface UserPreferences { hotkey: HotkeyBinding; dictationHotkey: ShortcutBinding; @@ -407,7 +398,6 @@ export interface UserPreferences { echoCancellationEnabled: boolean; audioDsp?: AudioDspConfig; /** Desktop resource policy, applied after restarting the application. */ - lowMemoryBackgroundEnabled: boolean; /** Enable local-only voice activation while idle (Windows desktop only). */ voiceActivationEnabled: boolean; voiceActivationWakeEnabled: boolean; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index b87266a81..3c88c5303 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -25,10 +25,8 @@ import { VOICE_ACTIVATION_MODEL_ALIAS, type VoiceActivationStateSnapshot, } from '../../lib/voiceActivation'; -import { getNativeCapsuleStatus } from '../../lib/ipc/settings'; import { getPlatformCapabilities, getCachedPlatformCapabilities } from '../../lib/platform'; import type { - NativeCapsuleStatus, CapsuleStyle, HotkeyMode, MicrophoneDevice, @@ -44,7 +42,6 @@ import { SelectLite } from '../../components/ui/SelectLite'; import { Card, Collapsible, Btn } from '../_atoms'; import { Segmented } from '../../components/ui/Segmented'; -import { restartApp } from '../../lib/ipc'; import { SectionTitle, SettingRow, Toggle, inputStyle } from './shared'; import { MicrophoneSelect } from './MicrophoneSelect'; import { AudioDspSettings } from './AudioDspSettings'; @@ -931,18 +928,6 @@ export function RecordingInputSection({ {showDesktopStartup && ( - - - savePrefs({ ...prefs, lowMemoryBackgroundEnabled }) - } - /> - - @@ -955,82 +940,7 @@ export function RecordingInputSection({ ); } -// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / -// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 -// 低内存模式只在启动时读取一次开关;此处对比运行态与已保存的开关值, -// 不一致时提示重启,并顺带显示 sidecar 启动及运行失败的健康告警。 -function LowMemoryRuntimeNotice({ enabled }: { enabled: boolean }) { - const { t } = useTranslation(); - const [runtime, setRuntime] = useState(null); - useEffect(() => { - if (!isTauri) return; - let cancelled = false; - let revision = 0; - let unlisten: (() => void) | undefined; - const refresh = async () => { - const current = ++revision; - try { - const status = await getNativeCapsuleStatus(); - if (!cancelled && current === revision) setRuntime(status); - } catch (error) { - if (!cancelled) console.error('[low-memory] status failed', error); - } - }; - void import('@tauri-apps/api/event') - .then(async ({ listen }) => { - const dispose = await listen('native-capsule:health-changed', () => void refresh()); - if (cancelled) { - dispose(); - return; - } - unlisten = dispose; - await refresh(); - }) - .catch((error: unknown) => console.error('[low-memory] health listener failed', error)); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [enabled]); - if (!runtime) return null; - const needsRestart = runtime.active !== enabled; - const capsuleBroken = runtime.active && !runtime.available; - if (!needsRestart && !capsuleBroken) return null; - return ( - <> - {needsRestart ? ( - - - {t('settings.recording.lowMemoryRestartNow')} - - - ) : null} - {capsuleBroken ? ( - - {runtime.error ? ( - - {runtime.error} - - ) : null} - - ) : null} - - ); -} - +// Autostart state is owned by the operating system; it is intentionally not mirrored in prefs. function AutostartRow() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(false); From 6cde548d7e5683fc5ca28356e3387b015850070e Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Fri, 30 Oct 2026 22:17:44 +0800 Subject: [PATCH 2/4] ci: keep native capsule build contract --- openless-all/app/harmony/ipc-audit.json | 4 ++-- openless-all/app/package.json | 4 +++- openless-all/app/src-tauri/tauri.macos.conf.json | 7 ++++--- openless-all/app/src-tauri/tauri.windows.conf.json | 7 +++++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index 000fb75c5..51bfc707f 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 200, + "total": 199, "wired": 103, - "missing": 97, + "missing": 96, "commands": [ { "name": "accept_pending_correction", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 78c90b6e9..4af1a03e1 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -33,7 +33,9 @@ "check:android-shizuku-scaffolding": "node scripts/merge-android-shizuku-manifest.test.mjs && node scripts/patch-android-shizuku-deps.test.mjs", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", - "test:ui-motion": "playwright test" + "test:ui-motion": "playwright test", + "build:native-capsule": "node scripts/build-native-capsule.mjs", + "build:native-capsule:dev": "node scripts/build-native-capsule.mjs --debug" }, "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/openless-all/app/src-tauri/tauri.macos.conf.json b/openless-all/app/src-tauri/tauri.macos.conf.json index 5ad713efb..0e34611d1 100644 --- a/openless-all/app/src-tauri/tauri.macos.conf.json +++ b/openless-all/app/src-tauri/tauri.macos.conf.json @@ -21,10 +21,11 @@ "y": 253 } } - } + }, + "externalBin": ["binaries/openless-native-capsule"] }, "build": { - "beforeBuildCommand": "npm run build", - "beforeDevCommand": "npm run dev" + "beforeBuildCommand": "npm run build:native-capsule && npm run build", + "beforeDevCommand": "npm run build:native-capsule:dev && npm run dev" } } diff --git a/openless-all/app/src-tauri/tauri.windows.conf.json b/openless-all/app/src-tauri/tauri.windows.conf.json index e04ed9b7d..073c949d6 100644 --- a/openless-all/app/src-tauri/tauri.windows.conf.json +++ b/openless-all/app/src-tauri/tauri.windows.conf.json @@ -59,7 +59,10 @@ ] }, "build": { - "beforeBuildCommand": "npm run build", - "beforeDevCommand": "npm run dev" + "beforeBuildCommand": "npm run build:native-capsule && npm run build", + "beforeDevCommand": "npm run build:native-capsule:dev && npm run dev" + }, + "bundle": { + "externalBin": ["binaries/openless-native-capsule"] } } From 89fa0fe8f366d1753284822c44e3fcdff9e67e7a Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Fri, 30 Oct 2026 22:25:26 +0800 Subject: [PATCH 3/4] test: remove retired low-memory capsule health contract --- .../scripts/native-capsule-health.test.mjs | 96 ------------------- openless-all/app/src-tauri/src/lib.rs | 1 - openless-all/app/src/i18n/index.ts | 5 - 3 files changed, 102 deletions(-) delete mode 100644 openless-all/app/scripts/native-capsule-health.test.mjs diff --git a/openless-all/app/scripts/native-capsule-health.test.mjs b/openless-all/app/scripts/native-capsule-health.test.mjs deleted file mode 100644 index 83bd48ee3..000000000 --- a/openless-all/app/scripts/native-capsule-health.test.mjs +++ /dev/null @@ -1,96 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import vm from 'node:vm'; -import ts from 'typescript'; - -const source = readFileSync( - new URL('../src/pages/settings/RecordingInputSection.tsx', import.meta.url), - 'utf8', -); -const ast = ts.createSourceFile( - 'RecordingInputSection.tsx', - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX, -); -const notice = ast.statements.find( - (node) => ts.isFunctionDeclaration(node) && node.name?.text === 'LowMemoryRuntimeNotice', -); -let effect; -function visit(node) { - if (ts.isCallExpression(node) && node.expression.getText(ast) === 'useEffect') - effect = node.arguments[0].getText(ast); - ts.forEachChild(node, visit); -} -visit(notice); -assert.ok(effect, 'execute the production health subscription'); -const code = ts.transpileModule('globalThis.effect = (' + effect + ');', { - compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, -}).outputText; -const tick = () => new Promise((resolve) => setImmediate(resolve)); -function mount() { - const requests = []; - const writes = []; - let listener; - let disposed = 0; - const context = { - isTauri: true, - console, - setRuntime: (value) => writes.push(value), - getNativeCapsuleStatus: () => new Promise((resolve) => requests.push(resolve)), - require: () => ({ - listen: async (name, callback) => { - assert.equal(name, 'native-capsule:health-changed'); - listener = callback; - return () => { - disposed += 1; - }; - }, - }), - }; - vm.runInNewContext(code, context); - const cleanup = context.effect(); - return { requests, writes, cleanup, emit: () => listener(), disposed: () => disposed }; -} -const healthy = { active: true, available: true, error: null }; -const failed = { active: true, available: false, error: 'capsule handshake timed out' }; -{ - const view = mount(); - await tick(); - view.requests[0](healthy); - await tick(); - view.emit(); - view.requests[1](failed); - await tick(); - assert.equal(view.writes.at(-1), failed); - view.emit(); - view.requests[2](healthy); - await tick(); - assert.equal(view.writes.at(-1), healthy); - view.cleanup(); - assert.equal(view.disposed(), 1); -} -{ - const view = mount(); - await tick(); - view.emit(); - view.requests[1](failed); - await tick(); - view.requests[0](healthy); - await tick(); - assert.equal(view.writes.at(-1), failed, 'old healthy reply must not hide a new failure'); - view.emit(); - view.cleanup(); - view.requests[2](healthy); - await tick(); - assert.equal(view.writes.length, 1, 'unmounted notice must ignore replies'); -} -{ - const view = mount(); - view.cleanup(); - await tick(); - assert.equal(view.disposed(), 1); - assert.equal(view.requests.length, 0); -} -console.log('native capsule health: live failure, recovery, response ordering and cleanup passed'); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8fa83ebc1..c89565a46 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -254,7 +254,6 @@ macro_rules! app_invoke_handler_desktop { () => { tauri::generate_handler![ commands::get_startup_snapshot, - native_capsule::native_capsule_set_locale, get_capsule_auxiliary_snapshot, commands::get_capsule_snapshot, commands::get_dictation_translation_target, diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts index 1af8b87c3..7955f1b9c 100644 --- a/openless-all/app/src/i18n/index.ts +++ b/openless-all/app/src/i18n/index.ts @@ -138,11 +138,6 @@ export async function setLocalePreference( // requests its index). Non-Tauri (browser dev) hits a mock no-op; failures are silent. function syncRemoteLocale(resolved: SupportedLocale): void { void setRemoteLocale(resolved).catch(() => {}); - if (typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window) { - void import('@tauri-apps/api/core') - .then(({ invoke }) => invoke('native_capsule_set_locale', { locale: resolved })) - .catch(() => {}); - } } export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE; From 4a3826f79d01ff7b56adf36f55b861fc639bc400 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Fri, 30 Oct 2026 22:51:53 +0800 Subject: [PATCH 4/4] refactor: remove native capsule build path --- .github/workflows/ci.yml | 10 +- .../crates/openless-core/src/preferences.rs | 2 + openless-all/app/package.json | 4 +- .../app/scripts/build-native-capsule.mjs | 44 -------- .../app/scripts/native-capsule-build.test.mjs | 102 ------------------ .../app/src-tauri/tauri.macos.conf.json | 7 +- .../app/src-tauri/tauri.windows.conf.json | 7 +- 7 files changed, 10 insertions(+), 166 deletions(-) delete mode 100644 openless-all/app/scripts/build-native-capsule.mjs delete mode 100644 openless-all/app/scripts/native-capsule-build.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a38a03a0d..ca54a1f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -590,8 +590,8 @@ jobs: native-capsule: # The capsule crates compile into the desktop shells. They get their own macOS + Windows - # runners only because their `ui,host` feature set is not built anywhere else (the shells - # use `build:native-capsule:dev`), and that warm cache makes the job ~40 s. It is gated on + # runners only because their `ui,host` feature set is not built anywhere else, and that + # warm cache makes the job ~40 s. It is gated on # its own area, so the common PR pays zero runners for it: measured on run 40510306688, # moving these three commands into `cross-platform` cost 117 s on the critical-path Windows # job, which is more than the two runners it saved. @@ -736,10 +736,6 @@ jobs: if: runner.os == 'macOS' 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 @@ -880,8 +876,6 @@ 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 diff --git a/openless-all/app/crates/openless-core/src/preferences.rs b/openless-all/app/crates/openless-core/src/preferences.rs index 70ee31bb4..a76f075a8 100644 --- a/openless-all/app/crates/openless-core/src/preferences.rs +++ b/openless-all/app/crates/openless-core/src/preferences.rs @@ -126,6 +126,8 @@ fn strip_local_only_preferences(value: &mut serde_json::Value) { object.remove("lowLatencyDictationEnabled"); object.remove("echoCancellationEnabled"); object.remove("audioDsp"); + // Retire the removed low-memory mode from legacy sync payloads. + object.remove("lowMemoryBackgroundEnabled"); } } diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 4af1a03e1..78c90b6e9 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -33,9 +33,7 @@ "check:android-shizuku-scaffolding": "node scripts/merge-android-shizuku-manifest.test.mjs && node scripts/patch-android-shizuku-deps.test.mjs", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"assets/remote-input/*.{js,css,html}\"", - "test:ui-motion": "playwright test", - "build:native-capsule": "node scripts/build-native-capsule.mjs", - "build:native-capsule:dev": "node scripts/build-native-capsule.mjs --debug" + "test:ui-motion": "playwright test" }, "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/openless-all/app/scripts/build-native-capsule.mjs b/openless-all/app/scripts/build-native-capsule.mjs deleted file mode 100644 index effbfc321..000000000 --- a/openless-all/app/scripts/build-native-capsule.mjs +++ /dev/null @@ -1,44 +0,0 @@ -// Build a separate graphics executable: the Tauri host keeps its UI-independent -// dependency graph and never links egui/eframe. Both executables use the project Rust toolchain. -import { spawnSync } from 'node:child_process'; -import { copyFileSync, mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const debug = process.argv.includes('--debug'); -const profile = debug ? 'debug' : 'release'; -const cargo = process.env.CARGO ?? 'cargo'; -const rustc = process.env.RUSTC ?? 'rustc'; -const info = spawnSync(rustc, ['-vV'], { encoding: 'utf8' }); -if (info.status !== 0) throw new Error(info.stderr || 'rustc is unavailable'); -const host = /^host: (.+)$/m.exec(info.stdout)?.[1]; -const target = process.env.TAURI_ENV_TARGET_TRIPLE || host; -if (!target || (!target.includes('darwin') && !target.includes('windows'))) { - throw new Error( - 'The desktop capsule bundling step requires a macOS or Windows target. Linux uses its existing egui host.', - ); -} -const args = ['build', '--locked', '-p', 'openless-native-capsule', '--target', target]; -if (!debug) args.push('--release'); -const build = spawnSync(cargo, args, { cwd: root, stdio: 'inherit' }); -if (build.error) throw build.error; -if (build.status !== 0) process.exit(build.status ?? 1); -const suffix = target.includes('windows') ? '.exe' : ''; -const name = `openless-native-capsule${suffix}`; -const targetRoot = process.env.CARGO_TARGET_DIR - ? resolve(root, process.env.CARGO_TARGET_DIR) - : resolve(root, 'target'); -const source = resolve(targetRoot, target, profile, name); -const binaries = resolve(root, 'src-tauri/binaries'); -mkdirSync(binaries, { recursive: true }); -// Tauri's externalBin convention uses a target-suffixed source filename. -copyFileSync(source, resolve(binaries, `openless-native-capsule-${target}${suffix}`)); -// Raw cargo/tauri dev launches also discover the helper next to the host. -const hostTarget = process.env.CARGO_TARGET_DIR ? targetRoot : resolve(root, 'src-tauri/target'); -for (const hostOutput of [resolve(hostTarget, profile), resolve(hostTarget, target, profile)]) { - mkdirSync(hostOutput, { recursive: true }); - const destination = resolve(hostOutput, name); - if (source !== destination) copyFileSync(source, destination); -} -console.log(`[native-capsule] prepared ${target} ${profile} helper`); diff --git a/openless-all/app/scripts/native-capsule-build.test.mjs b/openless-all/app/scripts/native-capsule-build.test.mjs deleted file mode 100644 index 9d62b6175..000000000 --- a/openless-all/app/scripts/native-capsule-build.test.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import vm from 'node:vm'; -import yaml from 'js-yaml'; - -const scriptUrl = new URL('./build-native-capsule.mjs', import.meta.url); -const script = readFileSync(scriptUrl, 'utf8') - .replace(/^import .*;\r?\n/gm, '') - .replaceAll('import.meta.url', JSON.stringify(scriptUrl.href)); -const root = resolve(dirname(fileURLToPath(scriptUrl)), '..'); -function run(env = {}, debug = false) { - const copies = []; - const commands = []; - vm.runInNewContext(script, { - dirname, - resolve, - fileURLToPath, - console: { log() {} }, - process: { argv: debug ? ['node', 'script', '--debug'] : ['node', 'script'], env }, - spawnSync: (command, args) => { - commands.push([command, [...args]]); - return { status: 0, stdout: 'host: x86_64-pc-windows-msvc\n' }; - }, - mkdirSync() {}, - copyFileSync: (source, destination) => copies.push([source, destination]), - }); - return { copies, commands }; -} -const target = 'x86_64-pc-windows-msvc'; -{ - const { copies, commands } = run({}, true); - assert.deepEqual(commands[1][1], [ - 'build', - '--locked', - '-p', - 'openless-native-capsule', - '--target', - target, - ]); - assert.deepEqual( - copies.map(([, destination]) => destination), - [ - resolve(root, 'src-tauri/binaries', 'openless-native-capsule-' + target + '.exe'), - resolve(root, 'src-tauri/target/debug/openless-native-capsule.exe'), - resolve(root, 'src-tauri/target', target, 'debug/openless-native-capsule.exe'), - ], - ); -} -{ - const targetDir = resolve(root, 'custom-target'); - const { copies, commands } = run({ CARGO_TARGET_DIR: targetDir }); - assert.equal(commands[1][1].at(-1), '--release'); - assert.equal(copies[0][0], resolve(targetDir, target, 'release/openless-native-capsule.exe')); - assert.equal(copies[1][1], resolve(targetDir, 'release/openless-native-capsule.exe')); - assert.equal(copies.length, 2, 'never copy the target-specific helper onto itself'); -} -{ - const { copies } = run({ TAURI_ENV_TARGET_TRIPLE: 'aarch64-apple-darwin' }); - assert.equal( - copies[0][1], - resolve(root, 'src-tauri/binaries/openless-native-capsule-aarch64-apple-darwin'), - ); -} -assert.throws( - () => run({ TAURI_ENV_TARGET_TRIPLE: 'aarch64-linux-android' }), - /macOS or Windows target/, -); -const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); -for (const platform of ['macos', 'windows']) { - const config = JSON.parse( - readFileSync(new URL('../src-tauri/tauri.' + platform + '.conf.json', import.meta.url), 'utf8'), - ); - for (const hook of [config.build.beforeBuildCommand, config.build.beforeDevCommand]) { - for (const [, name] of hook.matchAll(/npm run ([\w:-]+)/g)) - assert.ok(scripts[name], platform + ' references missing npm script: ' + name); - } - assert.ok(config.bundle.externalBin.includes('binaries/openless-native-capsule')); -} -const ci = yaml.load( - readFileSync(new URL('../../../.github/workflows/ci.yml', import.meta.url), 'utf8'), -); -for (const job of ['cross-platform', 'macos-msrv']) { - const steps = ci.jobs[job].steps; - const helperBuild = steps.findIndex((step) => step.run === 'npm run build:native-capsule:dev'); - const hostBuild = steps.findIndex((step) => - /cargo[^\n]*--manifest-path src-tauri\/Cargo.toml/.test(step.run ?? ''), - ); - assert.ok( - helperBuild >= 0 && helperBuild < hostBuild, - job + ' must build the real externalBin before compiling Tauri', - ); - assert.equal( - steps[helperBuild].if, - undefined, - job + ' must prepare the helper on every desktop platform', - ); -} -console.log( - 'native capsule build: output directories, target selection, platform hooks and CI prerequisites passed', -); diff --git a/openless-all/app/src-tauri/tauri.macos.conf.json b/openless-all/app/src-tauri/tauri.macos.conf.json index 0e34611d1..5ad713efb 100644 --- a/openless-all/app/src-tauri/tauri.macos.conf.json +++ b/openless-all/app/src-tauri/tauri.macos.conf.json @@ -21,11 +21,10 @@ "y": 253 } } - }, - "externalBin": ["binaries/openless-native-capsule"] + } }, "build": { - "beforeBuildCommand": "npm run build:native-capsule && npm run build", - "beforeDevCommand": "npm run build:native-capsule:dev && npm run dev" + "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run dev" } } diff --git a/openless-all/app/src-tauri/tauri.windows.conf.json b/openless-all/app/src-tauri/tauri.windows.conf.json index 073c949d6..e04ed9b7d 100644 --- a/openless-all/app/src-tauri/tauri.windows.conf.json +++ b/openless-all/app/src-tauri/tauri.windows.conf.json @@ -59,10 +59,7 @@ ] }, "build": { - "beforeBuildCommand": "npm run build:native-capsule && npm run build", - "beforeDevCommand": "npm run build:native-capsule:dev && npm run dev" - }, - "bundle": { - "externalBin": ["binaries/openless-native-capsule"] + "beforeBuildCommand": "npm run build", + "beforeDevCommand": "npm run dev" } }