diff --git a/.gitignore b/.gitignore index 4985d878c..be8d5c105 100644 --- a/.gitignore +++ b/.gitignore @@ -115,3 +115,6 @@ CapsWriter .reasonix /.codex + +# Mimosa 安全钩子运行状态(不属版本库) +.mimosa/ diff --git a/README.md b/README.md index 4298c5f94..c2b13cb1f 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ For the full end-user walkthrough, see [USAGE.md](USAGE.md). ## Build from source (developers) -The active workspace lives in `openless-all/app/`. `crates/openless-core` is the framework-independent backend, `src-tauri` hosts macOS/Windows/Android, and `linux-egui` contains the native Linux UI and its platform adapters. The macOS Tauri build links a vendored C ASR engine ([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr), forked from `antirez/qwen-asr`) under `src-tauri/vendor/qwen-asr/`; initialize submodules for macOS Tauri development. The root core/Linux workspace deliberately excludes `src-tauri`, so Linux core and host checks neither initialize that submodule nor parse the Tauri manifest. +The active workspace lives in `openless-all/app/`. `crates/openless-core` is the framework-independent backend, `src-tauri` hosts macOS/Windows/Android, and `linux-egui` contains the native Linux UI and its platform adapters. Initialize submodules before a Tauri source build: its manifest resolves local path dependencies even when their target-specific code is not compiled. These include macOS ASR engines such as [`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr) under `src-tauri/vendor/`. The root Core/Linux workspace excludes `src-tauri`, so its independent checks do not parse the Tauri manifest or require those submodules. Start with the [documentation index](docs/index.md), [architecture](docs/architecture.md), and [source structure](docs/structure.md). Rust 1.88 is the minimum supported toolchain for source builds; the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS, Windows, and Linux. @@ -269,7 +269,7 @@ cd "openless-all/app" npm ci # macOS/Windows/Android: Vite at :1420 + Tauri host -# Initialize submodules first when building the macOS local-ASR target. +# Initialize submodules before resolving the Tauri manifest. git submodule update --init --recursive npm run tauri dev @@ -277,7 +277,7 @@ npm run tauri dev ./scripts/build-mac.sh INSTALL=0 ./scripts/build-mac.sh # build only, skip install -# Shared backend and Linux non-UI host (no Tauri/WebKitGTK) +# Shared backend and Linux Host/UI (no Tauri/WebKitGTK) cargo check -p openless-core cargo check -p openless-linux-egui --all-targets @@ -357,7 +357,7 @@ What features does this app still need? …not a list of missing features. -Long-term reference rewrites are stored as `raw → polished → rule` triples and will be retrieved as similar-example references (never as conversation context) once a vector store is wired in. See [docs/polish-reference-corpus.md](docs/polish-reference-corpus.md) and [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl). +Long-term reference rewrites are represented as `raw → polished → rule` triples in [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl). The sample format describes reference data; current module responsibilities are documented in [Architecture](docs/architecture.md). ## Dictionary @@ -389,13 +389,13 @@ React UI ── Tauri Adapter (macOS/Windows/Android) ──┐ egui UI ── Linux Adapter (no Tauri/WebKitGTK) ────┘ ``` -`openless-core` owns the stable DTOs, errors, semantic events, repositories, credentials contract, and host-facing use-case Interface. Host-only concerns—IPC, windows, tray, permissions, updater, keyring, fcitx5, and package resource paths—are implemented by Adapters. Legacy React command/event names stay in the Tauri compatibility Adapter; Linux calls the typed Rust Interface in process. See [`docs/linux-egui-backend-contract.md`](docs/linux-egui-backend-contract.md) and the [full migration plan](docs/linux-egui-shared-backend-plan.md). +`openless-core` owns the stable DTOs, errors, semantic events, repositories, credentials contract, and host-facing use-case Interface. Host-only concerns—IPC, windows, tray, permissions, updater, keyring, fcitx5, and package resource paths—are implemented by Adapters. Legacy React command/event names stay in the Tauri compatibility Adapter; Linux calls the typed Rust Interface in process. See [`docs/linux-egui-backend-contract.md`](docs/linux-egui-backend-contract.md). The `v-tauri` / `v-Beta.N-tauri` workflows publish the macOS, Windows, and Android hosts. Linux deb/rpm/AppImage assets are built by `release-linux-egui.yml` with an independent manifest; automatic release remains gated on successful artifacts and real Ubuntu install/runtime/upgrade/rollback evidence. The dictation pipeline: `hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`. -See [CLAUDE.md](CLAUDE.md) for invariants and module-wiring rules. +See [AGENTS.md](AGENTS.md) for repository rules and [Architecture](docs/architecture.md) for module responsibilities and wiring. ## Roadmap @@ -432,7 +432,7 @@ OpenLess ships two release channels. The branch name equals the channel name (se ### Post-release verification (always run) -Run the 5-step checklist in [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md): page status (pre-release flag), asset-filename channel correctness, Stable user flow, Beta opt-in flow, and raw endpoint sanity. +Follow [RELEASING.md](RELEASING.md) and verify the release page's pre-release flag, asset-filename channel correctness, Stable user flow, Beta opt-in flow, and raw update endpoints. ## Acknowledgements diff --git a/README.zh.md b/README.zh.md index 14e93de12..d5062b154 100644 --- a/README.zh.md +++ b/README.zh.md @@ -263,7 +263,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 ## 从源码构建(开发者) -活跃 workspace 位于 `openless-all/app/`:`crates/openless-core` 是框架无关后端,`src-tauri` 承载 macOS/Windows/Android,`linux-egui` 包含 Linux 原生 UI 与平台 Adapter。macOS Tauri 构建会链接 `src-tauri/vendor/qwen-asr/` 下的 vendored C ASR 引擎([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr),fork 自 `antirez/qwen-asr`),因此开发 macOS Tauri 目标时需要初始化子模块。根 core/Linux workspace 显式排除了 `src-tauri`,Linux 的 core/host 检查既不初始化该子模块,也不解析 Tauri manifest。 +活跃 workspace 位于 `openless-all/app/`:`crates/openless-core` 是框架无关后端,`src-tauri` 承载 macOS/Windows/Android,`linux-egui` 包含 Linux 原生 UI 与平台 Adapter。源码构建 Tauri 前需初始化子模块:即使不编译对应平台代码,Cargo 仍会解析 manifest 中的本地 path 依赖,其中包括 `src-tauri/vendor/` 下的 macOS ASR 引擎,如 [`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr)。根 Core/Linux workspace 排除了 `src-tauri`,其独立检查不解析 Tauri manifest,也不要求这些子模块。阅读入口为 [docs/index.md](docs/index.md)、[架构](docs/architecture.md)和[目录结构](docs/structure.md)。 Rust 1.88 是从源码构建所支持的最低工具链版本;建议使用最新 stable Rust。CI 会在 macOS、Windows 和 Linux 上同时验证 Rust 1.88 与 stable。 @@ -274,7 +274,7 @@ cd "openless-all/app" npm ci # macOS/Windows/Android:Vite 运行于 :1420 + Tauri 宿主 -# 构建 macOS 本地 ASR 目标前先初始化子模块。 +# 解析 Tauri manifest 前先初始化子模块。 git submodule update --init --recursive npm run tauri dev @@ -282,7 +282,7 @@ npm run tauri dev ./scripts/build-mac.sh INSTALL=0 ./scripts/build-mac.sh # 仅构建,跳过安装 -# 共享后端与 Linux 非 UI host(不含 Tauri/WebKitGTK) +# 共享后端与 Linux Host/UI(不含 Tauri/WebKitGTK) cargo check -p openless-core cargo check -p openless-linux-egui --all-targets @@ -362,7 +362,7 @@ OpenLess 的润色模型只重塑文本。它不回答问题、不执行任务 ……而不是一份缺失功能的清单。 -长期参考改写以 `raw → polished → rule` 三元组存储,待向量库接入后,将作为相似示例参考被检索(绝不作为对话上下文)。见 [docs/polish-reference-corpus.md](docs/polish-reference-corpus.md) 与 [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl)。 +长期参考改写的 `raw → polished → rule` 三元组格式见 [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl)。该文件说明参考数据格式;当前模块职责见[架构文档](docs/architecture.md)。 ## 词典 @@ -394,13 +394,13 @@ React UI ── Tauri Adapter(macOS/Windows/Android)──┐ egui UI ── Linux Adapter(无 Tauri/WebKitGTK)───┘ ``` -`openless-core` 负责稳定 DTO、错误、语义事件、repository、凭据契约和面向宿主的 use-case Interface。IPC、窗口、托盘、权限、更新、keyring、fcitx5 与打包资源路径等宿主能力由 Adapter 实现。旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 与 core 同进程,通过类型化 Rust Interface 调用。详细契约见 [`docs/linux-egui-backend-contract.md`](docs/linux-egui-backend-contract.md) 与[完整迁移计划](docs/linux-egui-shared-backend-plan.md)。 +`openless-core` 负责稳定 DTO、错误、语义事件、repository、凭据契约和面向宿主的 use-case Interface。IPC、窗口、托盘、权限、更新、keyring、fcitx5 与打包资源路径等宿主能力由 Adapter 实现。旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 与 core 同进程,通过类型化 Rust Interface 调用。详细契约见 [`docs/linux-egui-backend-contract.md`](docs/linux-egui-backend-contract.md)。 `v-tauri` / `v-Beta.N-tauri` 工作流发布 macOS、Windows 与 Android 宿主。Linux deb/rpm/AppImage 由 `release-linux-egui.yml` 使用独立 manifest 构建;自动发布仍以产物成功和 Ubuntu 真实安装、运行、升级、回滚证据为门禁。 听写流水线:`hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`。 -不变式与模块接线规则见 [CLAUDE.md](CLAUDE.md)。 +仓库规则见 [AGENTS.md](AGENTS.md),模块职责与接线见[架构文档](docs/architecture.md)。 ## 路线图 @@ -438,7 +438,7 @@ OpenLess 提供两个发布频道。分支名即频道名(见[贡献流程](#贡 ### 发布后验证(始终执行) -执行 [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md) 中的 5 步清单,并额外核对 Android: +按 [RELEASING.md](RELEASING.md) 验证发布页的 pre-release 标记、产物文件名与渠道、Stable 用户流程、Beta 选择流程和更新端点,并额外核对 Android: 1. Release 页面含 `latest-android-aarch64.json`(Stable 无 `-beta` 后缀混用)。 2. Beta pre-release 含 `latest-android-aarch64-beta.json`,URL 指向具体 tag。 diff --git a/docs/2.0-desktop-acceptance.md b/docs/2.0-desktop-acceptance.md index ba7210989..069337c5f 100644 --- a/docs/2.0-desktop-acceptance.md +++ b/docs/2.0-desktop-acceptance.md @@ -1,81 +1,32 @@ -# Windows / macOS 2.0 功能保留验收 +# 桌面验收清单(Windows / macOS / Android) -范围依据:[当前2.0需求](./2.0-requirements.md)。本清单定义必须验收什么,不预填“已通过”。 -基线:各平台实际交付的 Tauri 1.x 能力,以及 PR #1019 已明确承诺的 2.0 能力。 +状态:要求清单,不是通过声明;更新:2026-09-08。每个功能域给出源码入口与必须提供的真实证据;自动化测试通过不等于设备验收通过。证据分级见[范围](2.0-requirements.md)第 4 节。 -## 1. 逐项保留的用户能力 +## 1. 功能域 → 源码入口 → 验收要求 -每一行均检查入口、成功路径、失败/取消、重启后状态和旧数据;不能只检查接口存在。 - -| ID | 功能组 | 必须保留并验收 | +| 域 | 源码入口(`openless-all/app/`) | 必须的真实证据 | | --- | --- | --- | -| D01 | 启动与生命周期 | 首次配置、权限引导、正常/最小化启动、单实例、第二次启动动作、原有CLI入口、退出释放资源 | -| D02 | 主听写 | Hold/Toggle/Auto、自定义组合键、停止/Esc、组合键仲裁、去抖/收尾冷却、无声停止、设备故障与重试 | -| D03 | 音频与反馈 | 默认/指定麦克风、设备刷新/切换、音量预览互斥、录音静音及恢复、提示音、胶囊状态和终态可见性 | -| D04 | 云ASR/LLM/Omni | 各平台1.x已支持provider的认证、模型、批量/实时、润色/翻译、代理、超时/取消和错误反馈;实验总开关有效 | -| D05 | 渠道与凭据 | 创建/改名/删除/启停/排序/活动渠道、端点/模型/自定义头等设置、验证/列模型、无鉴权自建端点、OAuth既有流程 | -| D06 | 本地模型 | 原平台目录/镜像/下载/续传/取消/删除、准备/加载/保活/释放、原子激活、模型切换、真实推理与失败补偿 | -| D07 | 风格/语言 | 原文/轻度/结构化/正式等原有模式、工作语言、目标语言、简繁、上下文窗口和前台应用归因 | -| D08 | 全局快捷键 | 听写、翻译、QA、选区、切换风格、打开主窗、指定风格包及各平台实际提供的Agent入口;冲突、重绑与重启恢复 | -| D09 | 插入 | 普通/流式、Unicode/换行、原目标恢复、final reconciliation、剪贴板恢复/降级;Inserted/PasteSent/CopiedFallback/OutcomeUnknown不混淆 | -| D10 | QA | 文本/语音、多轮、选区/上下文、实时输出、停止/取消/关闭/重开、历史偏好;慢启动/转写期间仍可取消 | -| D11 | 选区操作 | 原有润色、预览、确认/取消/撤回、stale保护;原平台及本PR承诺的Selection Voice意图与编辑流程 | -| D12 | Less Computer | 已承诺的文本/语音入口、多轮、增量/工具/审批、拒绝/取消、CLI检测/配置/工作目录、进程树清理 | -| D13 | 历史/录音/统计 | 列表与原有操作、原始/最终文本、真实provider/model/耗时、重新润色/转写、录音播放/导出、独立保留上限、统计热图 | -| D14 | 词典与纠错 | 增删/启停、预设、规则顺序、词条命中、建议接受/拒绝;平台已有编辑观察及隐私开关 | -| D15 | 风格包 | 创建/编辑/启用/激活/重置/删除、提示词诊断、ZIP导入导出、快捷键和活动状态联动 | -| D16 | 风格市场 | 原有列表/详情/安装/下载/上传、点赞/收藏/作品管理、GitHub登录/取消/退出及错误恢复 | -| D17 | 手机输入 | 服务/端口/PIN、地址和二维码、TLS/H5、默认模式、手机PCM、电脑落字开关、停止/取消/断连、连接隔离 | -| D18 | 设置和维护 | 原有主题/字号/语言、胶囊、托盘、启动选项、网络诊断/日志、更新渠道及检查/下载/安装/重启 | -| D19 | 1.x数据 | preferences、渠道/凭据、风格包、词典/规则、历史/录音、模型及自定义目录;幂等、失败保留来源、不覆盖新配置 | - -功能是否属于1.x必须核对实际入口和生产路径;仅有预留配置字段或旧路线图不算已交付。 - -## 2. Windows 原生验收 - -- 左右修饰键、组合键、全局监听及原有应用内回退;停止/重绑时注销旧监听。 -- TSF DLL安装、Profile/category注册、键盘列表选项、管道/会话生命周期、原窗口/控件指纹和输入法恢复。 -- TSF/SendInput/Paste、非TSF降级开关、CRLF/Unicode;原生timeout/cancel和派发后断连不得触发重复落字。 -- 普通编辑器、浏览器、聊天框、终端,以及无权限/目标失效场景;不能把假成功写入历史。 -- Foundry/Sherpa按当前实际支持模型与运行时验证,包含取消、异常、保活及旧释放任务不得卸载新模型。 -- 托盘/任务栏/窗口、自启与系统麦克风;真实CLI满stdin、取消、孤孙和自然完成行为。 -- 新装、1.x升级、更新/卸载/回滚;只把实际生成并验证的安装包类型列为通过。跳过MSI不能算MSI成功。 - -## 3. macOS 原生验收 - -- 麦克风/Accessibility拒绝与重新授权,Secure Input、密码等敏感场景边界。 -- 关闭光标上下文时零文档读取;前台应用元数据仍用于既有归因/换行规则。 -- AX选区/上下文、TIS输入源准备/恢复、普通与流式落字,Auto/ShiftReturn/LineFeed/Return。 -- AppKit主线程、NSPanel、胶囊、菜单栏/Dock、多显示器、窗口重开和焦点行为。 -- Apple Speech、Qwen/MLX/C、Whisper按原有OS/架构支持矩阵交付;不把Apple Silicon能力当作Intel能力。 -- Finder启动时的PATH、外部CLI检测与取消、Keychain及自启。 -- 声明支持的arm64/x86_64构建、安装/升级/更新,正式分发的签名、公证与Gatekeeper。 - -当前2.0 macOS普通听写复用了选区路径的app/PID级目标恢复;1.x普通听写未调用该恢复路径,不能将它描述为既有基线能力。精确恢复同应用内原控件/光标仍未得到证明。 - -## 4. 证据与阻塞规则 - -每个D编号按Windows/macOS分别记录: - -| 字段 | 记录内容 | -| --- | --- | -| 基线与范围 | 1.x版本/提交、实际入口、OS/架构;原平台不支持的明确写“不适用” | -| 2.0生产路径 | UI/热键/CLI → Tauri Adapter → Core → 原生效果/事件,指出删除的旧业务副本 | -| 自动证据 | 行为回归、类型/编译/链接、目标runner与精确head;不把mock结果称作设备成功 | -| 设备/安装证据 | 安装包、OS/目标应用、步骤、期望/实际、日志或截图;涉及数据须用测试资料 | -| 结论 | 已实现且验证 / 已实现待设备验收 / 未实现 / 原平台不适用 | - -- 原平台已有功能出现未实现、错误结果、数据丢失或取消失效:本批桌面2.0阻塞项。 -- 平台声明支持但只有构建/fixture证据:保留待验收,不直接标“完整支持”。 -- Linux应用/UI/原生验收未完成:移交egui团队,不单独阻塞本批桌面2.0;共享Core缺陷仍阻塞。 -- 正式GitHub评审、签名和发布权限按项目流程处理;内部子代理通过不替代这些门禁。 - -## 5. 2026-09-06 发布收口决定 - -按维护者决定,优先完成2.0交付;本次提交收口后不再启动新的全面审查轮次。影响输入目标、结果正确性、数据和主要流程的已确认问题保留修复;低影响显示边界暂缓,不将其扩大为新的发布阻塞。 - -- QA语音启动极快失败、首条Recording尚未被事件桥消费时,胶囊可能没有错误提示;QA面板仍接收Core错误事件。 -- 极短跨会话切换中,尚未呈现首帧的QA与旧Selection Voice自动隐藏定时器,仍可能出现胶囊提示被忽略或提前隐藏。已呈现QA的旧事件/定时器覆盖后继原生提示已补epoch检查。 -- 上述为源码时序分析的显示风险,不宣称已在真实设备稳定复现,也不等于数据、回答或录音结果错误。完整显示origin接线及旧Selection Voice定时器统一留待后续体验修复。 - -现有设备、安装包、签名、公证和最终head CI证据仍分别记录;本次收口不把未执行项改写为通过。详细修复及验证见[第九轮记录](./pr1019-desktop-review-2026-09-06.md)。 +| 启动/生命周期 | `src/App.tsx`(`?window=` 分支)、`src/components/CoreStartupScreen.tsx`、`src-tauri/src/lib.rs` 启动合同 | 启动快照与版本校验、错误可读与重查、崩溃后恢复 | +| 听写 | `src-tauri/src/coordinator/dictation_core.rs` + Core `dictation_engine.rs` | 首帧、热键触发、取消、静默自动停止、落字 reconciliation、错误恢复 | +| 快捷键与插入 | `src-tauri/src/lib.rs`(`global_hotkey_runtime`)、`src/components/ShortcutRecorder.tsx`、`windows-ime/` | 全局注册/冲突提示/重绑/重启还原;Windows IME 落字与回退 | +| AI 服务与渠道 | `src/pages/settings/{ChannelList,ProvidersSection}.tsx`、`src-tauri/src/commands/{providers,channels,credentials}.rs` | 渠道增删改/启停/验证结果展示/凭据安全存储(明文不落盘) | +| 本地模型 | `src/pages/LocalAsr/`、`src-tauri/src/commands/local_asr.rs`、Core `model_store.rs` | 下载/取消/激活原子性;Windows Foundry/Sherpa;macOS Apple Speech/MLX | +| 历史 | `src/pages/History.tsx`、`src-tauri/src/commands/history.rs` | 列表/详情切换(窄窗)、重润色结果保留、录音归档与导出 | +| 词库与纠错 | `src/pages/{Vocab,Corrections}.tsx`、`src-tauri/src/commands/dictionary.rs`、Core `vocabulary.rs` / `correction.rs` | 词库管理、纠错记录展示/删除/批量清理、重启后持久化与错误反馈 | +| 风格包与市场 | `src/pages/{Style,Marketplace}.tsx`、`src-tauri/src/commands/{style_packs,marketplace}.rs` | 导入/应用/导入导出失败反馈;市场浏览/安装/OAuth | +| 划词与 QA | `src/pages/{SelectionAsk,SelectionPolishPreview,SelectionVoiceIntentPicker,QaPanel}.tsx` | 触发→意图→预览→应用/取消/撤回;epoch 防旧事件覆盖 | +| Less Computer | `src/pages/LessComputer{Panel,Glow}.tsx`、`src-tauri/src/coding_agent/` | 工具审批/输出/取消;Windows Job 对象与进程清理 | +| 手机输入 | `src-tauri/src/remote_server/`、远程输入页面 | 配对/TLS/会话桥接/弱网与陈旧地址处理 | +| 胶囊与多屏 | `src/components/Capsule.tsx`、`src-tauri/src/coordinator/capsule_focus.rs` | 跟随光标所在屏、多显示器/多 Space 正确显示 | +| 音频提示 | `src/components/AudioCue.tsx`、`src-tauri/src/audio_mute.rs` | 提示音、系统静音/恢复所有终态恢复音量 | +| 设置与更新 | `src/pages/settings/`(`navigation.ts`)、`src/components/{AutoUpdate,AutoUpdateGate}.tsx` | 自动保存提示、焦点返回、更新安装失败可见与手动兜底 | + +## 2. 平台特有门槛 + +- **Windows**:TSF/IME 管线(`windows-ime/` 工程;`scripts/windows-ime-*.ps1`、`scripts/windows-package-msvc.*`);Foundry/Sherpa 本地 ASR;`scripts/windows-*-smoke.ps1` 套件在真机执行。 +- **macOS**:AX/TIS 授权流、Apple Speech、MLX(`scripts/macos-mlx-*.mjs`、`scripts/macos-capsule-spaces-contract.test.mjs`)、签名与公证(`scripts/build-mac.sh`、release workflow)。 +- **Android**:APK 工作流(`.github/workflows/android-apk.yml`)+ 悬浮窗/无障碍/Shizuku 契约(`scripts/android-*.test.mjs`)。 + +## 3. 完成口径 + +安装包、签名、公证与最终 head CI 证据分别记录;未执行项不得改写为通过。设备/安装/升级/回滚验收是发布门,不属于本清单可由代码自证的部分。 diff --git a/docs/2.0-requirements.md b/docs/2.0-requirements.md index acc23781f..884ff872c 100644 --- a/docs/2.0-requirements.md +++ b/docs/2.0-requirements.md @@ -1,72 +1,34 @@ -# OpenLess 2.0:当前需求与交付范围 +# OpenLess 2.0:范围与完成条件 -更新:2026-09-06。本文是本轮范围与验收的权威入口,优先于此前迁移计划、复核记录中的旧范围。 -初始盘点基线为 `2b3155722758a2b027f94e7032accf468d03106c`;后续实现/合同修复以本文件所在提交及[桌面复审记录](./pr1019-desktop-review-2026-09-06.md)为准。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 -## 1. 目标 +## 1. 平台与版本锚点 -1. **Windows、macOS 是本批 2.0 完整支持的平台。** 各自在 Tauri 1.x 中实际已有的用户功能,必须在 2.0 保留并完整实现。 -2. **Linux 本批交付可接入的 Core。** egui 团队接管 Linux Host Adapter 的剩余实现、生产接线、egui UI 与 Linux 产品验收。 -3. Linux 交接采用[独立目录](./linux-egui-handoff/README.md)中的小文档;逐项说明现成能力、真实缺口、接口、实施责任与验收条件。 - -## 2. Windows/macOS 的“完整” - -- 比较的是用户可执行的完整操作:入口 → Core 业务 → 原生效果 → 结果/历史/错误反馈。只有类型、接口、按钮或 fixture 不算实现。 -- 以迁移前各平台实际交付的 1.x 功能为基线。现有源码锚点是 `v1.3.18-tauri` 和 PR 的 1.x base `fc9824ee`;还须覆盖其中较新的 Beta 能力及本 PR 已承诺的功能。 -- 不得因修改架构而丢失功能、设置效果、旧数据、快捷键、取消、异常恢复或平台行为。 -- Windows 的 TSF/Foundry/Sherpa、macOS 的 AX/TIS/Apple Speech/MLX 等按各自原有 OS/架构支持范围验收,不要求移植另一平台的专有机制。 -- Windows Less Computer 等本 PR 已明确增加的交付仍保留,不借重新定义 1.x 基线撤销。 -- 1.x 的预留字段或未实现设计不自动成为“已交付功能”;也不能把真实存在的实现误标为从未支持。每项须有源码/产品入口证据。 -- 原生能力必须以真实结果表达,不能用 `Unsupported`、no-op、伪成功或仅有单元测试替代该平台原先可用的功能。 - -详细功能与平台验收见[桌面验收清单](./2.0-desktop-acceptance.md)。该清单是需求,不是“全部已通过”的声明。 - -## 3. 责任划分 - -| 交付 | 本轮 Core/Tauri 工作 | egui 团队接手 | +| 目标 | 载体 | 版本锚点 | | --- | --- | --- | -| 共享业务 | Core 领域实现、状态机、配置/凭据/模型迁移、取消/错误/结果规则 | 调用 Core;发现业务接口缺项时回报 Core 负责人 | -| Windows/macOS | Tauri/React 接线、原生 Adapter、完整 1.x 功能保留、自动与设备验收 | 不属于 Linux 接入任务 | -| Linux 接入面 | 可用的公开接口、DTO/事件、Adapter 合同、fixture、示例和缺口文档 | Linux 原生 Adapter 剩余实现、全局热键、窗口/托盘/权限/更新等宿主效果 | -| Linux UI | 提供已有代码和行为合同作起点 | egui/eframe 页面、交互、事件消费、错误展示、完整产品流程 | -| Linux 发布 | 保留现有验证脚本与已有证据供复用 | Linux 打包、真实桌面/设备、安装升级回滚与正式发布验收 | +| Windows / macOS / Android | Tauri 2 Host + React 界面(`src-tauri/` + `src/`) | `src-tauri/tauri.conf.json` 与 `package.json` 均为 `2.0.0-Beta.1`;identifier `com.openless.app` | +| Linux | egui/eframe Host + 原生 UI(`linux-egui/`,crate `openless-linux-egui`) | crate 版本 0.1.0;发布版本跟随上方 2.0.0-Beta.1 | +| 共享业务 | `crates/openless-core`(平台无关,crate 版本 0.1.0) | 公开合同 `openless-all/app/contract/backend-2.0.json` | -这里的“egui 团队”包含本次明确移交的 **Linux Host 工作**。egui 框架本身不会自动完成系统全局热键、音频、Secret Service 或安装器。 +显示版本与内部 crate 版本是两个概念;发布版本号、标签与渠道以仓库发布流程(`RELEASING.md`、GitHub workflows)为准。 -## 4. Core 对 Linux 的交付门 +## 2. “完整”的定义(Windows / macOS) -“预留 Core”要求能接入、能验证,不是未来再实现的空接口: +- 比较对象是用户可执行的操作链:入口 → Core 业务 → 原生效果 → 结果/历史/错误反馈。仅类型、按钮、fixture 或接口存在不算实现。 +- 功能基线为各平台 1.x 实际交付(标签 `v1.3.18-tauri` 及其 beta 线);架构迁移不得丢失功能、旧数据、快捷键、取消与异常恢复路径。 +- 平台特有能力按各自 OS 范围验收,不要求跨平台移植:Windows 的 TSF/IME(`windows-ime/` 工程)、Foundry/Sherpa 本地 ASR;macOS 的 AX/TIS、Apple Speech、MLX。 +- 原生能力以真实结果表达;`Unsupported`、no-op、伪成功或仅单元测试通过不构成交付。 -1. Linux 可依赖 `openless-core`,Core 不依赖 Tauri/egui 或要求调用 Tauri 私有模块。 -2. 业务规则有真实实现;平台动作由明确的 Host Interface 承接。缺 Linux 原生效果可以标注未接入,不能把缺少 Core 业务实现推给 UI。 -3. 提供 `2.0.0` 启动合同、DTO、事件顺序/重放、session/取消、错误和能力语义。 -4. 提供可运行的无设备 fixture/headless 示例及对应 contract tests。 -5. Linux 现有 Adapter、UI 和包构建作为可复用起点保留;每项分别标明“已有实现”“接线缺口”“原生验证待补”。 -6. 确定缺口在交接目录有责任、实现位置、Core 调用点、依赖与验收要求。 - -## 5. 本批 2.0 完成条件 +## 3. 完成条件 | 范围 | 完成条件 | | --- | --- | -| Windows | 各项原有能力完成真实生产接线;无未关闭的确定功能/迁移缺陷;通过必要自动测试、安装与目标应用/设备验收 | -| macOS | 同上,并覆盖所声明支持的 Intel/Apple Silicon 与权限、输入源、原生模型、签名分发要求 | -| 共享 Core | 跨平台依赖/合同/迁移/会话与错误语义通过验证,可供 Linux 团队直接接入 | -| Linux 本轮移交 | 本文第4节及拆分交接资料完整;无需等待 Linux 应用所有功能、UI、真实设备和安装升级完成 | - -**Linux 应用侧的未完成项不再阻塞本批 Windows/macOS 2.0 交付。** -但缺陷若位于共享 Core、会破坏 Windows/macOS,或使承诺的 Linux Core 接口不可调用,仍是本轮问题。 - -验收报告必须区分:源码已实现、自动测试通过、目标平台编译通过、安装运行通过、设备交互通过。CI 和内部审查不替代正式发布所需的真实行为证据。 - -## 6. 现有成果和其它平台 - -- 已完成的 PR 修复、Linux 代码、构建和测试不回退、不删除;本次调整的是责任与完成条件。 -- Android 不纳入本次新增的“首批完整支持”目标;若保留现有构建,其已有合同不能因共享 Core 修改而无故破坏。 -- 本次是需求与交接范围更新;当前代码版本快照仍为 `2.0.0-Beta.1`。版本号、标签与正式发布按项目发布流程执行。 -- [此前复核记录](./pr1019-2.0-final-review.md)保留历史问题和证据;其中旧 Linux 发布阻塞条件以本文为准。 +| Windows | [桌面验收清单](2.0-desktop-acceptance.md)各域生产接线 + 安装、目标应用与设备验收 | +| macOS | 同上,另加 Intel/Apple Silicon、权限、输入源、本地模型、签名与公证 | +| Android | APK 工作流与悬浮窗管线按 [Android 计划](android-mobile-apk-overlay-plan.md) | +| Linux(本批移交) | 可接入的 Core + 交接资料完整;应用侧缺口按[登记表](linux-egui-handoff/02-gap-register.md) L01–L12 推进,不阻塞 Windows/macOS 交付 | +| 共享 Core | 合同、迁移、会话与取消语义可被 Linux 直接接入,见[契约](linux-egui-backend-contract.md) | -## 7. 后续报告口径 +## 4. 证据分级 -- 分别报告“Windows 2.0 完成度”“macOS 2.0 完成度”“Core 移交完成度”“Linux 团队待办”。 -- Linux 宿主未接线应写作“Core 已有,Linux Host/UI 待实现”,不要写作“Core 缺失”,也不要写作“Linux 功能已完成”。 -- 若接口本身尚不足以承载原有业务,列为 Core 缺口并由本轮团队修复。 +源码已实现 / 自动测试通过 / 目标平台编译通过 / 安装运行通过 / 设备交互通过,五级分开表述。CI、单元测试与内部审查不替代真实发布所需的行为证据;macOS 上的构建或 cfg 排除不能冒充 Linux 原生结果。 diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index cf4229d7a..4e62b3c35 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -1,349 +1,32 @@ -# OpenLess Android APK 与悬浮窗实施计划 +# Android APK 与悬浮窗实施计划 -> 状态:实施中 / 后端分层已落地 -> 日期:2026-06-07 -> 范围:Android APK v1(应用内录音)→ IME v2(跨 App 输入)→ 悬浮窗 v3;不改桌面语义 +状态:实施中(后端分层已落地);更新:2026-09-07(以源码为准重写)。 -目标是把现有 OpenLess 桌面应用扩展为 Android APK,并在手机端提供可用的语音输入体验。当前项目是 React/Vite + Tauri v2 + Rust 后端,Tauri 官方支持 Android 构建;现仓库已增加 Android 分层与脚手架,桌面核心能力(全局热键、托盘、桌面浮窗、TSF IME 等)通过 `#[cfg(not(mobile))]` 收口。 +## 1. 现有管线(源码锚点) -核心策略:先产出可安装 APK,再做 Android 输入法服务,最后做跨 App 悬浮窗。桌面功能保持不受影响。 +| 组成 | 位置 | +| --- | --- | +| Kotlin 宿主 | `android/kotlin/`(含 `androidTest/`、`test/`) | +| AIDL 桥接 | `android/aidl/com/` | +| Manifest 合成 | `android/manifests/`(基础)+ `scripts/merge-android-updater-manifest.mjs`、`merge-android-overlay-manifest.mjs`、`merge-android-v1-manifest.mjs` | +| Shizuku | `scripts/merge-android-shizuku-manifest.mjs`、`patch-android-shizuku-deps.mjs`(含各自 `.test.mjs`) | +| 前端片段 | `android/frontend/`(经 vite 别名 `@android` 被 `src/` 引用:`AndroidPermissionsPanel`、`androidMicrophonePermission`、`androidIpc`、`androidTypes`) | +| Rust 桥接 | `src-tauri/src/android/`(jni/native_bridge/insert/overlay/shizuku/updater 等) | +| CI | `.github/workflows/android-apk.yml`;打包脚本 `scripts/copy-android-scaffolding.mjs`、`configure-android-release-signing.mjs` | +| 更新器 | 公钥检查 `scripts/check-android-updater-pubkey.mjs`;manifest updater 合成 | -参考依据: -- Tauri Android 依赖:Android Studio、SDK/NDK、`ANDROID_HOME`、`NDK_HOME`、Android Rust targets。 -- Tauri Android 构建命令:`tauri android init`,`tauri android build --apk`。 -- Android 悬浮窗:Android 8.0+ 使用 `SYSTEM_ALERT_WINDOW` + `TYPE_APPLICATION_OVERLAY`。 +## 2. 契约与边界测试(npm test 自动执行) ---- +`scripts/android-ipc-import-boundary.test.mjs`(pretest 强制)+ `android-accessibility-{enabled-detection,paste-cache,selection-ipc}-contract`、`android-credential-keystore-contract`、`android-insert-tier-fallback-contract`。IPC 面与 `contract/backend-2.0.json` 的 `androidJni` 对齐。 -## 1. 目标与非目标 +## 3. 剩余工作 -### 目标 +- 悬浮窗(overlay)与无障碍链路的真机验收(契约测试之外的真实设备证据)。 +- Shizuku 插件路径在真机的安装/授权/失败反馈闭环。 +- 发布签名配置的密钥管理与出包验证(`configure-android-release-signing.mjs` 流程化)。 -- **APK v1**:应用内主窗口、设置、历史、云端 ASR/LLM、基础录音、复制结果 -- **IME v2**:`OpenLessImeService` 作为跨 App 输入主路径 -- **悬浮窗 v3**:前台服务 + `TYPE_APPLICATION_OVERLAY`,仅作录音控制入口 -- **平台能力查询**:前端通过 `get_platform_capabilities` 隐藏桌面专属设置项 -- **桌面零破坏**:所有适配经 `#[cfg(not(mobile))]` / `#[cfg(mobile)]` 分层 +## 4. 参考 -### 非目标 - -- APK 首版不纳入本地 ASR(Foundry、Sherpa、Qwen 桌面假设) -- 首版不承诺直接写入其他 App(无 IME 时走复制兜底) -- Accessibility 跨 App 输入不作为默认路径 -- 悬浮窗不承担最终文本插入职责 -- 不改桌面现有功能语义 - -### 明确边界 - -- **不动** macOS / Windows / Linux 热键、托盘、胶囊、QA 浮窗逻辑 -- **Android 命令名与桌面一致**;不支持的命令返回明确 unavailable 状态 -- **Coordinator 听写主链路复用**;`end_session` 在 Android 增加 IME commit 分支 - ---- - -## 2. 架构定位 - -``` -openless-all/app/ - package.json # tauri:android:* scripts - vite.config.ts # TAURI_ENV_PLATFORM → 0.0.0.0 + HMR - src-tauri/ - tauri.conf.json # bundle.android.minSdkVersion ≥ 26 - tauri.android.conf.json # 单 main 窗口(无 capsule/qa/tray) - capabilities/ - default.json # 桌面 - mobile.json # Android 主窗口 - android-scaffolding/ # Kotlin 模板(init 后复制到 gen/android) - src/ - lib.rs # desktop/mobile run() 分层 - android_ime.rs # IME 状态 + commit(JNI 桩) - android_overlay.rs # 悬浮窗权限/状态(JNI 桩) - permissions.rs # Android 麦克风 runtime permission 分支 - persistence.rs # Android 凭据加密文件桩 - types.rs # PlatformCapabilities - commands.rs # get_platform_capabilities + 桌面命令 stub - coordinator/dictation.rs # end_session Android IME 分支 -``` - -平台分层示意: - -``` -┌─────────────────────────────────────────────────────────┐ -│ React UI(能力查询 → 隐藏桌面专属设置) │ -├─────────────────────────────────────────────────────────┤ -│ Tauri commands(同名 IPC;mobile 返回 unavailable stub) │ -├──────────────┬──────────────────────────────────────────┤ -│ desktop │ mobile (Android) │ -│ hotkey/tray │ in-app dictation + cloud ASR │ -│ capsule/qa │ android_ime (v2) / android_overlay(v3) │ -│ TSF/AX/粘贴 │ IME commit (v1); clipboard TBD │ -└──────────────┴──────────────────────────────────────────┘ -``` - -> **v1 剪贴板**:APK v1 不使用 Android 剪贴板兜底(未接 arboard);跨 App 文本输入依赖后续 IME/JNI 接线。 - ---- - -## 3. 模块设计 - -### 3.1 `PlatformCapabilities`(`types.rs`) - -```rust -pub struct PlatformCapabilities { - pub platform: String, - pub supports_ime_input: bool, - pub supports_overlay: bool, - pub supports_desktop_hotkey: bool, - pub supports_tray: bool, - pub supports_local_asr: bool, - pub supports_capsule_overlay: bool, -} -``` - -- Android:`supportsImeInput=true`(v2 起)、`supportsOverlay=true`(v3 起)、`supportsDesktopHotkey=false`、`supportsTray=false` -- 桌面:按 OS 填真实能力 - -### 3.2 `android_ime.rs` - -- `get_android_ime_status()` → 是否已启用 OpenLess 输入法 -- `commit_text(text)` → 通过 JNI 提交到 `InputConnection`(桩 → 日志 + 返回未连接) -- Kotlin:`OpenLessImeService` 继承 `InputMethodService` - -### 3.3 `android_overlay.rs` - -- `get_android_overlay_status()` → `SYSTEM_ALERT_WINDOW` 授权状态 -- `request_android_overlay_permission()` → 跳转 `OverlayPermissionActivity` -- Kotlin:`OpenLessOverlayService`(前台服务 + overlay window) - -### 3.4 `permissions.rs` / `persistence.rs` - -- 麦克风:Android runtime permission 分支(JNI 桩;未接线时 `NotDetermined`) -- 凭据:Android 加密 JSON 文件桩(`credentials.enc.json`),不沿用桌面 keyring - -### 3.5 `coordinator/dictation.rs` - -`end_session` 插入阶段: - -``` -#[cfg(target_os = "android")] - android_ime::commit_text → Inserted - 失败 → copy_fallback → CopiedFallback -#[cfg(not(mobile))] - 现有 Windows TSF / AX / paste 路径 -``` - ---- - -## 4. 原生层接口 - -| 组件 | 职责 | -|---|---| -| `MainActivity` | Tauri WebView + 权限状态桥接 | -| `OpenLessImeService` | 接收识别结果,`commitText` 到当前输入框 | -| `OpenLessOverlayService` | 悬浮窗开始/停止录音、显示状态 | -| `OverlayPermissionActivity` | 引导用户授权 `SYSTEM_ALERT_WINDOW` | - -Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,init 后接线)。 - ---- - -## 5. 实施里程碑 - -### M0 环境与文档(本期) - -- 扩展本计划文档(元数据、架构、文件表、风险) -- `package.json`:`tauri:android:init|dev|build` -- `vite.config.ts`:mobile dev host `0.0.0.0` + HMR -- `tauri.conf.json`:`bundle.android.minSdkVersion: 26` -- `tauri.android.conf.json` + `capabilities/mobile.json` - -### M1 Rust 分层 + APK 骨架(本期) - -- `lib.rs` desktop/mobile `run()` 分层 -- `Cargo.toml` gate 桌面专属依赖 -- `PlatformCapabilities` + 命令 stub -- `permissions.rs` / `persistence.rs` Android 分支 -- `cargo check` 桌面通过;Android target 尽力验证 - -### M2 IME v2 - -- 接线 `OpenLessImeService` JNI -- 设置页显示输入法启用状态 -- 跨 App 提交验收 - -### M3 悬浮窗 v3 - -- 接线 `OpenLessOverlayService` -- 授权引导 + 前台服务稳定性 - ---- - -## 6. 文件触达表 - -| 文件 | 变更 | -|---|---| -| `docs/android-mobile-apk-overlay-plan.md` | 扩展为完整实施规划(本文档) | -| `openless-all/app/package.json` | `tauri:android:*` scripts | -| `.github/workflows/android-apk.yml` | Android debug APK CI | -| `openless-all/app/scripts/merge-android-v1-manifest.mjs` | v1 manifest merge (RECORD_AUDIO) | -| `openless-all/app/vite.config.ts` | mobile dev server / HMR | -| `openless-all/app/src-tauri/tauri.conf.json` | `bundle.android` | -| `openless-all/app/src-tauri/tauri.android.conf.json` | 单 main 窗口 | -| `openless-all/app/src-tauri/capabilities/mobile.json` | Android 权限集 | -| `openless-all/app/src-tauri/Cargo.toml` | gate desktop deps | -| `openless-all/app/src-tauri/src/lib.rs` | mobile/desktop 分层 | -| `openless-all/app/src-tauri/src/types.rs` | `PlatformCapabilities` 等 | -| `openless-all/app/src-tauri/src/commands.rs` | 能力查询 + mobile stub | -| `openless-all/app/src-tauri/src/permissions.rs` | Android 麦克风 | -| `openless-all/app/src-tauri/src/persistence.rs` | Android 凭据 | -| `openless-all/app/src-tauri/src/android_ime.rs` | 新增 | -| `openless-all/app/src-tauri/src/android_overlay.rs` | 新增 | -| `openless-all/app/src-tauri/src/coordinator/dictation.rs` | `end_session` Android 分支 | -| `openless-all/app/src-tauri/android-scaffolding/*.kt` | Kotlin 模板 | - ---- - -## 7. 风险与对策 - -| 风险 | 对策 | -|---|---| -| 本机无 Android SDK / NDK | 文档记录手动脚手架;`android-scaffolding/` 供 init 后复制 | -| `global-hotkey` / `enigo` / `arboard` 无法编 Android | `Cargo.toml` `cfg(not(mobile))` gate | -| `keyring` 在 Android 不可用 | 加密文件桩 + 后续 Keystore 接线 | -| 桌面构建被 mobile 分层破坏 | 桌面 `cargo check` 为 CI 门禁;mobile 代码 `#[cfg(mobile)]` 隔离 | -| JNI 未接线时 IME/overlay 假成功 | 状态查询返回 `enabled=false`;commit 走 copy fallback | -| 多窗口配置污染移动端 | `tauri.android.conf.json` 仅声明 `main` | -| 本地 ASR 拖慢 Android 首版 | 明确排除;`supportsLocalAsr=false` | -| `SYSTEM_ALERT_WINDOW` 用户拒绝 | 设置页显示状态;悬浮窗功能降级为应用内入口 | - ---- - -## Android APK CI Workflow - -GitHub Actions workflow: [`.github/workflows/android-apk.yml`](../.github/workflows/android-apk.yml) - -### Capability platform isolation - -Desktop permissions live in `capabilities/default.json` with `"platforms": ["macOS", "windows", "linux"]`, so updater, autostart, and multi-window permissions do not apply on Android. Android uses `capabilities/mobile.json` with `"platforms": ["android"]` for the main-window permission set. In-app updates use a custom Rust updater (`src-tauri/src/android/updater.rs`) because `tauri-plugin-updater` does not support Android. - -### Triggers & channels - -| Trigger | Build mode | Behavior | -|---|---|---| -| `workflow_dispatch` | **release** if `ANDROID_KEYSTORE_*` secrets configured; else **debug (unsigned)** | Upload Actions artifacts; non-blocking fallback with job summary notice when unsigned | -| Push tag `v*-tauri` / `v*-beta-tauri` | **release** (required secrets) | Signed release APKs + minisign `.sig` + `latest-android-{arch}[-beta].json` → attach to GitHub Release | - -`OPENLESS_RELEASE_CHANNEL` matches desktop `release-tauri.yml`: `-beta-tauri` → beta (prerelease manifests); otherwise stable. - -Tag releases require secrets: `TAURI_SIGNING_PRIVATE_KEY` (minisign), `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` (APK signing). - -### APK naming - -| ABI | Release (tag) | Debug (dispatch) | -|---|---|---| -| arm64-v8a | `OpenLess__arm64-v8a.apk` | `OpenLess-android-debug-arm64-v8a-run-.apk` | -| armeabi-v7a | `OpenLess__armeabi-v7a.apk` | `OpenLess-android-debug-armeabi-v7a-run-.apk` | -| x86 | `OpenLess__x86.apk` | `OpenLess-android-debug-x86-run-.apk` | -| x86_64 | `OpenLess__x86_64.apk` | `OpenLess-android-debug-x86_64-run-.apk` | - -Actions artifact names: `openless-android-release-{abi}` (tag) or `openless-android-debug-{abi}` (dispatch). - -Updater manifests: `latest-android-aarch64.json` (arm64), `latest-android-armv7.json`, `latest-android-i686.json`, `latest-android-x86_64.json` (+ `-beta` / `-mirror` variants). - -### Command chain (CI) - -```bash -cd openless-all/app -npm ci && npm run build -npm run tauri -- android init --ci -node scripts/copy-android-scaffolding.mjs -node scripts/merge-android-v1-manifest.mjs -node scripts/merge-android-overlay-manifest.mjs -node scripts/merge-android-updater-manifest.mjs -# tag only: -node scripts/configure-android-release-signing.mjs -npm run tauri:android:build:debug # dispatch -npm run tauri:android:build:release # tag -# tag only: -node scripts/sign-android-apks.mjs -OPENLESS_UPDATE_APK_DIR=... OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH=aarch64 node scripts/write-updater-manifest.mjs -``` - -`ci.yml` also runs `cargo check --target aarch64-linux-android` as a lightweight mobile cfg gate. - -### Manifest merge - -- `merge-android-v1-manifest.mjs` — `RECORD_AUDIO` (v1 in-app dictation) -- `merge-android-overlay-manifest.mjs` — overlay + accessibility service -- `merge-android-updater-manifest.mjs` — `REQUEST_INSTALL_PACKAGES` + `FileProvider` for APK install - -### In-app updater (Android) - -Custom Rust module [`openless-all/app/src-tauri/src/android/updater.rs`](../openless-all/app/src-tauri/src/android/updater.rs) + shared helpers [`updater_logic.rs`](../openless-all/app/src-tauri/src/android/updater_logic.rs). Desktop continues to use `tauri-plugin-updater`. - -**Manifest URLs** (generated by [`write-updater-manifest.mjs`](../openless-all/app/scripts/write-updater-manifest.mjs)): - -| Channel | Filename | GitHub path | -|---|---|---| -| Stable | `latest-android-{arch}.json` | `releases/latest/download/...` | -| Beta | `latest-android-{arch}-beta.json` | `releases/download/{v*-beta-tauri tag}/...` | - -Client tries mirror URL first (`-mirror.json`), then direct GitHub. Beta tag is resolved from `releases.atom` (first `-beta-tauri` entry). - -**User-facing behavior**: - -- **Settings → About**: manual “Check stable update” always fetches stable manifest. -- **Settings → Advanced**: Beta toggle sets `prefs.updateChannel` for background checks; “Check Beta update” always fetches beta manifest (independent of toggle). -- **Settings → Advanced → Auto-update** (Android): `autoUpdateCheck` toggle — when on, `AutoUpdateGate` checks on launch (+4s) and every 60 minutes using `updateChannel`, then **automatically downloads**, minisign-verifies, and opens the **system APK installer**. When off, only manual buttons run. -- **Desktop**: `autoUpdateCheck` only auto-checks; user confirms in `UpdateDialog` before download/install/restart. - -**Install flow**: download to app cache → minisign verify → JNI `install_apk_from_path` → Android system installer. Over-the-air replace is **not** implemented in-app; the user completes install via the system UI. Requires matching APK signature for upgrade. - -**Prefs**: `updateChannel` (`stable` | `beta`) = background auto-update channel only; manual buttons pass explicit channel and ignore this pref. - ---- - -## 8. 验收标准 - -### 构建验证 - -```bash -cd openless-all/app -npm run build -cargo check --manifest-path src-tauri/Cargo.toml -# 需 Android SDK / NDK(与 CI 一致:debug APK): -npm run tauri:android:init -npm run merge:android-v1-manifest -npm run tauri:android:build -# 等价于 CI 的 debug 构建: -# CI=true npm run tauri -- android init --ci && node scripts/merge-android-v1-manifest.mjs && CI=true npm run tauri:android:build -``` - -### APK v1 - -- 首次启动进入主界面 -- 麦克风授权流程可触发 -- 应用内录音 → 云端转写 → 历史 + 复制 -- 桌面专属命令不导致前端白屏(返回 unavailable) -- 桌面 `cargo check` 仍通过 - -### IME v2 / 悬浮窗 v3 - -见原 Summary 中 Test Plan 章节(启用输入法后跨 App 提交;悬浮窗授权与前台服务稳定性)。 - ---- - -## Compatibility fixes(2026-06-07) - -- **`app_invoke_handler_mobile`**:仅保留 dictation / settings / credentials / history / cloud ASR / platform capabilities / Android IME·overlay / permissions / marketplace / style packs / mic devices;已移除 `get_hotkey_*`、`set_shortcut_recording_active` 及全部 desktop-only 命令(热键 setter、updater、local ASR、coding agent、tray 等)。前端 `ipc.ts` 在 `supportsDesktopHotkey === false` 时本地返回 stub,不再 invoke 这些命令。 -- **`mobile_stubs`**:`unicode_keystroke` 补齐 `typed_chars()` / `Partial`(与 coordinator 流式插入一致);`shortcut_binding::binding_from_legacy_trigger` 与桌面实现对齐。 -- **`Cargo.toml`**:`enigo` / `global-hotkey` / updater / single-instance / autostart 仅在 `cfg(not(mobile))`;Android 侧 `jni` + `ndk-context` 已声明。 - ---- - -## 9. 相关参考 - -- Tauri Android:https://v2.tauri.app/develop/mobile/ -- 桌面 Windows ASR 规划风格:`docs/windows-sherpa-onnx-asr-plan.md` -- 主听写链路:`openless-all/app/src-tauri/src/coordinator/dictation.rs` -- Windows IME unavailable 模式:`openless-all/app/src-tauri/src/windows_ime_profile.rs` +- Tauri Android: +- 主听写链路:`src-tauri/src/coordinator/dictation.rs` +- Windows IME unavailable 模式对照:`src-tauri/src/windows_ime_profile.rs` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 000000000..82e14e8cb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,96 @@ +# OpenLess 2.0 架构 + +状态:canonical,当前实现说明;更新:2026-09-08。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 + +## 1. 分层与工作区 + +应用开发与构建源在 `openless-all/app/`。下文源码路径以该目录为基准。根 [Cargo workspace](../openless-all/app/Cargo.toml) 成员为 `crates/openless-core` + `linux-egui`;`src-tauri`(及其 `backend-tests` 测试 crate)被 exclude,独立构建。Core 是与 Host 同进程的业务库。 + +| 层 | 位置 | 职责 | +| --- | --- | --- | +| 界面(Win/mac/Android) | `src/`(React/TypeScript/i18next,五语言) | 页面、设置、窗口分支;调用 typed IPC,展示快照与事件 | +| Host(Win/mac/Android) | `src-tauri/`(crate `openless`) | `src/lib.rs` 注册命令;适配窗口、热键、音频、凭据、插入、IME 和生命周期 | +| 共享 Core | `crates/openless-core/` | 业务规则、会话、服务调用和数据仓储;通过 trait 接入 Host 能力 | +| Linux Host + UI | `linux-egui/`(crate `openless-linux-egui`) | `backend.rs` 组装 `OpenLessBackend`,`main.rs` 实现 egui/eframe UI,不依赖 Tauri/WebKitGTK | + +Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotlin、manifests、frontend);`android/frontend` 经 Vite 别名 `@android` 被 `src/` 引用;manifest 由 `scripts/merge-android-*.mjs` 合成。Linux 已有可复用 Host/UI 起点,剩余能力与产品验收见 [交接目录](linux-egui-handoff/README.md)。 + +## 2. 数据流 + +```mermaid +flowchart TB + React["React 页面 / 窗口"] --> IPC["src/lib/ipc · typed wrapper"] + IPC --> Tauri["Tauri commands / coordinator / core_adapters"] + Egui["egui UI"] --> Linux["LinuxHost / LinuxBackendBuilder"] + Tauri --> Core["OpenLessBackend · Core"] + Linux --> Core + Core --> Stores["历史 / 设置 / 词库 / 风格包仓储"] + Core --> Network["云端 provider / 风格包 API"] + Core --> Ports["录音 / 插入 / 凭据 / 本地模型等接口"] + Ports --> Native["Host 原生实现"] + Core --> Events["BackendEvent · 语义事件"] + Events --> Tauri + Events --> Linux +``` + +- 桌面:React → 类型化 IPC 门面(`src/lib/ipc/`)→ Tauri command → Core;Core 事件由 Host 转发回界面。 +- Linux:egui UI → `LinuxHost`(`lib.rs`:`snapshot` / `subscribe` / `save_settings` / `drain_events` 等)→ `OpenLessBackend` → Core,类型化 Rust 接口,不经 IPC。 +- 浏览器预览:provider 公开目录由 Core 生成到 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors` 重新生成;只含公开元数据,无凭据);原生端走同一受启动合同保护的 IPC。 +- 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;跨平台合同以 `contract/backend-2.0.json` 为准。 + +启动时,`src/App.tsx` 经 `src/lib/ipc/shared.ts` 请求 `get_startup_snapshot`,校验合同版本和 backend 运行状态后进入业务界面。Core 事件定义在 `events.rs`,Tauri 的转译入口为 `src-tauri/src/tauri_events.rs`,Linux 直接订阅类型化事件。 + +听写主链由 `dictation_engine.rs` 管理:触发会话 → 录音/ASR → 清理与润色 → Host 插入 → 历史与事件。Tauri 在 `coordinator/dictation_core.rs` 接入该链路;本地 ASR 的模型管理归 Core,原生执行实现分别位于 Host。取消、失败和旧会话事件处理也属于该业务链,而不是页面各自实现。 + +## 3. Core 模块地图(按域,见 `src/lib.rs` pub mod 清单) + +- 听写链路:`dictation_engine` / `dictation_context` / `audio` / `external_audio` / `silence_auto_stop` / `streaming_insert` / `hotkey_interpreter` / `voice_session` +- 服务与凭据:`provider_rules` / `provider_registry` / `provider_resolution` / `provider_service` / `provider_transport` / `cloud_providers` / `providers` / `omni` / `llm_gemini` / `credentials`(+`credentials_legacy`) / `endpoint_security` / `net` +- 本地模型:`model_store` / `local_asr_service` / `local_asr_catalog` / `asr/`(云与本地 provider 实现) +- 文本加工:`polish` / `prompt_compose`(+`prompts/`,`include_str!` 编译进二进制) / `output_cleaning` / `correction` / `vocabulary` +- 知识与历史:`history` / `activity` / `style_packs` / `style_pack_store`(+`style_pack_archive`) / `marketplace` +- 交互域:`qa_service` / `selection_service` / `selection_voice_service`(+`selection_voice_intent`) / `edit_plan` / `less_computer` / `coding_agent`(+`coding_agent_guard`) / `remote_input_service` / `auxiliary` / `cli` +- 基座:`api` / `events` / `ports` / `settings` / `preferences` / `persistence` / `config` / `errors` / `types` / `shared_types` / `shortcut_types` / `domains` / `android_types` / `host_document/` / `testing` / `vendor/` + +## 4. Host 注入点 + +操作系统集成由 Host 提供。Core 的 `ports.rs`、`config.rs`、`domains.rs` 和 `credentials.rs` 定义录音、插入、任务执行、系统动作、领域运行时及凭据接口;Core 自身仍包含 HTTP 调用和框架无关的文件仓储。 + +Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装原生依赖并对接已有 persistence。Linux 在 `linux-egui/src/backend.rs` 使用 `LinuxBackendBuilder`,注入音频、凭据、服务、设置、本地 ASR 和平台动作。`BackendConfig` 由 Host 提供数据、缓存、资源路径与平台能力。 + +业务规则缺失时应修复 Core;平台能力缺失时修复对应 Host。设置值、测试 fixture 或 `Unsupported` 实现不能代表原生能力已就绪。 + +## 5. 窗口体系 + +`src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`、`selection-polish-preview`、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。 + +## 6. 存储与外部服务 + +| 数据或连接 | 所有者与源码入口 | +| --- | --- | +| 历史、活动、偏好、词库、纠错、风格包 | Core 对应仓储模块;Tauri `src-tauri/src/persistence/` 提供平台路径及兼容存储适配 | +| 模型与录音文件 | Core `model_store.rs`、Host 本地运行时和 `persistence/paths.rs`;录音归档受设置控制 | +| 服务凭据 | Core `CredentialStore` 合同,Tauri keyring/Android Keystore 或 Linux `credentials.rs` 适配 | +| 云端 ASR / LLM | Core provider 目录、选择与传输模块;平台本地引擎位于 `src-tauri/src/asr/local/` 或 Linux Host | +| 风格包市场 | Core `marketplace.rs` 管理 HTTP、GitHub device flow 与本地安装;地址由 `MarketplaceConfig` 注入,内置默认值在该模块 | +| 风格图标 | React `src/lib/stylePackIcon.ts` 清理上传的 SVG 并转成 PNG;`set_style_pack_icon` / `read_style_pack_icon` 经 Core `style_pack_store.rs` 保存资源、校验读取范围并返回图片 data URL。图标沿用 ZIP 的 64 KiB 限制,与风格包一起导出 | +| 局域网手机输入 | Core `remote_input_service.rs` 定义共享业务,Tauri `remote_server/` 提供本机网络入口和网页资源 | + +应用不会把普通听写交给风格包市场后端。市场安装完成后使用本地风格包;官网也不参与应用的业务调用。长期参考数据、训练准备和历史快照不是 Core 的在线训练服务。 + +## 7. 验证入口 + +以下命令均在 `openless-all/app/` 执行,按变更范围选择: + +| 范围 | 命令与依据 | +| --- | --- | +| 前端与合同 | `npm test`;`pretest` 先构建,`scripts/frontend-test-runner.mjs` 发现前端测试和脚本合同检查,含 Core 快捷键回归 | +| Core/Linux 格式 | `cargo fmt --all --check`,仅根 workspace | +| Core | `cargo test -p openless-core --locked` | +| Linux Host | `cargo test -p openless-linux-egui --locked`;原生能力在 Linux 目标环境验证 | +| Tauri 格式与编译 | `cargo fmt --manifest-path src-tauri/Cargo.toml --check`、`cargo check --locked --manifest-path src-tauri/Cargo.toml` | +| Tauri 库与独立回归 crate | 依平台选 `cargo test --locked --manifest-path src-tauri/Cargo.toml --lib`、`cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml`;适用矩阵见 [CI](../.github/workflows/ci.yml) | + +源码构建 Tauri 前执行 `git submodule update --init --recursive`。其 manifest 含受 target 条件控制的本地 path 依赖,Cargo 解析仍需对应子模块;部分 CI 作业通过专用脚本去除非目标依赖。Core/Linux workspace 排除 Tauri,不需要为这些独立检查初始化 Tauri 子模块。 + +仅 Markdown 变动检查相对链接、源码路径与描述一致性。平台交付按 [桌面验收](2.0-desktop-acceptance.md)、[Linux 验收](linux-egui-handoff/07-acceptance.md)和 [发布规范](../RELEASING.md)完成。 diff --git a/docs/bailian-asr-models.md b/docs/bailian-asr-models.md index 5d1590abb..beb37f792 100644 --- a/docs/bailian-asr-models.md +++ b/docs/bailian-asr-models.md @@ -1,32 +1,27 @@ -# Alibaba Bailian ASR models +# 阿里百炼(DashScope)ASR 模型 -OpenLess uses one Alibaba Bailian provider and selects the DashScope protocol from the model name. Configure the API key, region or workspace endpoint, and model in Settings > Providers. +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 -| Mode | Model examples | Behavior | -| --- | --- | --- | -| Realtime WebSocket | `fun-asr-realtime`, `fun-asr-flash-8k-realtime`, `paraformer-realtime-v2`, `sensevoice-realtime-v1` | Streams text while recording. OpenLess downsamples 16 kHz recorder audio for 8 kHz models. | -| Qwen realtime | `qwen3-asr-flash-realtime`, versioned snapshots | Streams text through the Qwen Realtime WebSocket API. | -| Synchronous recording | `fun-asr-flash-*`, `qwen3-asr-flash`, `qwen-audio-3.0-asr-flash` | Sends the recording after capture and waits for one synchronous response. These models are intended for short recordings (`qwen-audio-*-streaming` variants are not supported). | -| Asynchronous file transcription | `fun-asr`, `fun-asr-mtl`, versioned snapshots, `paraformer-v2` | Uploads the recording, starts an asynchronous task, polls it, then downloads the transcript. | +## 1. 代码中的定义 -Future dated snapshots that retain one of these model prefixes are routed to the same protocol, so they can be entered manually before they are added to the model picker. +- Provider:`bailian`,`authRequirement = api_key`;默认端点 `wss://dashscope.aliyuncs.com/api-ws/v1/inference/`;默认模型 `fun-asr-realtime`。 +- 协议按模型名选择(DashScope WebSocket 推理协议);验证探针 `asr_silence`。 +- 目录来源:`provider_rules::provider_descriptors` → 生成文件 `src/lib/ipc/provider-descriptors.generated.json`(重新生成:`cargo run --locked -p openless-core --example export_provider_descriptors`)。 -## Endpoints +## 2. 当前静态模型目录(14 个) -The default endpoint is `dashscope.aliyuncs.com`. A workspace or regional host may also be entered, for example `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com`. OpenLess preserves the host and derives the protocol-specific path and scheme automatically. +| 模型 | 说明 | +| --- | --- | +| `fun-asr-realtime`(默认) | Fun-ASR 实时转写 | +| `fun-asr-flash-8k-realtime` | 8k 采样实时版 | +| `fun-asr-flash-2026-06-15`、`fun-asr`、`fun-asr-2025-11-07`、`fun-asr-2025-08-25` | Fun-ASR 各版本 | +| `fun-asr-mtl`、`fun-asr-mtl-2025-08-25` | 多语种(MTL) | +| `qwen3-asr-flash-realtime`、`qwen3-asr-flash-realtime-2026-02-10`、`qwen3-asr-flash-realtime-2025-10-27`、`qwen3-asr-flash` | Qwen3 ASR 实时/闪速 | +| `qwen-audio-3.0-asr-flash` | Qwen Audio 3.0 ASR | +| `paraformer-v2` | Paraformer v2 | -API keys are region-specific. The endpoint and API key must belong to the same region. +模型 ID 直选与目录以生成文件为准;新增模型先改 Core `provider_rules.rs` 再重新生成目录,不要手工编辑生成文件。 -## Temporary uploads +## 3. 在应用内配置 -Asynchronous models require a URL. OpenLess obtains a temporary upload policy from DashScope, uploads the local WAV to the returned OSS host, and submits the resulting `oss://` URL with `X-DashScope-OssResourceResolve: enable`. - -Alibaba documents this temporary storage as a development and low-concurrency facility. It is rate limited, and uploaded URLs expire after 48 hours. OpenLess uploads one file per completed recording; it does not retain or reuse the temporary URL. - -For deployment or high-concurrency use, use stable Alibaba Cloud OSS storage and the official DashScope API directly. - -## References - -- [Fun-ASR non-real-time HTTP API](https://help.aliyun.com/en/model-studio/fun-asr-recorded-speech-recognition-http-api) -- [Qwen-ASR API reference](https://help.aliyun.com/en/model-studio/qwen-asr-api-reference) -- [DashScope temporary file upload](https://help.aliyun.com/en/model-studio/get-temporary-file-url) +设置 → AI 服务 → 语音识别 → 添加渠道,选择百炼;填入 API Key(DashScope),选择模型;保存后执行“验证”。 diff --git a/docs/core-platform-boundary-audit.md b/docs/core-platform-boundary-audit.md deleted file mode 100644 index 6d267f979..000000000 --- a/docs/core-platform-boundary-audit.md +++ /dev/null @@ -1,48 +0,0 @@ -# Core / Host 平台边界审计 - -> 2026-09-06范围更新:[当前2.0需求](./2.0-requirements.md)要求Windows/macOS完整保留各自1.x功能;Linux剩余Host/UI和产品验收移交egui团队,见[拆分交接目录](./linux-egui-handoff/README.md)。本审计保留代码边界证据,不表示Linux产品完整;Linux应用待办不单独阻塞本批桌面2.0。 - -审计基线:OpenLess 2.0.0-Beta.1(2026-09-04 工作树)。范围覆盖 `openless-core`、Tauri、Linux egui、React/TypeScript、C++ 插件及构建脚本;vendor、生成物和纯视觉实现排除。 - -2026-09-05 更新:下文为边界迁移索引;最新生产行为缺口、修复和证据在 -[`pr1019-2.0-final-review.md`](./pr1019-2.0-final-review.md)。有接口和通过 fixture -不能证明对应 Host 的启用、取消、资源释放或实际输入行为正确。 - -## 已迁移项 - -- Provider descriptor、Credential channel mutation/active/order、ASR/LLM/Omni、设置事务与事件位于 Core;React/egui 只渲染 descriptor 和本地化标签。 -- `LessComputerVoiceSession` 在 Core 持有 session lease、ASR snapshot、PCM 校验、TranscriptDelta、静音/fault 决策和 Agent submit。 -- 主听写与 Selection Voice 的 Hold/Toggle/Auto/Combined、press generation、250ms debounce、450ms terminal cooldown 均由 Core 解释。 -- 录音 plan、`SilenceAutoStop`、silent retry、correction 顺序/归因、流式 final reconciliation、edit observation generation 与历史重转 mutation 均位于 Core。 -- `ModelStore` 统一 1.x/custom-root 迁移与 Local ASR 原子激活;Qwen/Whisper 用 target generation lease,Linux Qwen 使用打包 runtime 和进程组 cancel/timeout。 - -## 本批修复项 - -- Shared realtime ASR 将 Qwen、StepFun、Bailian、Volcengine、讯飞 interim 回调接入 Unicode replace-from `TranscriptDelta`;React/Linux 使用同一 reducer 语义。 -- Linux production factory 注入 QA、Remote Input、Selection preview/revert、Provider/Model、Less Computer 与打包 Qwen runtime;AppImage 在 hotkey listener 前处理 fcitx5 插件。 -- Tauri 只保留窗口、原生录音/native ASR、秘密存储、插入目标、进程和协议 transport effect;Credential/QA/Selection/stream/history 的旧 Host policy 已删除并由 source gate 固定。 -- Linux/Tauri manifest、打包脚本和 AppStream 使用 `AGPL-3.0-only`;2.0.0-Beta.1 为许可证生效边界,1.x 发布物仍为 MIT。 - -## 有意保留的 Host 项 - -- Tauri 窗口、胶囊、原生录音/native ASR、系统凭据、插入和生命周期。 -- Linux cpal、fcitx5、Secret Service、资源布局和单实例。 -- Windows Foundry/Sherpa、macOS Apple Speech/MLX/Whisper 等单平台 runtime。 - -## Deferred 候选 - -- 不再为当前迁移拆独立 process/transport crate;现有 Core `AgentCommand` + Host `ProcessAdapter` 已满足边界,新增 crate 只会制造中间层。 -- Ubuntu 实际焦点输入、音频设备、Secret Service、QA/Remote/Selection、Qwen 性能与 deb/rpm/AppImage 安装升级回滚证据。 -- Windows 原目标恢复/PasteSent/TSF unknown/mute/fault/Foundry/Sherpa,macOS newline/context/edit/login-shell,以及 Android readiness/overlay/IME 的真实设备 smoke。 - -## Standards - -依赖方向保持 Host → Core;Core 不引用 Tauri/egui,凭据只经 `CredentialStore`,未注入 runtime 明确返回 `Unsupported`。共享 session 通过一个 mutable lease 和 `BackendEvent.session_id` 关联。 - -## Spec - -2.0 目标的 Core voice session、实时增量、三档热键解释和 Linux typed event 已有公共入口;平台原生录音/本地模型仍由 Adapter 提供,不能以 headless fixture 代替设备证据。 - -## 剩余风险和证据边界 - -本审计证明源码边界和可运行 contract,不证明云服务凭据、真实音频设备、CLI 安装、签名、设备运行或发布产物。Backend contract 版本为 `2.0.0`,不随应用版本升级。 diff --git a/docs/images/pr-711/dark-overview.png b/docs/images/pr-711/dark-overview.png deleted file mode 100644 index a1a12525f..000000000 Binary files a/docs/images/pr-711/dark-overview.png and /dev/null differ diff --git a/docs/images/pr-711/light-overview.png b/docs/images/pr-711/light-overview.png deleted file mode 100644 index ee9cae618..000000000 Binary files a/docs/images/pr-711/light-overview.png and /dev/null differ diff --git a/docs/images/volcengine-setup/01-login.png b/docs/images/volcengine-setup/01-login.png deleted file mode 100644 index e27ac106a..000000000 Binary files a/docs/images/volcengine-setup/01-login.png and /dev/null differ diff --git a/docs/images/volcengine-setup/02-create-legacy-app.png b/docs/images/volcengine-setup/02-create-legacy-app.png deleted file mode 100644 index 997cc4a76..000000000 Binary files a/docs/images/volcengine-setup/02-create-legacy-app.png and /dev/null differ diff --git a/docs/images/volcengine-setup/03-streaming-asr-page.png b/docs/images/volcengine-setup/03-streaming-asr-page.png deleted file mode 100644 index 0e1dd1c44..000000000 Binary files a/docs/images/volcengine-setup/03-streaming-asr-page.png and /dev/null differ diff --git a/docs/images/volcengine-setup/04-openless-settings.png b/docs/images/volcengine-setup/04-openless-settings.png deleted file mode 100644 index 9df4041e1..000000000 Binary files a/docs/images/volcengine-setup/04-openless-settings.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..3dbf7cdf9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,36 @@ +# OpenLess 文档入口 + +状态:canonical;更新:2026-09-08。实现说明与当前源码保持一致;范围、接口合同和验收要求由各自文档维护。各专项文档的更新时间与状态单独标注。 + +## 范围与架构 + +- [2.0 范围](2.0-requirements.md):平台边界、版本锚点与完成条件。 +- [架构](architecture.md):Core / 平台 Host / 界面分层、模块地图与验证入口。 +- [目录与工程结构](structure.md):仓库与应用工作目录、构建清单、源码定位和生成目录。 +- [桌面验收清单](2.0-desktop-acceptance.md):功能域 → 源码入口 → 必须的真实证据。 +- [发布规范](../RELEASING.md):分支、渠道、版本同步和平台发布条件。 + +## Linux 接入交接(egui,目录内互链) + +- [交接总览](linux-egui-handoff/README.md):阅读顺序、责任与复用起点。 +- [01 Core 合同](linux-egui-handoff/01-core-contract.md)、[02 缺口登记](linux-egui-handoff/02-gap-register.md)(L01–L12)、[03 热键与窗口](linux-egui-handoff/03-hotkeys-and-windows.md)、[04 页面与领域](linux-egui-handoff/04-ui-domains.md)、[05 原生宿主与数据](linux-egui-handoff/05-native-host-and-data.md)、[06 事件与会话](linux-egui-handoff/06-events-and-sessions.md)、[07 验收](linux-egui-handoff/07-acceptance.md)。 + +## 接口契约 + +- [Linux egui 后端契约](linux-egui-backend-contract.md):`contract/backend-2.0.json`、启动快照、事件面与公开签名。 + +## 平台与运营 + +- [Android APK / 悬浮窗计划](android-mobile-apk-overlay-plan.md)(实施中) +- [火山引擎 ASR 配置](volcengine-setup.md) +- [讯飞(iflytek)ASR 配置](xfyun-asr.md) +- [百炼(DashScope)ASR 模型](bailian-asr-models.md) +- [Tauri CSP 边界](tauri-csp.md) +- [qwen-asr 子模块升级清单](qwen-asr-submodule-upgrade-checklist.md) + +## 验证入口(在 `openless-all/app` 执行) + +- `npm test`:构建 React + 全部前端/合同测试(含 Core 快捷键回归)。 +- `cargo fmt --all --check`:根 workspace 的 openless-core、linux-egui;Tauri 单独执行 `cargo fmt --manifest-path src-tauri/Cargo.toml --check`。 +- `cargo test -p openless-core --locked`、`cargo test -p openless-linux-egui --locked`。 +- `src-tauri` 及 `backend-tests` 被 workspace exclude,按平台独立构建。源码构建 Tauri 前初始化子模块:`git submodule update --init --recursive`;Core/Linux 独立检查不依赖 Tauri 子模块。 diff --git a/docs/linux-egui-backend-contract.md b/docs/linux-egui-backend-contract.md index a84e17dd1..aa803fcfb 100644 --- a/docs/linux-egui-backend-contract.md +++ b/docs/linux-egui-backend-contract.md @@ -1,779 +1,38 @@ # Linux egui 后端接口契约(2.0.0) -> **2026-09-06更新**:接手请先读[Linux交接目录](./linux-egui-handoff/README.md)中的短文档;本文保留长接口与历史实现参考,不再作为唯一交接材料。 -> 当前范围以[2.0需求](./2.0-requirements.md)为准:Windows/macOS完整保留各自Tauri 1.x功能;Linux交付可接入Core。egui团队承接剩余Linux Host原生接线、UI、设备与发布验收,不仅负责绘制;Linux产品未完成不单独阻塞本批Windows/macOS交付。 -> -> 2026-09-04 当前状态:2.0.0 facade、repository、Provider/Credential、听写 Pipeline、Local ASR -> 原子激活、主热键仲裁、录音生命周期、上下文/手改观察、静默重试、插入 reconciliation、 -> Transcript reducer、Less Computer/Coding Agent 以及全部领域 Interface 均由 Core 持有。 -> Tauri 与 Linux 共用云 ASR/LLM/Omni/Auxiliary Implementation;平台 Host 只注入原生录音、 -> 凭据、窗口、进程、焦点和插入 Adapter。Linux production factory 已注入真实 QA、Remote Input、 -> Selection preview/revert 与打包的 Qwen runtime;AppImage 会在 listener 前安装 fcitx5 插件。 -> 自动 contract 不替代真实平台证据:Ubuntu/Windows/macOS/Android 的设备、安装、升级和签名 -> 结果仍须单独记录,当前责任与门槛按[桌面验收](./2.0-desktop-acceptance.md)和[Linux验收](./linux-egui-handoff/07-acceptance.md)区分;以下旧日期状态不能证明Linux无剩余缺口。 -> -> 2026-09-05 复核补充:最新生产行为修复见 [`pr1019-2.0-final-review.md`](./pr1019-2.0-final-review.md)。 -> 原生预加载必须使用请求的 target/provider type;平台无法准备流式时以 `supports_streaming=false` -> 保留一次性落字。Remote stop 保持可取消的 session,socket 下行只转发本连接所属 session 的事件。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 -## 1. 依赖与职责 +## 1. 合同文件 -Linux UI 只能依赖 `openless-linux-egui -> openless-core`。`openless-core` 的正常 -依赖树不得出现 `tauri`、`wry`、`webkit2gtk`、`egui` 或 `eframe`;Tauri 只能在 -`src-tauri` adapter 中出现。 +`openless-all/app/contract/backend-2.0.json`(`contractVersion` 2.0.0)顶层键: -```toml -[dependencies] -openless-core = { path = "../crates/openless-core" } -``` - -核心拥有业务状态、会话、provider 选择、持久化和语义事件。Linux host 拥有 -Tokio runtime、窗口/托盘、fcitx5、麦克风、凭据和系统操作。egui frame 只读取 -快照、非阻塞地 drain 事件,并在事件到达后请求重绘。 - -## 2. 构造与生命周期 - -核心构造不创建窗口、不申请权限、不注册全局热键,也不启动网络任务。宿主按 -以下顺序初始化: - -1. 解析 `data_dir`、`cache_dir`、资源目录、locale 和 `PlatformCapabilities`。 -2. 创建各 platform port,组装 `BackendDependencies`。 -3. 调用 `OpenLessBackend::new`。 -4. 创建事件订阅和 host action sink。 -5. 调用 `start()`,把返回的 `StartupSnapshot` 放入 UI view model。 -6. 只有启动成功后才启用热键、设备 watcher 和下载 watcher。 - -Linux 生产宿主不自行组装 provider,也不读取 credential account。以下生产 factory 必须在Host已有的Tokio runtime上下文中调用;同步GUI初始化用短作用域`runtime.enter()`包住构造,随后退出作用域再启动运行时任务。具体executor生命周期与自定义注入约定见[当前接入说明](./linux-egui-handoff/01-core-contract.md)。 - -```rust -let runtime = LinuxBackendBuilder::from_shared_providers(config)?.build()?; -let backend = Arc::clone(&runtime.backend); -``` - -`LinuxBackendBuilder::new(config, transcription, polisher)` 仅供测试或特殊宿主显式替换 provider; -egui UI 不应调用它,也不应注册 router 或解析 endpoint/model/extra headers/temperature。 - -关闭时先禁止新会话,取消活动任务,停止 host watcher,flush 必要持久化,停止 -事件桥,再调用幂等的 `shutdown()`。 - -```rust -let backend = Arc::new(OpenLessBackend::new(config, dependencies)?); -let mut events = backend.subscribe(); -let startup = backend.start().await?; -let _session = backend.start_dictation().await?; -let result = backend.stop_dictation().await?; -backend.shutdown().await?; -``` - -`start()` 和 `shutdown()` 可重复调用;重复启动不会重复发布 -`BackendStarted`,重复关闭不会 panic。`snapshot()` 是同步、无副作用的 owned -读取,不返回内部锁或引用: - -```rust -let current: BackendSnapshot = backend.snapshot(); -``` - -## 3. 当前公开接口 - -### 3.1 Facade - -| 方法 | 线程/等待 | 语义 | -| --- | --- | --- | -| `new(config, deps)` | 同步 | 校验配置并注入依赖;不启动副作用 | -| `start()` | async | 初始化核心生命周期;幂等 | -| `shutdown()` | async | 停止核心生命周期;幂等 | -| `snapshot()` | 同步 | 返回可克隆快照 | -| `subscribe()` | 同步 | 创建独立事件订阅 | -| `start_dictation()` | async | 建立唯一活动 session,`Starting` 成功后进入 `Recording` | -| `stop_dictation()` | async | `Transcribing -> Polishing -> Inserting -> Completed`,执行 ASR/润色和插入 | -| `cancel_dictation(session)` | async | 取消指定或当前 session;session 不匹配时报错 | - -同一时刻最多一个 dictation session。UI 不应通过按钮状态猜测可用性;调用仍需 -处理 `Busy`、`InvalidState` 和 `Cancelled`。 - -### 3.2 已建立的领域 Interface - -数据领域直接由深 facade 提供,所有宿主共用同一份 repository 和规则: - -| 分组 | 主要操作 | -| --- | --- | -| preferences | `get_preferences`;完整文档和单项更新统一通过 `LinuxHost` 的 validated settings Interface | -| credentials/channels | status、显式 `SecretValue` read/write/remove、channel CRUD/reorder/test、active provider | -| history/activity | list/recent/append/update/delete/clear、activity snapshot/bump | -| vocabulary/correction | list/add/remove/enable/hits、preset、correction-rule lifecycle;`enabled_vocabulary_phrases()` 与 `asr_vocabulary_phrases()` 返回 Core 过滤/排序后的 owned 词条 | -| style packs | list/get/create/update/activate/enable/reset/delete、安全 ZIP import/export;`preview_style_pack_runtime(style_pack)` 返回由 Core 统一组装的单轮/多轮 prompt 诊断 | -| dictation | start/stop/cancel、snapshot、session-scoped progress 与插入终态 | - -有平台、网络、进程或 runtime 变化点的复杂领域通过 -`OpenLessBackend::services() -> &BackendServices` 暴露: - -| Interface | 已冻结的 use-case | +| 键 | 内容 | | --- | --- | -| `ProviderApi` | validate、list models,按 ASR/LLM/Omni 和可选 channel 选择 | -| `LocalAsrApi` | settings/catalog/status/remote info、目录/模型/镜像/keep-loaded 设置、download/prepare/preload/release/delete/test | -| `ModelStore` | Core 统一 catalog、HF tree 分页、文件过滤、Range/断点下载、SHA-256、staging/ready sentinel、归档解压与 1.x 模型目录迁移 | -| `SelectionApi` | snapshot、begin polish、confirm、cancel、revert | -| `SelectionVoiceApi` | begin/mark processing、Core-owned transcript 处理、intent confirm、edit delivery、QA preview create/revise、preview apply ticket/finish、cancel/revert | -| `QaApi` | snapshot、toggle recording、按每轮token stop_recording、submit text、edit-instruction mode、session cancel、dismiss | -| `LessComputerApi` | submit、cancel、dismiss、begin turn、approval decision;Core 统一 provider/model/permission/workdir/prompt/guard/continuation | -| `RemoteInputApi` | 同步 status、configure、显式读取/重新生成 pairing PIN、locale、local IPs、connect/disconnect、start/feed/stop/cancel stream | -| `MarketplaceApi` | list/detail/install/download/upload/like/delete、my lists、GitHub device flow、logout | -| `CodingAgentApi` | detect/list models/risk/run/cancel/approve | -| `PlatformApi` | microphone devices/permission、accessibility permission、permission request、hotkey status | -| `AuxiliaryApi` | 对既有文本执行单轮 repolish;对宿主提供的规范 PCM 执行单轮 retranscription | - -`BackendServices::unsupported()` 是正式的降级 Adapter:每个调用返回 -`BackendErrorCode::Unsupported`,不会启动 task 或返回空成功。egui crate 不得直接 include -`src-tauri/src/*.rs`,也不得为了暂时可用而复制业务逻辑。 - -`preview_style_pack_runtime(style_pack)` 是同步、无 I/O 的 Core 查询。它读取当前偏好和已启用 -词典,使用与生产润色路径相同的 prompt composer,返回 `StylePackRuntimeDiagnostics`(包括 -单轮/多轮 prompt、上下文 premise、热词块及字符数)。宿主只渲染返回 DTO;不得在 Tauri 或 -egui 中重新拼接 prompt、过滤热词或推导字符数。 - -### 3.3 `ProviderApi` - -`ProviderApi::validate` 与 `list_models` 由 Core `ProviderService` 统一实现。Tauri -`commands/providers.rs` 只负责 `kind` 字符串解析、请求构造和旧错误字符串转换;Linux -生产入口 `LinuxBackendBuilder::from_shared_providers` 注入同一 service,egui 不读取凭据或 -构造 HTTP/WS client。 - -- `ProviderRequest { kind, channel_id }`:`channel_id = None` 使用该类别的 active provider;显式 - channel 必须存在于 metadata。Omni 不接受 channel id,返回 `InvalidArgument`。 -- channel 的 `provider_type` 来自 credential metadata,不能用 channel id 猜协议;凭据通过 - `CredentialKey { namespace, provider_id, account }` 读取,A/B channel 不会串线。 -- `validate` 使用与正式 ASR/LLM/Omni 调用相同的 Core provider 构造路径;静音探活不保存用户音频。 -- `list_models` 对 Codex、Bailian、Qwen、Mimo、ElevenLabs 等无远端列表接口返回 Core 静态清单; - OpenAI-compatible 与 Gemini 使用受大小上限约束的远端列表响应,并校验 JSON schema。 -- 参数/模型缺失返回 `InvalidArgument`,凭据或网络/HTTP/WS 失败返回 `Provider`,超时/连接失败 - 标记 `retryable`,取消返回 `Cancelled`,native/未知 provider 返回 `Unsupported`。 -- 模型响应最大 2 MiB;状态码只以 `providerHttpStatus:` 形式返回。API key、token、Authorization、 - endpoint credential 和响应 body 不进入 DTO、错误详情、日志或 `Debug`。 - -Core、Tauri wire 和 Linux factory contract 覆盖 channel 隔离、Omni 拒绝、静态/远端模型解析、 -错误脱敏和非 `Unsupported` 生产接线。未注入的其他领域仍稳定返回 `Unsupported`。 - -当前 provider 管理面已可供 egui 调用。模型列表请求通过 Core `ProviderTransport` seam;生产实现使用 -无 redirect、显式 timeout 和 2 MiB 响应上限,`FakeProviderTransport` 已覆盖 HTTP 状态、超时、取消、 -无效 JSON、响应过大和 redirect 拒绝,并验证秘密不会出现在请求 `Debug`、错误或 fixture 输出。Core -静态模型清单已按迁移前 Tauri 顺序完成 provider parity 与去重测试;LLM extra headers/temperature -写入已按显式 `provider_id` 定位并有 A/B channel 回归测试。上述 Interface 收口升级为 2.0.0 公共调用面, -但真实 provider 网络、keyring/Secret Service、取消/超时和各平台 runner 仍需在 M9/M10 留下独立证据, -不能用 fake、WSL 或本地 parser/unit 测试代替。 - -Less Computer 文字入口可直接提交;语音/物理热键入口必须先建立 Core capture lease, -再把同一个 session id 贯穿宿主录音、取消和提交: - -```rust -let result = backend.submit_less_computer("列出当前目录的文件".to_string()).await?; - -let session_id = SessionId::new(); -backend.begin_less_computer_capture(session_id)?; -// Host starts its recorder/native ASR. Startup failure must release the lease: -// backend.abort_less_computer_capture(session_id)?; - -// 录音热键完成转写后,使用同一 session 以便 Esc/release 精确取消: -let result = backend - .submit_less_computer_with_session(session_id, transcript) - .await?; - -// Esc/cancel must signal Core before the host drops recorder/ASR resources. -backend.cancel_less_computer(Some(session_id)).await?; -// If submission has not promoted the lease to a run, release the capture lease: -backend.abort_less_computer_capture(session_id)?; -``` - -两个 submit 方法都只接受用户文本;provider、executable、model、permission mode、workdir、 -autonomous prompt、命令护栏、审批重跑和 dsh continuation 均由 Core 从 preferences 与实例状态 -解析。`begin_less_computer_capture` 是实例级、session-scoped 的互斥 lease;重复 reserve 返回 -`Busy`,`less_computer_active_session` 可用于重连/诊断,`less_computer_capture_cancelled` 在 -宿主释放 capture lease 前保持可见。`abort_less_computer_capture` 只释放仍处于 capture 阶段的 -lease,对已提升为 Agent run 的 session 是幂等 no-op。`LessComputerRuntimeAdapter` 只负责宿主 -进程/Git/临时文件/stream transport;未注入 runtime 时返回 `Unsupported`。同一实例同时提交 -第二轮返回 `Busy`,不能覆盖当前运行。 - -返回的 `LessComputerRunResult` 只表达该轮唯一终态: -`Completed { text, cost_usd }`、`Failed { message }` 或 `Cancelled`。UI 不应根据 delta、窗口关闭 -或进程退出自行猜测终态;必须等待对应的 `LessComputerEvent`。 - -Linux 生产 factory 会注入 Core `MarketplaceApi`。需要把归档保存到用户文件系统时,UI 调用 -`LinuxHost::download_marketplace_archive(pack_id, target)`:Core 负责 HTTP/OAuth/大小限制与 ZIP -校验,Linux host 只接受已有父目录下的绝对路径,以 create-new 方式写入并拒绝覆盖;写入或 -`sync_all` 失败时删除不完整文件。UI 不处理 bearer token,也不自行重复校验归档。 - -### 3.3 `AuxiliaryApi` - -`AuxiliaryApi` 是“重新润色”和“从既有录音重新转写”的共享 use-case,不拥有文件选择、 -WAV 解码、窗口或进度 UI:宿主先把输入转换为契约数据,再调用 Core。 - -- `repolish(RepolishRequest)` 接收 owned `raw_text`、可选 `style_pack_id` 和可选 - `front_app`。Core 在调用开始时冻结 preferences、指定或当前 style pack、启用词典以及 - LLM/Omni provider;它不解析 ASR provider。该方法只执行一轮,不写 history/activity、 - 不插入文本、不改变 active style pack,也不发流式 delta。当前模式不使用 polisher 时, - 原文原样返回。 -- `retranscribe_pcm(Vec)` 只接受非空、偶数字节的 16 kHz mono signed Int16 - little-endian PCM;文件读取、WAV header 去除和格式转换归宿主。Core 只冻结 ASR provider, - 不要求 LLM/Omni 凭据,且把全部 PCM 精确送入一个 `TranscriptionSession` 后只 finalize 一次。 - 成功返回 `RetranscriptionResult { text, duration_ms, asr }`;`asr` 是 Adapter 实际使用的 - provider/model(包含默认模型解析后的值),不是 UI 提交值。 -- 失败返回 `RetranscriptionFailure { error, attempted_asr }`。ASR session 尚未建立时 - `attempted_asr` 可为 `None`;建立后必须携带实际归因。Foundry 的 terminal fallback 使用 - `details.terminal = "foundry_fallback"`,`is_terminal() == true` 且 `retryable == false`; - 其他 transcription 启动/finalize 失败统一标为可重试。 -- 如果调用方 future 在 finalize 完成前被丢弃,Core 的取消 guard 通过宿主注入的 - `TaskSpawner` 调用该 session 的幂等 `cancel()`;宿主不能对同一 PCM 自动启动第二次转写。 - -Linux 生产 factory 已注入共享 Auxiliary polisher 与 transcription router;它与 Tauri 使用同一套 -provider 选择、凭据路由、默认值、取消和错误语义。native/local ASR 仍需 Linux 对应 runtime; -未注册的 native/local provider 稳定返回 `Unsupported`,不能把 headless fixture 当作真实能力。 - -### 3.4 `SelectionVoiceApi` - -Selection Voice 的业务入口是高层 use-case,不是让宿主拼装 prompt 或 EditPlan 的工具箱: - -- 宿主完成平台录音/ASR 后,先调用 `mark_processing(session_id)`,再把未加工的 transcript 交给 - `process_transcript(session_id, transcript)`。Core 依次执行 correction rules、指令润色、按冻结偏好 - 选择 manual/heuristic/prompt/auto intent;auto 模型失败时只由 Core 回退 heuristic。宿主不得预先 - 润色、分类或传入模型分类结果。 -- `SelectionVoiceDisposition::Question` 只要求宿主打开 QA surface、提交 Core 返回的 instruction, - 成功后调用 `complete`;`AwaitingIntent` 只要求显示 prompt 并把用户选择交给 `confirm_intent`。 -- `SelectionVoiceDisposition::Edit` 后调用 `prepare_edit(session_id, owner)`。Core 根据 - `selection_polish_output_mode` 返回 `SelectionVoiceEditAction::OpenConversation`,或在内部完成 - translation target 推断、provider 调用、EditPlan 解析/应用后返回 `ReadyToApply { preview }`。 -- QA 编辑模式调用 `edit_preview(SelectionVoiceEditRequest)`。首次调用建立或复用匹配的 Core session; - 后续调用以当前 preview 为 draft,只保留一步 revert。`replaced_existing` 是 QA 按钮状态的唯一真相, - `answer_text()` 是稳定的 assistant message 投影;Adapter 不应重复格式化 summary。 -- 真正替换文本仍是双阶段握手:Core `begin_preview_apply` 返回 ticket,平台 Adapter 用 opaque target - 校验/插入,再以 `finish_preview_apply(ticket_id, outcome)` 回报。只有 `Inserted` 或 - `CopiedFallback` 才会消费 preview 并写 history/activity;`Failed` 保留 preview。宿主无法确认 - 插入结果时必须返回错误,且不得自动重试。 - -`resolve_instruction`、`set_preview` 和 `replace_preview` 是 compatibility/headless 测试原语,不是新 UI -工作流入口。平台仍拥有麦克风、ASR native handle、窗口/热键、焦点恢复和 opaque insertion target; -correction、prompt、intent、EditPlan、translation 和 output-mode 判断不得进入 Tauri 或 egui。 - -### 3.5 设置事务 - -egui view model 必须从同一份快照取得偏好和 revision,再通过 `LinuxHost` 提交完整文档: - -```rust -let snapshot = host.snapshot(); -let outcome = host.update_settings_strict( - preferences, - snapshot.preferences_revision, -)?; -``` - -公开入口只有两种产品语义: - -| 方法 | 冲突策略 | style 字段 | 适用场景 | -| --- | --- | --- | --- | -| `save_settings(preferences, revision)` | `Reconcile`:按 Core 固定优先级恢复旧值或停用低优先级键 | 保留当前值,防止陈旧整表覆盖刚发生的 style 切换 | 设置页整表保存 | -| `update_settings_strict(preferences, revision)` | `Reject`:任何冲突返回 `InvalidArgument` | 使用提交值 | 单项快捷键或明确的聚焦更新 | - -Core 在单写入 gate 内完成 legacy 字段同步、style-pack 对齐、冲突校验/协调和 typed effect plan, -再按 `prepare -> commit effects -> persist once -> publish once` 执行。Linux Adapter 只消费计划中的 -显式目标,不读取或修改 `UserPreferences`;失败时按 typed receipt 逆序恢复已应用的 fcitx5/凭据 -副作用。成功只增加一次 `preferences_revision` 并发布一次 `PreferencesChanged`。 - -revision 不匹配时返回 `BackendErrorCode::Busy`、`retryable = true`,`details` 包含 -`expectedPreferencesRevision` 和 `actualPreferencesRevision`。UI 必须重新读取 snapshot/偏好、合并 -用户仍想保留的编辑后再提交;不得无条件重放陈旧整表。 - -Linux settings Adapter 当前支持 dictation、QA、Selection Polish、translation、Coding Agent 的 fcitx5 热键和 -active ASR provider metadata;启动时同步保存的热键,禁用 Coding Agent 时解绑其语音键。修改 switch-style、open-app、style-pack hotkey 或 -Windows keyboard effect 会稳定返回 `Unsupported`,且不写偏好、不增加 revision、不发布事件。 -UI 不得直接调用 `OpenLessBackend::set_preferences*`;这些低层兼容方法不属于 Linux UI Interface, -也不能绕过 `LinuxHost` 的 revision、冲突和补偿契约。 - -## 4. DTO 字段契约 - -### 4.1 `DictationStateSnapshot` - -| 字段 | 类型 | 规则 | -| --- | --- | --- | -| `phase` | `DictationPhase` | `idle/starting/recording/transcribing/polishing/inserting/completed/cancelled/failed` | -| `sessionId` | `SessionId?` | 非 `idle` 时存在;用于丢弃晚到结果 | -| `elapsedMs` | `u64` | 毫秒,不在 UI 侧换算成秒后再回写 | -| `level` | `f32` | 规范化到 `0..=1`;无音频时为 `0` | -| `message` | `String?` | 非敏感、用户可读提示;不可放 token/PIN | -| `translationActive` | `bool` | 会话开始时冻结;用于两个宿主显示当前会话的翻译状态 | - -阶段是后端事实,不由 UI 猜测: - -| phase | 含义 | egui 操作规则 | -| --- | --- | --- | -| `idle` | 无活动 session | 允许开始 | -| `starting` | engine/录音资源正在启动 | 显示准备态;允许取消,不允许重复开始/停止 | -| `recording` | 正在采集音频 | 允许停止或取消 | -| `transcribing` | ASR 正在处理/输出增量 | 禁用重复停止;可显示 `TranscriptDelta` | -| `polishing` | LLM/规则润色正在处理 | 可显示 `PolishDelta` | -| `inserting` | 已提交文字插入请求 | 不自动重试;等待明确/fallback/unknown 结果 | -| `completed` | 本 session 唯一成功终态 | 读取 `DictationResult` 后清理本地 session | -| `cancelled` | 用户、关闭或 generation guard 取消 | 丢弃该 session 后续增量 | -| `failed` | 本 session 失败 | 按 `BackendErrorCode` 提供重试或用户操作 | - -### 4.2 `DictationResult` - -`sessionId`、`rawText`、`polishedText` 和 `inserted` 均为 owned 字段。公开 Rust 类型 -`DictationInsertStatus` 是 `InsertStatus` 的稳定契约别名;`inserted` 的 serde 值为 -`inserted`、`copiedFallback` 或 `unknown`。`unknown` 表示插入请求超时后结果不可证明, -UI 必须显示待确认状态,不得自动重试以免重复输入。 - -### 4.3 能力与秘密 - -`PlatformCapabilities` 只描述能力布尔值和平台标识。`CredentialsStatus` 只包含 -已配置的 provider id;凭据值、Authorization header、PIN 和完整请求永不进入 -快照、事件、错误 details 或日志。 - -`PreferencesChanged` 事件只携带单调递增的 `revision`,UI 收到后重新调用设置 -查询;不要把任意 JSON 或秘密塞进事件 payload。 - -所有 DTO 使用 Rust 的 `serde` 定义;与 React IPC 的兼容字段由 Tauri adapter -显式转换,不能让 egui 依赖 React 字段名。 +| `startupSnapshot` | 启动快照结构与版本校验规则;UI 必须先消费快照再渲染 | +| `backendEvent` | 语义事件清单、顺序与重放规则 | +| `lessComputerVoice` | Less Computer 语音事件面 | +| `androidJni` | Android JNI 合同(src-tauri android 桥接共用) | +| `linuxFacade` | Linux 专用 facade 面(`LinuxHost` 公开方法对应) | +| `enums` | 共享枚举(provider 类型、状态、错误等) | -### 4.4 `QaSnapshot` 与 Remote Input +## 2. Linux 侧公开签名(源码为准) -`QaSnapshot` 是 QA view model 的唯一状态来源: +- `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`drain_events(...)`、`feed_less_computer_pcm(&[u8])`。 +- `linux-egui/src/backend.rs`:`LinuxBackendRuntime`;`LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_task_spawner / with_recorder / with_auxiliary_polisher / with_text_inserter / with_credential_store / with_services / with_host_actions / with_settings_runtime / with_local_asr_runtime / with_polish_failure_policy` → `build() -> LinuxBackendRuntime`。 +- 事件消费:`drain_events`(`lib.rs`)批量取走 Core 语义事件;订阅经 `EventSubscription`。 -| 字段 | 类型 | 规则 | -| --- | --- | --- | -| `phase` | `QaPhase` | `idle/recording/thinking/awaiting_approval/completed/cancelled/failed` | -| `sessionId` | `SessionId?` | 每轮 turn 的 generation token;每个成功 follow-up turn 都分配新 ID,所有 progress、cancel 和迟到结果 guard 均绑定此 ID | -| `conversationId` | `SessionId?` | 同一面板成功多轮间稳定的 Selection Voice preview owner;仅在 dismiss/clear 后清空,不用于接受上一轮迟到结果 | -| `messages` | `QaMessage[]` | 有序 user/assistant message log;UI 不自行补写 provider 结果 | -| `editInstructionMode` | `bool` | 只能在非活动 turn 修改;活动 turn 修改返回 `Busy` | -| `pendingApprovalToken` | `String?` | 只用于显示和提交明确审批;不得当作跨 session 全局 token | -| `lastError` | `String?` | 已脱敏的公开错误;不得包含 provider body、Authorization 或选择全文 | - -`QaLevel` 规范化为 `0..=1`;`AnswerDelta` 只对匹配当前 `sessionId` 且处于 -`thinking/awaiting_approval` 的 turn 有效。成功 turn 保留 `conversationId`,`sessionId` 继续标识 -该轮终态;下一轮开始时必须替换为新的 generation token,因此上一轮迟到 delta 不能污染 follow-up。 -`dismiss()` 幂等地取消活动 runtime、清空 snapshot、清理与 `conversationId` 匹配的 Selection -Voice preview 并请求宿主隐藏面板;窗口 focus、NSPanel 和键盘仲裁不是 `QaSnapshot` 字段。 - -`LessComputerEvent` 是 Coding Agent 对话的唯一 UI 事件源: - -| `kind` | 字段 | 规则 | -| --- | --- | --- | -| `voice_state` | `sessionId`, `phase`, `level`, `elapsedMs` | Core语音快照;phase为`starting/recording/transcribing/idle`,按原`seq`去重;旧session终态不得覆盖新录音 | -| `user` | `text`, `fresh` | Core 接受输入后发布;`fresh=true` 表示 dismiss 后的新会话 | -| `started` | — | runtime 已启动 | -| `delta` | `text` | 增量输出,按事件 `seq` 去重后追加 | -| `tool` | `name` | 仅展示工具活动;UI 不执行工具 | -| `compaction` | — | provider 正在压缩上下文,可作为非阻塞提示 | -| `approval` | `token`, `command`, `reason` | 只展示脱敏 command/reason,并把 token 原样回传 `approve`;不得写日志/持久化 | -| `completed` | `text`, `costUsd?` | 唯一成功终态 | -| `error` | `message` | 唯一失败终态,message 已脱敏 | -| `cancelled` | — | 唯一取消终态 | - -Core 维护实例级 conversation flag、最多两轮 dsh continuation、approval token registry 和 -90 秒 approval timeout。`dismiss()` 会取消当前 runtime、拒绝所有 pending approval、清空 -continuation,并使下一轮 `user.fresh=true`。egui 只保存渲染所需的派生消息,不复制上述状态机。 - -语音显示还可从`backend.event_publisher().latest_less_computer_voice_state()`读取最后一条有效投影,保留原session/seq且占用固定一条空间。Tauri既有`less_computer_sync`返回可选`voiceState`,即使长转写的阶段事件已被2048条replay驱逐,重开也能恢复;该投影不推进聊天事件水位。合同版本仍为`2.0.0`,没有新增IPC入口。 - -`RemoteInputStatus` 字段和规则如下: - -| 字段 | 类型 | 规则 | -| --- | --- | --- | -| `enabled` / `running` | `bool` | 前者是期望配置,后者是真实 transport 状态;两者不能互相替代 | -| `port` | `u16` | `1..=65535`;端口变化由 `configure` 串行 stop/restart | -| `urls` | `String[]` | 仅在 transport 成功绑定后存在 | -| `locale` | `String` | 仅接受 `zh-CN/zh-TW/en/ja/ko` | -| `connectionCount` | `usize` | 当前认证连接数,不包含已断开的历史连接 | -| `activeSessionId` | `SessionId?` | 任一连接正在推流时存在;仅作状态展示,不代替 connection/session 校验 | - -pairing PIN 只能通过 `read_pairing_pin()` 的 `SecretValue` 显式读取;不得加入 -`RemoteInputStatus`、事件、错误、`Debug` 或普通日志。PCM frame 必须为非空、偶数字节、最多 -65536 bytes 的 signed Int16 little-endian;音频格式固定 16 kHz mono。每个连接最多一个活动 -stream,重复 start 返回 `Busy` 且不得覆盖原 session lease。stop/cancel/disconnect 后的 frame -返回 `Cancelled`,宿主不得自动新建 session 重放。transport restart 必须先取消旧 stream、 -使旧 connection/session lease 失效;旧 lease 再次 start/feed/stop 时稳定返回 `Cancelled`。 - -## 5. 事件契约 - -M0 的可机器读取基线位于 -[`linux-egui-command-event-baseline.json`](./linux-egui-command-event-baseline.json)。该文件 -记录从当前 Tauri handler、React `listen()` 和 Rust `emit*()` 调用点观察到的 command/event -名称;迁移期间新增或删除名称必须先更新该基线,再更新对应 adapter 和兼容测试。 - -`BackendEvent` 包含 `sequence: u64`、可选 `session_id` 和 `BackendEventKind`。 -每个 backend 实例的 sequence 从 1 开始严格递增;事件总线是通知而不是状态 -真相。backend 同时保留最近 2048 条实例级 backlog;`replay_events_after(sequence)` 返回 -`events/oldestSequence/latestSequence/truncated`。`truncated=true` 表示调用方的游标早于当前 -backlog,必须先重新读取 facade/领域 snapshot,再从最新 sequence 续订。 - -Less Computer 的 Tauri compatibility UI 使用同一 replay 水位:mount 时先安装实时 listener, -再以 `afterSequence` 拉取 replay;同步窗口内到达的事件先进入 pending 队列,随后按 -`replay.events -> pending` 顺序合并。带 `seq` 的事件按最大已应用 sequence 去重,无 `seq` 的 -legacy fallback 仍需保留。`truncated=true` 时旧派生时间线不再可信,调用方先清空本地时间线, -把水位重置为 `oldestSequence - 1`,从本次保留的 replay 重新构建,再接续 pending;最终水位 -至少推进到 `latestSequence`。egui 不需要复用 React helper,但必须实现相同的顺序与去重语义。 - -| 事件 | UI 处理 | -| --- | --- | -| `BackendStarted` / `BackendStopping` | 更新宿主生命周期状态 | -| `DictationStateChanged` | 替换录音/处理/终态和 level | -| `TranscriptDelta` | 按 session 和 offset 合并原文增量 | -| `PolishDelta` | 按 session 和 offset 合并润色增量 | -| `DictationCompleted` | 展示结果并根据 `inserted` 显示确认/回退提示 | -| `InsertFallback` | 显示 clipboard/fallback 状态,不重试插入 | -| `PreferencesChanged` | 使设置缓存失效并重新读取 | -| `CredentialsChanged` | 更新 provider 配置状态,不显示秘密 | -| `HistoryChanged` / `VocabularyChanged` / `StylePacksChanged` | 使列表缓存失效 | -| `DownloadProgress` | 更新模型下载进度 | -| `PermissionChanged` / `HotkeyStatusChanged` | 更新能力和降级文案 | -| `Notification` | 放入非阻塞通知队列 | -| `CodingAgentTest` | 按 `session_id` 消费 started/delta/tool/completed/error/cancelled 流 | -| `LessComputerEvent` | 按 payload `seq` 构建会话时间线;fresh user 清空旧会话 | -| `LocalAsrPrepareProgress` / `LocalAsrDownloadProgress` | 按 runtime/model 更新本地模型准备与下载状态 | -| `LocalAsrEngineChanged` | 替换当前本地 ASR runtime 快照 | -| `MicrophoneDevicesChanged` | 使设备列表失效并重新调用 `PlatformApi::microphone_devices()` | -| `QaLevel` / `QaState` | 按 QA session 更新录音电平、阶段、增量和消息列表 | -| `RemoteInputStatusChanged` / `RemoteInputFailed` | 更新远程输入状态/错误;事件永不携带 pairing PIN | -| `VocabularySuggestionsChanged` | 替换当前待确认纠正建议 | - -当前 29 个 `BackendEventKind` 已由机器基线和 serde fixture 校验。30 个旧 Tauri event 均已 -分类;12 个原 `migrationRequired` 事件现已通过 typed core event 进入同一事件总线,再由 -`tauri_events.rs` 映射为旧 React 名称。复杂领域后续迁移只能替换事件生产者的 -Implementation,不能重新建立 host-only 状态流。 - -订阅可能返回 `EventRecvError::Lagged(n)`。收到该错误时,UI 必须丢弃本地增量, -重新读取 `snapshot()` 或对应领域查询,然后继续订阅;不能静默使用旧状态。 -`Closed` 表示 backend 已被销毁。终态事件只发布一次,旧 session 的任何晚到 -事件都必须被 session id guard 丢弃。 - -egui frame 不得 `.block_on()`。`EventSubscription` 同时提供异步 `recv()` 和非阻塞 -`try_recv()`;frame 只应使用后者: - -```text -后台订阅任务 -> 有界 UI channel -> frame 每次非阻塞 drain - -> 更新 view model - -> request_repaint() -``` - -最小的非阻塞循环如下(`Empty` 只表示本帧没有更多事件,`Lagged` 必须触发快照重同步): - -```rust -loop { - match events.try_recv() { - Ok(event) => view_model.apply(event), - Err(EventRecvError::Empty) => break, - Err(EventRecvError::Lagged(_)) => { - view_model.replace_from_snapshot(backend.snapshot()); - break; - } - Err(EventRecvError::Closed) => view_model.mark_backend_closed(), - } -} -``` - -`openless-linux-egui` 另提供 `drain_events()`,把上述循环收敛成 -`EventDrainOutcome::{Idle, Lagged, Closed}`;它不引用 egui 类型,因此 UI 组可在 -`eframe::App::update` 中直接调用,只有 `Lagged` 时才回读 `LinuxHost::snapshot()`。 - -## 6. 错误与取消 - -核心返回 `BackendError { code, message, retryable, details }`。UI 只判断 code, -不解析 message。 - -| code | 典型情况 | UI 行为 | -| --- | --- | --- | -| `invalid_argument` | 参数或 session id 不合法 | 修正输入;不重试原请求 | -| `invalid_state` | 未启动或无活动 session | 重新读取 snapshot | -| `busy` | 已有活动 session | 禁用重复开始 | -| `cancelled` | 用户取消、关闭或 session 过期 | 清理本地草稿 | -| `permission_denied` | 麦克风/辅助功能被拒绝 | 提供系统设置入口 | -| `unsupported` | fcitx5、托盘或平台能力不存在 | 显示降级,不假装成功 | -| `provider` | ASR/LLM 请求失败 | 依据 `retryable` 提供重试 | -| `persistence` | 读写数据失败 | 保留当前页面状态并提示 | -| `platform` | host adapter 失败 | 显示平台诊断 | -| `internal` | 未分类内部错误 | 显示通用错误并记录 request id(若有) | - -取消必须绑定 session。取消与停止并发时,停止路径在调用 inserter 前再次检查 -session/phase;已取消 session 不得产生插入副作用。shutdown 对活动 session 发送 -`Cancelled` 状态,并等待 host 侧录音/热键任务退出。 - -Windows TSF Adapter 把失败分成两类:连接/准备阶段的 definite failure 可以按冻结的策略 -尝试 SendInput/clipboard fallback;请求写入 pipe 后的超时、断连或无法判定的响应属于 -outcome-unknown,返回明确的 `BackendError` 且不得再次插入。Core 的公开结果只包含 -`Inserted`、`CopiedFallback` 或错误;这样即使 TSF 提交迟到,也不会与 fallback 形成重复文本。 - -`DictationEngine::finish` 返回 `Result`。`EngineFailure` -除 `BackendError` 外,还携带 `EngineFailureStage::{Transcribing, Polishing}`、可选原文、 -录音时长、ASR/润色实测耗时和实际录音归档状态。facade 据此统一写失败历史: - -- ASR 启动、录音停止或 ASR finalize 失败使用 `transcribeFailed`; -- ASR 返回空白文本使用 `emptyTranscript`; -- 禁止回退的润色失败使用 `polishFailed`,并保留已产生的原文; -- 插入失败使用 `insertFailed`,并保留原文、最终文本和 `polishSource`; -- 失败不增加 activity,Failed 事件仍携带原 session id,资源释放后同步 snapshot 回到 Idle。 - -## 7. Host ports - -`BackendDependencies` 由宿主注入,核心不在方法内部创建系统对象: - -- `TaskSpawner`:后台任务执行器;Linux 可使用 Tokio,测试可使用确定性执行器。 -- `DictationEngine`:完整的 `start(session, progress) -> finish(session, progress) -> - cancel(session)` 录音、ASR/润色生命周期;`EngineProgressSink` 使用 - `RecordingLevel { elapsed_ms, level }`、`EngineStage`、`TranscriptDelta` 和 `PolishDelta` - 回报进度;`cancel` 也会在 backend shutdown 时调用,不暴露窗口或 UI 类型。迟到进度若 - session 已失效会返回 `Cancelled`,adapter 必须停止发送。 -- `AudioRecorder` / `ActiveRecording`:宿主采集设备音频并输出规范化的 - 16 kHz / mono / signed Int16 little-endian PCM;`ActiveRecording::stop(self)` 消费句柄, - 保证 finish/cancel 竞争时最多释放一次。可恢复录音通过 `RecordingArchive` 精确表示, - `is_available()` 报告真实状态,`read_pcm()` 支持冻结 provider 的 silent retry,`discard()` - 删除实际归档;`RecordingEvent::Fatal` 实时进入 Core controller,不得仅按 preferences 猜测 - `hasAudioRecording` 或等到 stop 才报错。 -- `TranscriptionEngine` / `TranscriptionSession`:在录音前建立 ASR session,持续消费 PCM, - `finish()` 返回最终原文,`cancel()` 终止 provider 请求。 -- `TextPolisher`:接收最终原文并产生润色结果/可选增量;失败是否回退原文由 - `PolishFailurePolicy` 统一决定,宿主不得另写一套 fallback 判断。 -- `TextInserter`:fcitx5、AX、TSF 或 clipboard fallback 的统一会话接口: - `begin(session, context)` 在录音/ASR 启动前捕获 opaque target 并返回 - `TextInsertionSession::{supports_streaming,write,copy,finish,cancel}`。Core `ActiveTextInsertion` 独占 streamed prefix、 - Unicode tail、final divergence 与 clipboard fallback reconciliation;Host 报告已消费源 Unicode scalar 前缀长度,包含按约定吞掉的 CR,失败字符不计入。平台准备不能流式时返回 `supports_streaming=false`,仍可一次性落字。 - target 无法恢复时必须返回明确 copied/error,不能向当前焦点盲写;所有终态都要恢复输入法、 - 剪贴板和平台资源。 -- `HostActions`:`ShowDictationFeedback`、`HideDictationFeedback`、打开系统设置、 - 外部 URL 和通知等语义动作;不传窗口 label。 - -此外已经存在: - -- `CredentialStore`:status、显式 secret read/write/remove、provider channel metadata; -- `ResourceResolver`:只解析相对资源路径,拒绝绝对路径与 `..` traversal; -- `TaskSpawner`:由宿主注入 runtime,core 不创建窗口线程或专用全局 runtime; -- `BackendServices`:复杂领域 Adapter 集合,缺失时使用稳定的 unsupported 实现。 -- `QaRuntimeAdapter`:捕获 selection host context、持有 recorder/ASR/LLM/Coding Agent 资源并执行 - `prepare_text/start_recording/finish_recording/answer/cancel`;Core 拥有 session、phase、messages - 和迟到结果 guard,Adapter 不得复制这些状态。Tauri 与 Linux production factory 均注入真实 - runtime;编辑/回答路由继续由 `QaService` 决定,Adapter 只执行上下文、录音和 provider effect。 -- `RemoteInputRuntimeAdapter`:PIN secret persistence、TLS/socket/WSS/H5、local IP 与共享听写桥接; - Core 拥有配置、连接/session 关联、PCM 校验和 transport 生命周期规则。 -- `TranscriptionRouter` / `TextPolisherRouter` / `DictationEngineRouter`:分别按会话快照中的 - ASR、LLM 与 traditional/Omni 选择固定 Adapter;`provider_id` 是 channel/scoped credential - 标识,`provider_type` 才是协议路由 key,二者不能混用;ID、type、model 在 session 开始时 - 一次冻结,运行中切换 active channel 或更新注册项只影响下一会话,缺失 provider 返回 - `Unsupported`。重复 session 必须原子返回 `Busy`,不得覆盖原 Adapter 或 cancellation route。 -- `SharedCloudTranscriptionEngine` / `SharedCloudTextPolisher` / - `SharedAuxiliaryTextPolisher` / `SharedOmniDictationEngine`:Core 生产 Implementation,负责 - credential account、默认 endpoint/model、协议选择、extra headers/temperature 校验、流式输出、 - 取消和 session 占用;宿主注入 `CredentialStore`、`AudioRecorder` 和 `TaskSpawner`,UI 不接触 - 这些细节。实时 ASR provider 的发送、接收和关闭任务必须使用该 `TaskSpawner`,core 不得创建 - 私有 Tokio runtime。 - Omni 的 API key、endpoint、model、extra headers 和 temperature 必须使用 - `CredentialKey.providerId == DictationContext.omni.providerId` 读取;宿主不得在读取期间改写或借用 - active provider。活动 provider 切换只影响下一会话,任何公开错误都不得包含旧、新 provider secret。 - -`PipelineDictationEngine` 固定执行顺序为:启动 ASR session → 启动录音并推送 PCM/level → -停止录音 → finalize ASR → 按会话归档策略处理成功录音 → 发布最终原文 delta → 润色 → -发布最终润色 delta → 返回结果。ASR 失败和空转写保留可恢复录音;非空 ASR 成功且 -`recordAudioForDebug == false` 时请求 Adapter 删除归档;删除失败时继续报告真实的 -`hasAudioRecording == true`,不能产生“历史显示无录音但文件仍在”的假状态。 -Facade 在 session capture 时冻结启用的 correction rules;Pipeline 在 ASR 成功后、任何 -Less Computer/polisher 调用前先应用,并只在实际变化时把规则前文本写入 `asrTranscript`。 -非流式最终文本在插入前按同一规则收口;已流式落字的路径不得事后改写 history 制造屏幕/记录 -不一致。禁用或格式无效的规则不生效,读取失败只记录非敏感 warning,不丢整段听写。 - -History 的 provider 归因也由 facade 统一完成:traditional 流程从冻结 context 记录 ASR -channel/model,并仅在实际使用 LLM 时记录 LLM channel/model;multimodal 流程的 ASR 字段和 -`asrMs` 为 `None`,LLM 字段记录冻结的 Omni channel/model,`polishMs` 保留 Omni 调用耗时。 -成功和失败记录遵循同一规则,宿主不得自行重写归因。 -这些细分 ports 只用于宿主组装和测试注入,不是 UI use-case;egui view model 只调用 facade。 -热键状态通过 `PlatformApi` 查询;Host 只发送携带同一 `press_id` 和单调时间的 -pressed/released/combined 边沿。Core `HotkeyInterpreter` 统一解释 Toggle/Hold/Auto、modifier -grace、250ms debounce 和 450ms terminal cooldown;组合键在 start await 前后均能取消同一代次。 -Host 不得再保存 cooldown、began-session 或重复的 mode policy。 - -## 8. Linux 非 UI Adapter 契约 - -`openless-linux-egui` 已交付以下宿主能力,不包含 `eframe::App`: - -- `LinuxCredentialStore`:secret value 只写 Linux Secret Service/keyring; - `credential-metadata.json` 仅保存 channel、active provider 与已配置 key 标识,并原子替换; - 删除按namespace+channel清理全部secret,失败保留可重试元数据;读操作不暴露尚未提交索引的孤立secret。 - 只有Host提供`BackendConfig.home_dir`时才尝试导入旧`com.openless.app` vault/分片与该目录下的旧JSON;Core解析旧格式,Host执行幂等写入,新配置优先,全部成功才写标记,保留旧来源。None配置不隐式访问系统旧凭据或真实HOME。 -- `LinuxResourceLayout` / `LinuxResourceResolver`:分别定义 development、AppImage、deb、rpm - 的资源根与 fcitx5 插件相对路径; -- fcitx5 Adapter:availability、DBus commit、selection read、hotkey sync、clipboard fallback; - 普通听写使用 `CaptureDictationTarget(s: session) -> b`、`CommitDictationTarget(ss: session, text) -> b`、 - `CancelDictationTarget(s: session) -> b` 冻结并释放原输入上下文;新目标不能替换活动 session 的原目标。 - Selection 同时冻结原上下文及 surrounding text/cursor/anchor;取消选中、移动光标或修改文本后拒绝替换,全局 PRIMARY 不能证明原选区仍存在。 - `CommitText(s: text) -> b` 的 `true` 才表示文字已提交到输入上下文,`false` 表示当前没有 - 可用焦点输入上下文(例如启动或无焦点/headless 场景)。Rust Adapter 必须把 `false` 转为 - 明确的插入失败/平台错误并按策略决定 clipboard fallback,不能向 Core 或 UI 报告假成功; - DBus 调用本身也不得因 no-context 让 fcitx5 进程崩溃。AppImage 可从版本化资源同步用户插件, - deb/rpm 只验证系统安装,不能覆盖用户文件; -- `LinuxCapabilitySnapshot`:明确区分 X11、Wayland 与 headless,以及 tray、overlay、fcitx5、 - updater 和麦克风能力;未知权限返回 `Unknown`/`Unsupported`,不伪造 granted; -- `LinuxHostActions`:线程安全队列、非阻塞 drain 与可选 wake/repaint callback; -- `LinuxSettingsRuntime`:只执行 Core `SettingsEffectPlan` 的显式目标,通过 fcitx5 DBus 同步 - dictation/QA/Selection Polish/translation/Coding Agent,并通过 Linux credential metadata 同步 active ASR - provider;不支持能力返回稳定 `Unsupported`,失败按 receipt 逆序恢复; -- `LinuxCpalRecorder`:选择偏好设备或默认输入设备,在专用线程持有 cpal stream,把常见 - sample format 下混、重采样和量化为 core PCM 契约,并报告 `0..=1` level;runtime fault - 实时进入 Core recording controller,不等待用户 stop。 -- `Fcitx5HotkeyListener`:监听 dictation press/release/combined、QA、selection polish 和 - translation signals,提供非阻塞 `drain()`、`take_error()` 和可停止/join 生命周期; - selection 信号调用共享 `SelectionApi`,空闲态先到达的 translation 信号会固定到下一次 - dictation press 的会话快照,活动 session 不会被中途改写。 -- `SingleInstanceBroker`:私有 Unix socket + process lock;第二实例把 typed launch intent 转发 - 给 primary 并等待 acknowledgement,primary 非阻塞 drain 后通过 `LinuxHost` 调用 core。 -- `LinuxBackendBuilder::from_shared_providers(config)`:唯一生产 factory,组装 Core 共享云 - ASR/LLM/Omni/Auxiliary router、Marketplace、QA、Remote Input、Selection、打包 Qwen runtime、 - 传统 `PipelineDictationEngine`、recorder、inserter、credentials、platform services、host actions - 与 settings runtime,返回不包含 egui 类型的 - `LinuxBackendRuntime`;`new(...)` 只用于测试/特殊宿主。 -- `LinuxHost::download_marketplace_archive`:保存 Core 已校验归档;只接受绝对 filesystem path, - 不创建缺失父目录、不覆盖已有文件,失败时不遗留部分文件。 - -`linux-egui/src/main.rs` 已使用 `eframe::run_native` 接入 `LinuxHost`、Core event、fcitx5 -hotkey 与 Single Instance Adapter;UI 不读取 Core 私有模块,也不复制业务规则。 - -## 9. 测试夹具 - -`openless_core::testing` 提供: - -- `RecordingHostActions`:记录 host action 顺序; -- `FixtureAudioRecorder`:推送固定 PCM/level,并记录 stop 次数; -- `FixtureTranscriptionEngine` / `FixtureTextPolisher`:固定 ASR/润色结果、错误和取消行为; -- `FixtureDictationEngine::successful/failing`:在不测试细分 Pipeline 时提供固定结果; -- `FixtureTextInserter::with_outcome/failing`:覆盖 inserted、fallback、unknown 和失败,并通过 - `actions()` 暴露 prepare/insert/cancel 的 session-scoped 调用顺序。 -- `FixtureSelectionRuntime`:记录 capture、preview、apply、revert、cancel;Linux production - Adapter 以 fcitx5 ticket 实现可见 preview、confirm/cancel/revert 与 stale guard。 -- `RecordingRemoteInputRuntime`:不绑定 socket 的内存 transport,记录 server/audio - start/stop/cancel 次数和 PCM frame,用于验证单 connection 单 stream、restart 取消、stale - lease 与 secret-surface 契约;它不代表生产宿主具备 WSS 能力。 -- `LinuxCapabilityFixture::x11_full/wayland_degraded/headless`:覆盖 X11 完整能力、 - Wayland/fcitx5/托盘/权限降级以及无桌面会话;这些 fixture 只描述状态,不探测测试机。 - -egui view model 测试应只使用这些 fixture 和 `BackendSnapshot`/事件,不启动窗口、 -麦克风、网络或真实凭据库。最低 contract test 集合: - -1. 启动/关闭幂等和事件顺序; -2. 开始 → 处理 → 完成的主链路; -3. ASR 失败、插入失败、fallback、unknown; -4. 错误 session 取消不会改变活动 session; -5. 事件 lagged 后 snapshot resync; -6. 序列化 DTO 不包含秘密字段。 - -## 10. 能力降级矩阵 - -| 能力 | Linux 状态 | UI 规则 | -| --- | --- | --- | -| 全局热键 | available / unavailable | 不可用时隐藏快捷键设置或给出降级说明 | -| fcitx5 插入 | plugin missing / ready | missing 时允许 clipboard fallback,不能假成功 | -| 托盘 | available / unavailable | 不可用时保留主窗口内退出入口 | -| 悬浮反馈 | X11 / Wayland limitation | feedback 失败不升级为 ASR 失败 | -| 本地 ASR | model absent / ready | 显示下载、准备、释放状态 | -| 自动更新 | package-dependent | 不显示假更新按钮 | -| 麦克风 | granted / denied / no device | 区分权限拒绝和无设备 | - -## 11. 版本与变更流程 - -当前代码常量为 `openless_core::BACKEND_CONTRACT_VERSION = "2.0.0"`。运行时 wire 只接受 -2.0.0;1.x 兼容仅存在于 preferences、history、activity、credentials、model 和 style-pack -持久化迁移读取器中,不暴露 legacy runtime contract 常量。本文的 contract version 随破坏性 -接口变更递增。新增可选 DTO 字段必须有默认 -值;删除字段、改变枚举值、改变事件顺序或单位必须: - -1. 更新 contract version; -2. 在计划文档的待决事项和迁移表记录影响; -3. 同时更新 Tauri mapping、Linux fixture 和示例; -4. 先让 contract tests 通过,再通知 egui 组切换。 - -egui 组发现缺少能力时,应提交一个只依赖 facade/DTO/event 的可复现测试;不得 -读取 core 私有字段或复制内部实现。 - -### 11.1 从 0.1.0 迁移到 0.2.0 - -0.2.0 把可变偏好读取收敛为每会话 `Arc`,属于有意的破坏性 Interface -变更: - -- `DictationEngine::start` 新增 context;Pipeline 在 session 生命周期内持有同一快照; -- `AudioRecorder::start`、`TranscriptionEngine::start`、`TextPolisher::polish` 和 - `TextInserter::insert` 都接收同一 context; -- UI 不构造 context。宿主仍调用 `OpenLessBackend::start_dictation()`,或在需要翻译时调用 - `start_dictation_with_options(DictationStartOptions)`;facade 从 preferences、active style pack、 - provider metadata 与 vocabulary 一次性生成快照; -- 会话开始后修改麦克风、provider、模型、语言、风格包或插入策略只影响下一会话; -- 自定义 Adapter 必须停止在执行中重新读取 preferences,并只使用传入 context。 - -### 11.2 从 0.2.0 迁移到 1.0.0 - -1.0.0 扩展了听写结果和润色结果,用于让共享 core 独立持久化完整历史: - -- `TextPolisher::polish` 的成功类型从 `String` 改为 `PolishOutput`;Adapter 应把最终文本放入 - `text`,组合润色加翻译时把润色后的源文放入 `source_text`,其他模式使用 `None`; -- `DictationEngine::finish` 的错误类型从 `BackendError` 改为 `EngineFailure`;Adapter 必须标注 - `EngineFailureStage`,并在已经产生时保留原文、录音时长、ASR/润色耗时和归档状态; -- `EngineResult` 新增 `polish_source`、`polish_failed`、`asr_ms`、`polish_ms` 和 - `has_audio_recording`;公开 `DictationResult` 新增 `polish_source` 与 `duration_ms`; -- `ActiveRecording::has_archived_recording` 被 `archive() -> Option>` - 取代,使 Pipeline 能在 stop 消费录音句柄后精确保留失败录音或删除成功录音; -- `DictationResult` 的两个新增 serde 字段均有兼容默认值,因此 0.2.0 JSON fixture 仍可读取: - 缺失 `polishSource` 时为 `None`,缺失 `durationMs` 时为 `0`; -- 宿主不得在 Tauri/egui Adapter 重复拆解组合翻译输出或重复写入成功 history;这些语义由 - core Pipeline 与 facade 统一负责。 -- `TextInserter` 现在具有 `prepare/insert/cancel` 会话生命周期,且三个方法都接收或绑定 - `SessionId`;旧的只实现 `insert(context, text)` 的 Adapter 必须迁移。Core 在 engine 启动前 - 调用 prepare,并在启动失败、处理失败、取消和 shutdown 路径调用幂等 cancel。 -- `DictationInsertionContext` 额外冻结 `windows_sendinput_newline_mode` 与 - `android_insert_strategy`,平台 Adapter 不得在会话执行中重新读取偏好。 - -### 11.3 从 1.0.0 迁移到 2.0.0 - -2.0.0 将跨平台业务 Implementation 收口到 `openless-core`: - -- 模型清单、Range 下载、断点索引、SHA-256、staging/ready sentinel 和旧目录迁移统一由 - `ModelStore` 提供;宿主只注入模型根目录、原生 runtime 和 typed progress sink; -- Coding Agent 由 Core `CodingAgentRunner` 统一构造四种 provider 的请求、解析 stream、过滤 - `session_id` 并产生唯一终态,宿主只实现进程创建、stdio、kill/wait 和临时文件; -- 文档窗口、最小差异/词汇学习和其它协议纯函数位于 Core,Tauri/Linux 仅保留 AX、窗口、 - 输入法、socket、keyring 等 Adapter; -- 1.x preferences/history/activity、凭据元数据、旧模型根目录/mirror/sentinel 与 style-pack - origin 字段继续按迁移规则读取,不因 contract 升级丢失。 - -## 12. 当前交付状态 - -| 交付物 | 状态 | -| --- | --- | -| `openless-core` package 和无 Tauri 依赖门禁 | 已建立 | -| facade 生命周期、听写状态机、事件 sequence | 已建立 | -| headless Linux host 示例 | 已完成,位于 `linux-egui/examples/headless_host.rs`;覆盖生命周期、数据领域、Less Computer、听写、Selection/Selection Voice、QA 与 Remote Input contract;真实 socket/窗口/设备证据另记 | -| fake host/recorder/transcription/polisher/engine/inserter/selection/remote transport | 已建立;fixture 固定完整业务状态,Linux production Adapter 另实现真实 TLS/WS、fcitx5 preview/revert 与 cpal effect | -| preferences/history/activity/vocabulary/correction/style-pack/credentials 共享实现 | 已建立;Tauri/Linux persistence Adapter 使用同一 Core mutation 与 active policy | -| Linux validated settings Interface | 已建立;`save_settings`/`update_settings_strict` 强制携带 snapshot revision,Core 统一校验、协调、持久化、事件和补偿,Linux Adapter 只消费显式 target | -| 全部复杂领域 DTO/Interface 与 unsupported 语义 | 已建立,位于 `domains.rs` / `BackendServices` | -| 2.0 公共 re-export 边界 | 已冻结;`openless-core`/`openless-linux-egui` 只公开 facade/DTO/event/host Interface/fixture,repository 与内部状态机不属于 UI 契约;`check-linux-public-surface.ps1` 防止边界回退 | -| Tauri command/event 完整迁移 | Core 业务路径已收口;React/CLI/Android JNI/Remote Input/桌面听写使用 2.0 contract,Tauri 仅保留 command/event wire 与平台 Adapter | -| 复杂领域真实共享 Adapter | Core `ModelStore`、`CodingAgentRunner`、Voice session、Provider policy、Remote Input 与 Style Pack 已接入;Linux Qwen runtime 已进入打包链,Foundry/Sherpa 与真实设备/发布物仍需平台证据 | -| 会话级 provider router | 已建立;ID/type/model 在 session 开始时固定,Core 持有云 ASR/LLM/Omni 协议 Implementation;Tauri 与 Linux 注册同一共享实现,Tauri 另行追加 native/local ASR | -| provider 验证/模型列表管理面 | 已建立;Core `ProviderService` 统一 channel-scoped credential、静态/远端模型列表、验证探活和错误脱敏;Tauri command 只做 wire 转换,Linux shared factory 注入同一 service;真实网络/keyring 和平台 runner 仍按主计划 M9/M10 留证 | -| Linux credentials/resources/fcitx5/capabilities/host-actions | 已建立非 UI Adapter 和 contract tests;WSL Ubuntu 已显式通过真实 Secret Service set/read/remove、fcitx5 plugin/method/listener/signal contract;无焦点输入时 plugin 不抛异常导致 fcitx5 崩溃 | -| Linux cpal 录音、共享 Pipeline builder、热键 listener、第二实例 intent 转发 | 已建立;selection/translation 已路由到共享 Interface;WSL 当前无 ALSA 设备时 cpal contract 已证明稳定分类错误,真实设备和桌面 runtime 生命周期仍见计划 M8/M9 | -| Linux 打包 workflow/manifest 契约 | 已建立但正式发布仍需真实 Ubuntu 安装、运行、升级和回滚证明 | -| egui UI | 已使用真实 `eframe::run_native`,覆盖 startup/error、听写、QA、Remote Input、Selection preview/revert、Less Computer/approval、Provider/Credential、模型、history 与 settings;视觉深化不属于 2.0 Core 收口 | +## 3. 注入接口(`crates/openless-core/src/ports.rs`) -完整验收以主计划第 12 节为准;本契约证明 Linux UI 可以在不依赖 Tauri 的前提下使用冻结的 -2.0.0 Interface。真实 Ubuntu 原生能力、发行包与安装升级回滚仍由 Linux runner 门禁证明。 +`AudioRecorder`、`TextPolisher`、`TextInserter`、`CredentialStore`、`SettingsRuntime`、`TaskSpawner`、`EditObservationSink`/`EditObservationAdapter`(默认 `NoopEditObservationAdapter`)、`LinuxHostActions`。缺省实现表示"未接线",不是"不支持"。 -## Less Computer 语音接口(2.0) +## 4. 与桌面共享 -`OpenLessBackend::start_less_computer_voice(session_id, recording_control)` 返回 Core-owned -`LessComputerVoiceSession`。Host 必须只发送 16 kHz、mono、signed 16-bit little-endian -PCM;空帧、奇数长度和累计超过 provider 上限会返回 `InvalidArgument`。`finish` 只允许调用 -一次,ASR 失败或空 transcript 不会启动 Agent,并释放 capture lease;`cancel` 同时取消 -ASR/Agent 并释放尚未提升的 lease。`recording_control` 是窄平台 effect:Core 的共享 -`SilenceAutoStop`/fault controller 决定 stop 或 cancel,Host 只关闭自己持有的 capture handle。 +- 云 ASR/LLM/Omni/Auxiliary 实现两端共用(Core `asr/`、`provider_*`、`omni`、`llm_gemini`);平台 Host 只注入原生录音、凭据、窗口、进程、焦点与插入 Adapter。 +- 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 与 Core 同进程,经类型化 Rust Interface 调用。 +- provider 公开目录:Core `provider_rules::provider_descriptors` → 生成 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors`)。 -实时 provider 的 interim 文本通过既有 `BackendEventKind::TranscriptDelta` 发布,使用同一 -`session_id` 且 `offset` 单调递增;批式 provider 只发布一次 `is_final=true`。Agent 阶段继续 -使用 `LessComputerEvent`(approval、stream、completed、cancelled、error),不新增 ASR 事件 -类型,Backend contract 版本为 `2.0.0`。 +## 5. 行为约定(合同级) -Linux `LinuxHotkeyEvent::{LessComputerPressed,LessComputerReleased,LessComputerCombined}` -只表达热键边沿;Hold/Toggle/Auto(Auto 长按阈值 350ms)由 Core 解释。三种录音入口与 -`silence_auto_stop_enabled` 共用同一设置,冲突时保留当前会话并返回 `Busy`。 +- 原生预加载必须使用请求的 target/provider type;平台无法准备流式时以 `supports_streaming=false` 保留一次性落字。 +- Remote stop 保持可取消的 session;socket 下行只转发本连接所属 session 的事件。 +- 自动 contract 不替代真实平台证据:Ubuntu/Windows/macOS/Android 的设备、安装、升级与签名结果按[验收](linux-egui-handoff/07-acceptance.md)与[桌面验收](2.0-desktop-acceptance.md)分别记录。 diff --git a/docs/linux-egui-handoff/01-core-contract.md b/docs/linux-egui-handoff/01-core-contract.md index 281930288..4fe1ac77f 100644 --- a/docs/linux-egui-handoff/01-core-contract.md +++ b/docs/linux-egui-handoff/01-core-contract.md @@ -1,68 +1,29 @@ -# 01:Core接入合同 +# 01:Core 合同(Linux 可调用的面) -前置:[交接入口](./README.md)。本文只约束接入,不规定页面布局。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。前置:[交接入口](./README.md)。本文只约束接入,不规定页面布局。 -## 1. 可以依赖的层 +## 1. 合同文件与启动门 -`egui UI → LinuxHost / Linux Adapter → openless-core`。 +- 公开合同:`openless-all/app/contract/backend-2.0.json`,顶层键 `contractVersion` / `startupSnapshot` / `backendEvent` / `lessComputerVoice` / `androidJni` / `linuxFacade` / `enums`。启动时校验版本;UI 必须先消费 startup snapshot 再渲染状态。 +- 构造:`LinuxBackendBuilder::from_shared_providers(config)`(`linux-egui/src/backend.rs`)创建与 Tauri 同源的 Core 后端;云 ASR/LLM/Omni 实现两端共用,不复制第二套。 +- 事件:Core `events.rs` 语义事件 → `LinuxHost::drain_events`(`lib.rs`)→ UI reducer;订阅用 `LinuxHost::subscribe` 返回 `EventSubscription`。 +- 会话/取消:所有长操作(听写、转写、下载、QA、Agent)走 Core session 取消语义;UI 只触发与显示,不自建取消规则。 -- Core拥有provider协议、渠道/模型/偏好事务、会话与取消、业务路由、纠错/历史、Agent命令和结果语义。 -- Linux Host拥有音频、全局热键、窗口、fcitx5、Secret Service、TLS/socket、进程与文件效果。 -- UI拥有页面、编辑草稿、进度/错误展示和事件消费;不另建业务状态机。 -- 不引用Tauri源码、不读取Core私有模块、不把`AppHandle`/egui类型加入Core公开合同。 +## 2. Core 公开模块(`crates/openless-core/src/lib.rs`) -## 2. 构造、启动、退出 +按域的模块地图见[架构文档](../architecture.md)第 3 节。Linux 直接相关: -1. Host确定显式`BackendConfig`中的data/cache/home等路径及能力;不要在测试中隐式使用真实用户目录。 -2. 使用`LinuxBackendBuilder::from_shared_providers(config)?.build()?`复用生产组装;测试可用显式注入入口。 -3. 创建事件订阅/Host action消费,再调用`LinuxNativeRuntime::start`;该方法内部启动Core,调用方不重复启动。 -4. 检查启动快照`running`和合同版本`2.0.0`。未就绪时禁用业务操作并展示错误。 -5. 从Core读取设置/渠道/模型/各领域快照,启动非阻塞UI消费;原生热键须同步保存的配置。 -6. 退出时禁止新请求,取消活动会话,停止Host listener/服务并调用幂等shutdown。 +- 业务门面:`api`(对外接口)、`domains`(领域出口)、`events`、`ports`(Host 注入接口,含 `NoopEditObservationAdapter` 等默认实现)。 +- 听写:`dictation_engine`、`silence_auto_stop`、`streaming_insert`、`hotkey_interpreter`。 +- 服务:`provider_*` 系列、`omni`、`credentials`、`endpoint_security`。 +- 本地模型:`model_store`、`local_asr_service`、`local_asr_catalog`。 +- 领域:`history`、`vocabulary`、`correction`、`style_pack_store`、`marketplace`、`qa_service`、`selection_*`、`less_computer`、`remote_input_service`。 -现有[backend.rs](../../openless-all/app/linux-egui/src/backend.rs)、[runtime.rs](../../openless-all/app/linux-egui/src/runtime.rs)和[headless示例](../../openless-all/app/linux-egui/examples/headless_host.rs)是实际构造依据。 +## 3. 注入点(`ports.rs`) -`from_shared_providers`和未显式注入executor的`build`必须在Host已经创建的Tokio runtime内调用;同步GUI初始化可用短作用域`runtime.enter()`包住构造,退出该作用域再`block_on`启动。builder捕获同一runtime的Handle,录音回调、静音终止和资源析构即使来自普通OS线程也能提交任务。未进入runtime时构造明确失败,不新建runtime或静默丢任务。使用自定义`LinuxBackendBuilder::new`的Host可通过`with_task_spawner`提供自己的实现,但它必须接受任意原生线程调用;Host保持executor存活直至shutdown与原生清理完成。不要用只查“当前线程runtime”的默认spawner承接cpal回调。 +Host 必须实现并注入:`AudioRecorder`、`TextPolisher`、`TextInserter`、`CredentialStore`、`SettingsRuntime`、`TaskSpawner`、`EditObservationAdapter`(未接时为 `NoopEditObservationAdapter`,见缺口 L02)、`LinuxHostActions`。注入清单与默认值以 `backend.rs` builder 为准;缺省实现表示"未接线",不是"不支持"。 -## 3. 公开能力与调用位置 +## 4. 纪律 -| 领域 | 入口/来源 | Host/UI使用要求 | -| --- | --- | --- | -| 听写 | `OpenLessBackend` start/stop/cancel、snapshot | 使用Core session;不要另做Hold/Toggle/Auto/静音/重试策略 | -| 设置 | `LinuxHost::save_settings` / `update_settings_strict` | 带读到的revision;执行Core effect plan,失败按receipt恢复 | -| 渠道/凭据 | Core channel facade、`ProviderApi` | 保留channel ID与provider type区别;默认值/认证规则来自descriptor | -| 模型 | `LocalAsrApi` / `ModelStore` / activation | 用一次activation事务,不拆成UI连续写prefs和active provider | -| 历史/词典/纠错/风格包 | Core facade,[api.rs](../../openless-all/app/crates/openless-core/src/api.rs) | 通过领域方法修改;不直接写JSON或重做ZIP/文本规则 | -| QA | `services().qa` | stop使用每轮token;conversation owner不等于每轮capture token | -| Selection/Selection Voice | 对应`services()`接口 | 预览/确认/撤回/意图由Core决定;Host保存原生目标并执行效果 | -| Less Computer / Agent | 对应`services()`及Core语音入口 | 复用命令、审批、解析与取消;Host只启动/终止进程 | -| Remote Input | `services().remote_input` | Core处理认证/会话/帧序号;Host负责TLS/WSS与H5传输 | -| 能力/权限 | Core snapshot + Linux capability probe | 未实现/未授权/未知要区分,UI不能凭平台名伪造可用 | - -DTO和平台Interface集中在[domains.rs](../../openless-all/app/crates/openless-core/src/domains.rs)、[ports.rs](../../openless-all/app/crates/openless-core/src/ports.rs)。 - -## 4. 业务不变量 - -- 听写、QA、Selection Voice、Less Computer共享语音互斥:Busy拒绝新会话,不抢占旧会话。 -- provider/channel/model与上下文按会话冻结;只有已有合同允许的停止时翻译切换可更新该轮。 -- Core按实际管线解析渠道:Omni不依赖传统ASR/LLM,Less/Selection录音只依赖ASR,QA文本不依赖ASR。Raw允许没有可用LLM;若停止时改为翻译,Pipeline使用冻结的LLM或`deferred_llm_error`,不会偷偷切到录音期间新增/启用的渠道。 -- 停止、取消、timeout、设备fault与迟到结果必须保持单一终态,不能让旧任务改变新session。 -- 逻辑取消会立即使本代token失效;原生初始化、stop或ASR清理仍在途时,Core资源hold继续阻止新语音。收到取消终态不表示已经可以绕过Core强行打开另一个麦克风。 -- 自定义`DictationEngine`须原样接收/转发`start_voice_capture`与`start_audio_capture`的`CancellationToken`;ASR启动返回后、开麦前及原生初始化完成后均检查它,迟到句柄必须关闭。生产factory已接好,不要替换成一个永不取消的新token。 -- `Inserted`、`PasteSent`、`CopiedFallback`、`NotRequested`和错误`OutcomeUnknown`不可互换。结果未知不得自动再插一次。 -- 流式尾段协调、纠错执行顺序、历史/统计归因留在Core;Host只回报真实效果。 -- 插入`begin()`本身也可能切换输入源。Core在准备前登记同一可等待结果,取消/丢弃调用方不能跳过尚未完成的原生恢复。 -- `TextInserter::capture_target()`在听写认领后、上下文/凭据等待和反馈前同步调用;焦点敏感Host返回仅持本轮原生目标的插入器,异步`begin()`再准备输入源。原生句柄不进入Core DTO,无需插入时不捕获;不依赖焦点的Adapter保留默认`None`。Linux Host接入原目标快照时应覆写此入口,不能等凭据读取后重新抓当前焦点。 - -## 5. 秘密和数据 - -- 凭据秘密只通过`CredentialStore`/`SecretValue`传递;通用状态、事件、日志和可序列化UI快照不包含API密钥、OAuth access/refresh token或PIN。配对页面通过显式`read_pairing_pin`受控显示PIN,不广播到通用状态。 -- 审批票据(如`Approval.token`、`pending_approval_token`)是Core业务合同的一部分,不是上述凭据秘密;UI须按原合同接收、关联并回传,不能自行伪造。 -- Linux已经提供旧凭据解码/迁移起点;只有显式home目录才触发旧来源访问,完成标记最后提交。 -- 设置revision冲突后重新读快照合并草稿,不无条件重放旧整表。 -- 1.x数据格式兼容与runtime `2.0.0`握手是两回事:继续读旧数据,不接受旧runtime合同冒充已就绪。 - -## 6. 接口缺口的回报 - -提交:用户操作、已有公开入口、缺少的业务结果/Host效果、期望事件/错误、最小复现或fixture。 -Core负责人修共享规则/接口;egui团队修平台适配/界面。详情按[缺口登记](./02-gap-register.md)分类,不能通过公开私有内部对象绕过。 +- 保留启动、版本校验、事件与退出合同;改动先过 `cargo test -p openless-core --locked` 与合同测试。 +- 合同测试不替代真实平台证据(见[07 验收](07-acceptance.md))。 diff --git a/docs/linux-egui-handoff/02-gap-register.md b/docs/linux-egui-handoff/02-gap-register.md index ed26a185d..19777c015 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -1,45 +1,35 @@ # 02:Linux 缺口登记与实施顺序 -基线:`2b315572`;责任依据:[当前范围](../2.0-requirements.md)。以下是源码盘点,不是通过声明。 +状态:canonical(2026-09-07 以源码为准重写,L-ID 体系沿用);更新:2026-09-07。本表是源码盘点,不是通过声明。 ## 1. 状态定义 - **接线缺口**:Core 已有业务,Linux Host 尚未执行必要的原生效果。 - **界面缺口**:已有 Core/Host 入口,生产 UI 未提供完整操作。 -- **待实测**:存在真实实现,但还需要对应桌面、设备或安装证据。 -- 本表由 egui 团队推进 Linux Host/UI;若发现共享接口不能承载该业务,回报 Core 负责人修复,不在 UI 复制规则。 +- **待实测**:存在真实实现,还需要对应桌面、设备或安装证据。 ## 2. 待办登记 -| ID | 当前状态与明确缺口 | 关闭标准 / 详细说明 | +| ID | 状态与缺口(源码锚点) | 关闭标准 | | --- | --- | --- | -| L01 | 接线+界面:`switch_style`、`open_app`、`style_packs` 热键修改被 Linux settings 明确拒绝 | 实际全局注册、触发、重绑、失败恢复和重启还原;分别声明 X11/Wayland 支持。[热键](./03-hotkeys-and-windows.md) | -| L02 | 接线:HostContext/EditObservation 仍为默认 Noop,Selection 的 source_app 为 None | 按隐私设置捕获真实应用/允许的上下文;原生手改观察能产生 Core 纠错建议并拒绝迟到结果。[原生](./05-native-host-and-data.md) | -| L03 | 接线:CPAL 无 RecordingArchive,未执行录音期间系统静音/恢复;缺提示音与胶囊 | 录音归档、保留策略、失败恢复、同归档重试和历史重转可用;所有终态恢复音量,反馈可见。[原生](./05-native-host-and-data.md) | -| L04 | 接线+界面:Selection Voice 未形成 Linux 生产触发/捕获/意图路由;QA 缺编辑模式/应用/撤回 UI | 完整触发至 Core intent、预览、目标核验、应用/取消/撤回;不能用已有 Selection polish 页面代替。[领域](./04-ui-domains.md) | -| L05 | 界面:词典、纠错规则/建议、风格包管理没有页面 | 各领域增删改/启停/预设/导入导出和失败反馈;通过 Core facade 持久化。[领域](./04-ui-domains.md) | -| L06 | 界面:Marketplace service 已接入,市场页面缺失 | 浏览、安装、上传/下载、点赞/作品管理、设备 OAuth 登录/取消/退出;复用 Core 协议。[领域](./04-ui-domains.md) | -| L07 | 界面+L03依赖:历史仅只读最近20条,缺完整历史/统计/录音操作 | 浏览和原有历史操作、重润色/重转写、录音播放/导出/清理与统计;真实归因不由 UI 拼装。[领域](./04-ui-domains.md) | -| L08 | 界面:本地 Qwen 有下载/激活/取消,缺完整模型管理和运行时控制 | 路径/镜像、详情/状态、删除、预载/释放和准备/测试取消;真实推理另附证据。[领域](./04-ui-domains.md) | -| L09 | 界面:多数 Linux 适用设置、Agent 检测/模型/路径/权限配置缺失 | 设置有实际消费者、revision/错误处理;保留现有 Less Computer 输出/审批/取消流程。[领域](./04-ui-domains.md) | -| L10 | 接线+界面:无托盘/自启;通知仅状态栏,重启仅提示,AppImage能力判定不等于更新器 | 窗口/后台运行、通知、自启、检查/下载/安装更新与重启有真实 Host 效果。[原生](./05-native-host-and-data.md) | -| L11 | 已实现待实测:fcitx5 输入/选区、CPAL、Secret Service、Qwen、CLI 进程、Remote TLS/H5 | 通过真实桌面/设备矩阵;未通过的逐项记录,不把整模块称为缺失。[验收](./07-acceptance.md) | -| L12 | 已有包构建,待 Linux 产品验收与正式分发 | 完成上述应用缺口及 Linux 安装/升级/回滚、签名和更新证据;不阻塞 Windows/macOS 首批交付。[验收](./07-acceptance.md) | - -## 3. 已有且应直接复用 - -- 云 ASR/LLM/Omni、Provider descriptor 和渠道管理、Linux vault、Core ModelStore、Generic Qwen runtime。 -- 普通听写与现有 fcitx5 热键、ticket 化落字、Selection polish 预览/撤回、QA 文本/语音、Less Computer 工具审批。 -- Remote Input TLS/H5、配对和会话桥接、单实例、Core 合同校验、事件重放、native shutdown。 - -对应实现位置见[Core合同](./01-core-contract.md)、[领域](./04-ui-domains.md)和[原生](./05-native-host-and-data.md)。这些代码是接入起点,不需要另建第二套后端。 - -## 4. 依赖顺序和完成口径 +| L01 | 接线+界面:`switch_style` / `open_app` / `style_packs` 热键修改在 `settings.rs:56-61` 被明确拒绝 | 实际全局注册、触发、重绑、失败恢复与重启还原;分别声明 X11/Wayland 支持 | +| L02 | 接线:未注入 `EditObservationAdapter`,Core 默认 `NoopEditObservationAdapter`(`ports.rs:539`)生效,Selection `source_app` 为 None | 按隐私设置捕获真实应用/允许的上下文;手改观察能产生纠错建议并拒绝迟到结果 | +| L03 | 接线:`audio.rs` `LinuxCpalRecorder` 无录音归档,未执行录音期间系统静音/恢复;缺提示音与胶囊 | 录音归档、保留策略、失败恢复、历史重转写可用;所有终态恢复音量 | +| L04 | 接线+界面:Selection Voice 未形成生产触发/捕获/意图路由;QA 缺编辑模式/应用/撤回 UI | 完整触发至 Core intent、预览、目标核验、应用/取消/撤回 | +| L05 | 界面:词典、纠错规则/建议、风格包管理没有页面(`ui_state.rs` 10 页无对应项) | 各领域增删改/启停/预设/导入导出与失败反馈,经 Core facade 持久化 | +| L06 | 界面:marketplace 服务已接入(`marketplace.rs`),市场页面缺失 | 浏览、安装、上传/下载、设备 OAuth 登录/退出,复用 Core 协议 | +| L07 | 界面+L03 依赖:历史仅只读最近条目,缺完整历史/统计/录音操作 | 浏览与既有历史操作、重润色/重转写、录音播放/导出/清理与统计 | +| L08 | 界面:本地 Qwen 有下载/激活/取消,缺完整模型管理与运行时控制 | 路径/镜像、详情/状态、删除、预载/释放与准备/测试取消;真实推理另附证据 | +| L09 | 界面部分改善:10 页导航、开始/环境引导、独立设置与手机输入页、跨页审批/取消已落地;多数 Linux 适用设置、Agent 检测/模型/路径/权限配置仍缺失 | 设置有实际消费者、revision/错误处理;保留 Less Computer 输出/审批/取消流程 | +| L10 | 接线+界面:无托盘/自启(`capabilities.rs` 仅探测 `supports_tray`,未实现);通知仅状态栏;AppImage 能力判定不等于更新器 | 窗口/后台运行、通知、自启、检查/下载/安装更新与重启有真实 Host 效果 | +| L11 | 已实现待实测:fcitx5 输入/选区(`fcitx5.rs`)、CPAL(`audio.rs`)、Secret Service(`credentials.rs`)、Qwen 运行时、CLI 进程、Remote TLS/H5(`remote_input.rs`) | 通过真实桌面/设备矩阵;逐项记录,不把整模块称为缺失 | +| L12 | 已有包构建(deb/rpm/AppImage workflow),待 Linux 产品验收与正式分发 | 完成上述应用缺口与安装/升级/回滚、签名和更新证据;不阻塞 Windows/macOS 首批交付 | + +## 3. 依赖顺序 1. 保留启动、版本校验、事件与退出合同,跑通一个现有听写流程。 2. 补 L01–L03 原生能力;页面可并行,但历史音频/自动纠错不能脱离其 Host 依赖单独宣布完成。 3. 补 L04–L09 领域入口及完整成功/失败/取消流程,再补 L10 桌面集成。 4. 按 L11–L12 获取真实环境与发布证据。 -任务记录格式:`ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。 -“待实测”不是“未实现”,也不是“已完成”;新发现必须按层归属,不能仅因 Linux 故障就判定 Core 缺失。 +任务记录格式:`ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。“待实测”不是“未实现”,也不是“已完成”。 diff --git a/docs/linux-egui-handoff/03-hotkeys-and-windows.md b/docs/linux-egui-handoff/03-hotkeys-and-windows.md index 6a9522dbc..a9b6a7f77 100644 --- a/docs/linux-egui-handoff/03-hotkeys-and-windows.md +++ b/docs/linux-egui-handoff/03-hotkeys-and-windows.md @@ -1,51 +1,19 @@ # 03:全局热键与窗口 -对应缺口:L01、L10。系统注册和窗口效果由 egui 团队的 Linux Host 负责,业务动作继续调用 Core。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应缺口:L01、L10。系统注册和窗口效果由 Linux Host 负责,业务动作继续调用 Core。 -## 1. 历史与当前差异 +## 1. 已有实现 -Tauri 1.x 在 **X11** 上已有切换风格、打开主窗和风格包热键,经过 `global-hotkey` 路径。 -源码锚点为 `v1.3.18-tauri` 的 `src-tauri/src/lib.rs` 三个 `start_*_hotkey_listener` 和 `coordinator/hotkey_loops.rs` 的实际处理分支。 -这不能证明原生 Wayland/fcitx 路径曾具备同等能力,也不能写成“1.x 从未实现”。 +- **fcitx5 热键监听**:`hotkeys.rs` `Fcitx5HotkeyListener::start()/drain()/take_error()`——经 fcitx5 插件路由热键事件(已实现待实测,见 L11)。 +- **fcitx5 输入/选区**:`fcitx5.rs`——插件安装(AppImage 在 listener 前安装)、PRIMARY 选区、落字。 +- **单实例**:`single_instance.rs` `SingleInstanceGuard::acquire(path)` + `SingleInstanceBroker::acquire_or_forward()/drain()`——二次启动意图转发给首实例。 +- **设置侧拒绝逻辑**:`settings.rs:56-61` 对 `switch_style` / `open_app` / `style_packs` 三类热键修改给出明确拒绝信息(L01 的直接证据)。 +- **能力探测**:`capabilities.rs` `LinuxCapabilitySnapshot`(session 类型、`PlatformCapabilities`、权限快照),`supports_tray` 只是探测字段。 -当前 Linux 已用 fcitx5 接听写、QA、选区润色、翻译和 Less Computer;但以下三项仍缺 Host 接线: +## 2. 缺口(L01) -| Core配置目标 | 当前缺口 | 触发后的业务入口 | -| --- | --- | --- | -| `HotkeyRuntimeTarget.switch_style` | Linux settings 拒绝修改,没有对应 native event 分支 | `activate_previous_style_pack` | -| `HotkeyRuntimeTarget.open_app` | 同上 | `request_host_action(HostAction::ShowMain)` | -| `HotkeyRuntimeTarget.style_packs` | 同上 | `activate_style_pack`,使用绑定的稳定风格包ID | +全局热键修改(改绑 `switch_style`、`open_app`、`style_packs`)被 settings 拒绝,因为 Host 尚未提供真实的系统级注册/重绑/失败恢复/重启还原。关闭标准:实际注册、触发、重绑、失败恢复与重启还原,分别声明 X11/Wayland 支持范围。 -入口见[Core api.rs](../../openless-all/app/crates/openless-core/src/api.rs)与[HostAction](../../openless-all/app/crates/openless-core/src/ports.rs)。 +## 3. 缺口(L10,窗口侧) -## 2. 应修改的生产路径 - -- [settings.rs](../../openless-all/app/linux-egui/src/settings.rs):`reject_unsupported_hotkey_changes`、`LinuxSettingsEffects::apply_hotkeys` 和 prepare/commit/restore。 -- [lib.rs](../../openless-all/app/linux-egui/src/lib.rs):`LinuxHotkeyEvent` 消费及业务派发。 -- [runtime.rs](../../openless-all/app/linux-egui/src/runtime.rs):启动同步、listener 重连及 pump。 -- [fcitx5.rs](../../openless-all/app/linux-egui/src/fcitx5.rs)与[插件](../../openless-all/scripts/linux-fcitx5-plugin):按所选原生机制扩展注册和事件传输。 -- [main.rs](../../openless-all/app/linux-egui/src/main.rs):快捷键编辑、冲突/不可用提示及窗口消费。 - -不能仅删除 `Unsupported` 判断:只有原生注册和实际事件路径一起完成,配置才算生效。 - -## 3. 注册事务 - -1. UI 提交草稿和读取时的 preferences revision,经 LinuxHost 调 Core 设置验证。 -2. Host 按 effect plan 注册新目标、清除旧目标;资源占用/无权限必须返回错误。 -3. 部分失败按 receipt 恢复旧注册及配置;不能保存“新键”却仍监听“旧键”。 -4. 启动和 fcitx5 重连读取同一 Core target 重新同步;退出/禁用时释放注册。 -5. 听写的 Hold/Toggle/Auto、去抖/冷却仍归 Core;Host 提供真实边沿,不另建解释器。 - -X11 与 Wayland/桌面环境分别报告支持情况。窗口内的 egui 键盘事件不等于系统全局热键;焦点离开 OpenLess 后仍须按承诺范围工作。 - -## 4. 窗口和后台行为 - -现有 `ShowMain`/`FocusMain` 已映射 egui viewport,单实例有转发机制;保留这些实现。 -尚需完整托盘/后台窗口行为、录音反馈层、桌面通知与真正重启。`ShowDictationFeedback`/`HideDictationFeedback` 当前不产生效果,通知只进状态栏。 - -窗口聚焦不得覆盖录音开始时捕获的输入目标。关闭业务面板应取消其会话;隐藏主窗、退出进程、关闭面板是不同操作。 - -## 5. 关闭证据 - -逐项覆盖:首次保存、重绑、旧键失效、冲突和回滚、重启还原、插件重载、后台触发、禁用/退出释放;风格包删除/禁用后的绑定也需一致。 -测试结果分别标注 X11/Wayland、桌面环境、实际前台应用。目标环境无法提供某动作时明确禁用并说明限制,不返回假成功。 +托盘、窗口/后台运行策略、通知、自启、更新器均未实现(capabilities 仅探测)。参考[05](05-native-host-and-data.md)第 4 节。 diff --git a/docs/linux-egui-handoff/04-ui-domains.md b/docs/linux-egui-handoff/04-ui-domains.md index 27258c855..5baab2a31 100644 --- a/docs/linux-egui-handoff/04-ui-domains.md +++ b/docs/linux-egui-handoff/04-ui-domains.md @@ -1,48 +1,28 @@ # 04:页面与领域操作 -对应 L04–L09。现有[main.rs](../../openless-all/app/linux-egui/src/main.rs)是可复用的真实 UI 起点,但并非功能齐全的产品。 -接口查[Core api.rs](../../openless-all/app/crates/openless-core/src/api.rs)与[domains.rs](../../openless-all/app/crates/openless-core/src/domains.rs)。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。页面定义:`linux-egui/src/ui_state.rs` `Page` 枚举(10 页);主循环与交互:`main.rs`。 -## 1. 已有页面与需要补齐的操作 +## 1. 十页现状 -| 领域 | 当前生产UI | egui团队接入要求 | +| 页面 | 已有操作 | 主要缺口 | | --- | --- | --- | -| Provider/渠道 | ASR/LLM/Omni渠道创建、重命名、启停、排序、激活、删除、编辑、列模型和校验已有 | 复用 descriptor;完善 vault 锁定/错误恢复、状态刷新和正式交互,不重做认证规则 | -| 本地模型 | Qwen列表、下载、激活、取消已有 | 接 `LocalAsrApi` 的路径/镜像、model_card/remote_info、状态、删除、preload/release、prepare/test_model及取消;激活使用 Core 原子事务 | -| QA | 文本/语音、流式回答、取消/关闭已有 | 接 `set_edit_instruction_mode`、编辑预览、应用/撤回及状态;用每轮 recording token 停止录音 | -| Selection polish | 可编辑预览、确认、取消、完成后撤回已有 | 保留原目标/session核验和错误反馈,不将剪贴板旧选区当有效原目标 | -| Selection Voice | Core service已有,Linux完整触发/意图路由与UI缺失 | 见下节;不是只加一个按钮即可完成 | -| Less Computer | 文本/语音、输出/工具状态、Allow/Deny审批和取消已有 | 接 `CodingAgentApi` 的detect/list_models/run_test/cancel_test和provider/model/executable/workdir/permission配置;不自建审批与续聊策略 | -| 风格包 | 无管理页 | 接Core列表、创建/编辑、启停/激活、内置重置、删除、提示词诊断、ZIP导入/导出;快捷键依赖L01 | -| 词典/纠错 | 无页面 | 接词条增删/启停、预设、规则;pending corrections接受/拒绝/清空。自动建议来源依赖L02 | -| Marketplace | service和下载归档Host已有,无页面 | 接 `MarketplaceApi` 的列表/详情/安装/下载/上传、点赞/作品、设备OAuth启动/轮询/取消/退出 | -| 历史/统计 | 仅最近20条只读和插入状态 | 完整浏览及历史操作、删除/清空、重润色/重转写、复制/导出、录音播放/定位/清理、活动统计;音频先补L03 | -| 设置 | 仅streaming_insert、Agent启用、Remote开关/端口 | 补Linux适用的麦克风/静音、语言/翻译、模式/热键、QA历史、选区、隐私/日志和外观;每项必须有真实消费者 | -| Remote Input | 开关、地址、PIN与重置已有,TLS/H5已接 | 补连接状态、陈旧地址/错误、二维码及LAN/证书信任说明;复用服务并进行真手机验收 | - -不要给 Linux 暴露无实现的 Windows TSF 或 macOS AX 权限按钮。当前 Linux 本地运行时为 Generic Qwen;Foundry/Apple MLX 不是这一轮要求跨平台移植的功能。 - -## 2. Selection Voice 接入链 - -1. Linux Host 捕获原生目标、选区及会话身份,接入 Core 的共享语音互斥和停止/取消路径。 -2. 使用 `SelectionVoiceApi` 的 `begin`、`process_transcript`、`confirm_intent`;由 Core 决定 `route_disposition`。 -3. UI 消费 Selection Voice 事件,展示意图选择或路由后的 QA/编辑结果;当前 main.rs 尚未消费这些事件。 -4. 编辑经过 `prepare_edit`、`begin_preview_apply`,Host 按 ticket 核验并执行替换,再以真实结果调用 `finish_preview_apply`。 -5. 目标失效、取消、重复确认和未知插入结果都应保持安全,不能绕过 Core 事务直接粘贴。 - -Linux QA 的 target rekey 适配和现有 Selection runtime可以复用;headless直接调用Core的测试不算生产接线完成。 -QA 内的预览必须传当前 turn token,使用 `QaApi::begin_edit_preview_apply`/`revert_edit_preview`;原生应用完成后用 `dismiss_session` 只关闭所属回合。不要拆成读取 conversation owner 后直接改 Selection Voice,再无条件关闭 QA。独立 Selection Voice 的 `begin_preview_apply`/`revert_preview` 是无异步原生效果的同步状态操作;`PasteSent` 只表示已发送,不能标成 `Inserted` 或失败。 - -## 3. 共同交互规则 - -- UI只保留编辑草稿、焦点等显示状态;持久化变更走Core facade,不直接读写JSON、凭据或风格ZIP内容。 -- 设置携带revision,冲突后重读;渠道ID与provider类型分开,默认值/认证来自descriptor。 -- 长操作在Host runtime调度,事件驱动重绘;egui frame不阻塞等待网络、模型下载或进程。 -- 字段禁用、加载、取消、错误、空列表、重试与重开须齐备;按钮存在但效果未接入应标明不可用。 -- 历史重转使用Auxiliary服务与`apply_history_retranscription`,真实provider/model/timing由Core生成;不要根据当前设置猜历史归因。 -- API密钥只写入不读回;配对PIN通过显式配对接口受控显示,不进入通用日志/状态广播。 - -## 4. 关闭标准 - -每个领域交付“用户入口 → Core调用 → Host效果 → 事件/持久化”的实际流程;验证成功、失败、取消、重新打开和重启。 -领域UI完成不自动关闭相关原生缺口;L02/L03/L01分别验收上下文、音频归档和全局热键。 +| Start | 下一步引导、环境准备说明(fcitx/插件、桌面会话、麦克风、Secret Service 的真实可知状态;未连接时保留诊断步骤,不展示成功) | — | +| Dictation | 普通听写(复用 Core `dictation_engine`)、ticket 化落字 | — | +| Qa | QA 文本/语音(`qa.rs`)、全局待办、审批、取消 | 编辑模式/应用/撤回 UI(L04) | +| Selection | 划词润色预览/撤回(`selection.rs`) | Selection Voice 生产触发/捕获/意图路由(L04) | +| Agent | Less Computer 工具调用、审批固定在页面外、后台事件保留 | Agent 检测/模型/路径/权限配置(L09) | +| Services | AI 服务配置、渠道管理(`settings.rs` + Core provider 面) | Omni 有效模式配置入口完整性(L09) | +| Models | 本地 Qwen 下载/激活/取消 | 完整模型管理:路径/镜像/详情/删除/预载/释放(L08) | +| Remote | 手机输入独立页、连接状态、陈旧地址处理 | — | +| History | 只读最近条目 | 完整历史/统计/录音操作(L07,依赖 L03) | +| Settings | 现有设置独立页 | 词典/纠错/风格包/市场无页面(L05/L06);多数设置缺实际消费者(L09) | + +## 2. 跨页约定 + +- Agent 审批固定在页面外展示,QA/Selection/Agent 的后台事件保留操作或导航提示。 +- 事件消费不得因导航停止(见[06](06-events-and-sessions.md));会话取消语义由 Core 承担。 +- 每个旧操作保留真实调用;页面增删不改变 Core 接口。 + +## 3. 新增页面的实施要求 + +L04–L08 每个领域补完整成功/失败/取消路径后再算完成;领域功能扩展时优先拆分 `main.rs`(当前较大),避免继续单文件增长。 diff --git a/docs/linux-egui-handoff/05-native-host-and-data.md b/docs/linux-egui-handoff/05-native-host-and-data.md index e294c9672..a65945a3e 100644 --- a/docs/linux-egui-handoff/05-native-host-and-data.md +++ b/docs/linux-egui-handoff/05-native-host-and-data.md @@ -1,60 +1,29 @@ -# 05:原生宿主、数据和系统集成 +# 05:原生宿主、数据与系统集成 -对应 L02、L03、L10、L11。平台效果归 Linux Host;现有代码可复用,具体缺口如下。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应 L02、L03、L10、L11。平台效果归 Linux Host;现有代码可复用。 -## 1. 音频、存档和反馈 +## 1. 音频(`audio.rs`) -现有[audio.rs](../../openless-all/app/linux-egui/src/audio.rs)提供 CPAL 设备、PCM 转换、音量和致命错误回调;Core决定静音停止和重试策略。 +- 已有:`LinuxCpalRecorder`(CPAL 录制,可指定首选设备)——已实现待实测(L11)。 +- 缺口(L03):无录音归档(RecordingArchive)、未执行录音期间系统静音/恢复、缺提示音与胶囊。关闭标准:归档、保留策略、失败恢复、同归档重试与历史重转写可用;所有终态恢复音量且反馈可见。 -- `LinuxCpalRecorder::start`目前只消费计划中的设备名称,没有执行录音期间系统静音/恢复。 -- `LinuxActiveRecording`没有覆写`archive()`;Core默认返回None。因此录音归档、失败归档恢复、同归档静默重试的音频来源尚不完整。 -- 补充[RecordingPlan](../../openless-all/app/crates/openless-core/src/dictation_context.rs)的适用效果及[RecordingArchive/read_pcm](../../openless-all/app/crates/openless-core/src/ports.rs);`archive_enabled=false`(QA/划词语音)时不得创建临时归档,不能用成功后删除代替不落盘。主听写/Less Computer 的保存与清理须遵循独立音频保留设置,不能误用历史条数。 -- 用户取消、设备断开、启动失败和正常停止都必须恢复已改变的系统音量;不要覆盖录音期间用户主动修改的新状态。 -- 提示音、录音胶囊/overlay尚未接入;消费Core阶段/反馈事件,避免UI自己猜测处理已结束。 -- Less Computer成功非debug录音的归档清理由Core执行;Host需提供真实`RecordingArchive`和可执行的`discard`。未提供archive不等于已经验证文件保留策略,失败/debug场景也不能无条件删除。 +## 2. 上下文与观察(L02) -验收:真实输入设备切换/拔出、无声停止、权限失败、归档开关/上限、失败重试与取消、历史录音播放/重转、各终态音量恢复。 +Core `ports.rs` 提供 `EditObservationSink` / `EditObservationAdapter`(`ports.rs:523-539`);Linux 未注入实现,默认 `NoopEditObservationAdapter` 生效,Selection 的 `source_app` 为 None。关闭标准:按隐私设置捕获真实应用/允许的上下文;原生手改观察能产生 Core 纠错建议并拒绝迟到结果。 -## 2. 输入目标、上下文和手改观察 +## 3. 数据与凭据 -[fcitx5.rs](../../openless-all/app/linux-egui/src/fcitx5.rs)已有ticket化插入、失败复制与结果区分;[selection.rs](../../openless-all/app/linux-egui/src/selection.rs)已有捕获/应用/取消/撤回和原目标核验。 +- `credentials.rs`:Secret Service 凭据存储(L11 待实测)。 +- `model_store`(Core):模型目录与下载;Linux 管理 UI 缺口见 L08。 +- `remote_input.rs`:Remote Input TLS/H5、配对与会话桥接(L11 待实测)。 +- `resources.rs`:打包资源路径。 -**确定缺口**:Linux factory 未覆写默认`NoopHostContextAdapter`与`NoopEditObservationAdapter`,选区`source_app`仍为None。 -接入[HostContextAdapter与观察合同](../../openless-all/app/crates/openless-core/src/ports.rs),采集平台能够可靠提供的应用身份和授权上下文,报告无法提供的能力。 +## 4. 桌面集成(L10) -- 关闭光标上下文时不读取文档;必要前台应用元数据与文档内容分开处理。 -- 记录开始时固定原目标,不因主窗获得焦点而改成当前窗口;迟到观察须核验generation/session。 -- 不把PRIMARY旧值或剪贴板副本当成当前原控件仍匹配的证明。 -- Core继续拥有流式尾段协调与纠错规则;Host不能因未知结果再粘贴一次。 +未实现:托盘(`capabilities.rs` 仅探测 `supports_tray`)、自启、通知(仅状态栏)、检查/下载/安装更新与重启。AppImage 能力判定(`LinuxPackageKind`)不等于更新器已接好。关闭标准:各能力有真实 Host 效果与失败反馈,不以配置状态伪造就绪。 -需在X11/Wayland各自的GTK、Qt、浏览器、终端验证;组合环境不支持可靠替换时明确降级/禁用,不伪造已插入。 +## 5. 进程与运行时 -## 3. 凭据和1.x数据 - -[credentials.rs](../../openless-all/app/linux-egui/src/credentials.rs)已有Secret Service、channel metadata与旧凭据迁移;[backend.rs](../../openless-all/app/linux-egui/src/backend.rs)仅在显式home路径下启用旧来源访问。 - -- 复用CredentialStore,不在UI读取vault数据库、推导账号名或保存明文密钥。 -- 旧来源读取、目标写入、完成标记的顺序必须可重入;失败保留来源,不能凭“开始迁移”删除旧数据。 -- 只操作OpenLess所属账号/命名空间;测试用独立data/cache/home和测试keyring,不触碰真实凭据。 -- vault锁定/拒绝/不存在分别展示,保存失败不可显示成功;渠道删除不能误删其他渠道的秘密。 -- 旧模型和自定义目录迁移复用Core ModelStore,不由UI复制目录并猜测ready状态。 - -## 4. 模型、进程与Remote - -| 已有实现 | 需要保留/验证 | -| --- | --- | -| Linux Generic Qwen runtime、Core下载/激活事务 | 包内runtime路径、真实推理、timeout/cancel和旧释放不卸载新模型;Foundry/MLX不在Linux移植要求内 | -| Linux ProcessAdapter | 使用Core AgentCommand;验证桌面PATH、stdin背压、取消后进程组清理、审批/拒绝与自然结束 | -| Remote TLS/WSS、H5、Core配对/音频桥接 | 多连接隔离、首帧、停止时仍可取消、断线恢复、端口占用、手机证书信任;不要另写认证或会话状态机 | - -这些模块有生产实现;缺真实凭据、模型或设备结果时写“待实测”,不要整模块标“未实现”。 - -若Host实现驻留模型缓存,须在`ModelRuntimeAdapter::claim_lease/preload_lease/release_lease`中原子维护模型与激活代次;同属Generic不代表同一缓存。普通使用应撤销旧激活的释放权,迟到加载不能覆盖新缓存,也不能取消仍有效的旧会话。Core负责激活失败补偿与metadata提交,Host不得仅按模型ID查询后再无条件释放整个runtime。 - -## 5. 系统集成仍需实现 - -现有[main.rs](../../openless-all/app/linux-egui/src/main.rs)将tray能力传为false;通知仅显示状态栏,`RequestRestart`仅提示手动重启,缺自启与实际更新器。 -[capabilities.rs](../../openless-all/app/linux-egui/src/capabilities.rs)按AppImage布局判断`supports_auto_update`,这只是能力判定,不能当作更新服务已完成的证据。 - -egui团队补窗口/托盘/通知/自启,以及AppImage检查、验证、下载、替换和重启;系统包遵循其分发方式,不硬套AppImage更新流程。直到接线完成,UI不得依据此flag展示假可用操作。 -缺fcitx5插件时当前程序进入启动失败页;产品可用性方案须明确安装/重载指引,若选择允许配置模式则只开放不依赖该插件的操作。 +- `runtime.rs`:任务派生与生命周期。 +- `coding_agent.rs`:CLI 进程管理(L11 待实测)。 +- `single_instance.rs`:单实例守护与启动意图转发(已实现)。 diff --git a/docs/linux-egui-handoff/06-events-and-sessions.md b/docs/linux-egui-handoff/06-events-and-sessions.md index 38bdf3b67..20bd11252 100644 --- a/docs/linux-egui-handoff/06-events-and-sessions.md +++ b/docs/linux-egui-handoff/06-events-and-sessions.md @@ -1,63 +1,22 @@ # 06:事件、会话与取消 -入口:[Core events.rs](../../openless-all/app/crates/openless-core/src/events.rs)、[types.rs](../../openless-all/app/crates/openless-core/src/types.rs)、[Linux主循环](../../openless-all/app/linux-egui/src/main.rs)。 -当前Linux已有事件消费/重放和若干时序修复,正式UI应保留这些语义,不仅复制页面布局。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 -## 1. 订阅与恢复 +入口:Core `events.rs` / `types.rs`、`linux-egui/src/lib.rs`(`LinuxHost`)、`linux-egui/src/main.rs`(主循环)。 -1. 建立订阅后初始化Core,读取startup和领域快照,检查`running`及`contract_version`。 -2. 按`sequence`去重,按`session_id`归属分发;不能将任何最新事件都归给当前页面。 -3. 消费落后时使用已有replay/快照恢复机制;不要跳过缺失事件后继续在旧状态上追加。 -4. 配置变更按revision刷新缓存;UI草稿不能反向覆盖较新的后台配置。 -5. 事件到达后请求重绘;不要在每帧启动新订阅、网络请求或后台任务。 +## 1. 订阅与消费 -恢复时保留各领域的不同策略:听写恢复状态,QA以完整messages校准,Less Computer按轮归属处理;不是一个通用“取最新事件”就能替代。 +- `LinuxHost::subscribe()` 返回 `EventSubscription`;`drain_events()` 批量取走语义事件交给 UI reducer。 +- 主循环必须在每帧消费事件;导航切换不得停止事件消费或丢弃会话(QA/Selection/Agent 的后台事件保留操作或导航提示)。 -听写`DictationStateSnapshot.recording_ready`的JSON字段为`recordingReady`,每轮初始为`false`;`phase=recording`仅说明原生启动已返回,不能据此提前显示麦克风就绪。AudioRecorder必须先向consumer交付非空PCM,再报告该帧的level;Core仅在这个首帧回调后将`recordingReady`置为`true`,即使首帧是`elapsedMs=0,level=0`也必须发布状态。不得用启动定时器或预填零电平伪造首帧。UI在`starting/recording`且`recordingReady=false`时显示待命;终态退出待命,新会话重新从`false`开始。旧JSON缺字段时Core按`false`读取。 +## 2. 会话与取消 -Less Computer语音提供`voice_state {sessionId, phase, level, elapsedMs}`及`BackendEventPublisher::latest_less_computer_voice_state()`固定一条最新投影。`phase`为`starting/recording/transcribing/idle`;投影保留原seq,供阶段事件被有界replay驱逐后恢复显示,不能推进聊天去重水位。Linux Host/UI接手此显示消费;旧session的Idle或迟到电平不得覆盖当前录音。 -该语音投影复用相同首PCM合同:原生start返回后仍保留`starting`,首帧level才转为`recording`;Host/UI不能自行补发就绪态。 +- 所有长操作走 Core session:创建/取消/终态由 Core 裁决,UI 只触发与显示。 +- Remote stop 保持可取消的 session;迟到结果按 epoch 丢弃(桌面同规则,见[桌面验收](../2.0-desktop-acceptance.md)划词域)。 +- 事件顺序与重放语义以 `contract/backend-2.0.json` 的 `backendEvent` 与 Core `events.rs` 为准;UI 不得自行重排或合并事件。 -## 2. TranscriptDelta不是简单追加 +## 3. 已有时序约定(保留,不因重写界面丢失) -`offset`表示Unicode scalar(Rust `char`)数量,不是UTF-8字节或UTF-16单元。 -新文本为旧文本前`offset`个scalar加`delta.text`,原尾段被替换。优先复用`TranscriptAccumulator`。 - -例:已有`你好世`,收到`offset=2,text="世界"`,结果是`你好世界`,不是`你好世世界`。 -中文、emoji、provider interim回修与最终delta都需测试。越界offset是错误,不截断猜测;迟到的上一轮delta不能改新轮文本。 - -## 3. QA与Less Computer的归属 - -- QA对话owner与每轮录音token不同。停止录音使用该轮token,不能把延迟的静音回调转换为无条件toggle。 -- QA增量事件的`messages=None`并不表示清空历史;追加chunk,完整消息到达时再校准。 -- 面板关闭/重开时丢弃旧owner的迟到输出;语音finish期间仍须能取消实际ASR。 -- Less Computer每个新User轮更新当前session,`fresh`仅决定是否清除会话显示;续聊也必须接收新轮输出与审批。 -- 工具/审批事件按Core标识关联,不在UI另建可绕过Core的approval token registry。 -- Less Computer录音反馈使用typed `voice_state`:`sessionId`、`phase`(starting/recording/transcribing/idle)、`level`、`elapsedMs`。与同一事件流的sequence一起消费,旧session的idle/电平不能清除新录音;Linux页面接入这组反馈仍由egui团队完成。 - -源码参考:[qa.rs](../../openless-all/app/linux-egui/src/qa.rs)、[Core QA/Agent接口](../../openless-all/app/crates/openless-core/src/domains.rs)。 - -## 4. 取消和原生资源 - -共享语音使用Busy拒绝新会话。停止录音、取消整个会话和正常结束不是同一动作。 -异步启动前登记owner;每个慢await返回后核验身份和取消状态,迟到得到的资源直接关闭。 - -录音移交给finish后仍要保留共享取消句柄;不能`take()`唯一资源后让取消路径找不到ASR。 -Core已有capture控制与单一终态规则;Host保留有效句柄并如实返回错误,UI只显示最终状态。 -Core资源hold覆盖在途原生初始化与收尾;关闭页面、取消回复或丢弃调用future不能让旧任务恢复新会话的静音/输入源。不要把Core暂时返回Busy当成可以在Host绕开的锁。 - -Remote socket下行只发本连接所属session;客户端stop后到finish结束之间仍保留取消路由。 -停止native listener/服务后再shutdown Core;退出过程中不接新业务。 - -## 5. 插入结果 - -- `Inserted`:原生确认写入;`PasteSent`:派发粘贴但不能证明目标接收。 -- `CopiedFallback`:已经复制且需要用户处理;`NotRequested`:本次没有请求插入。 -- `OutcomeUnknown`是错误语义:原生可能已写入,不能自动重试造成双写。 - -UI通知、历史和Remote结果不得把这些统一显示为“输入成功”。 - -## 6. 最小回归集合 - -重复/乱序/lag重放、Unicode回修、两轮QA、两轮Less Computer、旧session迟到事件;Starting/录音/转写/finish各阶段取消;旧静音回调到达新轮;面板关闭重开;插入未知结果不重复提交。 -复用[host contract](../../openless-all/app/linux-egui/tests/host_contract.rs)和Core各领域contract;fixture通过之后仍需验证原生Host效果。 +- 听写终态、录音停止与音量恢复事件必须成对(见[05](05-native-host-and-data.md) L03)。 +- 审批/待办事件跨页存活;审批动作固定在页面外展示。 +- 启动阶段先消费 startup snapshot,再消费增量事件(见[01](01-core-contract.md))。 diff --git a/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index f50e68c3d..8454f9f59 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -1,72 +1,30 @@ -# 07:验收和证据 +# 07:验收与证据 -## 1. 两个不同的完成门 - -- **本批Core移交门**:可调用的`2.0.0`合同、真实共享业务、平台Interface、无设备示例/fixture、跨平台依赖检查和逐项缺口文档。 -- **egui团队Linux产品门**:补齐[登记项](./02-gap-register.md),取得真实桌面、设备、安装升级与正式分发证据。 - -第二项不阻塞本批Windows/macOS 2.0;共享Core缺陷或不可调用的承诺接口仍属于本批问题。 -Windows/macOS自己的完整功能与设备验收见[桌面清单](../2.0-desktop-acceptance.md)。 - -## 2. 无设备验证 - -在仓库`openless-all/app`目录执行;Linux原生依赖安装方式沿用[CI](../../.github/workflows/ci.yml),不要把Windows上的Linux stub编译当作Linux目标验证。 - -```sh -cargo test -p openless-core --locked -cargo test -p openless-linux-egui --locked -cargo run -p openless-linux-egui --example headless_host --locked -cargo check -p openless-linux-egui --all-targets --locked -pwsh -NoProfile -File "scripts/check-core-deps.ps1" -pwsh -NoProfile -File "scripts/check-core-deps.ps1" -Package openless-linux-egui -pwsh -NoProfile -File "scripts/check-core-secret-surface.ps1" -pwsh -NoProfile -File "scripts/check-core-test-isolation.ps1" -pwsh -NoProfile -File "scripts/check-core-runtime-seam.ps1" -pwsh -NoProfile -File "scripts/check-linux-public-surface.ps1" -``` +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。 -headless示例使用fixture,不证明音频/fcitx5/Secret Service可用。上述命令是接手方可复用的验证入口,不表示本次文档更新重新执行过测试。 - -插件从仓库根目录构建,在已安装Fcitx5开发依赖的Linux环境执行;build目录使用独立路径: - -```sh -cmake -S "openless-all/scripts/linux-fcitx5-plugin" -B "build/linux-fcitx5-contract" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -cmake --build "build/linux-fcitx5-contract" -ctest --test-dir "build/linux-fcitx5-contract" --output-on-failure -``` - -这验证C++目标合同;不替代真实前台应用输入。保留发行版最低Fcitx版本编译覆盖,避免只在较新本机头文件通过。 - -## 3. 必须显式执行的环境测试 - -返回仓库的`openless-all/app`目录。以下测试默认ignored,只有在适当的测试账号、运行中服务和设备上才执行: +## 1. 两个不同的完成门 -```sh -cargo test -p openless-linux-egui --test cpal_contract -- --ignored -cargo test -p openless-linux-egui --test fcitx5_contract -- --ignored -OPENLESS_RUN_SECRET_SERVICE_CONTRACT=1 cargo test -p openless-linux-egui --test secret_service_contract -- --ignored -``` +- **本批 Core 移交门**:可调用的 `2.0.0` 合同、真实共享业务、平台 Interface、无设备示例/fixture、跨平台依赖检查、逐项缺口文档(已完成,见[01](01-core-contract.md))。 +- **egui 团队 Linux 产品门**:补齐[登记项](02-gap-register.md) L01–L12,取得真实桌面、设备、安装升级与正式分发证据。 -以上为Linux shell命令。Secret Service须在已启动测试keyring的DBus会话中执行;其显式环境开关只授权该次测试。 -先阅读相应[测试文件](../../openless-all/app/linux-egui/tests):fcitx5测试会修改热键并尝试向焦点目标提交文本,必须使用隔离桌面/测试编辑器,结束后恢复配置;keyring测试会写入并移除测试凭据。 -CPAL测试允许明确分类的无设备/权限错误,fcitx5部分调用也允许不可用错误;测试绿色不等于录到音频或成功落字,仍需下面的真机流程。不要导出真实密钥。 +## 2. 自动验证(在 `openless-all/app/` 执行) -| 环境/领域 | 必须记录的实际结果 | +| 验证 | 命令 | | --- | --- | -| X11与Wayland,声明支持的桌面环境 | 安装/加载插件、后台全局热键、重绑/冲突/重载、主窗与托盘行为;分别声明限制 | -| GTK/Qt/浏览器/终端 | 普通/流式Unicode落字、换行、切焦点、目标失效、选区确认/撤回、未知结果不双写 | -| 音频 | 真麦克风、切换/拔出、静音恢复、录音archive/重试/重转、Starting至finish全过程取消 | -| Provider/vault/model | 锁定与解锁、保存失败恢复、旧数据幂等迁移、真实云请求、Qwen推理与取消/释放 | -| QA/Selection Voice/Agent | 多轮、意图/预览、审批/拒绝、旧事件隔离、关闭重开、CLI stdin与进程树清理 | -| Remote | 真手机LAN/TLS、PIN更新、首帧、并发隔离、断连/重连、端口冲突与stop后cancel | -| deb/rpm/AppImage | 内容/依赖、安装/启动、插件路径、1.x升级数据、卸载/回滚、权限和签名/更新 | +| 格式 | 根 workspace:`cargo fmt --all --check`(openless-core / linux-egui);Tauri:`cargo fmt --manifest-path src-tauri/Cargo.toml --check` | +| Core | `cargo test -p openless-core --locked` | +| Linux Host/合同 | `cargo test -p openless-linux-egui --locked` | +| Linux 目标编译 | `cargo check -p openless-linux-egui --all-targets --target x86_64-unknown-linux-gnu --locked`(需配置好带 `core/std` 的目标工具链) | +| 打包 | `release-linux-egui.yml`(deb/rpm/AppImage,独立 manifest) | -仅包内文件存在、`--help`成功或CI绿色,不能证明上述完整用户流程。 +## 3. 真机矩阵(L11/L12) -## 4. Linux分发与回报 +- 桌面会话:X11 与 Wayland 分开记录。 +- 输入:fcitx5 插件热键路由、PRIMARY 选区、落字目标应用矩阵。 +- 音频:CPAL 设备、系统静音/恢复终态。 +- 凭据:Secret Service(含锁定/未解锁态)。 +- 打包:AppImage / deb / rpm 安装、升级、回滚、卸载残留;签名与分发渠道证据。 -已有[Linux打包脚本](../../openless-all/app/scripts/package-linux-egui.sh)和[独立工作流](../../.github/workflows/release-linux-egui.yml)供复用。 -无正式签名、manual URL或调试签名的制品按验证产物记录,不宣传为正式更新源;只有管理员可按[发布政策](../../RELEASING.md)发布。 +## 4. 记录格式 -每项关闭记录:`L编号 / commit / OS与桌面版本 / 前提 / 操作 / 期望 / 实际 / 自动日志 / 设备证据 / 仍有限制`。 -接口不足附最小复现、期望Core方法/事件/错误回报Core负责人;平台实现或UI缺口由egui团队继续负责。 +`ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。“待实测”不是“未实现”,也不是“已完成”;验收报告按证据分级表述(见[范围](../2.0-requirements.md)第 4 节)。 diff --git a/docs/linux-egui-handoff/README.md b/docs/linux-egui-handoff/README.md index 560501031..f6d42f54e 100644 --- a/docs/linux-egui-handoff/README.md +++ b/docs/linux-egui-handoff/README.md @@ -1,37 +1,26 @@ -# Linux egui 接入交接 +# Linux 接入交接总览 -更新:2026-09-06;接口以本目录所在提交的代码为准。初始缺口盘点基于`2b315572`,后续资源生命周期与VoiceState合同更新见01/06。需求依据:[2.0范围](../2.0-requirements.md)。 +状态:canonical(2026-09-07 以源码为准重写,基线 `openless-linux-egui` + Core `639c2fbf` 后的 beta);更新:2026-09-07。 -本批2.0完整交付Windows/macOS。Linux交付共享Core及接入资料;egui团队负责余下Linux Host、UI、集成和Linux发布验收。 -已有`linux-egui`代码和包构建是可复用起点,不等于Linux产品已完成。 +## 1. 责任与范围 -## 阅读顺序 +Linux Host Adapter 的剩余实现、全局热键、窗口/托盘/权限/更新等宿主效果、egui 界面与 Linux 产品验收由 egui 团队承接;共享业务全部在 `openless-core`,发现业务接口缺项时回报 Core 负责人,不在 UI/Host 复制规则。Linux 产品完成度不阻塞 Windows/macOS 交付(见[范围](../2.0-requirements.md))。 -| 文档 | 回答什么问题 | -| --- | --- | -| [01 Core接入合同](./01-core-contract.md) | 从哪里接、哪些规则归Core、生命周期和秘密边界 | -| [02 缺口与实施顺序](./02-gap-register.md) | 当前Linux还缺什么、由谁做、依赖顺序和关闭标准 | -| [03 全局热键与窗口](./03-hotkeys-and-windows.md) | 三类1.x X11热键缺口及原生注册、恢复和窗口效果 | -| [04 页面与领域操作](./04-ui-domains.md) | 已有页面能做什么、哪些Core能力尚缺操作入口 | -| [05 原生宿主与数据](./05-native-host-and-data.md) | 音频/插入/凭据/模型/进程/托盘/更新的接入与验证 | -| [06 事件与会话](./06-events-and-sessions.md) | sequence、重放、Unicode增量、QA/Agent和取消时序 | -| [07 验收与证据](./07-acceptance.md) | 自动命令、设备矩阵、Linux发布条件和回报模板 | +## 2. 已有起点(直接复用,不另建第二套后端) -## 工作规则 +- 组装:`linux-egui/src/backend.rs` — `LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_*` 注入(recorder/polisher/inserter/credential store/host actions/settings runtime/local ASR runtime/polish failure policy)。 +- Host 门面:`lib.rs` `LinuxHost` — `snapshot` / `subscribe` / `save_settings` / `update_settings_strict` / `drain_events` / `backend()`。 +- 已实现模块:`audio.rs`(CPAL 录制)、`credentials.rs`(Secret Service)、`fcitx5.rs`(输入/选区)、`hotkeys.rs`(fcitx5 热键监听)、`selection.rs`、`qa.rs`、`coding_agent.rs`、`marketplace.rs`、`remote_input.rs`、`settings.rs`、`single_instance.rs`、`capabilities.rs`、`resources.rs`、`runtime.rs`、`ui_state.rs`。 +- 页面:`ui_state.rs` `Page` 枚举 10 页(Start/Dictation/Qa/Selection/Agent/Services/Models/Remote/History/Settings),主循环 `main.rs`。 +- 打包:`release-linux-egui.yml`(deb/rpm/AppImage,独立 manifest)。 -- 先复用现有Core和Linux Adapter;不复制Tauri业务、不让UI读取秘密或私有Coordinator状态。 -- Linux系统功能由团队实现Host Adapter,egui框架只提供UI能力,不会自动注册全局热键或管理系统服务。 -- Core接口不足:带最小复现、所需业务语义和期望事件回报Core负责人;不要在UI临时补另一套规则。 -- 文档中的“已有”仅表示本快照有相应代码/合同;设备与发布证据单独确认。 -- 任务关闭时更新[缺口登记](./02-gap-register.md)及对应小文档,附准确commit、测试和设备结果。 +## 3. 阅读顺序 -## 入口文件 +1. [01 Core 合同](01-core-contract.md):可调用的 Core 面与合同文件。 +2. [02 缺口登记](02-gap-register.md):L01–L12 现状、责任与关闭标准(推进主表)。 +3. [03 热键与窗口](03-hotkeys-and-windows.md)、[05 原生宿主与数据](05-native-host-and-data.md):接线缺口细节。 +4. [04 页面与领域](04-ui-domains.md):10 页现状与界面缺口。 +5. [06 事件与会话](06-events-and-sessions.md):订阅、重放、取消语义。 +6. [07 验收](07-acceptance.md):两个完成门与证据要求。 -- [Core公开出口](../../openless-all/app/crates/openless-core/src/lib.rs) -- [Linux生产factory](../../openless-all/app/linux-egui/src/backend.rs) -- [LinuxHost facade](../../openless-all/app/linux-egui/src/lib.rs) -- [现有egui主循环/UI](../../openless-all/app/linux-egui/src/main.rs) -- [headless示例](../../openless-all/app/linux-egui/examples/headless_host.rs) -- [历史修复证据](../pr1019-2.0-final-review.md) - -旧的[长接口文档](../linux-egui-backend-contract.md)与[阶段计划](../linux-egui-shared-backend-plan.md)保留历史参考;范围冲突以新需求和本目录为准。 +接口细节见[后端契约](../linux-egui-backend-contract.md);长接口与历史实现参考保留在契约文档。 diff --git a/docs/linux-egui-shared-backend-plan.md b/docs/linux-egui-shared-backend-plan.md deleted file mode 100644 index 7f53a34b6..000000000 --- a/docs/linux-egui-shared-backend-plan.md +++ /dev/null @@ -1,2233 +0,0 @@ -# OpenLess Linux egui 与共享后端 lib 拆分实施计划 - -> **2026-09-06 范围更新**:当前需求以[2.0交付范围](./2.0-requirements.md)、[Windows/macOS验收](./2.0-desktop-acceptance.md)及[Linux拆分交接目录](./linux-egui-handoff/README.md)为准。Windows/macOS须完整保留各自Tauri 1.x功能;Linux本批交付可接入Core,剩余Linux Host/UI和产品验收交egui团队,不再作为本批桌面2.0完成条件。下方旧阶段记录不覆盖这一新范围。 - -> 历史说明:本文保留 2026-08-31 至 2026-09-01 的分阶段设计与证据,正文中的“当前”、旧 HEAD、 -> 测试计数和 UI stub 状态不代表 PR #1019 最新实现。当前 2.0.0 接口以 -> `linux-egui-backend-contract.md`、代码中的 canonical fixtures 和 PR #1019 body 为准。 - -> 文档状态:实施与复审记录。2026-09-05 完整复核发现此前 F01–F24 的接口/fixture 通过仍遗漏生产 Adapter、取消、输入目标与 UI 状态序列错误;问题、修复及最新验证以 [`pr1019-2.0-final-review.md`](./pr1019-2.0-final-review.md) 为准。不能继续用下面的历史收口矩阵单独证明迁移完成。 -> 更新日期:2026-09-05;真实设备、签名、安装、升级和回滚仍需单独证据,最终远端 run/head 以 PR #1019 body 和本轮复审记录为准。 -> 范围:抽取无 Tauri 依赖的共享 Rust 应用核心;保留 macOS / Windows 的 Tauri 前端;为 Linux egui 前端准备公共接口、事件契约、测试适配器和构建契约 -> egui 责任(2026-09-06调整):由另一组负责剩余Linux Host原生实现/接线、egui/eframe界面、交互、视觉、设备与Linux发布验收;本轮交付稳定Core与细化缺口文档 -> 最终审查基线:推送后重新读取 PR #1019 的真实 `baseRefOid/headRefOid`;不得继续使用历史 SHA 或把 `MERGEABLE` 等同发布就绪 - -## 0. F01–F24 收口矩阵(2026-09-04) - -下表保留上一轮生产调用者与自动证据索引;本轮新发现与修复见上方链接。“设备待补”始终不等于真实平台或发布就绪。 - -| 项 | Core 生产调用链 / 已删除的 Host policy | 自动证据 | 真实平台证据 | -| --- | --- | --- | --- | -| F01 | Linux egui → Core Credential/Provider/LocalAsr activation;UI 不再持有 endpoint/model/auth 默认值 | provider、credential、Linux factory/UI contract | Ubuntu 首次配置待补 | -| F02 | Linux factory → Qa/Remote/Selection Adapter;AppImage installer 先于 listener | Linux lib/host contract、WSL 条件编译 | Ubuntu QA/Remote/preview/fcitx5 待补 | -| F03 | Tauri/Linux → `start_less_computer_voice` +统一 voice lease/cancel;Host 不再因 `take()` 丢取消路由 | Less Computer capture/cancel/approval tests | Windows/macOS 冷启动语音待补 | -| F04 | `CredentialDirectory` 持有 channel ID/type/order/enable/active;Tauri mutation policy 已删除 | provider channel facade、CredentialDirectory contract、source gate | Keychain/Secret Service A/B channel 待补 | -| F05 | ProviderDescriptor auth requirement → validate 与真实 builder;空 key 不发 Authorization | local fake HTTP no-auth ASR/LLM tests | 自建无鉴权服务待补 | -| F06 | ProviderDescriptor validation probe/static model policy → ProviderService | StepFun/DashScope/static-list/timeout/cancel/redirect tests | 各云厂商错误 key 待补 | -| F07 | 共享 `SilenceAutoStop` + typed RecordingPlan/Event/Control;Host 仅 recorder effect | 六个 silence tests、QA/Selection/Less wiring、recording fault test | 三平台 mute/fault/设备切换待补 | -| F08 | `TextInserter::begin` 固定 opaque target;Core HostContext/EditObservation generation | cursor-off零文档读取(前台应用仍冻结)、edit stale、target restore/source gate | Windows 切焦点、macOS AX/privacy 待补 | -| F09 | Pipeline correction → actual ASR/LLM labels/timing/history;Host 归因逻辑已删除 | corrected-polisher/history/provider attribution tests | Foundry GPU→CPU notice 待补 | -| F10 | `HotkeyInterpreter` 持有 press generation、grace/debounce/cooldown;Host 只发 edge | Core 29、Tauri 39、Linux hotkey contract | 三平台物理三连按待补 | -| F11 | Pipeline 从同一 archive/冻结 context 最多重试两次 | retry success/exhaust/cancel/terminal contract | 冷云 ASR 失败恢复待补 | -| F12 | H5 bounded pre-ACK queue → Core `RemoteFrameCodec`/session guard | Rust remote contract、浏览器 queue contract | 手机冷启动首词待补 | -| F13 | Core `MacosNewlineMode` 与 frozen front app;Host 只执行 LF/Return effect | serde、Terminal/非 Terminal、Unicode streaming tests | macOS Terminal/聊天框待补 | -| F14 | 唯一 `effective_pipeline_mode` 供 Dictation/QA/Selection/Auxiliary/status 使用 | flag/mode 与 credential zero-read tests | 运行中切换待补 | -| F15 | Core `PasteSent` 与 `OutcomeUnknown` 分离;不自动重试未知结果 | Core/Tauri mapping 与 contract fixture | Windows Paste/TSF 待补 | -| F16 | Core `ActiveTextInsertion` 独占 final reconciliation;两 Host 副本已删除 | streamed/tail/Unicode/diverge/fallback tests、source gate | Tauri/Linux 实际落字待补 | -| F17 | Tauri presenter 按 generation 延迟终态,fallback 卡持有窗口;主窗仍收 cue | capsule visibility/ownership/timing/message tests | Windows/macOS 可见时序待补 | -| F18 | Unicode replace-from TranscriptDelta + session/sequence reducer/replay | Rust canonical fixture、TS reducer、Linux lag replay tests | provider interim 修订待补 | -| F19 | startup 同时迁移默认与 custom ModelStore root | Qwen/Q5/Sherpa/conflict/idempotence tests | 1.x 用户目录升级待补 | -| F20 | Core LocalAsr activation transaction + target/generation lease | activation rollback/switch/release、Linux process-group tests | macOS/Windows runtime 切换待补 | -| F21 | ModelStore partial/ready/card 与 Linux 打包 Qwen runtime | model-store contract、package script/workflow checks | deb/rpm/AppImage 安装与性能待补 | -| F22 | Core AgentCommand/materialization/PATH/parser;Host 只做 I/O/spawn/kill | coding-agent parser/guard/PATH/process tests | 四 CLI 桌面启动待补 | -| F23 | 真实 StartupSnapshot/BackendEvent DTO;所有 React webview 与 Android fail-closed | Rust canonical fixture、TS gate、Android snapshot tests | Android overlay/IME 待补 | -| F24 | Credential/QA/Selection/stream/provider/history 旧 Host policy 同阶段删除 | `shared-backend-wire-contract.test.mjs` residual allowlist | 不适用;随源码复审 | - -## 1. 执行摘要 - -当前仓库已经有 Cargo `lib` crate,但这个 `lib` 仍然是 Tauri 应用本身:应用入口、Tauri builder、插件、窗口、托盘、IPC 命令注册和部分核心协调逻辑都在同一层。目标不是把现有文件整体搬家,而是建立一个真正不依赖 Tauri 或 egui 的 `openless-core`,再由两个宿主适配器使用它。 - -架构结论:原方案方向正确,但“整个后端包装进一个 lib”应理解为“把跨平台业务和状态机 -收敛到一个深的 core Module”,而不是把所有 OS 能力塞进同一个 crate。麦克风、文本插入、 -热键、凭据存储、窗口、托盘、单实例和更新器仍通过 core 拥有的最小 Interface 由平台 -Adapter 实现;否则只是把 Tauri 耦合从应用入口搬进 library,Linux 仍无法真正独立。 - -目标结构: - -```text -React UI ── Tauri 适配器 ──┐ - ├── openless-core -egui UI ── Linux 适配器 ──┘ -``` - -核心规则: - -1. `openless-core` 只承载应用业务、会话状态机、provider 调度、持久化、类型化结果和语义事件。 -2. Tauri 只承载 IPC 转换、Tauri 插件、窗口/托盘生命周期和 WebView 事件桥接。 -3. Linux适配器只承载Linux平台能力和非UI宿主接线;依2026-09-06范围,其剩余工作与egui/eframe主循环、窗口及交互一并交egui团队实现。 -4. 核心接口不能出现 `AppHandle`、`tauri::State`、WebView 窗口 label、`emit_to` 或 egui 类型。 -5. egui 团队只接收稳定的 Rust 接口和事件契约,不需要了解核心内部模块。 -6. Android 暂时继续作为 Tauri mobile 适配器;本计划不把 Android UI 改成 egui,也不改变现有 Android 语义。 - -### 1.0.1 责任矩阵与移交门 - -| 责任方 | 本计划内的交付 | 明确不负责 | -| --- | --- | --- | -| 共享后端/架构组 | `openless-core`真实领域实现、facade、状态机、平台Interface、事件/错误/能力合同、fixture/headless、兼容测试与Linux拆分交接资料;修复接入所需Core缺口 | Linux剩余Host/UI及Linux完整产品验收 | -| Tauri组 | Windows/macOS完整保留各自1.x功能,React IPC/event接线、原生Adapter、自动与设备/安装验收;保留现有Android合同不回退 | 在command中重新实现Core业务规则;本次不新增Android首批完整支持承诺 | -| Linux Host工作(移交egui团队) | 复用已有Secret Service、fcitx5、cpal、单实例/runtime;补原生效果、全局热键、上下文/归档、系统集成与设备/发布验收 | 重写Core领域规则或将OS细节塞进Core | -| egui组 | 承接上一行Host工作,并基于2.0.0合同补齐页面、交互与Linux产品验收;缺口逐项见交接目录 | 读取Core私有模块、include Tauri源码、复制业务规则,或在UI绕过LinuxHost事务/读取秘密 | - -**当前移交门**:按[2.0需求第4节](./2.0-requirements.md#4-core-对-linux-的交付门)提供真实Core实现、可运行合同/示例与[拆分交接资料](./linux-egui-handoff/README.md)。保留已有Linux实现;未接入的原生效果明确表达不可用并移交,不等待Linux全部设备/UI验收才交付Windows/macOS。后文M7为历史阶段记录。 - -完成的定义: - -- macOS / Windows Tauri 功能仍通过原有 React IPC 契约工作。 -- Linux egui 程序可以直接依赖 `openless-core`,不编译 Tauri、WebKitGTK 或 Tauri plugin。 -- 两个宿主都能使用同一套听写、润色、设置、历史、词典、风格包和 provider 业务规则。 -- 核心可以在无窗口、无 WebView、无真实麦克风的测试环境中通过 fake adapter 验证。 -- egui 团队获得版本化的公共接口、事件语义、能力矩阵、错误码、示例 host 和 headless 测试夹具。 - -### 1.1 当前实施状态(2026-08-31) - -此表只描述当前工作树中的可验证状态,不替代后面的最终验收清单: - -状态统一使用以下口径:`已完成` 表示该里程碑的退出条件和当前相关门禁均已通过; -`已完成(Interface)` 只表示 egui 团队可以依赖的 Interface 已稳定,不表示真实 Linux 原生能力或 -正式产物已经验收;`进行中` 表示仍有源码、兼容、原生或发布证据缺口。任何会影响既有证据的 -后续改动都会使对应门禁重新变为待验证,历史测试数字只保留作参考,不能继续标为“最新通过”。 - -| 阶段 | 状态 | 当前证据 | 仍需完成 | -| --- | --- | --- | --- | -| M0 | 已完成 | 196 command / 30 legacy event / 29 core event kind 的机器基线、drift check 和平台/调用/版本决策已冻结 | 只有破坏性 Interface 变更才重新打开决策 | -| M1 | 已完成 | 根 workspace 仅含 core/Linux;Tauri 与 backend compatibility tests 使用独立 manifest/lockfile;依赖门禁通过 | 对应原生 target 仍由 CI 证明 | -| M2 | 已完成(Interface) | 共享 `types.rs` 已由 core 类型重导出;听写、设置、历史、词典、风格包、凭据及复杂领域 DTO/错误/serde fixture 已建立;快捷键语法、冲突规则、完整设置 DTO 和 Linux validated settings 公共 contract 已进入稳定 Interface;style-pack prompt 诊断和 ASR 热词排序也已收敛到 Core facade | 破坏性 Interface 变更才重新打开版本迁移 | -| M3 | 已完成(Interface) | lifecycle、`DictationEngine`、`AudioRecorder`、`TranscriptionEngine`、`TextPolisher`、progress sink、host/inserter/credential/resource Interfaces、`BackendServices` 与 fake/unsupported Adapter 已有 | 新 seam 仍须满足“两个真实 Adapter,或一个真实 Adapter 加一个测试替身”的建立条件 | -| M4 | 进行中 | core 已有完整 Pipeline、录音 level、sequence/session/lagged/late-result/cancel race tests;成功/失败 history、实测 ASR/润色耗时、失败录音保留、成功录音隐私清理和最终纠正规则已由 core 统一;backend 实例拥有 2048 条有界事件 replay,Less Computer 不再使用进程级静态 backlog;Android stop-time translation、remote external PCM、桌面普通听写的 Core Pressed/Released/Combined 和 Esc 取消已使用冻结 session context 与共享 Core 状态机;QA 的 phase/message/cancel/conversation 真相也已归 `QaService`,窗口可见性由 Coordinator/QA Adapter 共享的 `TauriQaHostContext` 持有,不再存在第二份 `QaHostState`;30 个 legacy event 已完成归类,原 12 个待迁移事件已获得 typed core event;`Coordinator::Inner` 与 `capsule_focus` 已恢复 module 私有,`bind_app(AppHandle)` 已删除,Coordinator/capsule 子模块中的 `AppHandle`、`WebviewWindow` 和直接 Tauri emit 已清零;capsule 的原生窗口操作、布局/穿透/style/fallback cache 与 deferred payload 均归 `TauriCoordinatorHost`,`TauriCapsuleWindow::apply_capsule_payload` 只接收窄值 | Core `LessComputerVoiceSession` 现统一 capture lease、ASR 取消、PCM 校验、TranscriptDelta final 和 Agent submit;Linux/Tauri edge 仅做宿主生命周期适配,pending stop 与静音策略继续由各自 Host 触发 Core;旧 compatibility `Coordinator` 仍持有显式 Tauri Host,并承担部分热键仲裁、native runtime 生命周期和兼容编排;继续完成宿主边界审计并取得 Android/macOS/Windows/Ubuntu 原生验证 | -| M5 | 进行中 | preferences/history/activity/vocabulary/correction/style-pack/ZIP/output-cleaning/credentials、完整 prompt compose、实时云 ASR/LLM/Omni 协议、provider 默认值/凭据路由/取消与 QA answer 已迁入 core;设置事务现由 Core 统一 legacy 同步、strict/reconcile、style 保留、typed effect plan、单写入 gate、乐观 revision、一次持久化/事件及 receipt 补偿;Selection Voice 的 transcript correction、instruction polish、自动意图模型/fallback、输出模式、EditPlan、translation 与 QA preview 迭代现由 Core 高层 use-case 统一;Tauri/Linux Adapter 只消费显式 action/target;Coding Agent、Local ASR、Marketplace、Selection、Selection Voice、QA、Remote Input 及 Provider 管理面(`ProviderService::validate/list_models`)均已有 Core Implementation | native/local ASR、平台录音、socket、窗口、授权和系统 effect 继续留在 Adapter;Selection Voice/QA/Remote Input、Provider 真实网络和设置原生 effect 仍需完整平台证明 | -| M6 | 进行中 | Tauri 已管理 `Arc`;生产云 ASR/LLM/Omni/Auxiliary/QA/Provider 管理面的运行时均调用 core 共享 Implementation,Tauri 只注入 `SystemCredentialStore`、平台录音、native/local ASR、窗口/插入与系统 runtime;Selection Voice Adapter 只提交原始 transcript、执行 Core `SelectionVoiceEditAction`、保存 opaque insertion target 并回报 apply outcome,源码门禁禁止业务规则回流;React command、CLI、Android JNI、remote WebSocket PCM、桌面普通听写热键及复杂领域的业务调用均调用 core Interface;Core `LessComputerVoiceSession` 已可供非 Tauri Host 使用并固定 provider/model、审批、continuation、stream 和终态,旧 Coordinator 语音 recorder/ASR 兼容编排仍待替换;Host 只注入 recorder/native runtime 与热键边沿;legacy provider/Selection Voice 业务副本及仅供历史测试使用的 coordinator runner/approval helper 已删除 | Provider command 已收窄为参数/错误转换,Linux factory 已接入同一 Core service、Generic/Qwen ASR CLI 和 Coding Agent runtime;继续收窄旧 Coordinator 兼容 host state,并补齐 Android/macOS/Ubuntu 原生证据 | -| M7 | 已完成(Interface) | `BackendServices` 全领域 Interface、完整 headless/unsupported 示例、Linux host contract、能力 fixture 和 unsupported 语义已交付;`LinuxHost::save_settings`/`update_settings_strict` 强制 snapshot revision;4 项公共 host contract 已覆盖设置事务以及 Selection/Selection Voice 的 preview、confirm、cancel、stale、outcome-unknown 与 Linux preview/revert `Unsupported`;Provider 管理面已有 Core/Tauri/Linux 接线和源码契约;当前公共面门禁和 headless 示例运行通过 | egui/eframe UI、交互、视觉与 UI 验收由另一组负责,不属于本交付 | -| M8 | 进行中 | Linux Secret Service、资源布局、fcitx5 插入、cpal 录音、DBus 热键 listener、HostActions、能力矩阵、单实例与统一 `LinuxNativeRuntime` 已实现;`LinuxBackendBuilder::from_shared_providers(config)` 无需 UI 注入 provider factory,即可组装共享云 ASR/LLM/Omni/Auxiliary、ProviderService、Marketplace、传统 Pipeline、凭据和 settings runtime;`LinuxHost` 暴露同一 ProviderApi;`LinuxHost::download_marketplace_archive` 提供不覆盖已有文件的 filesystem sink;`LinuxSettingsRuntime` 按 receipt 恢复显式 effect;WSL Ubuntu 已通过真实 Secret Service adapter 的 set/read/remove + secret 边界 contract、fcitx5 插件加载/DBus method/listener/press-release-combined-translation signal contract、cpal 无输入设备的稳定错误分类,以及 desktop/AppStream metadata 校验 | 仍需真实焦点输入上下文中的按键/translation 顺序、存在音频设备时 cpal start/stop、settings effect/单实例退出的桌面流程,以及正式 Ubuntu runner 的安装/签名证据;合成 DBus signal 和 WSL contract 不能替代这些证明 | -| M9 | 进行中(CI runner 门禁已通过;原生安装/设备证据待完成) | fork CI run 33408317390(head `06e85f7b`)四个平台 job 全部成功:Linux `openless-core` 596 unit + 79 contract、Linux crate 30 + 4 host contract(3 个真实 Linux native contract 明确 ignored);macOS Tauri 737 tests(730 passed、7 ignored)、Windows Tauri/Core checks、Android `aarch64`/`x86_64` mobile compile/Gradle/JVM/instrumentation/Keystore contract;frontend/contract 58、196/30/29 基线、依赖/秘密/隔离/runtime/public-surface/source/headless 等门禁均通过 | 仍缺 Android 签名安装/设备运行、macOS/Windows 安装升级 smoke、Ubuntu 真实桌面输入/音频/设置流程及正式 runner 的签名安装证据;Linux UI stub 仍不属于产品验收 | -| M10 | 进行中(验证产物已可生成;正式发布待外部门) | Tauri/Linux release workflow 已拆分;Linux deb/rpm/AppImage/fcitx5/updater 契约和 README/RELEASING 已加入;CI run 33408317390 的 Linux artifact job 成功上传并校验 `openless-linux-egui-x86_64`(artifact ID 9764249814),手动 Tauri/Android workflow 也已分别生成桌面和四 ABI debug artifact | UI stub 未替换,故 Linux workflow 不监听 tag;正式签名密钥、真实 Ubuntu runner 安装/运行 proof、正式 macOS/Windows/Android 签名安装仍缺 | - -### 1.1.1 当前 Coordinator 收口边界 - -本节是对上表中 M4/M6 状态的代码级澄清,防止把“Core 已提供接口”误读成“所有生产入口都已迁移”。 - -- Less Computer 的文字入口,以及宿主完成录音/ASR 后的 - `run_voice_agent_transcript -> submit_less_computer_with_session`,已经使用 Core 的 - provider、prompt、护栏、审批、continuation、stream 和终态规则。 -- Less Computer 的热键按下会先调用 - `OpenLessBackend::begin_less_computer_capture` 预留 Core capture lease,再由 - [`coordinator/hotkey_loops.rs`](../openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs#L779-L849) - 以同一个 session id 启动兼容 Coordinator 的 recorder/ASR;松开、Starting pending stop、 - 静音自动停止最终仍复用 `end_session`,但转录提交、Agent 运行、审批、取消和终态全部由 - Core 负责。启动失败、空转写和取消会释放未提升的 capture lease。 -- Coordinator 的 `state`、`voice_agent`、`pending_stop` 仍是 Tauri 兼容层的录音/热键状态, - 不是 Linux egui 可见的业务真相;Linux host 应直接使用 Core facade 的 active session、 - cancellation 和 typed events,不读取这些字段。普通听写的 Pressed/Released/Combined/Esc - 继续使用 Core dictation 状态机。 -- QA 编辑预览需要把平台的 opaque selection target 绑定到 Core preview;该动作现在由构造阶段 - 注入 `TauriQaHostContext` 的弱引用回调完成。QA Adapter 不再通过 `AppHandle.try_state` 反查 - `Coordinator`,并由 source contract 和 focused test 守护这一边界。 -- 本批迁移的完成判据是:Less Computer 的跨宿主身份使用同一个 Core session id,任何 Host - 只负责捕获资源和生命周期边沿;Provider、prompt、approval、continuation、stream、终态 - 和 cancellation 语义只能由 Core 产生,并由 headless/compatibility contract 覆盖。 - -### 1.2 本轮原生验证记录(2026-08-29) - -以下 Linux 证据来自 WSL2 Ubuntu(不是 Windows 交叉编译),Android Rust cross-target 证据来自 -当前 Windows 主机;临时 minisign key 只位于 WSL `/tmp`,未写入仓库: - -- `dbus-run-session` + `gnome-keyring-daemon --unlock --components=secrets` 下,`secret_service_contract` 以 `--ignored` 显式通过 1 项:adapter 实际写入、读取、删除 Secret Service 项,metadata 文件不包含 secret。 -- 系统安装并加载仓库构建的 `libopenless.so` 后,`fcitx5` 真实 DBus 对象注册成功;`fcitx5_contract` 以 `--ignored` 通过 1 项,覆盖 `Fcitx5HotkeyListener` 启停、DBus method、press/release/combined/translation signal 顺序映射和 no-focused-input 的安全返回。`CommitText(s: text) -> b` 的 `false` 被 Rust Adapter 转为明确的平台/插入失败,不能当作成功;测试后 fcitx5 进程仍存活。真实桌面输入上下文的物理按键顺序仍需 runner 证明。 -- `cpal_contract` 以 `--ignored` 通过 1 项;WSL 当前无 ALSA 输入设备,adapter 返回 `Platform`/`PermissionDenied`/`Unsupported` 中的明确错误而不是 panic 或假成功。存在真实设备时的 stream start/stop 仍需 runner 证明。 -- Android Tauri mobile 初始化、脚手架复制和 manifest 合并脚本均已在当前工作树执行成功;随后使用 - `cargo ndk -t arm64-v8a check --manifest-path "src-tauri/Cargo.toml"` 与 - `cargo ndk -t x86_64 check --manifest-path "src-tauri/Cargo.toml"` 通过,证明两个 Android - Rust target 的源码/依赖可编译。该证据不包含 JNI 在设备上的运行、Gradle/JVM 编译、APK/AAB - 组装、instrumentation 或签名安装;本机 Gradle cache 缺少 - `com.android.tools.build:gradle:8.11.0`,在线解析未稳定完成,因此这些项目必须由具备完整 - Android/Gradle cache 的 CI runner 证明。临时生成目录中的 AGP 版本尝试已恢复为仓库声明版本, - 未修改 CI 版本契约。 -- `desktop-file-validate` 与 `appstreamcli validate --no-net` 通过;AppStream metadata 已补齐 description/homepage。 -- 重新生成的 Linux 产物位于 `openless-all/app/target/linux-egui-packages/`:deb、rpm、AppImage;release binary/plugin 的 `ldd` 无 `not found`,且无 Tauri/Wry/WebKit 依赖;deb/rpm/AppImage 内容均含 binary、desktop/AppStream metadata 和 fcitx5 plugin。使用临时 minisign key 对 AppImage 的签名/验签已通过;独立 updater manifest 由 release workflow 生成,正式发布必须注入正式 secret,当前不把临时签名当作可发布凭据。 - -### 1.2.1 历史 Windows 本地重验(2026-08-30) - -本轮只记录当前工作树可在 Windows 主机复现的证据;它不能替代 Android、macOS 或真实 -Ubuntu 桌面 runner 的原生证明: - -- `npm.cmd test`(包含 pretest build)退出码为 0,发现并执行 58 项前端/契约测试; -- `cargo test --locked --manifest-path "src-tauri/Cargo.toml" --lib` 运行 752 个单元测试, - 结果为 745 passed、0 failed、7 ignored;Provider 旧 command 测试旁路已删除,解析与模型 - 响应测试归入 Core `ProviderService`。 -- `cargo test --locked -p openless-core` 运行 594 个 unit tests,领域 integration contract - 另有 79 项,全部通过; -- `cargo test --locked -p openless-linux-egui --all-targets` 运行 29 个 Linux crate tests 和 - 4 个 host contract tests;Secret Service/fcitx5/cpal 原生 contract 在 Windows 以 0 tests - 保持 ignored,不被误报为 Linux 原生成功; -- Core/Linux clippy(`-D warnings`)、workspace fmt、command/event baseline(196/30/29)、 - core/Linux 依赖、secret surface、test isolation、runtime seam、Linux public surface、 - source contract、headless example 和 `git diff --check` 均通过; -- 修正 `release-linux-egui.yml` 的版本解析路径:该步骤的 - `working-directory: openless-all/app` 现在读取 `src-tauri/Cargo.toml`,不会再拼出重复的 - `openless-all/app/openless-all/app` 路径;本地已用同一工作目录解析出 - `1.3.18-Beta.7`。 - -以上结果证明共享 core、Linux Interface 和 Tauri compatibility 在当前工作树可构建并通过 -本地契约;不证明真实音频设备、焦点输入、fcitx5 物理按键顺序、安装/签名、Android APK/JNI -或 macOS/Windows 安装包行为。 - -### 1.2.2 跨平台 CI runner 验收(2026-08-31) - -提交 `06e85f7b8b9e93db7df276952a18825e245e7c37` 在 fork 的 [CI run 33408317390](https://github.com/H-Chris233/openless/actions/runs/33408317390) 上四个平台及 Linux artifact job 全部成功: - -- Linux core and egui host:Core 596 unit + 79 contract、Linux crate 30 tests + 4 host contract、严格 clippy 和依赖/秘密/隔离/runtime/public-surface 门禁通过。 -- Android cargo check:`aarch64`/`x86_64` Tauri Rust check、Gradle scaffolding、JVM unit/instrumentation tests 和 Android Keystore instrumentation 通过。 -- Windows checks:前端/契约 58 项、Tauri check、Windows backend test compile、Rust-only backend tests、Core contract、Rust 1.88 MSRV 和五处版本同步通过。 -- macOS checks:前端/契约 58 项、Qwen3/Tauri check、737 个 Tauri Rust unit tests(730 passed、7 ignored)、Rust 1.88 MSRV、backend test compile 和版本同步通过。 -- Linux egui validation artifact:无 Tauri 的 deb/rpm/AppImage、fcitx5 plugin、ELF/包内容/desktop/AppStream 和 updater manifest SHA-256 校验通过。 - -该 run 证明当前提交在声明的原生 runner 上可编译并通过已配置的契约;它不等同于正式 release workflow 的安装包、签名、设备输入/音频或 egui UI 视觉验收。Linux egui UI 仍由 egui 组实现,正式发布仍按 M10 的 release gate 执行。 - -### 1.2.3 远端验证 artifact(2026-08-31) - -- Linux artifact job [run 33408317390](https://github.com/H-Chris233/openless/actions/runs/33408317390) 成功上传 artifact `openless-linux-egui-x86_64`(ID `9764249814`),包含 1 个 deb、1 个 rpm、1 个 AppImage 和 `latest-linux-egui-x86_64.json`;下载后的 AppImage SHA-256 `f9e061c7b27ba26eff886a68b1acaaf561a67389447f485b8593a02e341a9307` 与 manifest 一致,manifest URL 指向 `H-Chris233/openless`。 -- Tauri 手动构建 [run 33405500864](https://github.com/H-Chris233/openless/actions/runs/33405500864)(commit `80be78c2`)三个 job 全部成功,上传 macOS arm64/x86_64 DMG(artifact IDs `9757421887`、`9757475524`)和 Windows x64 NSIS 安装包(ID `9757290871`);Windows runner 的 NSIS 安装/卸载与 IME smoke 通过,非数字 Beta 版本明确跳过 MSI。 -- Android 手动构建 [run 33405500972](https://github.com/H-Chris233/openless/actions/runs/33405500972)(commit `80be78c2`)成功上传四个 ABI debug APK(artifact IDs `9757082911`、`9757086428`、`9757090328`、`9757094381`);`Collect split APKs` 已校验每个 APK 只包含一个预期 ABI,artifact 均未过期。 - -以上是 CI 验证 artifact,不是正式签名发布:当前 Linux UI 仍是 stub,Tauri/Android 手动构建未注入正式签名密钥;真实设备安装、升级/回滚和 Ubuntu 桌面输入/音频仍由 12.4 未勾选门禁负责。 - -### 1.3 外部依据与本项目决策映射 - -本节只记录用于验证分层方向的公开一手资料;具体接口、兼容字段和完成状态仍以仓库代码、contract tests -和对应平台 runner 为准(资料核对日期:2026-08-29)。 - -| 依据 | 可采用的事实 | 对本项目的决策 | -| --- | --- | --- | -| [Tauri Architecture](https://v2.tauri.app/concept/architecture/) | Tauri 的 Rust 应用层、WebView 前端以及 command/event 通道属于宿主运行时边界 | `src-tauri` 只做 IPC、WebView、窗口、托盘、插件和移动端宿主;业务状态不能反向依赖 Tauri 类型 | -| [Cargo Workspaces](https://doc.rust-lang.org/cargo/reference/workspaces.html) | workspace 成员共享解析/构建上下文;`exclude` 和独立 manifest 可隔离不应参与某一构建的 package | Linux workspace 只解析 `openless-core` 与 Linux adapter;Tauri 保持独立 manifest/lockfile,避免 Linux 构建解析 Tauri/native path dependency | -| [eframe API](https://docs.rs/eframe/latest/eframe/) / [egui API](https://docs.rs/egui/latest/egui/) | eframe/egui 提供 native/web GUI application loop、绘制和 UI 状态承载 | `egui`/`eframe` 只进入 Linux UI crate;core 只交付同步快照、非阻塞事件订阅、异步 use-case 和 host action,不规定布局或视觉 | - -由上述事实得到的项目结论是:用户提出的“后端包装进 lib、Tauri 薄包装、Linux 用 egui”方向正确, -但 lib 必须是“业务核心 + 由 core 所有的最小平台 Interface”,不能把窗口、热键、录音、凭据、 -文本插入等 OS 实现继续塞进同一个跨平台 crate。否则 Linux 虽然不直接编译 Tauri,仍会被错误的 -宿主耦合或不可替换的系统实现卡住。 - -## 2. 问题定义与现状证据 - -| 事实 | 位置 | 影响 | -| --- | --- | --- | -| Cargo 已声明 `openless_lib`,但主依赖包含 Tauri | [`src-tauri/Cargo.toml:9-23`](../openless-all/app/src-tauri/Cargo.toml#L9-L23) | 现有 `lib` 不是框架无关核心 | -| `run()` 直接分派到 `run_desktop()`,并在其中创建 Tauri builder | [`src-tauri/src/lib.rs:144-153`](../openless-all/app/src-tauri/src/lib.rs#L144-L153)、[`lib.rs:494-519`](../openless-all/app/src-tauri/src/lib.rs#L494-L519) | 启动和业务模块无法被 egui 直接复用 | -| 原 `Coordinator::Inner` 直接保存 `AppHandle`;当前已替换为显式 `TauriCoordinatorHost`,`Inner`/`capsule_focus` 已恢复私有,`bind_app(AppHandle)` 已删除,Coordinator/capsule 子模块中的 `AppHandle`、`WebviewWindow`、直接 emit 和直接 `tauri::async_runtime` 调用均已清零;原生 capsule window code/cache 已移入 Host | [`coordinator.rs`](../openless-all/app/src-tauri/src/coordinator.rs)、[`tauri_coordinator_host.rs`](../openless-all/app/src-tauri/src/tauri_coordinator_host.rs) | 窗口、事件和运行时 seam 已显式隔离;compatibility Coordinator 仍承担部分 Tauri-only 热键仲裁、native runtime 生命周期和兼容编排,尚未达到删除兼容层的终态 | -| 命令层以 `State`、`AppHandle`、`Window` 作为参数 | [`commands/mod.rs:11-24`](../openless-all/app/src-tauri/src/commands/mod.rs#L11-L24)、[`commands/mod.rs:130-148`](../openless-all/app/src-tauri/src/commands/mod.rs#L130-L148) | Tauri command 不是可移植的公共接口 | -| 兼容面包含 30 个旧 Tauri event,现已全部分类;原 12 个待迁移事件已集中映射 | [`linux-egui-command-event-baseline.json`](./linux-egui-command-event-baseline.json)、[`tauri_events.rs`](../openless-all/app/src-tauri/src/tauri_events.rs) | 后续新增业务事件必须先定义 core 语义事件,再由各宿主映射;纯窗口事件继续只归 Tauri host | -| Linux 入口包含 WebKitGTK compositing workaround | [`src-tauri/src/main.rs:4-20`](../openless-all/app/src-tauri/src/main.rs#L4-L20)、[`lib.rs:694-729`](../openless-all/app/src-tauri/src/lib.rs#L694-L729) | 分离 egui 的主要动机是降低 Linux WebView 风险,但 Wayland 仍需单独验证 | -| 原 Linux fcitx5 资源安装从 Tauri 取路径;当前 `openless-linux-egui` 已用 `LinuxResourceLayout`/`FcitxPluginInstallPlan` 独立实现 | [`linux-egui/src/resources.rs`](../openless-all/app/linux-egui/src/resources.rs)、[`linux-egui/src/fcitx5.rs`](../openless-all/app/linux-egui/src/fcitx5.rs) | Linux package 不再依赖 Tauri;真实安装顺序仍需 Ubuntu proof | -| 旧 Rust-only backend test 曾通过 path include 和 Tauri stub 绕开完整应用;当前该旁路已删除 | [`backend-tests/Cargo.toml`](../openless-all/app/src-tauri/backend-tests/Cargo.toml)、[`core_contract.rs`](../openless-all/app/src-tauri/backend-tests/tests/core_contract.rs) | compatibility package 现在只验证公开 core contract;原 Tauri 单测必须在 Tauri crate 自身运行,不能再复制源码 | -| React IPC 已按领域拆成多个模块 | [`src/lib/ipc/index.ts:1-20`](../openless-all/app/src/lib/ipc/index.ts#L1-L20) | 可保留现有 command 名称,降低 Tauri 迁移风险 | - -历史发布工作流曾把 Linux 放在 Tauri 矩阵中并安装 WebKitGTK。当前工作树已经把 Linux 从 -[`release-tauri.yml`](../.github/workflows/release-tauri.yml) 移出,并建立独立的 -[`release-linux-egui.yml`](../.github/workflows/release-linux-egui.yml)。在真实 egui 入口替换 -stub 前,Linux workflow 只允许手动或复用调用,不能由 release tag 自动发布。 - -## 3. 目标与非目标 - -### 3.1 目标 - -- 建立不依赖 Tauri / egui 的 `openless-core` Rust library。 -- 把 Coordinator、ASR/LLM pipeline、持久化和业务类型放到 core 的清晰模块中。 -- 通过类型化接口提供同步查询、异步命令、取消、快照和事件订阅。 -- 用宿主适配器承载窗口、托盘、权限、更新、开机自启、单实例和系统集成。 -- 保留现有 React IPC command 名称及其 JSON 字段兼容性,作为 Tauri 适配器的兼容层。 -- 为 Linux egui 团队提供可独立开发的接口包、示例、mock、事件映射和 headless 验证。 -- 把现有 Rust backend tests 迁移为 core 的单元测试和 adapter integration tests。 -- 在 CI 中证明 Linux egui package 的依赖树没有 Tauri/WebKitGTK。 - -### 3.2 非目标 - -- 本计划不实现 egui 页面、视觉设计、控件、布局、动画或 UI 自动化。 -- 不要求 React 与 egui 像素级一致;只要求业务语义和能力契约一致。 -- 不把所有代码强行合并为一个几千行的 `lib.rs`;一个 library 可以内部由多个深模块组成。 -- 不在第一阶段将后端拆成大量独立远程进程或引入 JSON/RPC;同进程 Rust 调用应保持类型化。 -- 不改变 Android 当前 Tauri mobile 适配器的产品行为。 -- 不顺手修改 provider 协议、ASR 模型、提示词、发布版本号或无关 UI 行为。 - -## 4. 目标包结构 - -### 4.1 推荐目录 - -在 `openless-all/app` 建立只包含 core/Linux 的 Cargo workspace;现有 `src-tauri` 和 -`src-tauri/backend-tests` 各自保留独立 manifest/lockfile。这样执行 Linux package 命令时, -Cargo 不会为了加载 workspace 元数据而解析 Tauri 的 macOS-only path dependency: - -```text -openless-all/app/ - Cargo.toml # core/Linux workspace root 与独立 Cargo.lock - crates/ - openless-core/ - Cargo.toml # 不出现 tauri、egui、eframe - src/ - lib.rs - api.rs # 对外 facade 和 use-case 接口 - events.rs # 语义事件和事件订阅 - errors.rs # 稳定错误码 - config.rs # BackendConfig / 路径 / 能力 - types.rs # 跨宿主共享 DTO 和领域类型 - coordinator/ - asr/ - polish/ - persistence/ - providers/ - ... - src-tauri/ # 根 workspace 显式 exclude - Cargo.toml # 独立 Tauri Adapter + macOS/Windows/Android host - Cargo.lock - src/ - main.rs # Tauri desktop/mobile entry - lib.rs # Tauri setup、commands、window/tray lifecycle - commands/ # 薄 command adapter - tauri_events.rs # core event -> WebView event bridge - host/ # Tauri-specific host actions - linux-egui/ - Cargo.toml # 当前为非 UI Linux Adapter;egui 团队在此接入 eframe - src/ - main.rs # 由 egui 团队实现 - host.rs # Linux host adapter;本计划提供接口 - src-tauri/backend-tests/ # 独立 compatibility-test package - Cargo.toml - Cargo.lock -``` - -当前已建立隔离后的 workspace 骨架:[`openless-all/app/Cargo.toml`](../openless-all/app/Cargo.toml)、 -[`openless-core`](../openless-all/app/crates/openless-core/)、 -[`openless-linux-egui`](../openless-all/app/linux-egui/)。Tauri 通过 path dependency 使用 -core,但不会成为 Linux workspace 的解析依赖。该结构不代表 Coordinator 和复杂 provider -实现已经完成迁移;实际状态以 1.1 表为准。 - -### 4.2 依赖方向 - -```text -openless-core - ├── serde / tokio / reqwest / persistence dependencies - ├── platform Interfaces (traits owned by core) - └── no Tauri, no egui, no WebView type - -openless-tauri(现有 Cargo package 名称可继续为 openless) ──> openless-core - ├── Tauri commands / plugins / windows / tray - └── React IPC event names - -openless-linux-egui ──> openless-core - ├── 当前:Linux credentials / fcitx5 / resources / capabilities / host actions - └── 后续由 UI 团队加入 eframe / egui 与窗口/托盘交互 -``` - -禁止以下反向依赖: - -- core `use tauri::*` -- core `use egui::*` 或 `use eframe::*` -- core 读取窗口 label(`main`、`capsule`、`qa`、`less-computer`) -- core 直接 `emit_to`、创建 WebView、调用 Tauri plugin -- Linux egui crate 通过 path include 复用 `src-tauri/src/*.rs` -- Tauri adapter 把业务判断重新实现一遍,导致第二份真相 - -## 5. 共享核心接口设计 - -以下 Interface 已在 M0/M1 冻结;后续只能按 contract version 规则演进,不能由某个宿主单方面改名或改变语义。 - -### 5.1 Backend facade - -核心对宿主提供构造、生命周期、快照、事件和领域 use-case 的 facade;复杂领域通过 -`BackendServices` 暴露稳定 Interface: - -```rust -pub struct OpenLessBackend { /* private state and adapters */ } - -impl OpenLessBackend { - pub fn new(config: BackendConfig, deps: BackendDependencies) - -> Result; - - pub async fn start(&self) -> Result; - pub async fn shutdown(&self) -> Result<(), BackendError>; - - pub fn snapshot(&self) -> BackendSnapshot; - pub fn subscribe(&self) -> EventSubscription; - pub fn services(&self) -> &BackendServices; - - pub async fn start_dictation(&self) -> Result; - pub async fn stop_dictation(&self) -> Result; - pub async fn cancel_dictation(&self, session: Option) - -> Result<(), BackendError>; - - pub async fn submit_less_computer(&self, transcript: String) - -> Result; - pub fn begin_less_computer_capture(&self, session: SessionId) - -> Result<(), BackendError>; - pub fn less_computer_active_session(&self) -> Option; - pub fn less_computer_capture_cancelled(&self, session: SessionId) -> bool; - pub fn abort_less_computer_capture(&self, session: SessionId) - -> Result<(), BackendError>; - pub async fn submit_less_computer_with_session( - &self, - session: SessionId, - transcript: String, - ) -> Result; - pub async fn cancel_less_computer(&self, session: Option) - -> Result<(), BackendError>; -} -``` - -以上 facade 已在 `openless-core` 中落地。`BackendServices` 当前包含 `ProviderApi`、 -`LocalAsrApi`、`SelectionApi`、`QaApi`、`RemoteInputApi`、`MarketplaceApi`、 -`CodingAgentApi`、`PlatformApi` 和 `AuxiliaryApi`。宿主尚未注入的领域统一返回稳定的 `Unsupported`, -不得伪造成功;这让 egui 团队可以先针对 Interface 编写 view-model tests,而不会误判运行能力。 - -约束: - -- `OpenLessBackend` 必须 `Send + Sync`,可以被 Tauri `State>` 或 Linux host 持有。 -- `new()` 不应隐式创建窗口、弹权限或启动全局热键;启动副作用由 `start()` 和 host lifecycle 明确触发。 -- `shutdown()` 必须幂等,重复调用不能 panic,也不能遗留录音、热键、下载或 provider task。 -- `snapshot()` 返回可安全克隆的 owned 数据;不暴露内部锁、引用或生命周期参数。 -- 所有长任务必须异步执行;宿主 UI 线程不能等待网络、模型加载、录音或磁盘 I/O。 - -### 5.2 领域接口分组 - -不要把现有 196 个 Tauri command 原封不动暴露为 196 个核心方法。按当前 IPC 领域模块形成稳定的 use-case Interface: - -| 领域接口 | 负责内容 | 典型操作 | -| --- | --- | --- | -| `DictationApi` | 听写会话和插入结果 | start/stop/cancel、状态快照、会话结果 | -| `SettingsApi` | 用户偏好和默认模式 | get、patch、默认风格提示词 | -| `CredentialsApi` | 凭据状态与安全读写 | status、set、read;UI 默认只拿 status | -| `ProviderApi` | ASR/LLM/Omni provider | 列表、验证、模型列表、激活项;实现必须由 Core `ProviderService` 提供,宿主只注入 credential/transport | -| `HistoryApi` | 历史与活动统计 | list/delete/clear/stats、录音导出 | -| `VocabularyApi` | 词典、纠正规则和建议 | list/add/remove/enable/accept/reject | -| `StylePackApi` | 风格包生命周期 | list/create/save/preview/activate/import/export | -| `LocalAsrApi` | 本地模型下载与运行时 | models、download、prepare、release、status | -| `SelectionApi` | 选区润色/选区语音 | capture、preview、confirm、cancel、revert | -| `QaApi` | QA 会话、录音和回答 | submit、sync、approve、cancel | -| `LessComputerApi` | Coding Agent 连续对话 | submit、cancel、dismiss、approve | -| `RemoteInputApi` | 远程输入服务器 | status、PIN、locale、local IP | -| `MarketplaceApi` | 市场与 GitHub OAuth | list/detail/install/upload/like/auth | -| `CodingAgentApi` | Coding Agent 检测、模型、风险、测试与审批 | detect、list models、risk、run/cancel、approve | -| `PlatformApi` | 能力和权限状态 | capabilities、microphone、accessibility、IME | -| `AuxiliaryApi` | 对既有文本/PCM 执行单轮共享处理 | repolish、retranscribe PCM、实际 ASR 归因、取消 | - -每个接口只返回 core DTO 和 `BackendError`,不接受 Tauri 类型。Tauri command 和 egui 调用层分别把本宿主输入转换为这些 use-case 参数。 - -#### Less Computer 接口约定 - -`OpenLessBackend::submit_less_computer(transcript)` 是普通文本入口,Core 从同一份 -preferences snapshot 解析 provider、可执行文件、model、permission mode、workdir、prompt -和护栏策略,并生成 Core-owned `SessionId`。需要把热键录音生命周期与 Agent 运行严格关联的宿主 -先调用 `begin_less_computer_capture(session)` 预留实例级 capture lease,再使用 -`submit_less_computer_with_session(session, transcript)`;其中 `session` 只用于取消和事件关联, -不允许宿主借此覆盖 Core 的 provider 或安全策略。宿主启动 recorder/ASR 失败、空转写或取消而 -未进入 Agent run 时,必须调用 `abort_less_computer_capture(session)`;该方法对已提升为 run -的 session 是幂等 no-op。 - -提交前宿主可以显示自己的窗口或录音反馈;`less_computer_active_session()` 用于重连/诊断, -`less_computer_capture_cancelled(session)` 在宿主释放 capture lease 前报告取消。提交后只订阅 -`BackendEventKind::LessComputerEvent`: - -| 事件 | 语义 | UI 建议(egui 团队实现) | -| --- | --- | --- | -| `User { text, fresh }` | Core 已接受一轮输入;`fresh=true` 表示 dismiss 后的新会话 | 追加用户气泡并清理旧会话状态 | -| `Started` | provider 进程已开始 | 显示运行中 | -| `Delta { text }` | Agent 增量文本 | 追加到当前助手消息 | -| `Tool { name }` | provider 报告工具调用 | 显示工具活动,不执行工具 | -| `Compaction` | provider 压缩上下文 | 显示“整理上下文”状态(可选) | -| `Approval { token, command, reason }` | Core 等待一次高风险命令决定 | 仅展示 command/reason;通过 `CodingAgentApi::approve(token, bool)` 回传 | -| `Completed { text, cost_usd }` | 唯一成功终态 | 固化助手消息和费用(若有) | -| `Error { message }` | 唯一失败终态 | 显示可读错误和重试入口 | -| `Cancelled` | 唯一取消终态 | 清理运行态但保留已显示历史 | - -事件中的 `seq` 由 backend 实例统一分配;UI 重连时先建立订阅,再调用 replay/snapshot,按 -`seq` 去重。UI 不应自己维护 approval token、conversation flag、continuation history 或 -provider 进程状态;这些均由 Core/Runtime Adapter 持有。未注入 `LessComputerRuntimeAdapter` -时,submit 必须返回 `BackendErrorCode::Unsupported`,不能伪造 `Completed`。 - -### 5.3 快照与事件 - -事件是跨两个宿主的真实接缝。核心事件必须表达“发生了什么”,而不是“哪个窗口要怎么显示”。建议定义: - -```rust -pub struct BackendEvent { - pub sequence: u64, - pub session_id: Option, - pub kind: BackendEventKind, -} - -pub enum BackendEventKind { - BackendStarted, - BackendStopping, - DictationStateChanged(DictationStateSnapshot), - TranscriptDelta(TranscriptDelta), - PolishDelta(PolishDelta), - DictationCompleted(DictationResult), - SelectionStateChanged(SelectionSnapshot), - SelectionVoiceStateChanged(SelectionVoiceSnapshot), - InsertFallback(InsertFallbackPayload), - PreferencesChanged(PreferencesChange), - CredentialsChanged(CredentialsStatus), - HistoryChanged(HistoryChange), - VocabularyChanged(VocabularyChange), - StylePacksChanged(StylePackChange), - DownloadProgress(DownloadProgress), - PermissionChanged(PermissionSnapshot), - HotkeyStatusChanged(HotkeyStatus), - Notification(NotificationPayload), - CodingAgentTest(CodingAgentStreamEvent), - LessComputerEvent(LessComputerEvent), - LocalAsrPrepareProgress(LocalAsrPrepareProgress), - LocalAsrDownloadProgress(LocalAsrDownloadProgress), - LocalAsrEngineChanged(LocalAsrRuntimeStatus), - MicrophoneDevicesChanged, - QaLevel(QaRecordingLevel), - QaState(QaStateEvent), - RemoteInputStatusChanged(RemoteInputRuntimeEvent), - RemoteInputFailed(RemoteInputErrorEvent), - VocabularySuggestionsChanged(Vec), -} -``` - -事件约束: - -- 每个 backend 实例的 `sequence` 单调递增;同一 session 的事件顺序可验证。 -- 事件携带 `SessionId` 的地方必须由宿主丢弃过期 session,防止晚到结果污染新会话。 -- 事件流是增量通知,不是唯一真相;收到丢失/滞后通知后,宿主重新读取 `snapshot()`。 -- `subscribe()` 应使用可检测滞后的广播/订阅机制;订阅者落后时返回显式 `Lagged`,不能静默继续使用旧状态。 -- 最终事件只发布一次;取消、失败和成功都必须有明确终态。 -- 核心不发布 `capsule:state`、`chat-panel:shown` 等 Tauri 窗口事件名。Tauri adapter 可把 `DictationStateChanged` 映射为现有事件名,egui adapter 直接更新自己的 view model。 - -机器基线已把 30 个旧 Tauri event 全部分类为“core 语义事件映射”“纯 Tauri 窗口事件”或 -“删除前需版本迁移”。以下 12 个原 `migrationRequired` 事件现已全部获得 typed core event, -并从业务模块的直接 emit 迁移到集中桥接: - -1. `coding-agent:test` -2. `foundry-local-asr-prepare-progress` -3. `less-computer:event` -4. `local-asr:engine-changed` -5. `microphone:devices-changed` -6. `qa:level` -7. `qa:state` -8. `remote-input:error` -9. `remote-input:running` -10. `sherpa-onnx-asr-download-progress` -11. `sherpa-onnx-asr-prepare-progress` -12. `vocab:suggested` - -这 12 项已按固定步骤完成迁移:在 core 定义稳定 DTO 和 11 个 `BackendEventKind` variant -(Foundry/Sherpa prepare 共用一个语义事件);在 `tauri_events.rs` 集中映射为旧 React -payload;把领域实现的直接 emit 改为共享 `BackendEventPublisher`;纯窗口事件继续留在 -Tauri;baseline/contract、serde fixture、30/30 分类完整性、Tauri mapping 和 -secret-surface tests 同步更新。后续事件仍必须遵守同一流程。特别是 remote PIN、token 和 -provider credential 不得进入 core event 或兼容 payload。 - -### 5.4 错误接口 - -现有很多 command 返回 `Result<_, String>`。核心应改用稳定错误类型,Tauri adapter 再把它序列化为兼容 JSON: - -```rust -pub struct BackendError { - pub code: BackendErrorCode, - pub message: String, - pub retryable: bool, - pub details: Option, -} - -pub enum BackendErrorCode { - InvalidArgument, - InvalidState, - Busy, - Cancelled, - PermissionDenied, - Unsupported, - Provider, - Persistence, - Platform, - Internal, -} -``` - -约束: - -- `code` 是机器可判断字段,`message` 是用户可读信息,不能让 UI 解析英文字符串。 -- 凭据、token、PIN、Authorization header 和完整 provider 请求不能出现在 `message`、`details` 或日志中。 -- 超时后的异步插入/提交必须表达“结果未知”状态,不能仅凭 timeout 自动重试导致重复插入。 -- `Cancelled`、`Unsupported`、`PermissionDenied` 不能被 Tauri wrapper 统一转换为普通字符串失败。 - -### 5.5 BackendConfig 与依赖注入 - -`BackendConfig` 只包含配置值和路径,不包含窗口对象: - -```rust -pub struct BackendConfig { - pub data_dir: PathBuf, - pub cache_dir: PathBuf, - pub resource_dir: Option, - pub home_dir: Option, - pub platform: PlatformCapabilities, - pub locale: String, -} -``` - -`BackendDependencies` 通过 Interface 注入真正会变化或需要替身测试的依赖: - -| Interface | 生产 Adapter | 测试 Adapter | -| --- | --- | --- | -| `TaskSpawner` | Tauri host / Linux Tokio runtime | 单线程 deterministic executor | -| `Clock` | 系统时钟 | 固定时钟 | -| `CredentialStore` | macOS/Windows/Linux/Android 安全存储 | 内存 vault | -| `TextInserter` | AX / TSF / fcitx5 / clipboard | 记录调用的 fake inserter | -| `AudioRecorder` | cpal + 平台设备 | PCM fixture recorder | -| `HotkeyController` | macOS/Windows/global-hotkey/fcitx5 | 可控 fake hotkey | -| `HostActions` | Tauri 窗口/托盘/系统操作 | 记录 action 的 headless host | -| `ResourceResolver` | Tauri resource dir / Linux 安装目录 | 临时目录 | - -只在存在两个真实 Adapter 或一个真实 Adapter 加一个测试替身时建立 Interface;纯 Rust provider 和业务函数不为了形式而包一层。 - -### 5.6 HostActions - -核心需要请求宿主执行窗口或系统动作时,使用语义 action,不接受窗口 label: - -```rust -pub enum HostAction { - ShowMain, - FocusMain, - ShowDictationFeedback, - HideDictationFeedback, - ShowSelectionPreview, - ShowQa, - ShowLessComputer, - OpenExternalUrl(Url), - OpenSystemSettings(SystemSettingsPage), - RequestRestart, - Notify(NotificationPayload), -} -``` - -Tauri adapter 把这些 action 翻译为 WebView/window 操作,Linux adapter 翻译为 egui 状态、窗口操作或 desktop integration。核心不决定实现方式,也不假设存在多个 WebView。 - -## 6. 状态、并发和生命周期契约 - -### 6.1 状态所有权 - -核心拥有: - -- 当前 dictation / QA / selection session -- session phase、取消状态、插入结果和终态 -- provider 选择、凭据状态、模型准备状态 -- preferences、history、vocabulary、style pack 数据 -- 下载进度、热键能力和权限快照 - -宿主 UI 拥有: - -- 当前页面、tab、滚动位置和焦点 -- 对话框展开与输入草稿 -- 动画、过渡和 egui immediate-mode 临时状态 -- 窗口大小、位置、装饰和前端布局 - -### 6.2 线程规则 - -- `update()`、Tauri command 入口和 egui frame 都不能执行阻塞的网络、磁盘、录音或模型加载。 -- core 不调用 `tauri::async_runtime::spawn`;通过 `TaskSpawner` 或 core 统一的 Tokio runtime 执行后台任务。 -- egui host 在 frame 中只 drain 非阻塞事件;收到事件后调用 `request_repaint()`,不能在 frame 内 `.block_on()`。 -- Tauri event bridge 单独运行订阅任务,退出时先停止订阅,再关闭 backend。 -- 所有可取消操作必须绑定 `CancellationToken` 或等价 session token;取消后仍可能晚到的结果必须被 session guard 丢弃。 - -### 6.3 初始化与关闭顺序 - -启动: - -1. 宿主解析数据目录、资源目录、locale 和平台能力。 -2. 宿主创建 platform adapters 和 `BackendDependencies`。 -3. 构造 `OpenLessBackend`。 -4. 注册事件订阅和 host action sink。 -5. 调用 `backend.start()`,读取 `StartupSnapshot`。 -6. 成功后启动全局热键、托盘 watcher、下载 watcher 等宿主任务。 -7. UI 显示;未满足的能力通过 snapshot 明确降级。 - -关闭: - -1. 禁止新 command/session。 -2. 取消正在运行的会话和下载。 -3. 停止热键、录音、设备 watcher 和远程 server。 -4. flush 必须持久化的数据。 -5. 停止 event bridge / runtime。 -6. 调用 `backend.shutdown()` 并退出宿主。 - -## 7. 模块抽取与归属矩阵 - -这是迁移审计的初始归类;混合文件必须按函数拆分,不以文件名作为最终架构。 - -### 7.1 进入 `openless-core` - -| 当前位置 | 迁移动作 | -| --- | --- | -| `src-tauri/src/types.rs` | 把领域 DTO、快照、枚举、serde 名称和校验迁入 core;平台专属类型另放 adapter module | -| `coordinator_state.rs` | 直接迁移 session phase、generation、终态和状态转换测试 | -| `correction.rs` / `edit_plan.rs` | 迁入 core 纯业务模块 | -| `endpoint_security.rs` | URL 格式校验和 DNS resolution/pinning 逻辑迁入 core;宿主只提供网络 executor | -| `selection_voice_intent.rs` | 意图分类、关键词和 JSON 解析迁入 core;UI 选择弹窗留在 host | -| `cli.rs` | CLI 参数解析和 `CliIntent` 作为纯输入类型迁入 core;进程激活/窗口操作留在 host | -| `persistence/` | 保留 store 逻辑;凭据底层改为 `CredentialStore` Interface;路径解析改为 `BackendConfig` | -| `asr/` | provider 协议、请求、响应、重试和 ASR 业务流程迁入 core;平台模型 runtime 由 adapter 注入 | -| `asr/local/` | 模型 catalog、选择和生命周期接口进入 core;Qwen3 MLX、Foundry、Sherpa、Whisper 等 native runtime 按 target 放入 platform adapter,避免 macOS vendored path 被所有 target 解析 | -| `polish/` / `llm_gemini.rs` / `net.rs` / `omni.rs` | 迁入 core;不得引用 Tauri window/event | -| `coordinator/{dictation,polish_flow,asr_wiring,resources,silence_auto_stop}.rs` | 保留业务流程,改用 event bus、宿主 Interfaces 和 session guard | -| `coding_agent/` | provider/model/权限/预算/路径/风险/版本/MCP 等跨宿主规则进入 core;进程、Git、临时配置、宿主审批和事件桥接留在 Adapter | -| 现有类型测试与状态机测试 | 测试随模块迁移,测试入口改为 core interface | - -### 7.2 进入平台 adapter,但仍可被 core 注入 - -| 当前位置 | 目标职责 | -| --- | --- | -| `recorder.rs` | `AudioRecorder` 的桌面实现;设备枚举和 level monitor 的 host bridge 单独拆出 | -| `audio_mute.rs` | `AudioMuteGuard` 作为平台音频 Adapter;core 只依赖可选的 mute Interface | -| `hotkey.rs` / `global_hotkey_runtime.rs` | `HotkeyController` 的平台实现 | -| `qa_hotkey.rs` | QA 热键监听 adapter;QA session 状态和 use-case 逻辑进入 core | -| `combo_hotkey.rs` / `side_aware_combo.rs` / `shortcut_binding.rs` | 按平台保留实现,公共 binding 校验迁入 core | -| `insertion.rs` / `unicode_keystroke.rs` | `TextInserter` 实现;业务层只看 `InsertResult` | -| `linux_fcitx.rs` | DBus commit/hotkey 能力可作为 Linux adapter;资源目录检查和插件复制不能依赖 AppHandle | -| `windows_ime_ipc.rs` / `windows_ime_protocol.rs` / `windows_ime_session.rs` | Windows TSF IPC、协议和 session adapter;公共状态类型留在 core | -| `windows_ime_restore.rs` / `windows_ime_profile.rs` | Windows IME 恢复、键盘列表和注册表 adapter;公共设置 patch 留在 core | -| `host_document/` | macOS host document adapter;core 只接收 optional context | -| `permissions.rs` | capability/permission Interface;系统设置打开动作属于 host | -| `device_watch.rs` | OS 设备 watcher;通过 `BackendEvent` 或 host callback 回报 | -| `remote_server/` | 协议和业务可进 core;监听生命周期、端口、资源路径由 host 注入 | -| `external_url.rs` | URL scheme 校验可进 core;实际打开浏览器/Android Intent 必须由 host 实现 | - -### 7.3 只进入 Tauri adapter - -| 当前位置/内容 | 处理 | -| --- | --- | -| `lib.rs` 的 `tauri::Builder`、plugin 初始化、`generate_handler!` | 移入 Tauri host | -| `commands/` 中 `#[tauri::command]` 函数 | 保留为薄转换层,只做参数解析、core 调用和错误序列化 | -| `AppHandle` / `Window` / `WebviewWindow` 操作 | 移入 `tauri_events.rs` 和 `host/` | -| tray menu、窗口创建/显示/隐藏/定位、vibrancy/Mica、single-instance | 移入 Tauri host | -| Tauri updater/dialog/shell/autostart/fs plugin | 只在对应 host 使用 | -| `tauri.conf.json`、capabilities、Tauri resources | 只服务 Tauri desktop/mobile | -| `mobile_runtime.rs` / `android/` | 继续作为 Android Tauri host/JNI/overlay/IME adapter,不进入 Linux package | - -### 7.4 Android 处理 - -- 保留现有 `#[cfg(mobile)]` 分支和 Android Tauri host。 -- 将 Android 业务可复用部分依赖 core;JNI、overlay、IME、Keystore 留在 Android adapter。 -- 不让 Linux egui 为 Android 的 unavailable 能力增加条件分支。 -- `PlatformCapabilities` 由 core 定义结构,宿主提供真实值;Android 继续返回当前约定。 -- `build_target.rs` 只保留为构建目标/`cfg` 的测试辅助;不得成为运行时模块或 core 的宿主依赖。 - -## 8. 详细实施步骤 - -### 8.1 执行顺序、责任和阻塞关系 - -里程碑按下表推进。除 M7 的 Interface 移交外,egui 团队不阻塞共享后端迁移;他们可以在 M7 -交付后基于 fake/headless Adapter 并行开发 UI。任何阶段都不能以复制业务规则到宿主来绕过前置项。 - -| 里程碑 | 主责 | 依赖 | 退出后解锁 | -| --- | --- | --- | --- | -| M0 决策与基线 | 架构/后端负责人 | 无 | 固定平台范围、兼容基线和版本规则 | -| M1 package 骨架 | 构建负责人 + core 负责人 | M0 | core/Linux 可独立解析和编译 | -| M2 类型与错误 | core 负责人 | M1 | 两个 Adapter 可共享 DTO、错误和能力语义 | -| M3 依赖注入与生命周期 | core 负责人 + 平台负责人 | M2 | 可用 fake Adapter 做 headless 测试 | -| M4 Coordinator 与事件 | core 负责人 | M3 | 两个宿主可消费同一状态机和语义事件 | -| M5 领域迁移 | 各领域后端负责人 | M2–M4 | 业务规则只有 core 一份实现 | -| M6 Tauri Adapter | Tauri 负责人 | 对应 M5 领域逐项完成 | React IPC 保持兼容且不再承载业务规则 | -| M7 egui Interface 移交 | core/Linux host 负责人 | M2–M4 的稳定 Interface;允许以 Unsupported 标记未接线能力 | egui 团队可独立开发 view model/UI | -| M8 Linux 非 UI Adapter | Linux host 负责人 | M3、M4、对应 M5 领域 | Linux 宿主可调用真实共享主链路 | -| M9 测试与质量门禁 | 测试/构建负责人 | M4–M8 逐项接入 | 合并与发布候选具备可重复证据 | -| M10 打包与发布 | 发布负责人 | M8、M9;真实 egui 入口由 UI 团队交付 | Linux 原生产物可独立发布 | - -交付发生变化时,主责方必须同步更新 Interface contract、fixture、迁移说明和对应 Adapter -contract tests;仅更新实现代码不能视为完成。 - -### 8.2 当前工作树的剩余关键路径 - -以下顺序是从当前实现推进到最终验收的唯一关键路径。每一步完成后先过本步门禁,再进入 -下一步;不得通过在某个宿主复制业务判断来绕过未完成的 core 工作。 - -1. **已完成:冻结每会话配置快照。** core 已定义 `DictationContext`,在 - `start_dictation()` 时一次性固定麦克风、ASR/LLM/Omni channel、模型、语言、翻译目标、 - ASR prompt、风格包/润色 prompt、流式插入和 fallback 策略。会话开始后修改设置只能影响 - 下一会话,不能让正在运行的 provider 读取到一半新一半旧的偏好。 -2. **已完成:让 Pipeline 消费会话快照。** `DictationEngine`、`AudioRecorder`、 - `TranscriptionEngine` 和 `TextPolisher` 的最小参数,使 recorder 选择设备、provider 选择、 - ASR prompt 与 polish prompt 都来自同一个快照;补设置并发修改、取消和迟到结果测试。 -3. **已完成:修复 provider router 的会话占用语义。** `DictationEngineRouter::start()` 使用 - `HashMap::entry` 原子占位;回归测试证明第二次 start 返回 `Busy` 后,原 session 仍由 - 第一次选中的 Adapter 完成,不能被新 Adapter 接管。 -4. **已完成:共享 provider registry、生产 factory 与 Provider 管理面。** core 已提供按会话固定 Adapter 的 - `TranscriptionRouter`、`TextPolisherRouter` 和 `DictationEngineRouter`,并覆盖设置切换后 - 旧 session 不漂移、缺失 provider 显式 `Unsupported`、traditional/Omni 分流测试。云/实时 ASR、 - OpenAI-compatible/Gemini/Codex LLM、Omni、Auxiliary 和 QA provider Implementation 现由 core - 持有,包含 credential account、默认 endpoint/model、协议选择、取消、流式输出和 session - 占用语义。Tauri 注册共享实现并追加 native/local ASR;Linux 通过 - `LinuxBackendBuilder::from_shared_providers(config)` 注册同一批共享实现,不读取 Tauri Adapter。 - channel ID、协议类型和模型在会话开始时分别冻结;Omni 的 API key、endpoint、model、extra - headers 和 temperature 全部按冻结的 provider ID 读取,不依赖运行中的 active provider;重复 - session 不会覆盖原 cancellation route。`ProviderService::validate/list_models` 已迁入 Core,按 - channel-scoped credential 解析 provider/type/model;Tauri command 只做参数和旧错误转换,Linux - `from_shared_providers` 注入同一 service。静态清单、OpenAI/Gemini 响应解析、Omni channel 拒绝、 - 错误脱敏和 Linux 非 `Unsupported` factory contract 已覆盖。 -5. **已完成:迁移完整润色 prompt 语义。** 旧 `polish/prompt_compose.rs` 的 XML envelope、输入净化、 - prompt injection 防护、前台应用、光标上下文、历史 turns、翻译规则和 user prompt envelope - 已移入 core,并由 3 项固定 prompt contract 和 core/Tauri provider tests 覆盖。 -6. **已完成:为 Tauri 构造真实 core Pipeline。** 录音、凭据、host action,以及按 session 执行 - `prepare/insert/cancel` 的 Windows TSF/SendInput/Paste、Android strategy 与 macOS 插入 Adapter - 已接入;TSF 派发后的超时被保留为 outcome-unknown,禁止触发可能重复落字的 fallback。 - setup 构造唯一的 `Arc`,Tauri 与 compatibility Coordinator 共享同一组 - repository;生产 provider Adapter 也已接入该 Pipeline。 -7. **进行中:逐入口切换 Tauri 听写主链。** React dictation start/stop/cancel command、CLI - toggle/cancel、Android JNI、remote WebSocket,以及桌面普通听写热键的 - Pressed/Released/Combined 和 Esc 取消已进入同一个 facade。Android 通过 - `DictationStopOptions` 保留“stop 时决定 translation”的既有语义,同时只更新冻结快照中允许 - 变化的翻译开关;remote 使用 16 kHz、单声道、signed Int16LE external PCM seam,session ID - 严格关联,stop/cancel 后拒绝迟到帧。桌面宿主继续拥有 QA panel 优先分流、shortcut - recording、modifier-only combo arbitration、debounce/cooldown 和物理 listener/window - fallback;这些宿主机制不能重新拥有听写 session 状态。静音自动停止和 Starting pending stop - 仍调用 Coordinator 旧 `end_session`,必须与 Less Computer 语音生命周期一起迁移后,才能 - 把桌面热键主链标为全链路完成。托盘审计未发现听写 start/stop 入口。 - TLS/PIN/WebSocket 与 Android overlay/IME 继续留在 Tauri Adapter。其余 Coordinator 入口必须 - 按行为 contract 逐项切换,不能通过整体代理改变产品行为。 -8. **已完成:集中完整 legacy event mapping。** 机器基线中的 30 个 legacy event 已逐项标注为 - “core 语义事件映射”或“纯 Tauri 窗口事件”,并通过完整性检查(30/30、无重复/遗漏);原 - `migrationRequired` 分类已清空。12 个旧事件由 11 个 typed core event 覆盖,统一在 - `tauri_events.rs` 映射,业务模块不再直接发射这些事件。后续禁止新增业务直接 emit 点。 -9. **进行中:迁移复杂领域 Implementation。** Coding Agent 的跨宿主规则和 DTO 已进入 core, - Tauri command 已收敛为授权/兼容转换层,真实进程、Git、临时文件和事件转发由 - `TauriCodingAgentApi` 负责。Local ASR 的 catalog、设置事务、运行时生命周期 Interface、 - Core Implementation、engine-changed 事件语义与 Generic/Foundry/Sherpa Tauri command 薄包装 - 已经落地;`TauriLocalAsrRuntimeAdapter` 直接使用共享 preferences repository 与 Qwen/Whisper - cache,不再通过 `AppHandle` 回取 Coordinator;完整本地门禁已通过,剩余工作是各原生 runtime - 证据。Marketplace/GitHub OAuth 的 HTTP、 - 认证、归档、安装、upload、device-flow 状态机已经进入 core,Tauri command 已只保留参数、旧 - wire/error 转换与最终文件写入;该领域的 core contract 17 项和严格 clippy 已通过。Selection - Core 的 17 项 contract 与 Selection Voice 的 13 项 contract 已完成,生产构造已注入新的 Tauri - runtime,旧 Coordinator wrapper、正式热键/command 与安全 revert 路径已经收口;QA Core、 - `TauriQaRuntimeAdapter` 生产接线和 Remote Input Core 也已建立;Linux 生产 factory 已自动注入 - Core `MarketplaceApi`,`LinuxHost::download_marketplace_archive` 只把 Core 校验后的归档写入用户 - 选择的绝对路径,使用 create-new 语义拒绝覆盖并在写入失败时清理不完整文件;QA/Remote lagged resync、 - Remote secret wire、Remote WebSocket 单 stream/restart stale-lease 和 Less Computer - listener-first replay/pending/dedup/truncation contract 已补齐;历史重润色、手工重转写和静默 - 重试已统一进入 `AuxiliaryApi`,repolish 只冻结 LLM/Omni,retranscription 只冻结 ASR,并由 - Transcription Adapter 报告默认值解析后的实际 provider/model。云 ASR/LLM/Omni/Auxiliary/QA - 协议构造、凭据路由和取消已统一进入 core;Selection Voice 的 correction、instruction polish、 - 自动 intent model/fallback、delivery decision、EditPlan、translation 和 QA preview revision 也已 - 进入 Core 高层 use-case,Tauri 只保留录音/窗口/热键/opaque insertion target 与 apply outcome。 - provider 验证/模型列表已由 Core `ProviderService` 统一,Tauri command module 的协议请求副本 - 已删除并由 source contract 守护。剩余重点是旧 Coordinator 其他宿主耦合审计与原生平台证明。每个 Tauri - command 只做参数/DTO/错误转换,Linux 未提供的平台能力由真实 Adapter 或稳定 `Unsupported` - 表达。逐项步骤见 8.3 节。 -10. **进行中:收窄旧 Coordinator 的兼容宿主职责。** 已完成的宿主隔离包括:`Inner.app` 已替换为 - 显式 `TauriCoordinatorHost`;`bind_app(AppHandle)` 已删除;`Inner` 与 `capsule_focus` 已恢复 - module 私有;Coordinator/capsule 子模块中的 `AppHandle`、`WebviewWindow`、直接 `emit*` 和 - `tauri::async_runtime::{spawn,spawn_blocking,block_on}` 均已清零;Sherpa/remote 等业务事件经 - typed Core event 与 `tauri_events.rs` 集中映射。capsule layout 去重、cursor passthrough、style、 - fallback card、presentation generation、deferred payload 以及 show/hide/no-activate 和 - macOS/Windows 原生窗口行为均由 Host 持有;`TauriCapsuleWindow::apply_capsule_payload` 只接收 - payload、显示决策、style 和 Space reassert 窄值,不再回调整个 `Inner`。此外, - `core_adapters.rs` 的 `managed_coordinator` 反向查询已删除;hotkey status 与 QA 可见性由构造层 - 创建的窄共享状态分别注入 Coordinator/Adapter,Local ASR 共享同一 repository/native cache。 - Selection Voice 本批已删除 Tauri 中的 correction、prompt、自动分类、EditPlan、translation 和 - output-mode 分支,QA Adapter 直接调用 Core `edit_preview` 后只绑定平台 target;相应 source - contract 会阻止这些业务 token 回流。 - Less Computer 的生产热键按下现在先调用 Core capture lease,再由 - `coordinator/hotkey_loops.rs` 进入 `begin_session_as_with_session_id`;松开、静音自动停止和 - Starting pending stop 仍调用兼容层 `end_session`,但 Core 通过同一 session id 接管提交、取消 - 和 Agent 终态。Coordinator 中剩余的 `state` 字段只表达宿主录音/热键生命周期,不能被 Linux - egui 读取或当作业务 API。下一步按生产调用图逐项分类剩余 Coordinator 方法:纯 Host 生命周期、 - 授权、wire 转换和 socket/native runtime 留在 Tauri Adapter;跨宿主业务状态、provider 协议和 - 设置/热键事务迁入 core;仅由旧测试引用且无生产消费者的 wrapper 删除。settings/hotkey 的“兼容化解/校验 → 生成显式 effect plan → 平台 - prepare/commit → 单次持久化/事件 → receipt 逆序补偿”事务已经迁入 core;Tauri/Linux Adapter - 不再回读偏好文档来猜 listener 目标,style-pack 删除也直接消费 Core 专用 outcome。完成每批迁移后重跑 command/event baseline、 - source contract、Tauri 全量测试和残余引用检查。窗口、托盘、updater、dialog、shell、autostart、 - single-instance、Android JNI/overlay/IME 与 native ASR runtime 始终留在宿主,不为形式共享塞入 core。 - 还必须完成 runtime seam 审计:core provider transport 可以使用宿主已经启动的 Tokio runtime, - 但生产路径不得在无 runtime 时自行 `Runtime::new()`;应改为注入的 runtime/task spawner 或 - 明确要求由异步宿主调用,并增加 headless no-private-runtime contract。`rg` 对 - `tokio::spawn`、`Handle::current`、`Runtime::new` 的结果要逐项标注为“宿主 runtime 内运行”或 - “测试专用”,未标注项不能进入 M9 完成状态。 -11. **已完成 Linux 非 UI runtime 接线;真实原生确认待 runner。** `SelectionPolishEvent` 已调用共享 - `SelectionApi`;空闲态先收到的 `TranslationModifierEvent` 已作为下一次 dictation press 的 - `DictationStartOptions`,不会修改已经冻结的活动 session;`LinuxNativeRuntime` 已统一拥有 - primary broker、hotkey listener、错误 drain 和 shutdown/join。下一步在真实 fcitx5 上记录 - translation 与 dictation 信号顺序:若 translation 可能后到,必须先冻结关联规则并补事件 - 时间线测试,不能靠修改活动 session 猜测用户意图。 -12. **已完成(Interface):冻结 1.x egui 接口交付。** contract、公开 re-export、完整 headless example、 - 能力 fixtures、view-model 映射和 `AuxiliaryApi` 单轮处理/取消/归因契约已更新;egui 组可以 - 只依赖 facade/DTO/event/fixture 并行开发 UI,不读取 core 私有模块,也不等待 M10 正式打包。 - 设置/快捷键 DTO、`LinuxHost::save_settings` reconcile 入口、`update_settings_strict` 严格拒绝入口、 - snapshot revision 和显式 Linux effect target 已进入公共 contract;egui 组不能直接调用底层 - `set_preferences*` 绕过事务。Selection/Selection Voice 的 preview、confirm、cancel、stale、 - outcome-unknown 与 Linux preview/revert `Unsupported` 已由 fixture、headless 示例和第 4 项 - host contract 覆盖;当前公共面/受影响门禁已经重跑通过。 -13. **已完成:消除测试旁路。** 已删除 `backend-tests/tests/backend_rust.rs` 的 `#[path]` - include 与 Tauri stub;`backend-tests` 现在只直接依赖公开 `openless-core` 并运行一项 core - contract。原 118 项测试由 Tauri crate 自身的 - `cargo test --locked --manifest-path "src-tauri/Cargo.toml" --lib` 承担,不再复制源码;capsule - Host 收口前的历史 Windows 基线为 1080 passed、7 ignored、0 failed;该数字已由下一步的最新 - 工作树结果取代。 -14. **已完成当前 Windows 本地重验;原生 runner 仍由下一步单独验收。** capsule Host 收口之前,Windows 本地的 - frontend build/58 项 tests、workspace fmt、Core 261 项 unit 与 75 项领域 contract、Linux 22 项 - crate 与 2 项 host contract、公开 Core compatibility 1 项、Core/Linux 严格 clippy、Tauri - `cargo check --lib`、Tauri 1080 passed/7 ignored 的 `cargo test --lib`、command/event baseline、 - 依赖方向、secret surface、测试隔离和公共面门禁均通过。最新 capsule 增量已通过 - `shared-backend-wire-contract`、macOS capsule Spaces、Windows UI config 三个源码契约、Tauri - `cargo check --locked --lib` 和 15 项 `capsule_` 定向测试;当时 check 报告 280 项既有/迁移期 warning, - warning 数不作为成功证明。共享 provider 抽取后的当时工作树已通过 Core 567 unit + 75 - integration contract、Linux 25 crate + 3 host contract、Core/Linux 严格 clippy、Tauri - `cargo check --locked --lib`(compiler summary 276 项 warning)和 778 passed/0 failed/7 ignored; - frontend build/58 tests、公开 Core compatibility 1、fmt、196/30/29 基线、依赖方向、秘密面、 - 测试隔离、Linux 公共面和 diff hygiene 也曾在同一工作树通过。此后删除了 Tauri Adapter 中 - 永久禁用的 legacy 云 ASR/润色/Omni provider 副本,并加强 source contract;因此 source - contract、workspace fmt、Tauri check/test 必须重新运行。该轮后续工作树已通过 frontend build/58 - tests、Core 596 unit + 79 integration contract、Linux 30 crate + 4 host contract,另有 3 个显式 - ignored native contract、公开 Core compatibility 1、Core/Linux 严格 clippy、Tauri check 与 730 - passed/0 failed/7 ignored 的 macOS Tauri suite;最终证据以 fork CI run 33408317390 为准。fmt、 - 196/30/29 基线、依赖方向、秘密面、测试隔离、Linux 公共面、source contract、headless example - 和 tracked diff hygiene 也在该 CI run 通过。该本地证据不替代第 15 步的原生 runner 结果。 -15. **取得原生 CI 证据。** Ubuntu 验证 dbus/keyring/cpal/fcitx5、Linux host 和无 WebKitGTK - 依赖;macOS/Windows 验证 Tauri adapter;Android 验证 mobile target/JNI/Gradle。任何缺失的 - runner 证据保持未完成,不能由 Windows cross-target check 推断。 -16. **验证 Linux 打包。** 在 Ubuntu runner 构建 fcitx5 plugin 与 release binary,生成 deb、 - rpm、AppImage,检查 desktop/AppStream metadata、ELF `ldd`、包内路径、AppImage 解包内容、 - 单实例协议、资源解析、SHA-256、minisign 和独立 updater manifest。 -17. **解除发布门禁。** 只有 egui 团队替换 `main.rs` UI stub、UI 验收完成、签名 secret 可用、 - M9/M10 原生证据全部通过后,才允许 Linux workflow 响应 release tag;在此之前只允许 - `workflow_dispatch`/`workflow_call` 生成验证产物。 -18. **最终文档与删除审计。** 更新 README/RELEASING/contract/迁移说明和 M0 baseline;用 - `rg` 确认 core 无 Tauri/egui、Linux 无 Tauri/WebKitGTK、Tauri 业务模块无遗留直接 emit, - 最后逐项勾选第 12 节,不用“整体看起来可用”代替逐项证据。 - -### 8.3 剩余复杂领域的逐项执行清单 - -本节是 8.2 第 9–12 步的可执行展开。每个领域都遵循同一顺序:先冻结 Interface 和 observable -contract,再把业务 Implementation 放进 core,随后实现平台 Adapter,最后切薄 Tauri command。 -不能先让 command 代理旧 Coordinator,再把代理层称为共享实现。 - -#### 8.3.1 设置与热键事务收口 - -设置和快捷键不是单纯的 JSON 持久化:一次保存可能同时改变 legacy 字段、快捷键冲突关系、 -原生 listener、活动 ASR provider 的安全存储映射和 Windows 键盘列表。业务规则必须归 core, -平台调用必须归 Adapter,而“全部成功或按既定策略恢复一致状态”的事务语义也必须只有一份。 - -**当前已完成** - -1. `openless-core::shortcut_types` 已拥有快捷键字符/修饰键语法、左右修饰键限制、物理重叠判定、 - legacy trigger 转换、dictation legacy 字段同步,以及 dictation/translation/QA/style/open-app/ - Selection/Coding Agent/style-pack 之间的冲突规则。 -2. Core 已提供 `SettingsCollisionPolicy`、`SettingsUpdateOptions`、`expected_preferences_revision`、 - strict/reconcile、preserve-style、legacy 同步、typed effect plan、typed receipt/failure/outcome 和 - 单写入 gate。stale revision 在运行平台副作用前稳定返回可重试 `Busy`。 -3. Tauri `shortcut_binding` 只保留 `ShortcutBinding -> global_hotkey::HotKey` 的原生转换;mobile - stub 复用 core 语义校验,但原生解析继续显式返回 mobile unavailable。 -4. `OpenLessBackend::update_settings` 已成为 core-owned transaction use-case:先 prepare/commit - 平台 effect,再只持久化一次、发布一次;prepare、commit 或 persistence 失败时按 receipt 逆序 - restore,补偿错误与主错误结构化返回,不会把部分成功伪装成成功。 -5. `commands/settings.rs::reconcile_hotkey_collisions` 的“核心 dictation 优先、非核心键按优先级 - 恢复旧值或停用、translation 必须回退默认值、style-pack hotkey 最低优先级”的产品规则迁入 - core。整表 settings 保存可以按既有 #904 兼容策略自动化解;单项快捷键命令仍应对冲突直接 - 拒绝,两个入口保持不同的既有产品语义。 -6. 已定义最小 `HotkeyRuntime` Interface。输入是 core 计算出的完整目标 binding set/diff,输出是可供 - 补偿的 typed receipt;Adapter 不得反向读取已保存 preferences,也不得接收 `Coordinator`、 - `AppHandle` 或窗口 label。Tauri 实现注册 global-hotkey/combo/side-aware listener,Linux 实现 - fcitx5/DBus listener,测试实现记录 apply/restore 顺序。 -7. 设置事务严格执行以下顺序: - - 1. 读取并规范化 `previous`,生成经校验或兼容化解后的 `next`; - 2. 计算 hotkey、活动 ASR provider 和平台设置的 typed effect plan; - 3. 让对应 Adapter 以显式 `next` 执行可失败副作用,不允许 Adapter 从全局状态猜目标值; - 4. 全部副作用成功后只持久化一次 `next`,再发布一次变更事件; - 5. 任一步失败时按逆序补偿到 `previous`;补偿失败时返回包含主错误和补偿错误的结构化失败, - 按现有一致性策略决定恢复旧状态或 roll-forward,绝不能返回假成功或留下无报告的分叉; - 6. 整个事务使用单写入 gate,拒绝并发设置保存相互覆盖。 - - 活动 ASR provider 继续通过安全存储 Interface 同步;Windows 键盘列表只由 Windows Adapter - 执行。不要为了复用而把 Windows 注册表、global-hotkey 或 fcitx5 类型放进 core。 -8. 整表 `persist_settings`、dictation/translation/QA/switch-style/open-app/selection/Coding Agent/ - combo/style-pack 快捷键生产入口已切换到该 use-case。command 内旧 settings/hotkey 事务副本及 - previous/write/refresh/rollback helper 已删除;style-pack 删除通过 Core 专用 outcome 返回显式 - hotkey effect。 -9. Linux 公共面只暴露携带 snapshot revision 的 `LinuxHost::save_settings`(reconcile + - preserve-style)和 `update_settings_strict`;合法保存、冲突拒绝、active provider、stale revision、 - effect compensation 与稳定 `Unsupported` 已由 3 项 host contract 覆盖。 -10. Core 成功/失败矩阵已覆盖校验失败、持久化失败、runtime prepare/commit 失败、补偿失败、 - 并发/stale revision、一次持久化/一次事件和 preserve-style;Linux runtime 覆盖 receipt 逆序恢复。 - -11. `legacy-preferences-write` feature 与 `OpenLessBackend::set_preferences*` 公共兼容面已删除;四个 - 旧 writer 仅以 core crate 内 `#[cfg(test)] pub(crate)` helper 存在,Tauri/Linux 宿主无法启用或 - 调用该旁路。公共面门禁同时拒绝 feature 回归与重新出现 `pub fn` writer。 - -**仍需完成(原生 runner)** - -1. 在对应原生 runner 完成跨宿主失败矩阵:原生注册失败、ASR vault 同步失败、Windows - keyboard apply 失败、第一次补偿失败、listener restore 失败、并发写入。每项都断言最终偏好、 - 原生 listener、revision、事件数和错误码;mobile/不支持能力必须稳定返回 `Unsupported`。 -2. **本地已完成,原生 runner 待完成。** settings/hotkeys/QA、Linux public-surface/host contract、 - Tauri 全量 suite 和 frontend compatibility 已重跑;残余引用确认旧 write/refresh/rollback 编排 - 与 Tauri 事务 helper 均已删除,listener runtime 不再从 preferences 反推 target。 - Android/macOS/Ubuntu 的原生失败矩阵仍按上一项保持未完成。 - -**退出条件** - -- 快捷键语法、冲突、兼容化解和设置事务只有 core 一份 Implementation。 -- Tauri/Linux Adapter 只执行显式 effect plan,并能以 receipt 恢复;不读取或修改业务偏好。 -- React 旧 command/字段/错误兼容不变,egui 只依赖 validated Interface 即可获得同样规则。 -- 成功只产生一次持久化与一次语义事件;任何失败都有可测试的一致最终状态和明确错误。 - -#### 8.3.2 Local ASR 收口 - -**当前已完成** - -1. core 已定义 Generic、Foundry、Sherpa ONNX 的统一 runtime/target/mirror、catalog、settings、 - status、remote info、model card 和 model test DTO。 -2. `LocalAsrService` 已拥有设置校验、模型选择、镜像、语言、keep-loaded、存储迁移和运行时 - 生命周期的业务语义;原生模型引擎、下载和文件操作通过 `LocalAsrRuntimeAdapter` 注入。 -3. 三组 Tauri command 已只调用 `BackendServices.local_asr`,只保留旧参数和 React wire DTO - 转换;Generic 下载进度已改由 typed core event 进入集中事件桥接。 -4. 成功的 runtime mutation 会读取并发布最新 `LocalAsrRuntimeStatus`;失败操作不发布伪造的 - 成功状态。`set_active_model`、`set_foundry_runtime_source`、`set_keep_loaded_secs`、`prepare`、 - `release` 和 `delete_model` 已统一该语义。 -5. Sherpa core model 到旧 wire DTO 的转换已改为 `TryFrom`;未知 family/mode 返回错误,不再 - `panic!`。 -6. 定向证据:core `local_asr_contract` 6 项、Tauri `wire_contract_tests` 4 项通过;Local ASR - 接线后的 Tauri `cargo check --lib` 已通过。 -7. `TauriLocalAsrRuntimeAdapter` 已直接注入共享 preferences repository 与 - `TauriNativeAsrDependencies`;storage/status/release/preload/delete/test 不再回取 Coordinator, - 非 Windows Coordinator 与 Core native ASR 使用同一 Qwen/Whisper cache。 -8. 格式检查、完整 frontend 58 项、Tauri wire contract、Tauri `cargo check --locked --lib` 与 - `cargo test --locked --lib` 已通过;旧 command 名、camelCase/nullable/error 字段由源码契约和 - 完整 suite 共同守护。 - -**剩余步骤** - -1. 在对应原生 runner 验证 Generic、Foundry、Sherpa runtime 的准备、释放、取消和 engine-changed - 事件;Windows 上的 fake/contract 不能替代 macOS/Linux/Android 的原生能力证明。 - -**退出条件** - -- Local ASR command 不再直接读取 Coordinator、native runtime `State`、下载 manager 或偏好 store。 -- core contract、Tauri wire contract、typed event mapping 和完整本地门禁全部通过。 -- Linux 未提供某个 native runtime 时返回 `Unsupported`,不引用 Tauri runtime 作为替代。 - -#### 8.3.3 Marketplace 与 GitHub OAuth - -Marketplace 的 HTTP、OAuth、归档校验和安装事务属于跨宿主业务规则,现已形成深的 core -Module;文件选择器、目标路径授权和 Android `content://` 最终写入仍属于宿主能力。 - -**当前已完成** - -1. `MarketplaceUploadResult`、`MarketplaceLikeResult`、`MarketplaceMyPackItem` 和 tagged - `OAuthPollResult` 已进入 core,并有稳定 host-facing JSON fixture。 -2. `MarketplaceApi` 已表达结构化 upload/like/my-packs/OAuth poll 结果;`download_archive` 返回由 - core 下载并验证的 bytes,最终 filesystem 或 Android `content://` 写入归宿主。 -3. `MarketplaceConfig` 和构造接线已进入 `BackendDependencies`;公共请求使用匿名 client 且绝不 - 附加 bearer,匿名与认证 client 都拒绝 redirect,认证 redirect 不会访问目标地址。 -4. `list/detail/install/download_archive/upload/toggle_like/delete/my_likes/my_packs/auth_status` 以及 - device-flow 的 start/poll/cancel/logout 均由 Core Implementation 提供,不再由 Tauri command - 持有 HTTP 或认证状态机。 -5. 通过注入的 `CredentialStore` 读写 GitHub token;401 会先设置 backend 实例内 tombstone,再 - 尝试持久删除。即使删除失败,认证状态也立即变为 signed-out,后续请求不会再次发送旧 token。 -6. core 同时检查 declared `Content-Length` 与 streamed bytes 上限,下载后执行 ZIP 校验;实例级 - `try_lock` 保证并发 install 在第二次请求出网前返回 `Busy`。 -7. 安装使用 `StylePackStore::import_from_zip_bytes_with_origin` 原子提交 pack/origin,成功后 revision - 只增加一次并发布一次 `StylePacksChanged`;失败不留下 pack、revision 或成功事件。 -8. upload 直接复用 core ZIP export 生成 multipart;首次上传成功后把 remote ID/login 写回本地 - origin,并沿用 style-pack revision/event 语义。 -9. device-flow registry 已收进 backend 实例,拥有 generation、start 竞态失效、cancel、expiry、 - poll interval、`slow_down`、in-flight cancellation guard 和单次 token consumption;token 保存前 - 会再次核对 lease。device code、access token、Authorization header 不进入 Debug、日志、event、 - error details 或普通 DTO。 -10. Tauri Marketplace/OAuth command 已只做参数转换、core 调用及旧 React wire/error 转换;归档 - 下载后的 filesystem 或 Android `content://` 写入仍留在宿主。旧 command 的全局 lock、HTTP - helper、OAuth registry 和 ZIP 业务逻辑已删除。 -11. 最新工作树已完整运行 `marketplace_contract`,17 项全部通过; - `cargo clippy --locked -p openless-core --all-targets -- -D warnings` 通过。 -12. Tauri Marketplace host sink 2 项与 GitHub OAuth wire 2 项通过;残余引用检查未发现 command - 中保留 HTTP、token vault、ZIP validation、全局 install lock 或 device-flow registry。 - -**剩余验证步骤** - -1. **已完成(Windows 本地)**:完整 Tauri `--lib`、frontend contract 和第 12 节可在本机执行的 - 全量门禁已经重跑;结果见第 12.4 节。该证据只证明当前 Windows 工作树,不能替代下列 Linux、 - Android 和 macOS 原生 Adapter 验证。 -2. **已完成(Linux Interface)**:生产 factory 通过 Secret Service credential Adapter 使用同一 - `MarketplaceApi`,`LinuxHost::download_marketplace_archive` 提供 filesystem archive sink;egui - 只接触 Interface/DTO,不接触 token、HTTP client 或 URI 解析。 -3. 在 Android/macOS/Ubuntu 原生 runner 验证各自 credential、文件授权和最终归档写入 Adapter; - Windows contract 不能替代这些平台证据。 - -**退出条件** - -- Marketplace 业务规则只有 core 一份 Implementation,宿主只处理平台授权、wire 转换和最终写入。 -- public/auth/401/archive/install/upload/OAuth/secret-surface contract 在同一最新工作树上全部通过。 -- React 字段、tagged union 与错误兼容测试通过;Linux 未接线的宿主能力明确返回 `Unsupported`。 - -#### 8.3.4 Selection polish 与 selection voice - -**当前已完成** - -1. `SelectionCapture`、`SelectionRuntimeAdapter`、`SelectionPhase`、`SelectionSnapshot` 和公开的 - `SelectionPolishOutputMode` 已成为 core Interface;窗口 label 与平台选区句柄没有进入 core。 -2. `SelectionService` 已拥有 preview、session-scoped confirm、direct apply、cancel、completed - replacement 单次 revert、重复 begin 的 `Busy`、generation guard、迟到 provider 结果丢弃和 - `Completed/Cancelled/Failed` 单次终态。 -3. Selection 与听写复用同一 provider resolution 和 `TextPolisherRouter`;每个 session 冻结 LLM - channel/provider type/model,以及 capture-time `front_app`,不读取 cursor context、dictation - history 或 ASR prompt。 -4. 成功 direct replacement 会写入 Selection history 并统计 vocabulary hits;失败会释放平台 - target;apply 的 `OutcomeUnknown` 会进入可见 snapshot 且绝不自动重试。 -5. `SelectionStateChanged` 已是 typed core event。当前工作树的 `selection_contract` 17 项整体通过, - 覆盖 preview/confirm、显示/隐藏事件顺序、shutdown、outcome-unknown、history、vocabulary、最终 - 纠正、Raw passthrough、防注入 instruction envelope、activity/timing attribution、provider/context - 冻结、单次安全 revert、cancel/Busy/迟到结果和 provider failure。 -6. Tauri 生产构造已注入 `TauriSelectionRuntime` 和共享 polisher;runtime 按 `SessionId` 保存 - `SelectionInsertionTarget`,区分 preview/direct apply,并把平台插入结果映射为 `InsertOutcome`。 -7. `TauriSelectionRuntime` Adapter contract 已覆盖 target 注册、preview 目标恢复、stale/cancel、重复 - capture 和单次安全 revert;切换窗口、session 过期、重复 revert 或 outcome-unknown 时不会向未知 - 前台窗口发送通用 Undo。旧 `SelectionCoordinatorBridge`、`ManagedSelectionCoordinator`、 - `TauriSelectionApi` 与重复 session 真相已删除,正式 Selection 热键和 preview commands 调用 Core。 -8. Selection Voice 的 intent、prompt、preview owner、confirm/cancel/revert、自动分类 fallback、 - stale-session guard、shutdown 和 typed lifecycle event 已进入 core;新增的 `process_transcript`、 - `prepare_edit` 与 `edit_preview` 高层 use-case 统一 transcript correction、instruction polish、自动 - intent model、输出模式、EditPlan/translation 和 QA preview revision。13 项 contract 覆盖模型 - prompt/输入、翻译 target、direct action、conversation action、首次 preview 与单步 revision;QA - 问答与编辑预览通过稳定 `conversation_id` 关联。 -9. `LinuxSelectionRuntime` 已通过 fcitx5 读取并在 commit 前重新校验 selection;变化或取消的 target - 返回 `Cancelled`,无法安全保留 preview/revert 的路径明确返回 `Unsupported`。 - -**完成状态与剩余原生验证** - -1. **已完成(Core/Tauri 业务边界)**:`selection_voice*` prompt snapshot、intent confirm、cancel、 - preview query/ticket/finish/revert commands 已直接调用 Core Selection Voice/QA Interface;原始 ASR - transcript 直接交给 `process_transcript`,编辑分支只消费 Core `SelectionVoiceEditAction`,QA 编辑 - 只调用 `edit_preview`。`SelectionVoiceHostState` 只保存物理热键仲裁、录音资源和 opaque insertion - target,不复制 selection text、instruction、intent、preview 或业务 phase。Tauri 中的 correction、 - instruction polish、自动意图 LLM、EditPlan、translation、preview answer 和 output-mode 判断均已 - 删除;Coordinator 只保留物理热键、QA panel 优先级、窗口创建/聚焦、录音和平台插入。 -2. **已完成(兼容契约)**:旧 React command 名、 - camelCase/nullable 字段、事件 payload、窗口来源授权和错误字符串的 compatibility contract。 - `SelectionSnapshot` 与 Selection Voice apply outcome 已有稳定 serde fixture;当前变更保持 - `BACKEND_CONTRACT_VERSION = "1.0.0"`,没有用版本升级掩盖 wire 破坏。 -3. **已完成(headless 移交)**:headless example 和 deterministic fixture 演示 preview → confirm、preview → cancel、 - stale session 被拒绝、apply outcome-unknown 不自动重试,以及 Linux preview/revert 稳定返回 - `Unsupported`;示例不创建窗口、不读取真实选区、不实现任何 egui 控件,并已实际运行通过。 -4. 在 Windows Tauri 上验证真实选区 capture/preview/revert/窗口切换,在 Ubuntu/fcitx5 上验证 - capture/commit/cancel/Unsupported 分支;原生证明完成前不能只凭 17+13 项 contract 宣布领域收口。 - -**退出条件**:Tauri Coordinator 不再拥有 selection session/preview 真相;Tauri 与 Linux 通过同一 -`SelectionApi` 得到一致状态,宿主仅实现选区读取、目标恢复、窗口和文本插入 seam;Core、Tauri -Adapter、React wire 与 Linux headless contract 在同一最新工作树全部通过。 - -#### 8.3.5 QA 与 Less Computer 会话 - -**当前已完成** - -1. `QaApi`、`QaRuntimeAdapter`、`QaProgressSink`、`QaInput`、`QaTurnRequest/Result` 和稳定 - `QaSnapshot` 已进入 core Interface;message log、recording/thinking/approval/completed/cancelled/ - failed phase、edit-instruction mode、pending approval token 和公开错误均由 `QaService` 表达。 -2. `QaService` 已实现文本/语音 turn、selection 防注入 envelope、recording level、answer delta、 - stale-result guard、cancel/dismiss 幂等、provider 错误脱敏和 shutdown cancel;`session_id` 是每轮 - generation token,`conversation_id` 是成功多轮间稳定的 Selection Voice preview owner;15 项 - core contract 覆盖文本、语音、多轮、取消、迟到回答、approval token、错误脱敏、preview 清理和 shutdown。 -3. `EventBus` 已由每个 backend 实例持有 2048 条有界 replay;`EventReplay` 显式返回 - `oldestSequence/latestSequence/truncated`,Less Computer sync 从 core replay 续接,不再依赖 - 进程级静态 event log。 -4. `TauriQaRuntimeAdapter` 已只持有 recorder/ASR、selection capture 的 opaque host context 和 - LLM/Coding Agent runtime 资源;生产 `BackendDependencies.qa_runtime` 已由 `QaService` 消费。 -5. QA hotkey、Esc、overlay finalize、`qa_toggle_recording`、`qa_submit_text`、edit-instruction 和 - dismiss 已接到同一 `QaApi`;Selection Voice 问答与编辑预览也复用同一 Core 链路。 -6. 独立 `QaHostState` 已完全删除;Coordinator、`TauriQaRuntimeAdapter` 与 `TauriHostActions` 共享 - 一个 `TauriQaHostContext`,其中 `AtomicBool` 只表达 Tauri panel 可见性,业务 phase/messages/ - cancel 仍只属于 `QaService`。dismiss 使用稳定 `conversation_id` 清理匹配的 Selection Voice preview。 -7. Tauri QA Adapter 4 项 contract 已通过(含共享 show/clear 可见性);React 已处理 - `awaiting_approval` 与 `cancelled` 终态; - QA snapshot resync 与 live event 共用同一字段转换,lagged 后不会产生第二套 phase/可选字段规则。 -8. QA panel、Less Computer window、键盘焦点、macOS NSPanel、热键优先级和 shortcut recording - 仍留在 Tauri host;这些不进入 `QaSnapshot`。 -9. `begin_recording`/`submit_text` 在 `HostAction::ShowQa` 失败时按 session/phase 原子回滚, - 不启动 recorder/prepare runtime;失败后可立即重试且不会残留 `Recording`/`Thinking` 活跃态。 -10. Less Computer 的文字入口已直接调用 `OpenLessBackend::submit_less_computer`;语音入口在 - Tauri 负责录音/native ASR 后,使用 `submit_less_computer_with_session` 把同一 session 交给 - Core。Tauri 不再构造 provider/model/permission/prompt/guard/continuation,也不再发射重复的 - `user/delta/tool/approval/terminal` 事件;这些全部来自 `LessComputerService` 的 typed event。 - -11. QA 编辑预览的 opaque insertion target 绑定已收窄为构造阶段注入的 - `TauriQaHostContext` callback;QA Adapter 不再从 `AppHandle` 反查 `Coordinator`,也不持有 - Coordinator 强引用。focused QA test 与 `shared-backend-wire-contract` source contract 已覆盖 - 该 seam,关闭时由 weak callback 自动失效。 - - 语音热键入口已补上 Core capture lease:按下先创建 Core session,宿主 recorder/ASR 与 - `submit_less_computer_with_session` 共用该 id;空转写、启动失败和取消会释放未提升的 lease。 - Starting pending stop、静音自动停止仍由 Coordinator 兼容层调用 `end_session`,但只负责宿主 - 录音/ASR 资源和热键生命周期,不得向 Linux egui 暴露其 `state`。Core 仍是 Agent provider、 - prompt、guard、approval、continuation、stream、cancel 和终态的唯一来源。 - -**剩余步骤(必须按顺序完成)** - -1. **已完成**:QA/Selection Voice/Remote Input 的 React source compatibility fixture 已覆盖 - command 名与 camelCase 参数、`awaiting_approval`/`cancelled`/`error`、typed QA event 字段、 - Remote status/error listeners 及 lagged resync;完整 `npm.cmd test` 58 项通过。 -2. **已完成(实例隔离)**:Less Computer 工具审批复用实例级 `CodingAgentApi.approve`,静态 approval - registry 已删除;contract 证明 token 不能跨 backend 实例解析。 -3. **已完成(compatibility UI)**:Less Computer mount 先建立实时订阅并暂存 pending,再以 - `afterSequence` 读取 `replay_events_after(sequence)`;按 replay 后 pending 的顺序合并,带 seq - 事件按最大水位去重,无 seq fallback 保留。`truncated=true` 时清空旧派生时间线、把水位重置为 - `oldestSequence - 1` 并从本次保留 replay 重建;同步期间新事件、重复 sequence 与截断重建已有 - 可观察 TypeScript contract。Linux/egui view model 必须实现同一语义,不复用 React 状态。 -4. **已完成**:为 Linux/headless 提供 `QaRuntimeAdapter` fixture 和显式 `Unsupported` 示例;egui 只消费 - `QaSnapshot`/typed events,不依赖 Coordinator、WebView backlog 或 Tauri window label。 -5. **已完成(Less Computer Core seam)**:`begin_less_computer_capture`、active session、 - capture cancellation/abort、同 session submit,以及 `LessComputerRunRequest/Result`、 - `LessComputerRuntimeAdapter`、 - `submit/cancel/dismiss/approve`、有界 continuation、approval timeout、stale stream 丢弃、唯一 - `Completed/Failed/Cancelled` 终态及 `Unsupported` 语义已由 Core contract 覆盖;Tauri - `TauriCodingAgentApi` 只实现进程/Git/临时护栏文件/stream transport。egui 只需要调用 facade、 - 订阅 `LessComputerEvent`、按 `seq` 去重并回传 approval。 -6. 在 Windows/macOS/Android Tauri 与 Ubuntu Linux host 上取得对应原生运行证明;本地 contract - 不能替代平台 recorder、selection capture、窗口和关闭生命周期验证。 - -**退出条件**:QA/Less Computer 的会话真相只在 core;Tauri command 只保留窗口来源授权与 wire -转换,Linux 不需要 Coordinator 或 WebView event backlog 即可驱动自己的 view model。 - -#### 8.3.6 Remote Input - -**当前已完成** - -1. `RemoteInputApi`/`RemoteInputRuntimeAdapter` 已冻结 status、configure、locale、显式 PIN read/ - rotation、local IP、connect/disconnect、start/feed/stop/cancel stream;status 是无 I/O 的同步快照, - transport 与 secret persistence 仍为 async。 -2. `RemoteInputService` 已拥有 enable/disable/port 状态转换、PIN 生命周期、locale、连接/session - 关联、64 KiB 上限的非空偶数字节 signed Int16LE frame 校验、重复/迟到 PCM guard、端口错误分类、 - typed status/error event 和 shutdown 清理。PIN 不进入 snapshot/event/serde/Debug;8 项 core - contract 通过。 -3. `TauriRemoteInputRuntimeAdapter` 只承载 PIN 文件、TLS/WSS server handle、local IP 和共享 backend - external dictation 桥接;认证后的连接与所有 PCM lifecycle 调用 Core。Coordinator 的 server、 - refresh generation/lock、PIN、locale、no-insert 状态和旧 persistence tests 已删除。 -4. settings diff、启动恢复和 remote commands 已调用 Core;Tauri `cargo check --lib` 与 7 项 - `remote_` 定向测试通过。托盘从 Core status 读取 locale,托盘刷新失败不改变业务结果。 - -**剩余步骤(必须按顺序完成)** - -1. **已完成(定向 contract)**:`get_remote_input_status` 保持旧 - `running/port/pin/urls` shape,PIN 只由这个显式 secret command 注入;core status/event 不含 - PIN。共享 React source contract 同时固定 locale、status/error listener 与 lagged resync 接线。 -2. **已完成(本地 Adapter contract)**:PIN 认证失败先于 `connect` 且继续使用 constant-time - compare;每连接仅一个活动 stream,重复 start 返回 Busy 且保留原 lease;disconnect 必须 cancel, - stop/cancel 后拒帧,服务 restart 取消旧 session 并让旧 connection/session 返回 `Cancelled`。 -3. **已完成(headless fixture)**:`RecordingRemoteInputRuntime` 提供不绑定 socket 的内存 transport, - 记录 server/audio start/stop/cancel 与 PCM frame;未注入生产 transport 时仍走稳定 - `Unsupported`,capability 不得伪造为 available。 -4. 在真实宿主验证证书安装、端口占用、WSS/H5、局域网 IP 与长连接 shutdown;Windows 本地 - contract 不能替代 Linux/macOS socket、证书或防火墙证明。 -5. **已完成(接口手册)**:fixture、错误码、16 kHz mono signed Int16LE、64 KiB frame 上限、 - 单 stream/restart/stale lease、幂等与 secret 规则已写入接口手册;后续破坏性变更才提升 - `BACKEND_CONTRACT_VERSION`。 - -**退出条件**:core 可以用内存 transport + external PCM fixture 完成远程听写 contract;Tauri -command/remote server 不再拥有业务 session、PIN 或 locale 的第二份真相。 - -#### 8.3.7 旧 Coordinator 与宿主耦合删除 - -1. 用 command/event baseline 逐项确认所有业务入口已有 core use-case 和 compatibility test。 -2. 删除 `Inner.app`、业务路径上的 `AppHandle`、直接 `emit*`、`tauri::async_runtime::spawn` 和由 - Coordinator 持有的重复领域状态;仅保留真正的 Tauri host orchestration。 -3. 把窗口、托盘、updater、dialog、shell、autostart、single-instance 和 Android JNI/overlay/IME - 移到明确 host Module;这些代码不得被 Linux package 引用。 -4. 对删除后的 import、State 管理、`manage(...)` 和 handler registration 做残余引用检查;不得保留 - 无消费者 manager 来掩盖迁移不完整。 -5. 重新生成 command/event baseline;任何数量变化都必须有兼容说明和前端调用点证据。 -6. 删除 `core_adapters.rs` 中已永久禁用的 `legacy_cloud_asr`、`legacy_cloud_polish` 和 - `legacy_omni` 迁移考古副本;对应行为只由 Core provider contract 保留。最终源码门禁应拒绝 - Tauri 重新出现第二份 endpoint/model/credential/cancellation 协议构造逻辑。 -7. 已删除无生产消费者的 `Coordinator::{start,stop,cancel}_dictation*` 兼容 facade;测试直接 - 覆盖宿主 helper,生产 React/CLI/Android/热键入口统一调用 Core。style-pack prompt 诊断和 - ASR vocabulary priority 同样已移入 Core,source contract 防止这些业务规则回流。 - -#### 8.3.8 2.0 Interface 冻结与 egui 移交 - -1. **已完成**:只 re-export facade、DTO、errors、events、capabilities、fake/headless fixtures;core 私有 - repositories、transport 和状态机实现不进入 egui 可依赖面。 -2. **已完成**:更新 `linux-egui-backend-contract.md`,逐项记录方法、字段、单位、nullable、幂等、取消、超时、 - outcome-unknown、事件顺序、lagged resync、线程规则和 capability 降级。 -3. **已完成**:headless example 演示 lifecycle、dictation、settings/history/style-pack、Local ASR、 - Marketplace、Selection、Selection Voice、QA、Remote Input 的可用或 `Unsupported` 分支;Selection - 还必须覆盖 preview/confirm/cancel/stale/outcome-unknown 和 Linux preview/revert `Unsupported`; - 示例不创建 egui 窗口,当前版本已实际运行通过。 -4. **已完成**:更新 Linux capability fixtures 和 view-model 映射表;每个未接线能力明确显示 unavailable,不能 - 用 fake 成功状态冒充生产支持。 -5. **本地已完成,原生 runner 待完成**:以固定审查基线 - `a569a8749188e7843d426f159523193c8d5363ce` 运行第 12 节 Windows 本地门禁并记录命令与测试数。 - 当前冻结版本为 `BACKEND_CONTRACT_VERSION = "2.0.0"`;破坏性变更必须附迁移说明。 - Android/macOS/Ubuntu 与发行包证据继续由 12.4 的未勾选项约束。 - -#### 8.3.9 Provider 验证与模型列表迁入 Core(核心迁移已完成;发布前收口进行中) - -这是本轮已收口的 provider 管理面迁移。云端 ASR/LLM/Omni 的正式运行与 -`validate_provider_credentials` / `list_provider_models` 现在都由 `openless-core::ProviderService` -承载;Tauri command 仅保留参数/旧 wire/error 转换,Linux 的 -`LinuxBackendBuilder::from_shared_providers` 注入同一 service。真实网络、Secret Service 和各平台 -原生 runner 仍属于 M9/M10 的独立证据,不能由本地 fixture 代替。 - -**目标边界** - -- Core 拥有 provider 类型解析、channel-scoped credential 读取、默认 endpoint/model、协议选择、 - endpoint 安全校验、验证请求、静态/远端模型列表、超时/取消和稳定错误码。 -- Tauri 只负责旧 command 参数解析(`kind` 字符串、可选 `channel_id`)、调用 Core、旧 JSON/错误 - 字符串兼容和 React event/wire 转换;不得再持有 HTTP/WS 请求或 provider 分支。 -- Linux 生产 factory 注入与 Tauri 完全相同的 Core `ProviderApi` 实现;egui 只调用公开 - `BackendServices::provider`,不读取凭据、不构造 client、不选择协议。 -- native/local ASR 的模型加载仍属于 Local ASR Adapter;本节只迁移 provider “连通性验证”和“模型 - 列表”管理面,不能把 macOS/Windows 专属 native runtime 引入 Linux workspace。 - -**建议文件与责任人** - -| 文件/目录 | 变更 | 主责 | -| --- | --- | --- | -| `openless-all/app/crates/openless-core/src/provider_service.rs`(新增) | `ProviderService`、credential resolver、validate/list_models 分派与错误映射 | Core 负责人 | -| `openless-all/app/crates/openless-core/src/provider_rules.rs` | 汇总默认值、协议判定、endpoint/model 校验;删除重复规则 | Core 负责人 | -| `openless-all/app/crates/openless-core/src/provider_service.rs`(module tests) | channel credential 隔离、Omni channel 拒绝、静态/远端模型解析、秘密边界和错误映射 contract | 测试负责人 | -| `openless-all/app/src-tauri/src/commands/providers.rs` | 仅保留 command 参数和旧 wire/error 转换,删除业务实现 | Tauri 负责人 | -| `openless-all/app/src-tauri/src/core_adapters.rs` | 注入共享 `Arc`,删除 `TauriProviderApi` 反向代理 | Tauri 负责人 | -| `openless-all/app/linux-egui/src/backend.rs` | `from_shared_providers` 注入同一 Core service | Linux host 负责人 | -| `openless-all/app/linux-egui/src/backend.rs`(module tests) | 断言 Linux factory 的 provider 非 `Unsupported` 且不依赖 Tauri | Linux host/测试负责人 | -| `openless-all/app/scripts/shared-backend-wire-contract.test.mjs` | command 源码门禁:禁止 vault/HTTP/provider 构造回流 | 测试负责人 | -| `docs/linux-egui-backend-contract.md` | provider 请求、错误、能力和版本契约 | 架构负责人 | - -**实施步骤记录(核心迁移与宿主接线已完成;发布前收口项见下)** - -1. **冻结输入输出契约。** - - 保留公开 `ProviderRequest { kind, channel_id }`、`ProviderCheckResult` 和 - `ProviderModelsResult` 的 serde 字段;`channel_id = None` 继续表示当前 active channel,不能 - 静默改变旧 React 行为。 - - 为 `ProviderApi` 增加文档化的超时、取消、幂等和错误映射:参数/模型缺失用 - `InvalidArgument`,凭据缺失用 `Provider`(带可操作的稳定 sentinel),网络/HTTP/WS 失败用 - `Provider`,取消用 `Cancelled`,未接线能力用 `Unsupported`;不得把错误统一压成普通字符串。 - - 明确秘密边界:API key、token、Authorization、device code、完整 endpoint credential 不得出现在 - DTO、`BackendError.details`、日志、`Debug` 或测试 fixture;验证结果只返回 `ok` 或脱敏错误码。 - - 在 `docs/linux-egui-backend-contract.md` 增加 provider 表格:请求字段、默认值、验证是否发真实 - 请求、模型列表是静态还是远端、超时上限、可重试性和 Linux capability。 - -2. **建立 Core-owned credential resolver。** - - 在 core 新增窄的 `ProviderCredentialResolver`(或等价私有 module),只依赖 - `CredentialStore::read(CredentialKey)`;按 `CredentialNamespace::{Asr,Llm,Omni}` 和 channel id - 读取 key/endpoint/model/extra headers/temperature/advanced config。 - - 将 `ProviderScope` 的 channel 规则迁入 core:ASR/LLM 允许 channel id,Omni 明确拒绝 channel id; - channel 的 `provider_type` 来自非秘密 metadata,不能用 id 猜协议;`None` 回退 active provider - 只能由 resolver 统一完成。 - - 将 `CredentialAccount` 到 core `CredentialKey` 的映射集中定义并加单元测试,验证 channel A/B - 不串 credential,active 切换不改变已捕获的请求配置。 - - resolver 返回不含秘密的 `ResolvedProviderSummary`(provider type、model、endpoint 是否配置、 - auth mode),真实 secret 仅在构造请求的短生命周期对象中存在。 - -3. **迁移 provider 规则和默认值。** - - 将 `parse_provider_kind`、默认 endpoint/model、Bailian endpoint 派生、StepFun 模型协议判定、 - DashScope/Whisper 请求格式、模型白名单和 URL scheme 校验迁入 core `provider_rules`/provider - service;已有同名规则只保留一份实现。 - - 复用 core 已有的 `SharedCloudTranscriptionEngine`、`SharedCloudTextPolisher`、 - `SharedOmniDictationEngine` 构造路径,确保验证请求和正式运行请求使用相同 provider type、 - channel、model、endpoint 与 credential account。 - - provider-specific 常量(Bailian、Qwen realtime、Volcengine、Xfyun、StepFun、Mimo、 - ElevenLabs、DashScope、Codex OAuth 等)放在 core provider module;只有 native engine 和平台 - 文件路径留在 Adapter。 - - 验证策略必须逐 provider 固定,不能由 Tauri 继续隐式决定: - - | provider 类别 | Core 验证动作 | 模型列表 | 关键凭据/规则 | - | --- | --- | --- | --- | - | OpenAI-compatible LLM/ASR | 最小 chat completion 或真实 transcription 请求 | 远端 `/models`(失败即明确错误) | endpoint URL、model 必填;LAN 无鉴权模式按 provider 规则允许空 key | - | Gemini LLM/Omni | `generateContent`/等价最小文本探活 | Core 静态或 Gemini 列表转换 | API key、endpoint、model 按 channel 读取 | - | Codex OAuth | 使用已保存 OAuth 状态执行最小 polish/授权检查 | Core 静态 Codex 模型清单 | token 只在 secret store;失效返回脱敏 OAuth 错误 | - | Bailian classic/Qwen realtime | WSS 握手 + 最小静音帧 + 收尾 | Core 静态清单,按模型分协议 | endpoint 按协议派生;必须校验 `ws/wss` scheme | - | Volcengine/Xfyun/StepFun realtime | 对应 WS 鉴权、session.update、静音收尾 | Core 静态清单 | 多字段鉴权、模型协议判定和错误码归 Core | - | Mimo/ElevenLabs/DashScope batch | 规范 WAV/官方示例音频的真实 HTTP 请求 | Core 静态清单或受限远端列表 | 响应大小、超时、示例音频和模型白名单固定 | - | local/native ASR | 不在本节验证;转交 `LocalAsrApi` runtime | 由 Local ASR catalog 提供 | 无法接线时返回 `Unsupported`,不把 Tauri native runtime 带入 Linux | - -4. **实现 Core `ProviderService`。** - - 新增 `ProviderService { credentials, task_spawner, http_client_factory/transport }`,实现 - `ProviderApi::validate` 与 `ProviderApi::list_models`;构造时注入 `CredentialStore`,不访问 - Tauri `State`、`AppHandle` 或全局 vault。 - - `validate` 按 `ProviderKind` 和 resolved provider type 分派:LLM/Codex OAuth、传统 HTTP ASR、 - realtime WS ASR、Omni 文本探活分别调用对应 core provider;静音音频、示例音频和握手收尾规则 - 与正式 provider 实现保持一致。 - - `list_models` 对无远端列表接口的 provider 返回 core 静态清单,并先执行与 validate 相同的凭据/ - endpoint 校验;对 OpenAI-compatible 等远端列表接口使用 core HTTP transport,限制响应大小、 - 禁止 redirect 到未经允许的地址并做 JSON schema 校验。 - - 所有请求使用显式 timeout 和 cancellation token;超时不得隐式切换渠道或重复发起可能产生 - 计费的探活请求。请求完成后释放 secret 和 transport handle。 - - 将当前 Tauri sentinel(例如 `providerHttpStatus:*`、`endpointInvalid`、`asrModelMissing`) - 转成 `BackendErrorCode` + 稳定 machine detail;Tauri 兼容层再把 code 映射回旧字符串,Core - 本身不依赖中文文案。 - -5. **补齐 provider fake/transport fixture。** - - 提供 `FakeProviderTransport`,可按 endpoint/model 返回成功、401/403/429/5xx、超时、无效 JSON、 - redirect 和取消;fixture 不保存真实 key。 - - 提供 `InMemoryCredentialStore` 的 channel A/B、active provider、缺失凭据和错误注入场景;每个 - 测试使用唯一临时目录并自动清理。 - - 为静态模型清单、远端模型清单、空模型、未知 provider type、Omni channel 拒绝、Bailian/StepFun - 双协议和 Codex OAuth fallback 建立 serde/行为 fixture。 - -6. **迁移 Tauri Adapter。** - - `TauriProviderApi` 改为在构造时持有 `Arc`(或直接复用 Core service),只做 - `String -> ProviderKind`、`ProviderRequest` 构造和 `BackendError -> legacy String/JSON` 转换。 - - 删除 `commands/providers.rs` 中的 `ProviderScope`、credential 读取、`ProviderConfig`、HTTP/WS - validation、model-list 分支、provider-specific request body 和 provider 错误分类;文件只保留 - `#[tauri::command]` 薄函数及兼容转换。 - - 删除 `TauriProviderApi` 对 `validate_provider_service`/`list_provider_models_service` 的反向 - 调用;source contract 必须拒绝 `CredentialsVault::get*`、`reqwest::Client`、provider 构造器和 - `tokio::time::timeout` 在该 command module 重新出现。 - - 保留旧 command 名、参数 key、nullable 语义和 React 错误映射;新增字段只允许向后兼容,破坏性 - 变更必须提升 `BACKEND_CONTRACT_VERSION` 并附迁移说明。 - -7. **接入 Linux 生产 factory。** - - `LinuxBackendBuilder::from_shared_providers(config)` 打开一次 `LinuxCredentialStore`,构造 - Core `ProviderService` 并写入 `services.provider`;不得让 egui 注入 provider factory 或 credential - account。 - - `LinuxBackendBuilder::new(...)` 的显式 provider 注入仅用于测试/特殊宿主;测试构造仍可用 fake - `ProviderApi`,但生产入口必须经过 shared factory。 - - Linux 无法提供 native/local runtime 时只对对应 Local ASR 能力返回 `Unsupported`;云端 provider - validate/list 不得因为 UI 非 Tauri 而返回 `Unsupported`。 - - capability snapshot 增加 provider 管理面状态(configured / unconfigured / unsupported),但 - 不泄露 key;egui 根据 snapshot 和错误码显示降级文案。 - -8. **补跨宿主 contract 与回归测试。** - - Core contract:channel scoped credential、provider/type/model 冻结、每种协议的成功/失败/取消、 - 静态/远端模型列表、秘密不泄漏、超时不重复、未知 provider 和 Unsupported。 - - Tauri wire contract:旧 command 名、camelCase 字段、错误 sentinel、Codex/Omni/ASR 分支结果与 - 现有 React fixture 一致;command 源码 contract 证明不含业务实现。 - - Linux host contract:使用同一 fake credential/transport 调用 `services.provider.validate` 和 - `list_models`,证明不经过 Tauri;生产 factory smoke 至少断言 `services.provider` 不是 - `UnsupportedDomainServices`。 - - 将 provider contract 纳入 M9 门禁和 `shared-backend-wire-contract.test.mjs`;任何 Tauri provider - 业务 token 回流、Linux factory 未注入或 channel credential 串线都必须使门禁失败。 - -9. **删除旧实现并做残余审计。** - - `rg` 检查 `commands/providers.rs` 不再出现 `CredentialsVault`、provider HTTP/WS client、 - provider struct constructor、模型列表静态清单和协议分支;残余只允许兼容转换函数。 - - `rg` 检查 core provider module 不出现 `tauri::`、`AppHandle`、window label、React event name; - Linux crate 不出现 Tauri/WebKitGTK。 - - 重新生成 command/event baseline,运行 `cargo fmt --check`、Core/Linux clippy、Core/Tauri/Linux - provider tests、frontend contract、完整 Tauri suite 和 `git diff --check`。 - - 记录迁移前后 provider 请求/错误行为差异;若发现行为变化,先补 fixture 再改实现,不通过修改 - React 调用方掩盖兼容问题。 - -**当前实现的发布前收口项(不阻塞 egui UI 开发,但阻塞 provider 生产发布)** - -- **已完成:可替换 transport 与 fake 覆盖。** `ProviderService` 通过 `ProviderTransport` 注入模型列表 - 请求;生产使用无 redirect、显式 15 秒 timeout、2 MiB response 上限的 reqwest 实现,测试使用 - `FakeProviderTransport` 覆盖 401/403/429/5xx、timeout、connection/request、cancel、invalid JSON、 - response-too-large 和 redirect 状态,并验证 URL/header value 不进入 `Debug`、错误或 fixture 输出。 -- **已完成:静态模型 parity。** Core 静态清单已按迁移前 Tauri 顺序补齐 Bailian/Qwen realtime、Mimo、 - Fun-ASR、ElevenLabs 与 Codex OAuth 条目;测试固定每个 provider 的顺序和去重,后续新增模型必须先更新 - parity fixture,不能把“可返回列表”误认为完整 parity。 -- **已完成:显式 channel-scoped LLM 写入。** `temperature` 与 `extra headers` 写入按指定 - `provider_id` 定位,不再无条件写 active channel;A/B channel 回归测试证明 active channel 不会被旁路 - 修改。读取期间的 provider snapshot 仍必须保持同一 channel 语义。 -- **仍未完成:真实 provider 与平台证据。** 真实 provider 网络、Secret Service/keyring、取消/超时在真实 - runtime 下的行为,以及 Android/macOS/Windows/Ubuntu runner 证据仍按 M9/M10 执行;本地 parser、unit、 - fake transport 和 WSL contract 只能证明纯函数与接线,不能代表真实服务可用。 - -**本节退出条件** - -- `ProviderApi` 的验证和模型列表在 Core 只有一份可测试实现,Tauri 与 Linux 使用同一实例语义。 -- Tauri provider command 只剩参数转换、Core 调用和旧 wire/error 兼容;源码门禁无业务副本。 -- Linux 生产 factory 不再把 provider 管理面设为 `Unsupported`,egui 可在无 Tauri 环境调用公开接口。 -- Core/Tauri/Linux provider contract、秘密扫描、依赖方向、完整本地门禁全部通过;真实网络、keyring - 和平台 runner 仍按 M9/M10 单独留证,不能用 fake 证明正式发布。 - -### M0:冻结决策和兼容契约 - -**任务** - -1. 确认目标平台:macOS/Windows = Tauri,Linux = egui,Android = 继续 Tauri mobile。 -2. 确认 Linux egui 与 core 是否同进程;本计划默认同进程直接 Rust 调用。 -3. 建立当前 command 名称、参数、返回 JSON、事件名和能力字段的基线清单。 -4. 用 `rg` 统计所有 `tauri::`、`AppHandle`、`emit`、`listen` 和 `#[tauri::command]` 使用点,保存为迁移 checklist。 -5. 标记 UI 专属命令:窗口打开/关闭、focus、拖动、动画和 WebView bridge 不进入 core。 -6. 写下 Android 保持 Tauri 的决定,避免迁移时误删 mobile 分支。 - -基线漂移检查由 [`scripts/check-command-event-baseline.ps1`](../openless-all/app/scripts/check-command-event-baseline.ps1) -执行;它会从当前 `lib.rs` handler 宏重新提取 command 名称,并拒绝缺失、意外新增、重复或计数不一致。 - -**产物** - -- 本计划文档完成评审。 -- `docs/linux-egui-backend-contract.md`(M7 生成的接口手册)目录和版本策略确定。 -- [`docs/linux-egui-command-event-baseline.json`](./linux-egui-command-event-baseline.json): - 机器可读的 command/event/capability 基线清单(当前观察到 196 个 Tauri command、30 个 - legacy event、29 个 core event kind)。 -- 每个模块的 owner、依赖和迁移顺序表。 - -**验收** - -- 任何新增公共接口都能回答“core、Tauri host 还是 Linux host 的职责”。 -- 产品与发布决策显式列在本文档“M0 决策记录”,不通过临时代码默认。 - -### M1:建立 package 骨架和依赖门禁 - -**任务** - -1. 创建 `crates/openless-core`,加入最小 `Cargo.toml` 和空 facade。 -2. 创建 `linux-egui` package 的空 host stub;不实现 UI,只验证能依赖 core。 -3. 将 `src-tauri` 标记为 Tauri 适配器(现有 Cargo package 名称可继续为 `openless`),先不改变 React command 名称。 -4. 根 workspace 只包含 core/Linux 并使用根 `Cargo.lock`;Tauri 与 backend compatibility tests 使用独立 manifest/lockfile,避免 Linux 解析 macOS/Tauri dependency。 -5. 为 core 增加依赖检查脚本:core 的正常依赖树不得包含 `tauri`、`wry`、`webkit2gtk`、`egui`、`eframe`。 -6. 为 Linux package 增加同样的依赖检查;允许 Linux 原生窗口依赖,但不得出现 Tauri/WebKitGTK。 -7. 审计 `qwen3-asr-rs`、`qwen-asr` 等 vendored path;Tauri 作为独立 manifest 保留 native runtime,根 core/Linux workspace 显式 exclude Tauri,避免 macOS-only path 在 Linux 元数据阶段被解析。 -8. 保留现有 Tauri package 能独立 `cargo check` 的能力。 - -依赖门禁脚本为 [`scripts/check-core-deps.ps1`](../openless-all/app/scripts/check-core-deps.ps1), -接受 `openless-core`(默认)或 `openless-linux-egui` 作为 package 参数。 - -**验收** - -```text -cargo check --locked -p openless-core -cargo check --locked -p openless-linux-egui -cargo check --locked --manifest-path "src-tauri/Cargo.toml" --lib -pwsh -NoProfile -File "scripts/check-core-deps.ps1" openless-core -pwsh -NoProfile -File "scripts/check-core-deps.ps1" openless-linux-egui -``` - -### M2:迁移共享类型、错误和能力模型 - -**任务** - -1. 从 `types.rs` 提取平台无关的枚举、DTO、快照和 serde 字段。 -2. 保持现有 `camelCase` / `snake_case` / `lowercase` 序列化名称,避免 React IPC 破坏。 -3. 把 `CapsulePayload` 重命名或包成 core 的 `DictationStateSnapshot`;Tauri adapter 提供旧名字映射。 -4. 建立 `BackendError` 和错误码,command 层暂时保留旧字符串输出的兼容转换。 -5. 将 `PlatformCapabilities` 的结构放入 core;`current()` 改为由 host 传入或由平台 adapter 构造。 -6. 将秘密字段、日志字段和 UI 可见 status 分开,禁止快照包含明文凭据。 -7. 把纯类型测试移到 core,确保 JSON fixture 与现有前端契约一致。 - -**验收** - -- `openless-core` 不包含任何窗口、WebView、Tauri 或 egui import。 -- React 现有 command 的字段名和枚举值不变,或有显式版本兼容转换。 -- core 类型测试、serde fixture、错误码测试全部通过。 - -### M3:定义宿主 Interface、依赖注入和生命周期 - -**任务** - -1. 定义 `BackendConfig`、`BackendDependencies`、`TaskSpawner`、`Clock`、`HostActions`。 -2. 把凭据、录音、文本插入、热键、资源目录等真实变化点定义为最小 Interface。 -3. 提供 `InMemoryCredentialStore`、`FakeRecorder`、`RecordingInserter`、`FakeHotkeyController` 和 `HeadlessHostActions`。 -4. 明确每个 Interface 的线程安全、超时、取消和错误语义。 -5. 在构造阶段完成依赖注入;禁止 core 在方法内部自行 `new` Tauri plugin 或系统窗口。 -6. 增加 `start()` / `shutdown()` lifecycle tests,包括重复调用、启动失败和取消中关闭。 - -**验收** - -- 无真实麦克风、窗口或 keyring 时可以构造 core 并运行状态机测试。 -- fake inserter 能验证成功、fallback、失败和 outcome-unknown,不需要 Tauri。 -- shutdown 后没有后台 task、录音或热键泄漏。 - -### M4:抽取 Coordinator 和统一事件总线 - -**任务** - -1. 将 `Coordinator` 拆为 core coordinator 与宿主 host action/event bridge。 -2. 移除 `Inner.app: Mutex>`,替换为 core event bus 和 `HostActions`。 -3. 把 `tauri::async_runtime::spawn` 替换为 core executor Interface。 -4. 将 `emit_to("capsule", ...)`、`emit_to("qa", ...)`、窗口显示/隐藏逻辑移到 Tauri adapter。 -5. 把 `coordinator_state` 的 generation/session guard 与所有异步完成路径接到统一 session token。 -6. 建立事件顺序、sequence、lagged subscription、最终态唯一发布的测试。 -7. 为 `DictationStateChanged`、transcript delta、polish delta、insert fallback 建立最小端到端 fixture。 - -**验收** - -- core 可以在 headless host 中跑完整“开始 → 录音 → ASR → 润色 → 插入 → 终态”测试。 -- Tauri 和 Linux host 都能收到同一套语义事件。 -- 旧 Tauri 事件名只存在于 adapter,不存在于 core。 - -**当前收口状态** - -- `Coordinator` 已不持有或接收 `AppHandle`/`WebviewWindow`,不直接发射 Tauri 事件; - `TauriCoordinatorHost::app()` 保持私有。 -- capsule 原生窗口行为和窗口状态已归 Host;Coordinator 只计算或传递语义状态与窄值。 -- M4 仍保持“进行中”:compatibility Coordinator 尚未完全删除,剩余生产调用必须继续按 - core 业务、Host 原生能力、无生产消费者三类处置,且原生 runner 证明尚未齐全。 - -### M5:迁移持久化、provider 和业务领域 - -按低风险到高风险顺序迁移,保持每个阶段可编译: - -1. `settings` / `preferences` / `PlatformCapabilities` -2. `history` / `activity` / `dictionary` / `correction` -3. `style_packs` / zip import-export / runtime diagnostics -4. `credentials` / provider channels / OAuth status(保留秘密隔离) -5. provider validation/model-list 管理面(按 8.3.9 迁入 Core `ProviderService`,再进入 ASR/LLM/Omni - 正式请求路径) -6. `asr` / `polish` / `omni` / `net` -7. local ASR model catalog、download、prepare、release -8. remote input server、marketplace、coding agent -9. selection polish、selection voice、QA、Less Computer - -每个领域的步骤: - -1. 把实现文件移入 core module。 -2. 把 `AppHandle`、`State`、window label 和 Tauri plugin 调用替换成宿主 Interface/语义事件。 -3. 将原 command 改为薄 wrapper,保留 command 名称。 -4. 将原模块测试移入 core interface tests。 -5. 用 fake adapter 补齐成功、失败、取消、过期结果和权限降级场景。 -6. 更新 Tauri adapter 和 Linux contract 示例。 -7. 运行领域测试、core 全量测试和 Tauri compile gate。 - -`asr` / `polish` / `omni` 的迁移还必须额外满足:provider router 的重复 session 不能覆盖 -原路由;所有生产 Adapter 必须按 `DictationContext` 固定到会话;旧 prompt compose 的 XML、 -净化、防注入、前台应用、光标上下文、历史 turns、翻译和 user prompt envelope 必须有兼容 -fixture。只有“能请求某个 OpenAI-compatible endpoint”不等于完成了旧主链语义迁移。 - -**当前实施状态** - -- settings/preferences 已由 `OpenLessBackend::update_settings` 统一 strict/reconcile、legacy 同步、 - preserve-style、乐观 revision、单写入 gate、显式 effect plan、receipt 补偿及一次持久化/事件。 -- history/activity/vocabulary/correction/style-pack/credentials、prompt compose、云 ASR/LLM/Omni、 - Coding Agent、Local ASR、Marketplace、Selection/Selection Voice、QA 和 Remote Input 已有 Core - Implementation/Interface;操作系统录音、插入、socket、native/local ASR 与授权副作用继续以 - 注入 Adapter 表达。 -- 旧 `set_preferences*` 公共兼容面与 `legacy-preferences-write` feature 已删除;测试私有 helper 不能 - 被宿主启用。Android/macOS/Windows/Ubuntu 原生 effect 与网络/音频/窗口行为仍须由对应 runner - 证明,不能由 Windows contract test 推断。 - -**验收** - -- 同一业务规则只在 core Implementation 中存在;Tauri/Linux Adapter 不保留第二份判断。 -- 领域 Interface 的成功、失败、取消、过期结果、权限降级和秘密隔离都有 contract test。 -- 对应 Tauri command 仍保持既有名称和序列化语义;Linux 未接线能力明确返回 `Unsupported`。 -- 每迁移一个领域即可独立合并和回退,不要求一次性搬完全部领域。 - -### M6:完成 Tauri 薄适配器 - -**任务** - -1. Tauri setup 只负责构造 dependencies、创建 `Arc` 和管理 host state。 -2. 每个 `#[tauri::command]` 只做参数转换、调用对应 core use-case、错误/DTO 序列化。 -3. 建立 `tauri_events.rs`:订阅 core event,映射为当前 React 监听的事件名。 -4. 把窗口创建、窗口定位、拖动、透明/点击穿透、vibrancy/Mica、托盘和 menu 放入 Tauri host。 -5. 把 updater、dialog、shell、autostart、single-instance plugin 保留在 Tauri host。 -6. 把 `restart_app`、system settings、external URL 等系统动作接到 `HostActions`。 -7. 保留 Android Tauri host 的 JNI/overlay/IME 分支,避免 core 被 mobile 专属类型污染。 -8. 为旧 IPC contract 增加 TypeScript/Rust 交叉测试:command 名称、参数 key、事件 payload 和错误码一致。 - -**当前实施状态** - -- setup 已构造共享 `Arc`;React command、CLI、Android JNI、remote PCM、桌面普通 - 听写热键及已迁移复杂领域通过 Core Interface 调用。 -- `backend_dependencies()` 使用同一个 `SystemCredentialStore` 构造 Core 的共享云 ASR、LLM、Omni、 - Auxiliary 与 QA provider;Tauri Adapter 只追加平台录音、native/local ASR、窗口、插入和 runtime。 -- Provider 管理面也已共享:`ProviderService` 的 `validate`/`list_models` 读取 Core credential port - 并执行统一 provider 构造/模型解析;`commands/providers.rs` 不再读取 `CredentialsVault` 或发起 - provider-specific HTTP/WS 请求,Linux factory 注入同一 Core service。 -- settings/QA/全部快捷键入口已切 Core transaction;`TauriSettingsRuntime` 只消费显式 target, - Coordinator 的 listener runtime target 与偏好文档分离,style-pack 删除消费 Core outcome。 -- command 内旧 settings/hotkey 事务副本及 previous/write/refresh/rollback helper 已删除; - `core_adapters.rs` 不再通过 `AppHandle` 反取 Coordinator,Local ASR 与 hotkey/QA 只接收构造层 - 注入的窄依赖;仍需继续审计 compatibility Coordinator 的非 settings 宿主职责并取得原生 runner - 证据。 - -**验收** - -- React 主窗口、capsule、QA、Less Computer、选择润色和设置页面仍可调用原 IPC。 -- `src-tauri` 是唯一出现 `#[tauri::command]` 和 Tauri window label 的 package。 -- Tauri adapter 不包含 provider validation/model-list 核心业务分支;source contract 已证明 command - module 不含 provider 协议、凭据读取和请求构造。 - -### M7:交付 Linux egui 接口包(本计划负责) - -这一阶段不实现 egui UI,只交付让另一组可以开始 UI 开发的完整材料。 - -**任务** - -1. 发布 [`openless-core` 接口手册](./linux-egui-backend-contract.md),包含: - - 构造与生命周期 - - 所有领域接口和参数 - - `BackendSnapshot` 字段 - - `BackendEvent` 分类、顺序和 session 规则 - - `BackendErrorCode` - - `PlatformCapabilities` 能力矩阵 - - 线程、取消、超时和重连规则 -2. 提供 `linux-egui/examples/headless_host.rs`,展示构造 backend、订阅事件、执行听写和 shutdown;示例不绘制 UI。 -3. 提供 `FakeBackend` 或 fake provider fixture,允许 egui 团队在无网络、无麦克风环境调试页面。 -4. 提供事件到 UI view model 的推荐映射表: - - | core 事件 | egui 团队应更新的状态 | - | --- | --- | - | `DictationStateChanged` | 录音/转写/润色/完成状态和 level | - | `TranscriptDelta` | 原文增量文本 | - | `PolishDelta` | 输出增量文本 | - | `InsertFallback` | fallback 提示卡片状态 | - | `PreferencesChanged` | 设置缓存 | - | `CredentialsChanged` | provider 是否配置,不显示秘密 | - | `HistoryChanged` | 历史列表失效并重新读取 | - | `DownloadProgress` | 模型下载进度 | - | `PermissionChanged` | 权限状态和降级文案 | - | `HotkeyStatusChanged` | 热键能力/错误状态 | - | `Notification` | 非阻塞通知队列 | - -5. 提供 Linux capability fixture,覆盖 X11、Wayland、fcitx5 可用/不可用、无托盘、无权限和不支持更新器等状态。 -6. 提供 headless host 的 contract tests:调用顺序、事件顺序、错误码、取消和 snapshot resync。 -7. 给 egui 团队一份“不得依赖内部实现”的检查表,明确只能依赖 core facade、DTO 和 event subscription。 -8. 约定接口版本:破坏性字段变更必须更新 contract version 和迁移说明;新增可选字段默认兼容。 -9. 设置公共入口只暴露 `LinuxHost::save_settings(preferences, expected_revision)` 和 - `update_settings_strict(preferences, expected_revision)`;调用方先从 `snapshot()` 获取 revision。 - reconcile 入口用于整表兼容保存,strict 入口用于单项/严格保存;两者都由 Core 事务执行 effect、 - 持久化和补偿,UI 不得调用低层 `set_preferences*`。 -10. 生产构造入口固定为 `LinuxBackendBuilder::from_shared_providers(config)`;它打开 Linux - `CredentialStore`,注册 Core 共享 ASR/LLM/Omni/Auxiliary、`ProviderService` 和传统 Pipeline。 - Provider 管理面已由 Core `ProviderService` 接线;egui UI 不注入 `TranscriptionEngine`、 - `TextPolisher`、credential account 或 provider router;显式 - provider 注入的 `LinuxBackendBuilder::new(...)` 只用于测试和特殊宿主。 -11. Marketplace 由同一生产 factory 注入 `MarketplaceConfig::production()`;UI 通过 - `LinuxHost::download_marketplace_archive(pack_id, target)` 保存 Core 已校验归档。`target` 必须是 - 已有父目录下的绝对路径;宿主不得覆盖已有文件,写入失败必须清理不完整文件。 - -`EventSubscription::try_recv()` 是 egui 帧内消费事件的非阻塞入口;收到 `Empty` 结束本帧 -drain,收到 `Lagged` 必须用 `snapshot()` 或领域查询重同步。Linux host contract test -位于 `linux-egui/tests/host_contract.rs`,不创建窗口也不依赖 Tauri。 - -**交付边界** - -- 我们负责 Rust core、host Interface、示例和 contract tests。 -- egui 团队负责 `eframe::App`、布局、控件、交互、绘制、输入法体验、视觉和 UI 测试。 -- egui 团队不需要修改 core 内部模块;发现缺少能力时提交接口需求和可复现 contract test。 - -**验收** - -- egui 团队只依赖公开 facade、DTO、事件订阅、fake/headless Adapter 和 contract 文档即可开始开发。 -- Interface 手册明确字段、线程、顺序、取消、错误、能力降级和版本兼容规则,不要求阅读 core Implementation。 -- 未完成的真实 Adapter 返回稳定的 `Unsupported`;示例和 fixture 不把未接线能力伪装为可用。 -- provider 验证/模型列表必须来自 Core `ProviderService`;Linux factory contract 已断言该 service - 已接线且未回退为 `Unsupported`,egui 不得复制 Tauri provider 逻辑。 -- headless 示例实际执行并覆盖听写、Selection、Selection Voice、stale session 与 outcome-unknown; - Linux host contract 从公开 API 验证同一能力边界。 -- 本阶段最初只移交接口;后续 F01/F02 已把可操作的 egui UI 纳入本 PR。生产 UI 的连续会话、审批和增量显示现纳入复核,视觉设计仍由 egui 团队负责。 - -### M8:Linux host 接口接线准备 - -此处保留早期非 UI 适配器阶段的设计;最终生产 UI 与其启动、事件和目标验证见本轮复核记录: - -1. 实现 Linux `TaskSpawner`、`HostActions`、`ResourceResolver`、`CredentialStore`。 -2. 将 fcitx5 DBus commit、热键同步、选区读取接入 Linux platform adapter。 -3. 把 `ensure_plugin_installed(app: &tauri::AppHandle)` 改为资源目录/目标目录接口;Tauri 和 Linux host 各自提供路径。 -4. 实现 Linux 单实例、启动器参数、退出和后台生命周期;CLI intent 只转为 core action。 -5. 实现 Linux 音频设备枚举、level monitor、录音和插入 fallback adapter。 -6. 明确 X11 / Wayland 的支持矩阵和降级行为;不把“egui 能启动”当作 overlay、global hotkey 或 fcitx 全部可用。 -7. 输出给 egui 团队的 host capability snapshot 和错误文案。 -8. 用统一 `LinuxNativeRuntime` 持有 primary single-instance broker 和 fcitx5 hotkey listener; - `pump()` 非阻塞 drain intent/event/error,`shutdown()` 先停止并 join 宿主线程,再关闭 core。 -9. 在 Ubuntu/fcitx5 记录 translation modifier 与 dictation press 的真实信号顺序,并用时间线 - contract test 固定关联规则;晚到 modifier 不得修改已经启动的 `DictationContext`。 -10. 实现 `LinuxSettingsRuntime`,消费 Core 显式 hotkey/active-provider target,通过 fcitx5 DBus 与 - credential metadata 执行平台 effect,并以 typed receipt 逆序恢复。Coding Agent 启用与语音热键必须有真实 fcitx5 effect;其余未实现的 switch-style、open-app、style-pack hotkey 和 Windows keyboard effect 才保持明确 `Unsupported`。 -11. 以 `LinuxBackendBuilder::from_shared_providers(config)` 作为唯一生产 factory:UI 只传配置, - factory 内部创建 Linux credentials、Core `ProviderService`、共享云 ASR/LLM/Omni/Auxiliary - router、cpal recorder、fcitx5 inserter 和 settings runtime;测试/特殊宿主才使用显式 provider - 注入构造器。factory contract 必须断言 `services.provider` 已接线,不能静默回到 - `UnsupportedDomainServices`。 - -**验收** - -- headless Linux host 可以调用 core 的听写主链路。 -- Linux 生产 factory 可以调用共享 Core `ProviderApi::validate/list_models`,不经过 Tauri;provider - 凭据按 channel 隔离且错误/取消语义与 Tauri 一致。 -- fcitx5 缺失时返回 `Unsupported`/`Platform`,不会让 core panic 或假装插入成功。 -- 资源路径、插件文件和用户目录写入行为在 AppImage/deb/rpm 场景分别有测试。 - -**当前原生证据与边界(WSL Ubuntu)** - -- `secret_service_contract` 已在真实 `dbus-run-session`/gnome-keyring 下显式通过,证明 - `LinuxCredentialStore` 的 set/read/remove 和 secret 不落 metadata;普通 `cargo test` 不运行该 - contract,避免把桌面服务设为默认依赖。 -- `fcitx5_contract` 已在真实 fcitx5 加载仓库 plugin 后显式通过,证明 DBus object/method、listener - 启停及 press/release/combined/translation signal 映射;插件在无焦点输入上下文时只记录警告并 - 返回,fcitx5 不崩溃。合成 signal 不等价于真实物理按键顺序,仍需桌面 runner。 -- `cpal_contract` 已显式通过;当前 WSL 无 ALSA 输入设备,adapter 返回明确的平台/权限/不支持错误。 - 真实设备下的 stream start/stop、settings effect/单实例退出和焦点插入仍需 runner。 - -### M9:测试迁移和质量门禁 - -**测试层次** - -1. **Core unit tests**:状态转换、提示词、纠正规则、数据迁移、provider 规则/默认值/协议判定、错误分类、sequence 和 generation guard。 -2. **Core integration tests**:fake recorder、fake ASR/LLM、fake inserter、fake vault、fake provider transport、fake clock 的完整听写链路及 provider validate/list_models。 -3. **Adapter contract tests**:Tauri event mapping、Tauri provider wire mapping、Linux host actions、Linux provider factory、能力矩阵和资源目录。 -4. **Tauri compatibility tests**:现有 React command/event JSON 不变。 -5. **Linux dependency tests**:Linux package 编译不拉 Tauri/WebKitGTK。 -6. **Egui UI tests**:由 egui 团队负责;我们只提供 headless backend fixtures,不验收视觉布局。 - -**现有测试迁移** - -- `src-tauri/backend-tests/tests/backend_rust.rs` 及其 Tauri stub 已删除; - `backend-tests/tests/core_contract.rs` 直接依赖公开 `openless-core`,只验证 framework-independent - contract。 -- 原先被 path include 的纯规则测试归 `openless-core`;Windows IME、macOS host、Linux fcitx - 等平台测试归各自 crate;Tauri 内部单测由 Tauri crate 自身的 `--lib` 测试运行。 -- 现有 `src-tauri/src/lib.rs`、`coordinator.rs`、`types.rs` 的纯 Rust tests 随对应 - Implementation 迁移到 core;尚未迁移的测试留在 Tauri crate,不复制第二份源码。 -- 保留 Windows IME、macOS host、Linux fcitx 的平台 tests,但不让它们成为 core 的编译依赖。 -- 所有会写 repository 的测试必须使用每测试唯一且自动清理的临时 `data_dir`;禁止使用 crate-local - `"data"`。门禁在测试前后检查 `crates/openless-core/data/` 不存在,避免并行污染和未跟踪产物。 - -**建议门禁** - -以下命令从 `openless-all/app` 执行;本地和 CI 都必须使用已提交 lockfile: - -```text -npm.cmd test -npm.cmd run build -cargo fmt --check --all -cargo clippy --locked -p openless-core --all-targets -- -D warnings -cargo test --locked -p openless-core -cargo test --locked -p openless-core provider -cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings -cargo test --locked -p openless-linux-egui --all-targets -cargo test --locked -p openless-linux-egui provider -cargo test --locked --manifest-path "src-tauri/backend-tests/Cargo.toml" -cargo check --locked --manifest-path "src-tauri/Cargo.toml" --lib -cargo test --locked --manifest-path "src-tauri/Cargo.toml" --lib -pwsh -NoProfile -File "scripts/check-command-event-baseline.ps1" -pwsh -NoProfile -File "scripts/check-core-deps.ps1" openless-core -pwsh -NoProfile -File "scripts/check-core-deps.ps1" openless-linux-egui -pwsh -NoProfile -File "scripts/check-core-secret-surface.ps1" -pwsh -NoProfile -File "scripts/check-core-test-isolation.ps1" -pwsh -NoProfile -File "scripts/check-core-runtime-seam.ps1" -pwsh -NoProfile -File "scripts/check-linux-public-surface.ps1" -node "scripts/shared-backend-wire-contract.test.mjs" -git diff --check -``` - -依赖检查命令预期无匹配;如果某个正常依赖间接拉入禁止包,必须先解决依赖方向,再增加 allowlist,不能把问题隐藏在脚本中。 - -**验收** - -- core unit/integration、Tauri compatibility、Linux Adapter contract 和依赖门禁在 CI 中分别可见,失败时能定位到所属 Module。 -- backend contract tests 直接依赖公开 crate/Interface,不再用 `#[path]` 或伪造 `AppHandle` - 绕过真实 package 关系;Tauri crate 的原测试数单独记录,不能再沿用旧“compatibility 118”数字。 -- Windows 本地、macOS/Android cross-target 和真实 Ubuntu 原生验证分别记录;缺少某个平台证据时保持未完成状态。 -- 所有质量门禁使用已提交 lockfile 和 `--locked`,避免验证时静默改写依赖解析结果。 -- Core tests 并行执行时不共享持久化目录,结束后不在源码树留下 history/preferences/activity/style-pack 数据。 - -### M10:构建、打包、发布和文档收尾 - -**构建** - -1. 保留 macOS / Windows Tauri 构建和签名路径。 -2. 新建 Linux egui 构建 job,直接构建 `openless-linux-egui` binary。 -3. Linux job 不安装 `libwebkit2gtk`,只安装 eframe/winit 实际需要的 X11/Wayland/音频/图形依赖。 -4. Linux fcitx5 plugin 继续独立编译;主程序通过 host resource adapter 找到插件资源。 -5. 根据最终打包工具生成 deb、rpm、AppImage;打包器不能重新引入 Tauri。 -6. 产出独立的 Linux updater manifest、签名文件和 artifact 命名,避免与历史 Tauri Linux asset 混淆。 - -**工作流** - -1. 把现有 `.github/workflows/release-tauri.yml` 的 Linux matrix 从 Tauri build 中移出,或拆成独立 `release-linux-egui.yml`。 -2. macOS/Windows job 继续使用 Tauri cache 和 `src-tauri` manifest。 -3. Android job 继续使用 Tauri mobile manifest。 -4. Linux job 使用 Linux package manifest、独立 cache key 和独立 artifact path。 -5. 更新 release notes、artifact 校验、updater endpoint 和安装说明。 -6. 增加发布后验证:ELF 依赖、AppImage 内容、fcitx5 plugin 路径、桌面文件、单实例和 updater manifest。 - -**平台 runner 验证步骤(发布前必须逐项留证)** - -| runner | 执行顺序 | 必须保存的证据 | 不能用来替代的证据 | -| --- | --- | --- | --- | -| Ubuntu 22.04 真实桌面 | 安装 X11/Wayland、PipeWire/ALSA、DBus、Secret Service、fcitx5 和打包工具;运行 Core/Linux contract;在真实登录会话中验证焦点输入、物理热键/translation 顺序、真实麦克风 start/stop、设置 effect、单实例转发/退出;再安装 deb/rpm/AppImage 做启动、升级、卸载 smoke | runner 日志、输入/音频设备信息、安装前后版本、包清单、ELF `ldd`、AppStream、签名和 updater SHA-256 | WSL 合成 DBus signal、无音频设备时的错误分类、临时 minisign、Windows Linux crate test | -| Windows | 先跑 frontend/Core/Tauri 全量门禁;再构建 MSVC Tauri artifact,执行 installer、启动/退出、真实 IME/插入、麦克风权限和 updater smoke;保留安装包、日志和校验值 | `cargo test --locked --manifest-path "src-tauri/Cargo.toml" --lib` 结果、artifact/installer、启动与升级日志、签名状态 | `cargo check` 或 Linux crate cross-platform contract 不能证明安装和原生输入 | -| macOS | 使用对应 SDK/Metal/ speech entitlement 构建 Tauri bundle;验证签名/notarization(如发布要求)、安装启动、NSPanel/Space、麦克风/插入和 updater;再运行 macOS 专属 contract | bundle/DMG、签名与 notarization 输出、真实窗口/输入日志、升级前后版本 | Windows 本地 Tauri test 或跨 target compile 不能证明 macOS native behavior | -| Android | 准备完整 JDK/SDK/NDK/Gradle cache;执行 `copy:android-scaffolding` 和全部 manifest/dependency merge 脚本;运行 `cargo ndk -t arm64-v8a check --manifest-path "src-tauri/Cargo.toml"`、`cargo ndk -t x86_64 check --manifest-path "src-tauri/Cargo.toml"`;执行 `npm run tauri:android:build:debug`(Windows shell 使用 `npm.cmd`)与 release/APK 分 ABI 构建;运行 Gradle JVM/unit、instrumentation、设备安装和 JNI/overlay/IME smoke;最后执行签名和产物校验 | Rust target、Gradle/JVM、APK/AAB、instrumentation、设备安装、签名和每 ABI SHA-256 | Rust cross-target check 不能替代 Gradle/APK、设备运行或签名证明 | - -**文档** - -- 更新 `openless-all/README.md` 和 `README.zh.md` 的平台说明、开发命令和 Linux 安装说明。 -- 更新 `RELEASING.md`,区分 Tauri desktop、Android 和 Linux egui 发布流程。 -- 新增 core API / Linux contract 文档,写明线程、事件、错误、能力降级和版本兼容策略。 -- 删除“Linux 使用 Tauri”或“所有桌面平台共用 Tauri bundle”之类的过期描述。 - -**验收** - -- macOS/Windows Tauri、Android Tauri mobile 与 Linux 原生宿主使用互相独立且可重复的构建入口。 -- Linux 依赖树和最终 ELF/AppImage 中都没有 Tauri、WebKitGTK 或历史 WebView 运行时。 -- deb、rpm、AppImage、desktop/AppStream metadata、fcitx5 资源、updater manifest、签名和校验值均由真实 Ubuntu runner 证明。 -- 发布 job 在检测到 UI stub、缺失签名、ELF 依赖缺失或 contract version 不匹配时必须失败。 -- 发布说明明确区分“Windows 本地 contract 通过”“跨 target CI 编译通过”和“真实 Linux 安装/运行通过”,三者不能互相替代。 - -**回退原则** - -- 在 Linux 正式切换前保留最近一个已发布 Linux 产物和安装说明;不复用相同 artifact 名覆盖历史文件。 -- 单个领域迁移失败时回退该领域的 Adapter 接线,不回退已经稳定的 core Interface 或其他领域。 -- Tauri compatibility gate 失败时停止对应领域迁移;不得通过修改 React 调用方来掩盖无意的 IPC 破坏。 -- Linux 原生验证失败时停止 Linux 发布,不影响 macOS/Windows/Android 的独立发布流程。 - -## 9. Tauri 适配器的命令迁移模板 - -迁移后的 Tauri command 应接近以下形状: - -```rust -#[tauri::command] -async fn start_dictation( - backend: State<'_, Arc>, -) -> Result { - backend - .start_dictation() - .await - .map(SessionIdDto::from) - .map_err(CommandError::from) -} -``` - -不允许在 command wrapper 中: - -- 判断 provider 优先级、重试、session phase 或 fallback 逻辑; -- 直接访问 `Coordinator` 的私有字段; -- 直接修改 preferences/history/vocabulary; -- 根据窗口 label 决定核心业务状态; -- 捕获错误后返回“看起来成功”的空结果。 - -事件桥接应集中在一个模块: - -```rust -async fn forward_core_events( - backend: Arc, - app: AppHandle, -) { - let mut events = backend.subscribe(); - while let Some(event) = events.recv().await { - for mapped in map_event_for_react(event) { - let _ = app.emit_to(mapped.target, mapped.name, mapped.payload); - } - } -} -``` - -`map_event_for_react` 是兼容层,不是业务层;Linux adapter 不应复用它。 - -## 10. Linux egui 团队接口手册要求 - -交付给 egui 团队的文档必须包含以下内容,缺一项就不能认为接口准备完成: - -### 10.1 调用示例 - -- 构造 backend 的最小示例。 -- 读取 startup snapshot。 -- 非阻塞订阅事件并触发 egui repaint。 -- 调用 settings/history/dictation/style pack 等领域接口。 -- 取消运行中的 session。 -- 正常关闭和异常关闭。 - -### 10.2 字段契约 - -- 每个 DTO 的字段、单位、默认值和 nullable 语义。 -- 时间统一使用明确的毫秒/秒或 ISO-8601 规则。 -- 音量 level 的范围固定为 `0..=1`。 -- 流式 delta 的 session、sequence、offset 和最终态规则。 -- `PlatformCapabilities` 每个字段在 Linux 不可用时的含义。 - -### 10.3 失败契约 - -- 权限未授权、provider 未配置、fcitx5 不存在、插入失败、下载失败、取消和超时的错误码。 -- 哪些错误可重试,哪些错误需要用户操作。 -- 哪些操作是幂等的:dismiss、cancel、shutdown、set enabled 等。 -- outcome-unknown 时 UI 应等待 snapshot 或显示待确认状态,不能自行重复执行。 - -### 10.4 能力契约 - -至少覆盖: - -| 能力 | core 字段 | Linux 可能状态 | UI 应看到的行为 | -| --- | --- | --- | --- | -| 全局热键 | `supports_desktop_hotkey` | available / unavailable | 隐藏或显示降级设置 | -| fcitx5 插入 | insertion capability | plugin missing / ready | 失败时提供 clipboard fallback | -| 托盘 | `supports_tray` | desktop / unavailable | 提供主窗口内替代入口 | -| 悬浮反馈 | host action | X11 / Wayland limitation | 不把窗口显示失败当作听写失败 | -| 本地 ASR | `supports_local_asr` | model absent / ready | 显示下载、准备和释放状态 | -| 自动更新 | `supports_auto_update` | package-dependent | 不显示假更新按钮 | -| 麦克风 | permission + device status | granted / denied / no device | 明确区分权限和设备 | - -## 11. 风险与对策 - -| 风险 | 影响 | 对策 | 责任 | -| --- | --- | --- | --- | -| 搬迁时把 Tauri 类型带进 core | Linux 仍无法独立编译 | 依赖 grep 门禁;core 禁止 Tauri import | core 负责人 | -| 两套 UI 演化出两份业务规则 | 行为不一致、修复重复 | 业务判断只进 core;adapter 只翻译 | 全部 | -| 事件丢失或顺序错乱 | egui 显示旧状态、重复插入 | sequence + session + snapshot resync + lagged 测试 | core 负责人 | -| egui frame 被网络/磁盘阻塞 | Linux UI 卡死 | 事件 channel + 非阻塞 drain + repaint | Linux host / egui 团队 | -| Tauri command 仍包含业务逻辑 | Tauri 与 Linux 结果不同 | command contract review;wrapper 禁止业务分支 | Tauri 负责人 | -| Provider 验证/模型列表回流 Tauri | Linux 设置页无法复用 provider 管理面,凭据/协议出现第二份真相 | `ProviderService` 已迁入 Core;Core/Tauri/Linux contract + source contract;factory 断言非 `Unsupported` | core / Tauri / Linux host | -| Linux fcitx5 缺失 | 无法插入文字 | 明确 capability;clipboard fallback;不假成功 | Linux host | -| Wayland overlay/点击穿透限制 | 胶囊体验不完整 | 单独记录支持矩阵;不把 UI 反馈失败升级为 pipeline 失败 | Linux host / egui 团队 | -| 多 lockfile 发生依赖漂移 | 两个宿主可能使用不同传递版本 | 三份 lockfile 分别提交;CI 逐项使用 `--locked`,core Interface 由 path version + contract tests 约束 | 构建负责人 | -| Android cfg 被误删 | APK 回归 | Android job 和 Tauri mobile compile gate 保留 | Tauri 负责人 | -| 迁移测试仍 path include | 测试通过但实际 package 不可用 | backend-tests 直接依赖 core;删除 Tauri stub | 测试负责人 | -| secrets 泄露到 DTO/日志 | 安全事故 | status/value 分离;日志扫描和 fixture 检查 | core 负责人 | -| 大模型加载和下载被重复初始化 | 内存和启动时间回归 | backend 统一 runtime/cache 生命周期;增加资源计数测试 | core 负责人 | -| Core 私自创建 Tokio runtime | Linux egui 关闭/取消路径可能启动隐藏线程,生命周期和错误不可控 | 生产路径禁止 `Runtime::new()` fallback;实时 ASR 后台任务与关闭清理由宿主注入 `TaskSpawner` 提交;`scripts/check-core-runtime-seam.ps1` 扫描 `tokio::spawn`/`Handle::current`/`Runtime::new`,并在 CI 与 Linux release workflow 执行 | core 负责人 | -| 重复 session 覆盖已固定 provider | 返回 `Busy` 后原会话被错误接管 | registry 使用 entry/原子占位;回归测试验证旧路由仍可 finish/cancel | core 负责人 | -| fcitx5 translation modifier 信号晚于 dictation press | Linux 翻译模式与旧产品语义不一致 | Ubuntu 记录真实顺序;冻结关联窗口和时间线测试;不修改活动 session | Linux host 负责人 | - -## 12. 验收标准 - -### 12.1 架构验收 - -- [x] core package 的源码和依赖树没有 Tauri、egui、eframe、WebView 类型。 -- [x] Tauri 是 adapter,不再是 core 的隐式运行时;Core 只经显式依赖注入与 `TaskSpawner` 运行,Tauri command/source contract 不再保留已迁移领域的第二份业务实现。 -- [x] `Coordinator` 不持有或接收 `AppHandle`/`WebviewWindow`,不直接 emit Tauri 事件;源码契约与残余引用检查已覆盖该规则。 -- [x] core 事件不包含窗口 label 和前端事件名。 -- [x] Linux package 不通过 path include 复用 Tauri 源码。 -- [x] Android 仍能通过现有 Tauri mobile compile gate;CI runner 已验证 `aarch64`/`x86_64` Rust target、Gradle scaffolding、JVM/instrumentation 和 Keystore contract。 -- [x] Core 生产异步路径不创建私有 Tokio runtime;实时 ASR 的后台任务和关闭清理由宿主注入 - `TaskSpawner` 提交,`check-core-runtime-seam.ps1` 已作为 no-private-runtime contract 在本地通过, - 并已加入 CI/Linux release workflow。 - -### 12.2 接口验收 - -- [x] `OpenLessBackend` 有构造、启动、快照、订阅、取消和关闭契约。 -- [x] 领域 Interface 覆盖现有 React IPC 的有效业务分组:运行时 provider 与 - `ProviderApi::validate/list_models` 均由 Core `ProviderService` 实现,Tauri/Linux factory 共用同一 - service;其余未注入实现显式返回 `Unsupported`。 -- [x] DTO serde 字段与现有 React IPC 的本地契约兼容;Local ASR 的 4 项 Tauri wire contract、Marketplace - host sink 2 项、OAuth wire 2 项、Selection Core 17 项、Selection Voice Core 13 项、QA Core 15 项、 - Tauri QA Adapter 4 项和 Remote Input Core 8 项已通过;QA/Remote/Selection Voice 的 React source、 - Remote WebSocket lifecycle、Less Computer replay contract 及完整 frontend 58 项已通过;原生 - 宿主行为证明由 12.4 的独立平台项约束,不与 DTO/serde 契约混算。 -- [x] 错误码稳定,默认错误/事件序列化 fixture 不包含秘密字段;后续领域仍需继续执行敏感信息扫描。 -- [x] event sequence、session guard、lagged resync 和终态唯一性有测试。 -- [x] egui 团队的 1.0.0 领域 Interface、完整 headless 示例、mock、fixture、能力矩阵和 contract - 文档已具备;设置/快捷键 DTO、携带 snapshot revision 的 validated transaction、Linux settings - runtime、Selection/Selection Voice 完整 headless 场景与 4 项 host contract 已进入移交基线, - 当前公共面门禁通过。该项只表示 Interface 移交完成,不包含 egui UI 或真实 Ubuntu 验收。 - -### 12.3 行为验收 - -- [x] core headless 测试覆盖听写成功、取消、ASR 失败、润色 fallback、插入 fallback 和 outcome-unknown。 -- [ ] Tauri React 主链路、设置、历史、词典、风格包和 provider 页面可用。 -- [x] Linux host 可通过 fake recorder/provider/inserter 调用同一 core Pipeline,且生产 factory 的 - provider validate/list_models 不经过 Tauri;Linux factory contract 已验证 provider service 非 - `Unsupported`。 - 真实 Linux 设备与桌面集成仍由 Ubuntu 原生门禁证明,egui 视觉由另一组验收。 -- [x] Linux fcitx5 可用/不可用、X11/Wayland、无托盘和无权限场景有 contract、fixture 与非 UI Adapter 测试。 - -### 12.4 构建和发布验收 - -- [x] Windows 本地 `cargo check -p openless-core`、`cargo check --manifest-path "src-tauri/Cargo.toml" --lib` 和 `cargo check -p openless-linux-egui --all-targets` 通过。 -- [x] core 与 Linux package 依赖检查无 Tauri/WebKitGTK。 -- [x] Tauri `cargo check --locked --lib` 在 legacy provider 副本清理后于当前工作树通过;既有/迁移期 - warning 不作为测试成功或跨平台原生证明。 -- [x] 历史 Windows 本地 Tauri `cargo test --locked --lib` 为 745 passed、0 failed、7 ignored;远端 - macOS CI 在 run 33408317390 运行 737 项,其中 730 passed、7 ignored。Provider - 旧 command 测试旁路已删除,解析与模型响应测试归入 Core `ProviderService`;旧“backend - compatibility 118 项”已由 Tauri 原 crate 测试取代,不再作为当前证据。 -- [x] 历史 Windows 本地门禁记录 frontend build/58 项 tests、Core 594 项 unit + 79 项领域 - contract、Linux crate 29 项 + 4 项 host contract;远端 CI 的最新数字以 1.2.2 的 - `openless-core` 596 unit、Linux crate 30 tests 和 4 项 host contract 为准。 -- [x] Core/Linux 严格 clippy、command/event baseline(196/30/29)、Core/Linux 依赖方向、secret - surface、test isolation、Linux public surface、workspace fmt、provider command source contract、 - headless example 与 tracked `git diff --check` 均在当前工作树重跑通过;Core/Tauri/Linux provider - 管理面 contract 已通过。 -- [x] Core runtime seam contract 已通过:生产源码不创建私有 `Runtime`,不直接调用 `tokio::spawn`; - 实时 ASR 的后台任务和关闭清理由宿主注入 `TaskSpawner` 提交,`check-core-runtime-seam.ps1` - 已加入 CI 与 Linux release workflow。 -- [x] CI artifact 门禁已通过:run 33408317390 的 Linux runner 生成 deb/rpm/AppImage、fcitx5 - plugin 和独立 `latest-linux-egui-x86_64.json`,验证 ELF/包内容/desktop/AppStream 及 - manifest SHA-256;artifact `openless-linux-egui-x86_64`(ID `9764249814`)可下载,空 - `release_tag` 的 `minisign` 为 `null`,不会被误当作正式签名。 -- [x] Core tests 使用每测试唯一且自动清理的临时目录;`check-core-test-isolation.ps1` 同时拒绝固定 crate-local `"data"` 和源码树运行残留。 -- [x] Local ASR command 接线后的 core contract 6 项、Tauri wire contract 4 项和 Tauri - `cargo check --lib` 通过;这只是定向证据,不能替代 Tauri 全量 tests 或其他平台证明。 -- [x] Marketplace/OAuth 的 core contract 17 项、core 严格 clippy、Tauri host sink 2 项、OAuth - wire 2 项和 Tauri `cargo check --lib` 已通过;本地全量门禁也已重跑。 -- [x] 前序 Selection Core contract 17 项、Selection Voice Core contract 13 项和 Tauri Selection - focused 22 项通过;Selection Voice Core/Tauri source contract 已覆盖业务边界;跨平台原生证明 - 仍未完成,不能把定向 contract - 当作最新全量门禁或全领域迁移完成。 -- [x] QA Core contract 15 项、Tauri QA Adapter 4 项与 Remote Input Core contract 8 项通过;QA/Remote - lagged resync、Remote secret wire、Remote WebSocket lifecycle、Less Computer replay 和共享 React - source contract 已由完整 frontend/Tauri suite 覆盖。跨平台原生证明仍未完成。 -- [x] 快捷键迁移增量的 Core shortcut 5 项与 Tauri hotkey 14 项定向测试通过。 -- [x] Core settings 成功/失败原子性、Linux validated 公共面与原生 effect substitute contract 已补; - 当前 fmt、Core/Linux clippy/test、frontend build/test、Tauri check/full test 与所有脚本门禁均通过。 - Android/macOS/Ubuntu 的真实 native effect 继续由对应未勾选项约束。 -- [x] Provider validation/model-list Core/Tauri/Linux contract 已通过,且 - `commands/providers.rs` 不再包含 provider 协议、凭据读取或 HTTP/WS 请求构造。 -- [ ] macOS/Windows Tauri artifact 的正式签名、完整安装/升级 smoke 仍未完成;run 33405500864 - 已构建 macOS arm64/x86_64 DMG 和 Windows x64 NSIS,Windows NSIS 安装/卸载与 IME smoke 已通过, - MSI 因 Beta.7 非数字版本按设计跳过。 -- [x] Linux egui deb/rpm/AppImage、fcitx5 plugin、ELF 依赖、desktop/AppStream metadata 和临时 - minisign 签名/验签已在 WSL Ubuntu 验证;正式 updater manifest 仍需 release workflow 注入正式 - secret 后验证,UI stub 和正式发布门禁仍保持未完成。 -- [x] README、RELEASING 和开发命令已区分 Tauri hosts 与 Linux egui host,并明确 UI stub 发布门禁。 -- [x] Android CI debug artifact gate 已通过:run 33405500972 上传四个 ABI debug APK, - `Collect split APKs` 校验每个 APK 只包含预期 ABI;这不包含 release 签名、设备运行或安装证明。 -- [ ] Android release 签名、设备安装/升级和 JNI/overlay/IME 真实 smoke;本机 `cargo ndk` 的 - `arm64-v8a` 与 `x86_64` Rust cross-target check 不能替代这些证明。 -- [ ] Ubuntu 真实桌面 runner 完成焦点输入、fcitx5 物理按键顺序、真实音频设备 start/stop、设置 - effect、单实例退出和安装后启动;WSL 合成 signal、无设备错误和临时签名均不能替代该证明。 -- [ ] Linux 正式签名密钥注入后的 updater manifest、artifact 校验和、安装/卸载与回滚验证。 - -## 13. M0 决策记录 - -以下事项已经冻结;若要改变,必须同步更新 contract version、fixtures 和两个宿主: - -1. `openless-core` 的 Interface 覆盖现有有效业务领域;egui UI 可以分阶段展示,但不能复制或绕过 core 规则。 -2. Linux deb/rpm 使用 `fpm`,AppImage 使用 `appimagetool`,产物由独立 workflow 生成;打包器不得引入 Tauri/WebKitGTK。 -3. runtime 由宿主提供,core 只依赖注入的 `TaskSpawner`;当前默认 Adapter 为 `TokioTaskSpawner`。 -4. `BackendEvent` 使用有界 `tokio::broadcast`,落后订阅者收到显式 `Lagged` 并从 snapshot/query 重同步。 -5. core 使用 `DictationStateSnapshot` 等语义名称;`CapsulePayload` 等旧名和窗口 payload 只存在于 Tauri compatibility Adapter。 -6. Linux tray/autostart/overlay/updater 都是 capability;不可用时 UI 隐藏或降级,不能伪造支持。 -7. `BACKEND_CONTRACT_VERSION` 独立管理 Interface 破坏性变更;应用发布版本仍由宿主产物共同决定。 -8. Linux 发布 workflow 在真实 egui 入口替换 stub 前不响应 tag;即使脚本能生成包,也不能把 stub 标为正式发布。 - -## 14. 参考资料 - -### 仓库内依据 - -- [`openless-all/app/src-tauri/Cargo.toml`](../openless-all/app/src-tauri/Cargo.toml) -- [`openless-all/app/src-tauri/src/lib.rs`](../openless-all/app/src-tauri/src/lib.rs) -- [`openless-all/app/src-tauri/src/coordinator.rs`](../openless-all/app/src-tauri/src/coordinator.rs) -- [`openless-all/app/src-tauri/src/commands/mod.rs`](../openless-all/app/src-tauri/src/commands/mod.rs) -- [`openless-all/app/src-tauri/src/types.rs`](../openless-all/app/src-tauri/src/types.rs) -- [`openless-all/app/src-tauri/backend-tests/tests/core_contract.rs`](../openless-all/app/src-tauri/backend-tests/tests/core_contract.rs) -- [`openless-all/app/crates/openless-core/src/provider_registry.rs`](../openless-all/app/crates/openless-core/src/provider_registry.rs) -- [`openless-all/app/linux-egui/src/runtime.rs`](../openless-all/app/linux-egui/src/runtime.rs) -- [`openless-all/app/src/lib/ipc/index.ts`](../openless-all/app/src/lib/ipc/index.ts) -- [`release-tauri.yml`](../.github/workflows/release-tauri.yml) - -### 稳定的上游资料 - -- [Tauri Architecture](https://v2.tauri.app/concept/architecture/) -- [Cargo Workspaces](https://doc.rust-lang.org/cargo/reference/workspaces.html) -- [eframe API](https://docs.rs/eframe/latest/eframe/) -- [egui API](https://docs.rs/egui/latest/egui/) - -这些资料只用于确认宿主/库的常见职责和 Cargo workspace 组织方式;项目最终接口以本文的仓库事实、contract tests 和实际构建结果为准。 - -## 附录 A:本次接口准备的实际交付 - -当前已落地、可供 egui 组开始 view model 和 headless 集成的内容: - -- `openless-core`:无 Tauri/egui 依赖的 facade、快照、错误码、语义事件、宿主 Interfaces、 - 听写状态机、共享 `PipelineDictationEngine`、16 kHz mono Int16LE PCM/WAV 契约、 - `start/finish/cancel` engine Interface、录音 level/阶段/增量 progress sink、非阻塞事件订阅 - 和取消/迟到结果/生命周期测试; -- 设置/快捷键:语法、左右修饰键、legacy 同步、跨功能冲突、strict/reconcile、preserve-style、 - revision guard、显式 effect plan、receipt 补偿和单写入 gate 已进入 core;完整设置 DTO 与 - `LinuxHost::save_settings`/`update_settings_strict` 已从 Linux contract 交付。Core settings 11 项、 - Linux 4 项公共 host contract 和本地全量门禁通过;真实原生 listener 事务仍由对应 runner 证明; -- 词典与 style-pack 诊断:`enabled_vocabulary_phrases`、`asr_vocabulary_phrases` 和 - `preview_style_pack_runtime` 均由 Core facade 提供;ASR 热词保底/命中排序/大小写去重及 - prompt 诊断不再由 Tauri Coordinator 复制,Tauri command 只做 Core/wire 转换; -- `openless-linux-egui`:只依赖 core 的 Linux host seam 和无 UI 的 - `examples/headless_host.rs`; -- `openless_core::testing`:记录 host action、fixture recorder/transcription/polisher/engine/inserter/ - selection 和固定结果/错误的 headless 测试替身;inserter 记录 session-scoped - prepare/insert/cancel 顺序,selection fixture 记录 capture/preview/apply/revert/cancel 并可表达 - Linux preview/revert `Unsupported`; -- `LinuxCapabilityFixture`:X11 完整、Wayland 降级和 headless 能力/权限快照; -- `scripts/check-core-deps.ps1`:core/Linux package 的禁止依赖门禁; -- `scripts/check-core-runtime-seam.ps1`:禁止 Core 生产路径创建私有 Tokio runtime 或直接 spawn, - 并确认后台任务经过宿主注入的 `TaskSpawner`; -- [`linux-egui-backend-contract.md`](./linux-egui-backend-contract.md):当前可用接口、 - 事件/错误/能力契约和未完成领域的明确边界; -- `BackendServices`:provider/local ASR/selection/QA/remote input/marketplace/ - coding-agent/platform/auxiliary 的稳定 Interface 与 DTO;未注入 Adapter 时统一失败为 - `Unsupported`;`AuxiliaryApi` 额外交付单轮 repolish、规范 PCM retranscription、实际 ASR - provider/model 归因、terminal Foundry fallback 和 future-drop cancel 契约; -- Provider 管理面已完成迁移:云端 provider 运行时及 - `ProviderApi::validate/list_models` 均由 Core `ProviderService` 共享实现承载,Tauri - `commands/providers.rs` 只做参数/旧 wire/error 转换,Linux 生产 factory 通过 - `from_shared_providers` 注入同一 service;`ProviderTransport`、fake transport 覆盖、静态模型 - parity 与 LLM 显式 channel 写入均已有回归测试。egui 可以直接调用公开 provider Interface;真实 - 网络、keyring 和平台 runner 仍须按 M9/M10 留证,不得用 fixture 冒充生产能力。 -- Coding Agent:provider/model/权限/预算/路径/风险/版本/MCP 解析等跨宿主规则位于 core; - Tauri 使用真实 `TauriCodingAgentApi` 处理 CLI 进程、Git 快照、临时 guard 配置、审批和 typed - event 转发,commands 只保留主窗口授权与旧 React wire 转换;Linux 可直接复用同一 Interface, - 未提供进程 Adapter 时稳定返回 `Unsupported`; -- Less Computer 语音生命周期:Core 提供实例级 capture lease、active session、取消可见性和 - 幂等 abort;Tauri 热键按下先预留 lease,再以同一 session id 驱动兼容 recorder/ASR,转录后 - 通过 `submit_less_computer_with_session` 进入 Core Agent run;Esc/启动失败/空转写不会遗留 - capture lease。egui 只消费这些 facade 与 typed events,不读取 Coordinator 状态; -- Local ASR:Generic/Foundry/Sherpa 的 catalog、设置事务、生命周期 Interface、engine-changed - 事件语义与共享 `LocalAsrService` 位于 core;Tauri 使用 `LocalAsrRuntimeAdapter` 承担原生引擎、 - 下载和文件操作,三组 command 只保留旧参数/DTO 转换,Generic 下载已通过 typed event 进入 - 集中桥接,未知 Sherpa family/mode 使用 fallible conversion 返回错误;Adapter 直接注入共享 - preferences repository 与 native cache,不再通过 `AppHandle` 回取 Coordinator;Coordinator 的 - ASR 就绪门禁也消费同一 Core 偏好快照,不再重新打开第二份 preferences store; -- Marketplace:完整 `MarketplaceApi`、HTTP/认证策略、归档大小与 ZIP 校验、安装事务、 - upload/origin 写回、实例级 OAuth device-flow registry、401 tombstone 和 secret-surface 规则已 - 进入 core;Tauri Marketplace/OAuth commands 只保留旧 wire/error 转换与归档最终写入;Linux - 生产 factory 已接线 Marketplace,`LinuxHost::download_marketplace_archive` 以 create-new 语义把 - 已校验归档写入绝对 filesystem path,拒绝覆盖并清理失败写入; -- Selection:Core Implementation 已拥有 preview/confirm/direct apply/cancel/revert 状态、typed - event、provider/context 冻结、history/vocabulary 写入、迟到结果与 outcome-unknown 语义;17 项 - Selection contract 与 13 项 Selection Voice contract 当前通过。fixture、headless 示例和 Linux - host contract 已覆盖 preview/confirm/cancel/stale/outcome-unknown 以及 Linux preview/revert - `Unsupported`。Tauri 已注入新的 runtime 和共享 polisher,旧 `TauriSelectionApi`/Coordinator - wrapper 已删除;Selection Voice 的 correction/instruction/intent/output-mode/EditPlan/translation - 和 QA preview revision 已由 Core 高层 use-case 统一,Tauri 只保留 native recorder/window/hotkey、 - opaque insertion target 与 apply outcome;跨平台原生验证仍待收口; -- QA:`QaService` 已拥有 message log、phase、text/voice turn、selection envelope、level/delta、 - approval token、cancel/dismiss、错误脱敏、`ShowQa` 失败回滚和 shutdown 语义,15 项 contract 通过;生产构造已注入 - `TauriQaRuntimeAdapter`,QA hotkey/commands/dismiss 与 Selection Voice 问答/编辑预览均调用同一 - `QaApi`,独立 `QaHostState` 已删除,Coordinator/QA Adapter 共享一个窄 `TauriQaHostContext`, - Tauri QA Adapter 4 项通过;Less Computer 已实现 listener-first replay、同步期 pending - 合并、sequence 去重与截断重建;原生平台证明仍待收口; -- Remote Input:`RemoteInputService` 已拥有配置、PIN、locale、连接/session、PCM 校验、事件与 - shutdown,8 项 contract 通过;Tauri TLS/WSS/PIN 文件与 external dictation 位于 runtime Adapter, - Coordinator 已删除重复状态;WebSocket contract 覆盖认证顺序、constant-time PIN、单 stream、 - disconnect/restart cancel 和 stale lease,`RecordingRemoteInputRuntime` 可供 headless 测试;真实 - WSS/证书/防火墙原生网络证明仍待补; -- Linux Adapter:Secret Service/keyring 凭据、非秘密 metadata、资源布局、fcitx5 DBus 与插件 - 安装契约、X11/Wayland/headless 能力、cpal 设备枚举与录音、DBus 热键 listener、 - `LinuxBackendBuilder`、HostActions 和 Unix socket 单实例 intent 转发;新增 3 个显式 ignored - native contract,已在 WSL Ubuntu 分别验证 Secret Service set/read/remove、fcitx5 plugin/method/ - listener/signal 映射和 cpal 无设备错误分类;无焦点输入时 fcitx5 plugin 不再抛异常导致宿主崩溃; -- `src-tauri` 已添加对 core 的 path dependency 并复用同一组 repository;云 ASR/LLM/Omni、 - Auxiliary、QA provider 运行时及 provider validation/model-list 管理面均改用 Core 共享实现; - Tauri provider commands 只保留旧参数/DTO/error 转换。Tauri 仍提供 - `SystemCredentialStore`、平台录音、native/local ASR、窗口/插入与 runtime;旧 Coordinator 仍按 - M4–M6 收窄,原 12 个 `migrationRequired` 事件已全部进入集中桥接; - React command、CLI、Android JNI、remote PCM 和桌面普通听写的主要热键边沿已切 core;QA/shortcut/combo/ - debounce 等宿主仲裁仍留在 Tauri。Less Computer 文字入口、capture lease、同 session submit/cancel - 已接线;其语音按下/松开、Starting pending stop 和静音自动停止仍使用 Coordinator 的兼容 host - session 状态,但该状态不再承载 Agent 业务规则,也不能被 Linux egui 读取。`Coordinator::Inner` 与 `capsule_focus` 已恢复 module 私有, - `bind_app(AppHandle)` 已删除,Coordinator/capsule 子模块不再出现 `AppHandle`、`WebviewWindow`、 - 直接 emit 或直接 `tauri::async_runtime`;capsule 原生窗口操作和 layout/cursor/style/fallback/ - deferred cache 已移入 `TauriCoordinatorHost`,payload 应用只接收窄值。compatibility Coordinator - 仍持有显式 Host,并承担部分热键仲裁、native runtime 生命周期和兼容编排,需要继续按生产调用图 - 收窄;`core_adapters.rs` 已无 `managed_coordinator` 反向查询,hotkey/QA 状态通过窄共享依赖注入; -- core facade 在插入、结果、history 和 activity 之前统一应用启用的最终纠正规则;traditional - history 使用冻结的 ASR/LLM channel 与 model,multimodal history 清空 ASR 归因并记录冻结的 - Omni channel/model; -- provider router 明确区分 `provider_id`(channel/scoped credential)与 `provider_type` - (协议路由),并冻结 session 的 provider ID/type/model;Tauri 生产 Adapter 的重复 session - 使用原子占位,不会在返回 `Busy` 时覆盖原取消路由。 -- Linux 生产 UI 只调用 `LinuxBackendBuilder::from_shared_providers(config)`;Core 共享 - ASR/LLM/Omni/Auxiliary、ProviderService、Linux credentials、cpal recorder、fcitx5 inserter 与 - settings runtime 由 factory 内部组装。`LinuxBackendBuilder::new(...)` 的显式 provider 注入只用于 - 测试/特殊宿主;仅 native/local ASR 等尚未提供 runtime 的能力可以返回 `Unsupported`。 - -补充本地与远端全量证据:历史 Windows 本地记录 frontend build 与 58 项 frontend/contract tests、 -Core 594 项 unit、Linux Adapter 29 项 crate tests + 4 项 host contract、Tauri 745 -passed/0 failed/7 ignored;最新 fork CI run 33408317390(head `06e85f7b`)记录 Core 596 unit、 -Linux Adapter 30 tests + 4 host contract、macOS Tauri 730 passed/0 failed/7 ignored,并通过 -workspace fmt、Core/Linux 严格 clippy、测试隔离、公共接口、command/event baseline(196/30/29)、 -依赖方向、secret surface、source contract、headless example、Provider command 禁回流 contract -与 tracked `git diff --check`。已删除 path-include suite 的 118 项数字不再作为证据。 -Windows 上的 Linux package test 只证明跨平台 Rust contract,不证明 DBus/Secret -Service/cpal/fcitx5 的真实 Linux 行为;Android、macOS、Ubuntu 打包与原生集成必须由对应 -runner 证明,不能从本机结果推断。 - -### 2.0.0-Beta.1 版本与许可证边界 - -本批 Tauri 应用版本统一为 `2.0.0-Beta.1`;`BACKEND_CONTRACT_VERSION` 已升级为 -`2.0.0`,应用版本和接口版本仍各自独立。根项目从该版本起采用 `AGPL-3.0-only`,已发布 1.x 版本仍保持 MIT, -第三方 vendor 文件保留其原始 MIT/Apache/LGPL 条款。Less Computer 语音 session、实时 -`TranscriptDelta` 和 Linux 三档热键事件已进入 Core/Host contract;真实设备、签名和 UI -验收继续按 M8–M10 单独取证。 diff --git a/docs/pr1019-2.0-final-review.md b/docs/pr1019-2.0-final-review.md deleted file mode 100644 index 1774c14a4..000000000 --- a/docs/pr1019-2.0-final-review.md +++ /dev/null @@ -1,90 +0,0 @@ -# PR #1019:2.0 合并前完整复核与修复记录 - -> 更新范围后的新团队复审另见[桌面复审闭环](./pr1019-desktop-review-2026-09-06.md)。其中R29起的问题覆盖此前审核未发现的生产路径;不能用本文旧轮次的零未关闭项代替最新结论。 - -> **2026-09-06需求调整**:本文保留此前问题/修复/测试证据;当前完成条件以[2.0需求](./2.0-requirements.md)为准。Windows/macOS完整保留各自1.x功能;Linux本批交付Core和[拆分交接资料](./linux-egui-handoff/README.md)。辅助全局热键等Linux剩余Host/UI缺口已列入[L01–L12](./linux-egui-handoff/02-gap-register.md)交egui团队,不再单独阻塞本批桌面2.0;本记录不能作为“Linux所有功能已完整”的证明。 - -本轮从 `2f961caec5d749ed444a0fa3dc24ef136a58f9a5` 开始,比较点为 -`beta` / `fc9824eeccf7218e3b44c1de6c6e284a4c494c81`;merge-base 等于该 base。 -平台优先级:Windows、macOS、Linux;同时覆盖 Android 条件编译和共享合同。 -本文件记录新发现、复现、修复及验证。此前 CI 成功是基线证据,不替代本轮行为复核。 - -## 问题登记 - -| 编号 | 优先级 / 平台 | 触发与证据 | 状态 | -| --- | --- | --- | --- | -| R01 | P1 / Windows | TSF Rejected/Failed 被映射为 CopiedFallback,却没有复制,因而跳过新 Adapter 的失败回退。 | 已修复;真实 Failed 与 OutcomeUnknown 分离,Windows IME 7 项通过 | -| R02 | P2 / 所有录音 Host | RecordingPlan 错读 history_max_entries,忽略独立 audio_recording_max_entries。 | 已修复;100/7 独立上限用例先红后绿 | -| R03 | P2 / 共享凭据事务 | CredentialDirectory 异步 load → mutate → save 无共享锁,并发创建得到相同渠道 ID;Tauri 的 revision CAS 会拒绝冲突保存,避免静默丢数据,但普通并发操作仍会失败,弱存储实现则会覆盖。 | 已修复;克隆共享事务锁,并发创建/重命名/激活回归通过;Tauri 持久层 CAS 保留 | -| R04 | P1 / 本地 ASR、跨平台 | activation/目录迁移在 await 前保存整份 preferences,随后提交/补偿覆盖期间编辑;排队 relocation 又使用锁外旧根。独立复核还发现旧渠道补偿会撤销准备期间的新选择。 | 已修复;原子更新所拥有字段、条件补偿、锁内读取根;全部可失败native操作在渠道最终提交之前;Local ASR 22 项通过 | -| R05 | P1 / Linux Less Computer | 第二轮 session 更新被 fresh=false 阻止,输出和审批全部丢弃。 | 已修复;每轮 User 接管 session,fresh 仅清历史;WSL 两轮回归通过 | -| R06 | P2 / Linux Provider 状态 | status 忽略 multimodal 总开关,和真实 pipeline 不一致。 | 已修复;复用 effective_pipeline_mode,开关组合回归通过 | -| R07 | P1 / Windows、macOS 本地 ASR | Generic prepare 和非 Generic preload 固定 Unsupported;Generic preload 读取旧 provider/model。 | 已修复;显式 target/provider type,核验真实 loaded 模型;生产 Tauri Adapter 回归通过 | -| R08 | P1 / Native ASR | 丢失动态超时和错误/取消释放;共享代次还可能遗留旧引擎或卸载新模型。 | 已修复;Core deadline、各 runtime 代次及锁内复验、Arc/Weak 所有权;边界/释放测试通过 | -| R09 | P2 / Windows 流式 | CR 被吞掉后计数减少,正常 CRLF 被 Core 误判部分写入。 | 已修复;已消费源前缀计数;Unicode 9 项和 insertion 11 项通过 | -| R10 | P2 / macOS 普通听写 | 非流式也强制切 ABC,TIS 错误阻止一次性听写。 | 已修复;只准备真实流式,失败回执由 Core降级一次性交付;Core回归通过,原生 TIS 待设备验证 | -| R11 | P1 / Linux 热键 | 启动未同步热键;已有 Less Computer UI 开关却被 settings 拒绝。 | 已修复;启动同步/插件重启重载、启用绑定与禁用解绑;Host contract通过 | -| R12 | P1 / 所有 Remote Input Host | stop 锁/同步等待阻止取消;Linux 丢 terminal;Tauri 全局 result 广播会发送本机/其它连接的听写。 | 已修复;保留可取消 session、异步 stop、逐连接事件隔离和迟到事件保护;Core 12 项及 Tauri Remote 4 项通过 | -| R13 | P2 / Linux QA | delta 覆盖全部状态,历史消失且仅显示最后一个 chunk。 | 已修复;按事件类型合并、终态以全量消息校准;WSL 增量回归通过 | -| R14 | P1 / Linux 普通落字 | 插件按其它按键更新全局 savedIc,听写可跨应用误写。 | 已修复;session ticket 冻结原 IC;真实 C++ InputContext 契约通过 | -| R15 | P1 / Linux Selection | PRIMARY 残留被当作原选区有效证明,光标移动后仍可能错误插入。 | 已修复;严格核对原 IC、文本、cursor/anchor;Debug/Release C++ 契约通过 | -| R16 | P1 / 共享普通听写启动 | AX/凭据捕获期间已有 lease 却无公开 session,Esc 无法取消,慢调用返回后继续录音。 | 已修复;首次 await 前公开 Starting;确定性取消回归及 Core API 73 项通过 | -| R17 | P1 / ASR 静默重试 | retry 的 provider start await 后未复验取消,Esc 只清旧 ASR,新返回的重试 ASR 仍接收 PCM/finish且失去取消路由。 | 已修复;资源注册与once标记同锁,迟到资源直接取消且不送PCM;15项Pipeline测试通过 | -| R18 | P1 / QA 启动 | Linux安装capture后没有arm/取消复验;两Host文本prepare在上下文await后才注册,取消期间无资源可清,迟到注册可泄漏旧session。 | 已修复;await前登记owner,返回后身份复验,Linux arm;两Host Adapter回归通过 | -| R19 | P1 / QA 转写取消 | Host take唯一capture后,Core finish关闭handle并移动transcription,Esc不能取消正在finish的ASR。 | 已修复;共享capture保持Finishing阶段取消句柄,ASR恰取消一次回归通过 | -| R20 | P1 / QA 静音停止 | 延迟静音任务调用无session限制的toggle,可能在旧轮取消后误开新录音或停止下一轮。 | 已修复;stop_recording按每轮token原子校验与转换,21项QA contract通过 | -| R21 | P2 / 手机默认操作模式 | 桌面Remote设置可选默认hold,但H5无已保存模式时固定toggle,设置没有生产消费者。 | 已修复;两Host渲染白名单默认值,手机显式偏好优先;7种组合及音频队列回归通过 | -| R22 | P1 / Linux 原生打包兼容性 | runner的Fcitx版本不支持复制/移动SurroundingText,保存该对象快照导致插件和CTest目标编译失败;WSL较新库未暴露差异。 | 已改为自有普通值快照;5.0.14旧头文件复现后构建通过,5.1.19本机Release CTest通过;完整22.04制品待新CI | -| R23 | P1 / Selection Voice 转写取消 | 与QA相同的take/finish(self)所有权断点导致转写阶段Esc无法取消ASR。 | 已修复;复用共享capture控制,原session限定Stop/Cancel及迟到启动/终态保护通过 | -| R24 | P1 / Windows TSF | 迁移删去native timeout/cancel HRESULT的OutcomeUnknown分类;原生已进入COM写入时超时仍可能迟到提交,随后SendInput回退会双写。 | 已恢复两种HRESULT分类及session/protocol响应验证;IPC10项通过 | -| R25 | P1 / 前台应用快照 | cursor开关错误地同时禁止无需AX的front-app查询,默认关闭丢历史应用/按应用润色/macOS Terminal自动LF。 | 已恢复capture(false)前台查询,禁用时丢弃文档文本;Terminal换行及历史归属回归通过 | -| R26 | P1 / CLI取消 | 两Host在启动取消/输出drain前等待stdin写完;不读stdin的大prompt阻断Esc,Tauri PATH查询亦有取消盲区。 | 已统一stdin/drain/wait/PATH/快照取消;Windows suspended+Job,1/4MiB、孤孙、future-drop、自然成功回归通过 | -| R27 | P1 / Linux渠道删除 | 删除仅移除metadata,保留SecretService和keys;同厂商ID重建时继承已删除账号的密钥。 | 已按namespace+channel清理;部分删除失败保持可重试,未提交孤立secret不可读;8项凭据回归通过 | -| R28 | P1 / Linux 1.x凭据迁移 | 新SecretService命名空间没有读取旧Tauri service/分片/legacy文件;升级后原渠道与密钥丢失。 | 已幂等导入旧JSON/分片/平铺账号,保留来源;新配置优先、失败重试、home_dir=None零迁移访问;旧格式4项及Linux cfg验证通过 | - -## 本轮覆盖范围 - -- Windows:启动、IPC/热键入口、录音、TSF/SendInput/Clipboard、目标恢复、Foundry/Sherpa、本地模型、凭据和安装包。 -- macOS:启动、全局热键、焦点/AX/隐私、输入源/换行/流式、MLX/Whisper/Apple Speech、进程与打包。 -- Linux:生产 factory、egui UI、Provider/凭据、本地模型、QA/Selection/Remote、fcitx5、事件重放、打包。 -- Core:所有领域入口、会话/取消/唯一终态、凭据/偏好事务、模型迁移、ASR/LLM、Agent、历史与录音保留。 -- 合同与仓库:97 个 UserPreferences 字段消费者、2.0 Rust/TS/JNI/Linux DTO、条件编译、锁文件、CI、发布脚本和迁移文档。 - -## 验证记录 - -修复前建立能失败的针对性行为验证;修复后执行相关测试,收尾执行完整本地检查及最终提交的远端 CI。 -本次 Windows 本地已完成:前端 build + 66 项;Core 705 passed / 1 ignored,contract 5、Local ASR 21、Marketplace 17、Prompt 3、QA 19、Remote 12、Selection 15、Selection Voice 15 全部通过;Core strict clippy通过。独立复核补修后,Local ASR 包含新增渠道补偿回归的22项再次全部通过。 -Tauri library 422 项通过,Rust 1.88 check通过,独立 backend contract 1项及其1.88编译通过。 -Linux Windows侧 38 library + 4 host contract 及strict clippy通过。WSL 45 library + 2 UI + 4 host contract及strict clippy通过,3项真实设备测试ignored;C++真实InputContext Debug/Release契约通过,含目标销毁后两类ticket失效。首轮WSL增量编译因内存不足中止,改用 `CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=1` 后完整通过。 -Core/Linux依赖、command/event基线(199/30/30)、secret surface、测试隔离、runtime seam、Linux公共面、source contract、workspace fmt与diff门禁均通过。 -`remote:result` 在历史兼容基线中保留并标为 retired;H5 wire result 保持兼容,实际socket只消费归属于本连接的Core事件。 - -子代理顺序:初始专项事实核查 → 修复完成后一名独立复核 → 提交推送 → 两名独立全 PR 审核。 -Standards(规范/边界/重复策略)与 Spec(缺失/错误行为/兼容性)分别记录结论。 -最终远端提交、CI和推送后审核结果记录于原 PR #1019 描述,避免把历史run误标为本次证据。 - -提交前独立复核已通过:Standards无未关闭项;Spec发现的R04渠道补偿及R12取消后重开两项遗漏均已修复并复读确认。后者以真实Core+永不完成的Host stop复现,Linux Remote 5项及strict clippy通过;终态后迟到cancel保留清理future。 - -已排除的候选:设置页同步切换ASR与异步渠道保存的混合并发不会静默覆盖元数据;`apply_credential_metadata` 已在持久层锁内检查 revision CAS,旧快照明确失败。该保护通过继续追踪持久层确认,未为此添加第二套机制。 - -### 2026-09-06 最终全 PR 审查的追加修复 - -两名全 PR 审核者又发现 R17–R28;其中 R21 来自97项偏好消费者追踪,R22来自真实Ubuntu22.04打包失败。这些问题均已实施并由独立提交前审查复读通过(Standards/Spec无未关闭确定项)。 - -追加修复后的本地验证:Core **713 passed / 1 ignored**,8组contract全部通过(5/22/17/3/21/12/15/15);Tauri **429 passed**;Linux Windows-host **48 lib + 4 host**;前端build和66项全部通过。Core/Linux严格clippy、全部改动的rustfmt和架构/秘密/基线门禁通过。Rust1.88 Tauri check通过。 - -Windows进程回归使用真实子进程树,验证大stdin阻塞、启动后取消、父先退出、future drop和自然完成的区别;只对本次新建进程作Job控制。Fcitx旧头文件验证准确复现了5.0.14的编译错误,修复后旧头文件构建及本机5.1.19的CTest通过;完整Ubuntu22.04运行仍由最终CI确认。 - -Linux迁移明确依赖调用者提供的home_dir;None不读取真实HOME或系统旧vault。旧文件仅来自该目录,旧来源不删除,Secret Service暂不可用不阻断本地ASR但保持迁移未完成标记。测试/自定义工厂不再依靠cfg(test)判断是否可读系统凭据。 - -旧head `d4430fcf` 的上游CI和桌面构建成功,但fork Linux制品因R22失败;Android APK在Rust编译后因Maven Central Kotlin POM GET 403失败。它们不能计为追加修复的最终CI,最后提交会重新触发全部验证工作流。 - -## 真实环境证据边界 - -当前会话可验证源码、Windows/WSL 自动测试和远端各平台构建。实际 GUI 焦点、麦克风断连、 -TSF/AX/fcitx5 与目标应用交互、系统凭据服务、安装升级回滚及正式签名/公证,需要相应设备证据。 -未完成的设备验证将逐项保留,不能因 CI 或 fixture 成功而标为已完成。 - -当前2.0 macOS普通听写复用了原选区路径的app/pid目标恢复,1.x普通听写未调用该路径;不能倒推为既有普通听写能力。同应用多窗口/多输入控件的精确焦点与光标恢复仍不在已证范围,真实AX/TIS/Secure Input行为需分别验证。 - -既有未跟踪 `openless-all/app/src-tauri/openless-ime-payload/` 不属于本轮修改。 diff --git a/docs/pr1019-desktop-review-2026-09-06.md b/docs/pr1019-desktop-review-2026-09-06.md deleted file mode 100644 index 2870aae6a..000000000 --- a/docs/pr1019-desktop-review-2026-09-06.md +++ /dev/null @@ -1,186 +0,0 @@ -# PR #1019:更新范围后的桌面复审闭环 - -范围依据:[2.0需求](./2.0-requirements.md)与[桌面验收清单](./2.0-desktop-acceptance.md)。Linux剩余Host/UI按[交接目录](./linux-egui-handoff/README.md)移交,不计作本批桌面功能缺陷。 -第一轮固定比较:`beta@fc9824ee` → `dc350780`;审查整个PR,不仅审查文档提交。本文续记此前R01–R28之后发现的问题。 - -## 第一轮:新的独立团队 - -| 审查方 | 范围 | 确定问题 | -| --- | --- | --- | -| Windows专项 | 原生输入/热键/录音/本地ASR/进程Job/窗口与打包;对照1.x生产路径 | R32、R33、R35 | -| macOS专项 | AX/TIS/隐私/流式/原生ASR/窗口/CLI/构建;对照1.x生产路径 | R30 | -| 共享产品专项 | React IPC/事件、渠道/凭据/模型、QA/Selection/Agent、历史/词典/风格/市场/Remote | R31、R34 | -| 主代理复核 | Core主听写/停止/取消/重试/插入、需求和证据边界,逐条确认团队发现 | R29及全部问题的修复复核 | - -本轮Standards未确认独立硬性违规;首轮独立审查发现7项确定问题,综合验证与修复追踪追加R36。内部审核不替代GitHub正式批准,也不证明真实设备功能全部通过。 - -## 问题与验证登记 - -| ID | 问题、基线与影响 | 修复及验证状态 | -| --- | --- | --- | -| R29 / P1 | Core停止请求在Starting等待时重新读取当前session;取消A并启动B后,旧stop会完成B并落字,违反D02会话隔离 | 已固定首次目标session;公开Core回归先红(返回B的Inserted)后绿 | -| R30 / P1 | Core流式取消未排空native write即恢复TIS,且先释放voice lease;旧CGEvent输入可能与新会话重叠。1.x先等typer结束再恢复,违反D09 | 已修复:排空后恢复、清理后释放占用;一次性落字也等待已提交效果,收尾由Host executor持有,不随stop调用方丢弃。流式/一次性/调用方drop三项先红后绿 | -| R31 / P1 | QA dismiss等待旧runtime.cancel后无条件清snapshot/HideQa;期间重开的新对话被清空,违反D10 | 已在首await前逻辑关闭,异步部分仅清旧owner;新对话/show-only/新preview三项先红后绿,全QA合同24项通过 | -| R32 / P2 | Windows普通落字恢复原目标失败后直接返回错误,遗漏1.x允许时的copy-only兜底,违反D09 | 已恢复按开关复制降级,不向新焦点粘贴;native源码合同先红后绿,既有结果映射随全Tauri测试通过 | -| R33 / P2 | Windows普通落字复用选区恢复逻辑,漏掉1.x的IsIconic→SW_RESTORE,最小化目标不能恢复,违反D09 | 已补还原后激活和原目标指纹复核;native源码合同先红后绿,真窗口证据单列 | -| R34 / P1 | 本地模型UI在Core激活前创建/启用/置顶渠道,提前修改active;模型缺失或prepare失败仍破坏原云渠道,违反D06 | 已改为Core准备成功后一次metadata提交;三runtime及缺失/禁用渠道回归先红后绿,含并发编辑共24项合同通过;前端source合同约束新生产路径 | -| R35 / P1 | Windows Less Computer窗口虽存在,设置组件、语音快捷键编辑和原生监听仍被macOS条件挡住,违反明确承诺的D12 | 已补内外层UI和原生热键;hook context按安装线程隔离,注册回执参与设置事务,迟到注册核对目标;重绑/禁用只清所属Starting/Recording slot。回归及全Tauri通过,native注入证据见下 | -| R36 / P1 | Less Computer取消会释放Capture,但capture_cancelled把不存在/被替换的lease判为未取消;冷启动迟到后可继续录音,现有headless示例亦失败 | 已将失去捕获所有权判为失效,首await前claim、context/native/转写后复验;取消标记先于资源等待,迟到事件不触碰Agent run。两类回归先红后绿,Less Computer24项及headless示例通过 | - -## 本轮已取得的自动证据 - -- 文档提交`dc350780`:15个文档文件,链接/空白检查通过,已推送原PR分支。 -- 第一轮Windows专项:183项限定Rust测试及4项Node合同通过;包含真实Job进程回归,不代表真实输入/设备验收。 -- 第一轮macOS专项:4项源码/打包合同通过;主机Windows,Speech检查按平台跳过,未执行本地macOS原生测试。 -- 第一轮共享产品:TypeScript/Vite与66项前端/合同测试通过;后续修复后还须重新完整执行。 - -### 第一轮修复后的综合验证 - -- Core:718 passed / 1 ignored,领域合同5/24/17/3/24/12/15/15全部通过。 -- Windows Tauri:434 passed / 1 ignored;Rust1.88 `cargo check`通过。ignored为需显式触发的原生键盘注入smoke,不是普通单元测试失败。 -- 前端:TypeScript/Vite构建与67个测试入口通过,含新增Windows原目标合同与更新后的Core激活接线合同。 -- Linux Windows-host:48 lib + 4 host通过;真实Linux条件编译/设备效果由对应runner/设备单列,不把零运行的cfg测试算通过。 -- Core/Linux严格Clippy与依赖、秘密、隔离、runtime seam、公开面、command/event基线检查通过;headless示例重新可运行。 -- Windows双monitor真实smoke曾通过独立收键、关闭和重建;末次复跑`SendInput=0`未通过,记录为原生注入环境未稳定复验,不宣称重复设备验收成功。最终主听写/ASR/Agent真实设备闭环仍待对应验收。 -- 文档head `dc350780` 的CI `33983624572`四平台通过;这只是修复前代码证据,新修复提交必须重新运行CI。 - -## 后续轮次记录 - -第一轮修复、综合验证并推送后,派全新的团队审查整个PR;若发现确定问题,继续修复、验证、推送,再交新的团队复审。准确head、测试计数和最终结论在完成时补录,不沿用旧head证据冒充新验证。 - -### 第二轮:`fc9824ee...98a0ca38` - -两名全新Windows/macOS审查员交叉覆盖整个PR的原生路径、React入口与共享领域,主代理补充复现及修复复核。不是仅审查第一轮补丁;本轮确认以下9项Spec问题,未确认独立Standards违规。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R37 / P1 | Less Toggle/Auto松键后Esc仅清Core,Host Recording slot常驻导致永久无法再开;关闭面板也未清原生捕获 | 已统一Esc、关闭、胶囊及CLI取消的所属capture收尾;Core CLI回归先红后绿,保留QA独立作用域;Host可取消后复录 | -| R38 / P2 | Less桥丢弃原生Instant,在ASR冷启动排队后重建now,把Auto短按当成长按 | 已透传modifier/combo真实Instant及generation;450ms启动+50ms短按回归先红后绿 | -| R39 / P2 | 录音控制ready-check与pending.push之间可被attach/flush穿过,静音Stop永久丢失 | 已固定pending→slot锁序;Starting保存同一control,静音和胶囊Stop交接前后恰执行一次 | -| R40 / P2 | Less录音、电平与转写没有生产事件,Composer只等旧operating胶囊状态,Windows无glow可代替 | 已接Core typed VoiceState、React/胶囊;保留一条带原seq/session的有效投影供截断重放恢复,拒绝旧终态覆盖新录音;语音忙时保留草稿且不提交 | -| R41 / P1 | Less成功非debug语音未删除WAV,回退到共享Core后丢失1.x成功归档清理 | 已按成功/非debug条件discard,debug与失败保留;录音保留策略回归先红后绿 | -| R42 / P1 | text_inserter.begin尚未登记时取消会释放voice lease;迟到TIS准备/恢复干扰新会话 | 已在begin前登记Shared preparation并持hold,取消与迟到start共同等待一次恢复;原回归和丢弃start回复回归通过 | -| R43 / P1 | 会话中关闭cursor_context_enabled后,完成仍按冻结true重启AX编辑观察 | 已与设置事务串行并读当前开关,普通/流式两分支回归先红后绿 | -| R44 / P1 | QA/Less先release gate再等待native stop,旧mute恢复可破坏新录音;冷ASR取消后仍可开mic | 已分离逻辑取消与资源hold,初始化/stop/ASR清理完毕前Busy;recorder前检查token,已提交任务持有收尾;也覆盖主听写无插入、初始化/停止/取消回复被丢弃与shutdown清理 | -| R45 / P2 | QA Completed/Failed/Cancelled被`phase != Idle`误判仍在使用语音,阻止Less启动 | 已只阻止真实活动阶段,三个QA终态保持面板打开也可启动Less;公开回归通过 | - -第二轮问题全部修复、测试并推送前,不将`98a0ca38`标为最终闭环;下一轮须由新的审查团队复审。 - -第二轮定向证据:Windows Host最终8项通过;Core生命周期10项(含最后CLI回归)、typed VoiceState合同及最近投影回归通过;插入准备、隐私、丢弃回复的回归通过。曾挂起的旧stop测试因owned startup首次poll前使用notify_waiters丢通知,已改为保留permit的notify_one,单项再次通过;不是跳过测试。 - -第二轮综合验证:Core 722 passed / 1 ignored,生命周期10项、其它合同6/24/17/3/24/12/15/15全部通过;Windows Tauri 440 passed / 1 ignored;前端构建与67个测试入口通过;Linux Windows-host 48+4、headless示例、Core/Linux严格Clippy、依赖和公开合同检查通过。MSRV与最终远端CI按精确修复head记录。`98a0ca38`的CI `34002890692`四平台通过,但不能代替第二轮修复提交的CI。 - -第二轮修复提交`c0807d97`已推送;CI `34006442463`的Windows、macOS、Linux、Android全部通过。Linux artifact按PR条件跳过,不算产物验收。 - -### 第三轮:`fc9824ee...c0807d97` - -两名新的独立审查员交叉检查桌面原生路径与整个共享Core,主代理复现并处理完成回调竞态。本轮确认以下6项Spec问题,未确认独立Standards违规。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R46 / P1 | Selection Voice通用Esc/故障/shutdown只取消Core状态,Host麦克风与slot仍存活;冷启动也可能留下目标owner | 在首await前绑定既有RecordingControlSink,所有终止入口只调用同一所属capture清理;同步撤销Starting目标,原生stop/ASR清理完成前保留resource hold。Core与真实Tauri Host seam先红后绿 | -| R47 / P2 | QA Completed/Failed/Cancelled仍拦截通用取消路由,Selection Voice不能收到Esc | 路由仅匹配Recording/Thinking/AwaitingApproval;三种QA终态与Selection并存的公开回归先红后绿 | -| R48 / P2 | Less在Agent启动前遇到recorder.stop、ASR.finish或空转写错误,仅回Idle/日志,没有可见错误 | 复用capture_fault原子终态认领,发布一次安全错误且返回脱敏错误;用户取消不报Error。各错误及取消排除回归先红后绿 | -| R49 / P2 | Host翻译标志可跨按钮/CLI/静音停止泄漏到下次会话;Starting捕获上下文期间的翻译请求又会丢失 | 删除Host重复状态,Core保存当轮意图并在所有停止入口统一应用冻结上下文,显式stop override优先;冷启动、三种停止入口与失败补偿回归通过 | -| R50 / P2 | Host重建Core胶囊payload丢失warming等字段,native start返回后首PCM未到却显示已就绪 | 透传完整payload;兼容缺省的recordingReady仅在首个实际PCM回调置真,零电平/0ms首帧亦有效;Less遵循同一首帧语义。Core、Host和事件桥回归通过 | -| R51 / P1 | A发布Completed后等待设置锁,期间A取消且B启动;A迟到回调会清B状态并为旧文本注册编辑观察 | 观察注册在锁后复核session,迟到原生注册按generation撤销;完成复用按session复位并检查反馈归属。确定性并发回归先红(B被置空)后绿 | - -第三轮综合验证时,新增wire字段暴露canonical fixture未同步、旧热键source合同仍绑定被删除的Host路径;同步实际合同而不跳过断言。完成回调并发测试改用多worker测试runtime,保持Core生产runtime边界检查不变。 - -第三轮最终本地验证:Core 725 passed / 1 ignored,生命周期14项、其它合同6/24/17/3/24/12/15/15全部通过;Windows Tauri 444 passed / 1 ignored;前端构建与67个测试入口通过;Linux Windows-host 48+4、headless示例、Core/Linux严格Clippy、Rust1.88 Tauri检查、依赖/秘密/隔离/runtime/公开面/command-event检查均通过。修复提交的远端平台CI与第四轮新团队结论仍单独记录,不把本地验证表述为macOS设备验收。 - -### 第四轮:`fc9824ee...90cbd014` - -新的Windows/macOS审查员再次审查整个PR,主代理复核共享合同和数据接线。本轮Standards未确认独立违规;确认以下3项Spec问题。第三轮提交已逐Git对象核对SHA后上传至原PR,未重写历史。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R52 / P2 | 市场服务在构造时独立保存HTTP client,绕过共享net代理缓存;关闭useSystemProxy仍使用系统代理,且运行中切换不生效。1.x市场/OAuth使用共享client | 已删除固定client和重复builder,逐请求URL复用共享net缓存,仍禁止重定向并为loopback直连;独立进程假代理回归先红后绿,覆盖启动关闭、同服务true→false→true及混合配置OAuth回环 | -| R53 / P2 | 取消A的原生清理已完成并释放资源,但A调用方尚未恢复时B可以启动;A迟到回复无条件发送HideDictationFeedback。其他失败/shutdown出口也缺归属检查 | 已统一所有Hide出口的session守卫,检查和同步Host enqueue保持同锁;手工延迟cancel回复的公开回归先红(Hide覆盖Show)后绿,R51回归保持通过。Tauri当前该HostAction是no-op,故这是共享Core合同问题,不声称已复现桌面胶囊故障 | -| R54 / P1 | Remote鉴权在获取lifecycle锁前读取旧PIN;轮换持锁期间已读取旧值的鉴权等待后可在新服务代次放行,生产WS没有第二次PIN检查 | 已将PIN读取置于与轮换相同的生命周期临界区;公开接口并发回归先红(Ok,预期BadPin)后绿,旧PIN被拒、新PIN仍可认证 | - -第四轮最终本地验证:Core 726 passed / 1 ignored,生命周期14项及其它合同6/24/18/3/24/13/15/15通过;Windows Tauri 444 passed / 1 ignored;TypeScript/Vite与67个前端测试入口、Linux Windows-host 48+4、headless示例、Core/Linux严格Clippy、Rust1.88 Tauri检查、六个架构/安全/兼容基线检查和定向格式检查均通过。独立修复复核确认R53全部10个原有Hide出口共用归属判断,没有复制遗漏。此提交后继续第五轮全新团队审核。 - -### 第五轮:`fc9824ee...87e09e1c` - -新的Windows/macOS审查员各自完整审核该PR,未确认独立Standards违规;确认两项Spec问题。`90cbd014`的Windows/Linux/Android CI通过,macOS被新提交自动取消,不计通过。为`87e09e1c`发起的桌面手动打包验证`34009260079`在本轮发现新问题后主动取消,待代码复审收敛再为最终head重建,不将旧产物当作新head证据。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R55 / P2 | Tauri与Linux Host注入仅查当前线程Handle的TokioTaskSpawner;真实麦克风OS线程没有runtime上下文,QA/Less/Selection静音与故障收尾任务被直接丢弃,检测器已fired后不会重试 | Tauri使用框架共享executor,Linux捕获已有Host Handle并用于所有原生线程;默认构造无runtime时在开仓前显式失败,自定义Host可显式注入。两Host真实std::thread回归先红(Disconnected/RecvError)后绿,Core不私建runtime;交接01及main构造已同步 | -| R56 / P2 | EventBus在锁外分配seq,backlog更新与broadcast也不处于同一临界区;并发原生/Agent事件乱序,UI最大seq去重丢有效事件,replay水位可能先于入队 | 8线程回归先红(首条seq=6,预期1)后绿;复用backlog锁覆盖分配、投影、入队及发送,replay在同锁内读取水位,事件完整10项通过。不是只对重放结果排序 | - -第五轮最终本地验证:Core 727 passed / 1 ignored,生命周期14项及其它合同6/24/18/3/24/13/15/15通过;Windows Tauri 445 passed / 1 ignored;Linux Windows-host 50+4、headless、TypeScript/Vite与67个前端测试入口、Core/Linux严格Clippy、Rust1.88 Tauri检查和六个架构/安全/兼容基线检查通过。R56线程回归另连续10轮、共8万消息通过;独立有界复核未发现锁序或消费者语义问题。R55两端从真正OS线程调用生产同型spawner,避免只在tokio测试线程内验证。旧大合同的构造示例也已链接当前executor前置说明。修复提交后交第六轮全新团队,不提前宣称闭环。 - -### 第六轮:`fc9824ee...588fb35b` - -两名全新审查员再次覆盖整个PR;macOS专项没有新增确定发现,Windows专项确认以下一项同时影响Windows/macOS的共享Tauri录音回归。Standards没有独立硬性违规;同时纠正两处非阻塞过时注释(Windows Less入口、Codex非流式能力/共享分流位置),不将其计作新功能缺失。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R57 / P2 | 可选录音准备被升级为硬依赖:输出设备不存在/静音操作失败,或归档目录路径准备失败时,TauriAudioRecorder直接返回Err而不启动可用麦克风。1.x对静音失败仅告警,归档路径以Option传入Recorder | 已恢复辅助效果告警降级;三种失败组合以旧策略复验均返回Err,新策略回归通过;成功guard持有/释放及禁用不调用平台另有回归。真实Recorder.start错误仍传播,归档为None时不伪造文件。这里验证准备函数与生产接线,不声称真实麦克风已实测 | - -第六轮独立Windows定向验证:Agent 5项、hook 12项、IME 50项通过,交互键盘注入1项未运行;macOS相关源码合同通过,Speech检查在Windows明确跳过。以上并未覆盖R57,不能以绿灯代替新增回归。修复提交后继续第七轮全新团队审核。 - -第六轮最终本地验证:Tauri 447 passed / 1 ignored,Rust1.88检查通过;Core 727 passed / 1 ignored、生命周期14项及全部领域合同、Linux Windows-host 50+4、headless、严格Clippy、前端构建67项和六个架构/安全/兼容基线检查通过。`588fb35b` CI `34009963206`四平台已通过,但修复提交须重新独立验证;Linux artifact条件跳过不算产物通过。 - -### 第七轮:`fc9824ee...5f668b1d` - -两名全新审查员确认3项Spec问题,主代理再复现QA跨线程关闭竞态,合计4项;Standards无独立硬性违规。macOS目标恢复失败复制候选未计缺陷:被引用的1.x失败分支在macOS不可达,不能仅按共享函数片段推断旧平台语义。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R58 / P2 | Windows默认Agent裸名无法定位npm生成的.cmd入口,产品推荐的安装方式导致检测/运行NotFound | Windows Host按有效PATH解析必要可执行扩展,显式路径不变,复用stdlib转义及原有Job/管道;真实shim先红NotFound后绿,含.exe/显式.cmd/裸名.cmd和空格、引号、元字符参数 | -| R59 / P2 | macOS Qwen/Whisper同属Generic但有独立cache;原子激活不释放同runtime旧lease,状态又优先报Qwen,切Whisper后仍显示旧模型并长期驻留 | 已按真实previous target认领/预载/释放代次;cache同锁核对模型和owner,状态按目标家族读取。Core合同先红双缓存后绿,覆盖同ID新代次、普通使用撤销旧权限、渠道切换和失败补偿;普通ASR迟到加载可用未缓存Arc继续本轮,激活所属迟到加载报错且不覆盖新cache | -| R60 / P2 | Foundry GPU→CPU切换/首次CPU下载提示被延迟缓存到转写完成,Notification又无Tauri消费者,原1.x实时反馈消失 | Native回调立即发布冻结session通知,released拒绝迟到回调;桥核验当前归属/阶段并显示胶囊,QA离开转写、取消与窗口接管清所属提示。实时/展示/清理回归先红后绿,无真实GPU验收声明 | -| R61 / P2 | QA dismiss在两个同步state锁之间允许另一OS线程重开;旧操作清空并隐藏B,发生在首await之前,R31原异步清理回归未覆盖 | 公开事件订阅边界控制线程交错后确定红:B录音返回Cancelled。presentation锁收口录音/文本/仅显示与关闭,所有native await在锁外;状态/回调/模式/答案替换与对应事件在同一state guard内发布,旧阶段先核对session/phase,Host动作不持state锁。三种重开及QA合同25项通过;独立复核又确认并关闭模式切换/撤回旧答案快照窗口 | - -修复提交后继续第八轮全新团队审查;未运行的macOS模型、真实GPU/GUI等仍按设备边界记录,不以源码合同替代。 - -第七轮最终本地验证:Core 727 passed / 1 ignored,生命周期14项及合同6/26/18/3/25/13/15/15全部通过;Windows Tauri 452 passed / 1 ignored;Linux Windows-host 50+4、headless、TypeScript/Vite与67项前端入口、Core/Linux严格Clippy、Rust1.88 Tauri和六个架构/安全/兼容基线检查通过。Windows CLI回归还覆盖大小写重复PATH的实际最后覆盖值;R59普通ASR加载与自动清理出口均保留新owner,R61所有当前修改已完成有界复核。实际macOS cache代码仍必须由新head的macOS CI编译,不沿用前一提交结果。 - -### 第八轮:`fc9824ee...2d8fd1b7` - -两名全新Windows/macOS审查员覆盖整个PR,主代理沿失败及并发链路补充复现,共确认以下8项Spec问题;没有独立Standards硬性违规。`2d8fd1b7`的CI `34013888722`四平台通过,但仍存在这些行为缺陷,绿灯不能替代产品合同复核。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R62 / P1 | Windows npm OpenCode `.cmd`可检测但无法运行:Core总会加入多行自动化提示,旧Argv传输被Rust Windows批处理参数校验拒绝 | 复用现有stdin管道;官方OpenCode v1.18.29及v1.2.18均读取非TTY stdin。实际Windows `.cmd`进程fixture先检测、再传Core多行提示,先红(换行参数被拒绝)后绿;不是已运行真实模型的OpenCode验收,不引入shell自拼转义 | -| R63 / P2 | Selection Voice把Windows已发送的PasteSent判成失败,预览可重复确认且历史丢失;QA确认又丢弃真实回执 | PasteSent贯通原生映射、Core状态、历史与IPC,保持“已发送”不同于“已确认插入”;直接/QA预览、重复确认、失败重试与wire回归通过 | -| R64 / P1 | 默认Raw不经过Polishing,却由真实Pipeline无条件发送最终PolishDelta,被Backend判InvalidState;finish异常也没有回收残留engine资源 | Raw仅返回最终EngineResult并走一次性落字,不准备流式/TIS;只在真实LLM阶段发PolishDelta。真实Pipeline连续两次Raw先红后绿;EngineFailure清理回归先红cancel=0后绿cancel=1,复用已有owned清理入口 | -| R65 / P1 | QA语音编辑已持QA麦克风lease,却通过SelectionVoice.begin再次领麦克风而Busy;文本预览还会长期占音频,阻止语音续问/主听写 | 纯编辑直接进入Processing,不创建音频占用;Preview只释放匹配session的逻辑lease,保留原生cleanup hold。公共回归原4项Busy失败修至5项全通过,覆盖活动录音互斥及原生清理等待 | -| R66 / P1 | QA撤销在会话owner读取、预览撤回、答案写回之间被新turn抢占;确认应用的同根因可拿B预览应用旧文本,或由旧完成回调关闭B | 撤销与签发均收为Core单个按turn校验的状态事务,删除Host三段编排及无身份replace_last_answer;原生完成仅dismiss_session所属回合,stale不撤销真实回执。两类撤销与两类确认窗口均先红后绿,共5项覆盖同conversation、新conversation、仅show空面板、失败重试及重复确认;主按钮/热键dismiss语义不变 | -| R67 / P1 | 旧CLI/按钮停止与已排队物理Press重叠,B已返回Started但A清掉其generation,Hold松开变Noop | 公开入口先真红;只改reset锁序仍红,最终将phase读取、Start决定和真实Starting认领置于同hotkey锁,锁外继续原异步启动。普通入口复用同一认领函数,无新增状态;32轮受控交错绿,runtime seam检查通过 | -| R68 / P1 | QA/划词语音迁移后被Tauri无条件写WAV,违反1.x不落盘边界;归档删除失败还阻断已有内存转写 | Core冻结RecordingPlan.archive_enabled=false,Host跳过归档目录/文件及prune,保留实际停止/清理错误;两种QA管线、划词语音与主听写/Less对照回归先红Persistence后绿,不以忽略删除失败替代不落盘 | -| R69 / P2 | Selection A发Completed后persist重新读全局state;此时B已begin,A历史被写为B/空原文及错误指标 | 在完成原锁内克隆既有SelectionState,并只持该快照持久化;公共事件边界回归先红空原文后绿,核对A原文、结果、LLM与耗时;没有新增并行状态或锁 | - -第八轮最终本地验证:Core 729 passed / 1 ignored,生命周期15项及合同6/26/18/3/5/25/5/13/16/16全部通过;Windows Tauri 455 passed / 1 ignored、Rust1.88检查通过;Linux Windows-host 50+4、headless示例、Core/Linux严格Clippy、TypeScript/Vite与68个前端入口、六个架构/安全/兼容基线检查全部通过。Tauri首轮并行测试在共享全局side-combo监听的既有测试无超时recv处挂起,已终止且不计通过;最终完整测试按单线程执行,两次通过,没有跳过该测试或改动生产监听。Raw/归档独立交叉复核无新增发现,R67已通过完整Core复验,R66完整5项通过后停改。修复提交后继续第九轮全新团队审核;新head平台CI与真实设备验收仍分别记录。 - -### 第九轮:`fc9824ee...d16324c3` - -Windows/macOS两名全新审查员均已完成整个PR的独立复核:Standards无新增确定违规,Spec共3项确定问题,主代理逐条核对并复现。`d16324c3`的CI `34016669726`四平台通过,Linux制品任务按PR条件跳过;该结果属于修复前head。 - -| ID | 确定问题 | 修复/验证状态 | -| --- | --- | --- | -| R70 / P1 | Windows主听写在等待上下文/凭据后才捕获落字目标;期间A切到B,最终把B当作原目标,违反1.x首await前冻结及D09 | `TextInserter::capture_target`在共用认领阶段同步冻结本轮目标,仅异步`begin`准备原生输入源;不增加全局槽/Map。直接/CLI/物理入口A→B先红后绿,取消后新B和不插入零准备共3项通过;macOS共享新机制,但不误称其1.x已有早期恢复 | -| R71 / P2 | QA普通语音的状态/电平仅发送给QA面板,原Recording/Transcribing/Polishing/终态胶囊反馈丢失,违反D03/D10 | 恢复同一Core事件的Host胶囊投影,区分语音/文本、CPU提示及旧owner;已呈现QA使用实际返回epoch,同锁检查/写入,旧终态及自动隐藏不覆盖后继原生提示。生命周期、迟到Recording和原生CAS出口均先红后绿;首帧快失败等小影响边界按维护者决定暂缓,见收口说明 | -| R72 / P2 | 共用context无条件解析ASR/LLM/Omni:Raw被禁用的无关LLM阻断,Omni被禁用的传统ASR阻断;QA文本也被无关ASR阻断 | Raw和Omni分别公开API先红InvalidState后绿。按实际听写/纯ASR/QA文本/QA语音用途解析所需渠道;Raw保留可选LLM解析失败,停止时临时翻译不能切到后来启用的渠道。实需ASR/LLM仍报错,QA文本只要求其响应provider | - -同时订正macOS证据表述:1.x普通听写未调用选区的app/PID恢复路径,当前2.0才复用该机制;不能将新实现倒写成旧基线能力。该项为文档事实更正,不另计运行时缺陷。 - -第九轮最终验证:Core 731 passed / 1 ignored、生命周期15项与合同6/3/26/18/3/5/25/5/13/16/16、Linux Windows-host 50+4、Core/Linux严格Clippy、headless示例、六个架构/安全/兼容检查、TypeScript/Vite及68个前端入口均通过。Windows Tauri完整串行测试460 passed / 1 ignored,Rust1.88检查通过;9个改动Rust文件的限定格式及diff检查通过。以上为本地自动证据,不代替最终head CI和真实设备验收。 - -### 发布优先收口 - -维护者明确要求:若无重大问题,不再修复小影响或不能稳定复现的问题,留下文档后提交推送原PR,准备合并。因此不启动第十轮全面审查,不继续扩展Host显示origin、Core QA开始顺序和旧Selection Voice定时器改造;这些尚未完成的方案未纳入本次代码。 - -已知暂缓项仅涉及QA胶囊展示:首条Recording尚未被桥消费时极快失败可能没有胶囊Error;旧Selection Voice自动隐藏及极短跨会话切换可能让未呈现首帧的QA提示被忽略或提前隐藏。QA面板仍接收Core错误/终态事件,当前证据未表明这些显示边界影响回答、录音结果或数据。其依据是源码时序分析,不宣称真实设备稳定复现。后续如有稳定用户复现或影响扩大,再补完整显示origin绑定和统一定时器归属。 - -本次两名审查员的整个PR复核及有界核验均等待最终返回。最终提交、验证计数和新head CI状态写入原PR描述;不把上一head成功或未执行的设备/制品检查记作本次通过。验收政策同步见[发布收口决定](./2.0-desktop-acceptance.md#5-2026-09-06-发布收口决定)。 - -## 不得混同的完成条件 - -- 源码/自动验证:本记录跟踪确定缺陷是否全部关闭。 -- Windows/macOS设备验收:按D01–D19逐项记录目标应用、录音、原生模型、权限、升级与签名证据;未执行的不填通过。 -- Linux接入:Core合同与文档可交接;Linux产品侧待办独立交egui团队。 -- GitHub:CI、正式review与merge gate分别记录;不自动合并、打tag或发布。 diff --git a/docs/provider-channels-plan.md b/docs/provider-channels-plan.md deleted file mode 100644 index 9dffde512..000000000 --- a/docs/provider-channels-plan.md +++ /dev/null @@ -1,200 +0,0 @@ -# 供应商渠道卡片化 实施计划 - -> 状态:P0 已完成(2026-08-07,PR #918) -> 日期:2026-08-04 -> 范围:设置 → AI 提供商,LLM 润色 + ASR 语音转写 -> 参考:[Calcium-Ion/new-api](https://github.com/Calcium-Ion/new-api) 的 Channel 模型与重试策略 - -## 1. 要解决的问题 - -今天一个供应商只能存一份配置(一把 key、一个 endpoint、一个模型)。实际使用中: - -1. **同一家有多把 key**(主号 / 备号 / 白嫖号),现在只能存一把,换 key 靠手动覆盖粘贴 -2. **key 之间要频繁切换**,切换过程中旧配置就丢了 -3. 某把 key 被限流(429)时没有任何自动应对,整条润色链路直接失败 - -目标:把配置从"一个供应商一个槽"变成"一张张可命名、可排序、可开关的卡片",并让失败能自动顺延到下一张卡片。 - -## 2. 现状核对 - -| 事实 | 位置 | -| --- | --- | -| 存储层已经是 `HashMap`,key 是 preset id | `credentials.rs:169` | -| `CredsLlmEntry` 已有 `displayName` 字段,前端从未使用 | `credentials.rs:257` | -| ASR 凭据按 provider 隔离正确(空槽才填默认值) | `ProvidersSection.tsx:370` | -| LLM 切 preset 会**强制覆盖** endpoint/model,注释所述的"共用槽"bug 早已不成立 | `ProvidersSection.tsx:305` | -| 全局零重试 / 零故障转移(`rg retry\|backoff\|fallback` 无命中) | — | -| 凭据读取是**隐式全局** `CredentialsVault::get(...)` 去查 `root.active.*`,调用方无法指定渠道 | coordinator.rs / commands/providers.rs 共数十处 | -| ASR provider id 同时承担**协议路由 key**(百炼一个 id 分三协议,stepfun 分两协议) | `coordinator.rs:341` | -| 新手引导直接嵌 `` | `Onboarding.tsx:208` | -| Windows 默认 ASR 是本地 Foundry(无需 key,开箱即用) | `credentials.rs:155` | -| LLM 单次请求超时 30s | `polish.rs:23` | - -**结论**:存储结构不用推倒,改 key 语义即可;真正的成本在"凭据显式化"这次重构。 - -## 3. 已定的设计决策 - -| 决策 | 结论 | -| --- | --- | -| 范围 | LLM 与 ASR **都**做卡片 | -| 排序 | 列表可拖拽,越靠上越优先;启用列表的**第一个 = 当前使用** | -| 开关 | 打开 = 加入重试队列;**关掉自动沉到列表末尾**;重新打开回到启用组末尾 | -| 触发切换 | 429 等错误**立即**切下一个渠道 | -| 超时 | **不触发**切换(本期先这样) | -| 渠道失败 | **只在卡片上标红**(如「上次失败 · 401 · 3 分钟前」),**不自动禁用** | -| 全部失败 | 润色链路降级为**直接插入 ASR 原文** + 右上角提示 | -| 特殊项 | 本地引擎(qwen3 / sherpa / Apple 语音 / Foundry)与 Codex OAuth **不做预置固定卡片**,它们是「+添加渠道」供应商下拉里的普通选项,选中即长出卡片,表单里没有 key/地址字段 | - -### 3.1 ASR 与 LLM 语义统一 - -429 只出现在**建连 / 鉴权阶段**——此时一个字都还没吐出来,音频缓冲尚未被消费,换渠道重连是安全的。因此两边共用同一套心智: - -> 排序 = 优先级;开关 = 在不在重试队列;失败(非超时)顺延下一个。 - -ASR 唯一的额外规则:**一旦开始出字就不再切换**,之后连接断了就是断了(流式已吐字,回滚会造成文字重复或跳变)。 - -### 3.2 429 冷却(必须有) - -若不加冷却,限流期间**每一次**听写都会白赔一次「打 1 号 → 429 → 打 2 号」的往返(数百毫秒,同步链路里能感知)。 - -- 渠道返回 429 → 打 **60 秒冷却**,冷却期内直接跳过 -- 冷却是**内存态**,不落盘,重启即清 -- 卡片上显示「限流中 · 47s」小字,到期自动恢复,无需用户干预 - -### 3.3 超时值下调(独立改动) - -保留"超时不切换"的规则,但把 `DEFAULT_REQUEST_TIMEOUT_SECS` 从 **30s 压到 8s**。润色是用户盯着屏幕等的同步链路,8 秒未返回的渠道等下去没有意义。 - -## 4. 数据模型 - -```rust -struct Channel { - id: String, // 迁移沿用 preset id;同厂商新卡按 -2 / -3 分配独立 id - name: String, // 用户取的名字,如「硅基流动-主号」 - provider_type: String, // deepseek / volcengine / sherpa-onnx-local / codex_oauth ... - // 决定协议路由 + 表单形状,必须独立于 id - enabled: bool, - order: u32, // 拖拽排序;关掉时自动置到末尾 - last_error: Option, // { kind, message, at } —— 卡片标红用 - last_test: Option, // { ok, latency_ms, at } —— 连通测试结果 - // 凭据字段沿用现有 CredsAsrEntry / CredsLlmEntry,按 provider_type 决定渲染哪些 -} - -// 仅内存,不落盘 -struct ChannelRuntime { - cooldown_until: Option, // 429 临时冷却 -} -``` - -**`provider_type` 必须独立于 `id`**:否则 `coordinator.rs:341` 那条"按 provider id + 模型名路由到具体协议实现"的链会断——这是漏了就整个 ASR 挂掉的点。 - -`active.llm` / `active.asr` 不再是用户直接选择的第二份真相,而是由排序与开关同步计算的 -**兼容缓存**;旧主链路仍读取它们,"当前使用"始终等于启用列表的第一个。 - -## 5. 迁移 - -1. 遍历现有 `providers.llm` / `providers.asr` 的每个非空 entry,各补齐渠道元信息 - - `id` 沿用原 map key,`provider_type` = 原 map key,`name` = `displayName` 或 preset 显示名 - - 新建同厂商的第二、第三张卡片使用 `-2`、`-3`,不改动迁移前的凭据 key -2. 原 `active.llm` / `active.asr` 指向的那张排到 **order = 0**,其余按 ASR_PRESETS / LLM_PRESETS 原顺序跟随 -3. 全部默认 `enabled = true` -4. **全新安装**(无任何 entry):按平台预置 - - Windows → 一张 Foundry 本地 ASR 卡片(保住开箱即用) - - mac / Linux → 不预置,走引导 -5. 迁移必须幂等,且失败时保留原 JSON 不动(参考现有 `load_credentials_for_update` 的写法) - -## 6. 重试策略 - -> **状态:未实现,属于 P2。** P0 里一次失败就是一次失败,不会换渠道。 -> 下表是已定但**尚未落地**的目标行为。 - -### 6.0 代码里已经存在的两样东西(别和渠道故障转移混为一谈) - -排查时容易在代码里搜到 `retry` 就以为做了,这两处都是**既有代码**,与渠道无关: - -1. **`net.rs::send_with_retry` —— 连接层重连,不是渠道切换。** - 只对 `err.is_connect()`(TCP 握手被拒 / 连接重置,请求**尚未送达**服务端)重试, - 150/300/600/900ms 退避。**拿到任何 HTTP 响应就直接返回**(含 429/401/5xx), - 超时明确不重试。它重连的始终是同一个 endpoint,永远不会换到另一张卡片。 - -2. **润色失败已经会回落 ASR 原文。** - `coordinator/polish_flow.rs::polish_or_passthrough` 的失败分支: - ```rust - Err(e) => { - log::error!("[coord] polish failed, falling back to raw: {reason}"); - (raw.text.clone(), Some(reason)) - } - ``` - 也就是说,「全部渠道试完仍失败 → 插入 ASR 原文」这条决策**天然满足**, - P2 要做的只是在回落之前多试几张卡片,而不是新建一条兜底路径。 - -### 6.1 目标行为(P2) - -照搬 New API `shouldRetry()` 的分类,按桌面场景裁剪: - -| 情况 | 行为 | -| --- | --- | -| 429 | **切下一个** + 当前渠道 60s 冷却 | -| 401 / 403 | **切下一个** + 卡片标红(不自动禁用) | -| 5xx / 连接失败 | **切下一个** + 卡片标红 | -| 超时 | **不切**,直接失败(本期决策) | -| 400 参数错误 | **不切**(换渠道多半是同样的错) | -| 2xx | 成功 | -| 全部启用渠道试完仍失败 | 插入 ASR 原文 + 提示 | - -**不抄** New API 的:`Weight` 加权负载均衡(单用户无负载可均衡,随机选渠道反而让"在用哪个"不可预测)、`Group` / `UsedQuota` / `Balance`(多租户计费概念)、`AutoBan`(桌面软件静默关用户配置会让人一脸懵)。 - -**缓一缓**:`ModelMapping` / `ParamOverride`,有用但非第一版必需。 - -## 7. UI - -``` -┌─ LLM 润色 ──────────────────────────────┐ -│ ⠿ ● 硅基流动-主号 deepseek-v4 28ms ⋮ │ ← 生效中 -│ ⠿ ○ Ark-备用 deepseek-v3-2 — ⋮ │ ← 备用 -│ ⠿ ○ 阶跃星辰 (限流中 · 47s) ⋮ │ ← 429 冷却 -│ ⠿ ⊘ OpenAI (上次失败 · 401) ⋮ │ ← 已关闭,沉底 -│ + 添加渠道 │ -└──────────────────────────────────────────┘ -``` - -添加/编辑弹窗:名字 → 选供应商(自动填 baseUrl / 模型占位)→ 按 `provider_type` 渲染凭据字段 -→ 「测试连通」;字段自动保存,关闭只负责退出弹窗。 - -可复用的现成件: -- `validateProviderCredentials` / `listProviderModels`(`ProvidersSection.tsx:854` 起) -- 按 provider 分支渲染凭据字段的逻辑(火山双鉴权模式、讯飞双字段、百炼词表等) -- 本地引擎卡片不显示凭据字段;模型下载与切换继续集中在「高级 → 本地模型」,避免两处管理同一份模型状态 - -**新手引导**:列表为空时直接摊开添加表单,跳过空态与加号,省一次点击。 - -**平台过滤**:macOS 只显示 Qwen3 Local / Apple Speech;Windows 只显示 Foundry / -Sherpa;Linux 与 Android 不显示这些桌面专有本地引擎。云端供应商全平台可选。 - -**草稿回收**:保持自动保存。只有打开后从未发生任何用户交互的草稿会在关闭时回收; -改过名字、供应商、凭据、模型,或执行过验证/模型拉取后都必须保留,即使内容后来清空 -或异步保存失败。这样无凭据的本地引擎 / Apple Speech / Codex OAuth 也能正常创建,且 -关闭弹窗不会与 blur/debounce 保存竞争删除卡片。 - -## 8. 分期 - -| 期 | 内容 | 可否独立发布 | -| --- | --- | --- | -| **P0** | 渠道数据模型 + 迁移 + 卡片 UI + 拖拽排序 + 测试连通。**不做重试** | ✅ 独立故事:「我有两把 key,想随手切」 | -| | ↑ 已完成(PR #918)。**此时多渠道的价值是"存档 + 手动切换",不是自动容错**:排在第二的卡片永远不会被自动用上,要用得手动拖到第一位。 | | -| **P1** | 凭据显式化重构:`CredentialsVault::get(...)` → 上层解析 `ResolvedChannel` 显式下传 | ❌ 纯重构,无用户可见变化,P2 前提 | -| **P2** | 重试 + 故障转移 + 429 冷却 + 超时下调 + 全挂兜底 | ✅ | - -P1 是本需求最大的单块工作量,比卡片 UI 大得多。P1 + P2 合成第二个 PR。 - -## 9. 待确认 - -- [x] 拖拽排序:手写 pointer 事件(已定)。Tauri webview 默认 `dragDropEnabled` 会吞掉 - HTML5 的 `dragstart`/`drop`,`draggable` 在打包后的 app 里不触发;pointer 方案 - Windows / Android 行为一致,并配合「拖拽结束吞掉补发 click」避免关掉设置弹窗。 -- [x] 卡片列表的移动端(Android)形态:与桌面共用同一渠道 UI;Android 走 - `load_credentials` 的同一条迁移路径,无需单独实现。 -- [x] Android 加密信封:未改版本号。v2 载荷新增的 `providerType`/`order`/`enabled`/ - `lastTest` 均为 `Option` 或带默认值字段,老版本 serde 忽略未知字段,可降级读取。 -- [x] P0 验收判据:已满足(同供应商多卡、重启后顺序与内容不丢、拖拽后第一张生效), - 由 `persistence::credentials` 迁移/排序测试与作者 macOS 实机验证覆盖。 diff --git a/docs/qwen-asr-submodule-upgrade-checklist.md b/docs/qwen-asr-submodule-upgrade-checklist.md index 653faa724..d13ab3cfa 100644 --- a/docs/qwen-asr-submodule-upgrade-checklist.md +++ b/docs/qwen-asr-submodule-upgrade-checklist.md @@ -1,51 +1,26 @@ -# qwen-asr submodule 升级检查清单 - -`openless-all/app/src-tauri/vendor/qwen-asr` 是 macOS 本地 Qwen3-ASR 的 C 引擎来源。OpenLess 只从组织 fork `https://github.com/Open-Less/qwen-asr.git` 拉取 submodule;`antirez/qwen-asr` 只作为上游同步来源,不直接进入主仓构建链路。 - -## 升级原则 - -- 不直接把 `.gitmodules` 改回个人上游仓库。 -- 不在未审查 upstream diff 的情况下推进 submodule commit。 -- 每次升级只推进 submodule 指针;除非编译或 FFI 必需,不混入 OpenLess 主项目逻辑改动。 -- 保留当前锁定 commit 的可回滚性,必要时能 `git checkout -- openless-all/app/src-tauri/vendor/qwen-asr` 回退。 - -## 操作步骤 - -1. 在 `Open-Less/qwen-asr` fork 中同步 upstream。 -2. 审查 fork 中待引入 commit: - - C 源码:`qwen_asr*.c`、`qwen_asr*.h` - - 构建脚本 / 模型下载脚本 - - 新增二进制、大文件、网络下载地址或 shell 命令 - - FFI API 是否改变:`qwen_load`、`qwen_free`、`qwen_set_token_callback`、`qwen_transcribe_audio`、`qwen_transcribe_stream` - - Dictation 行为是否改变:C 后端必须继续使用 stream、发送 `local-asr-token`,并保留 0.5 秒尾部静音收尾;MLX 后端保持 batch -3. 在 OpenLess 主仓更新 submodule: - ```bash - git submodule sync --recursive openless-all/app/src-tauri/vendor/qwen-asr - git submodule update --init --recursive openless-all/app/src-tauri/vendor/qwen-asr - cd openless-all/app/src-tauri/vendor/qwen-asr - git fetch origin - git checkout - cd - - ``` -4. 验证 submodule 来源仍是组织 fork: - ```bash - git config --file .gitmodules --get submodule.openless-all/app/src-tauri/vendor/qwen-asr.url - git -C openless-all/app/src-tauri/vendor/qwen-asr remote get-url origin - git submodule status openless-all/app/src-tauri/vendor/qwen-asr - git diff --submodule=log -- openless-all/app/src-tauri/vendor/qwen-asr - ``` -5. 至少运行: - ```bash - cd openless-all/app - npm run build - cargo check --manifest-path src-tauri/Cargo.toml - ``` - macOS 发布前还要用 `INSTALL=0 ./scripts/build-mac.sh` 验证 C 源编译和链接。 - -## PR / commit 说明必须包含 - -- 旧 submodule commit 和新 submodule commit。 -- 已审查的 upstream commit 范围。 -- 是否有 FFI API、模型文件结构、下载脚本或构建参数变化。 -- 是否改变 stream/batch、token 回调、取消门控或尾部静音语义。 -- 已运行的验证命令和未覆盖的平台。 +# qwen-asr 子模块升级清单 + +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 + +## 1. 子模块(`.gitmodules`,仓库根) + +| 子模块 | 路径 | 来源 | +| --- | --- | --- | +| qwen-asr | `openless-all/app/src-tauri/vendor/qwen-asr` | (组织 fork;`antirez/qwen-asr` 只作上游同步来源,不直接进构建) | +| qwen3-asr-rs | `openless-all/app/src-tauri/vendor/qwen3-asr-rs` | | + +qwen-asr 是 macOS 本地 Qwen3-ASR 的 **C 引擎**,由 src-tauri 构建链接(vendored);qwen3-asr-rs 为 Rust 运行时来源。Linux workspace(openless-core + linux-egui)不包含 src-tauri,不依赖这两个子模块。 + +## 2. 升级步骤 + +1. `git submodule update --init --recursive`(先在干净工作区)。 +2. 在子模块目录 `git fetch && git checkout <目标提交>`(只用 Open-Less fork 的提交;记录提交号)。 +3. 主仓 `git add` 子模块指针并提交(提交信息注明引擎版本/目的)。 +4. macOS 构建验证:`src-tauri` 按平台构建通过;如涉及 Metal/工具链,先跑 `npm run check:macos-metal-toolchain`;相关契约:`scripts/macos-compiler-runtime-contract.test.mjs`、`stage-macos-mlx-metallib.mjs`(MLX 路径)。 +5. CI 注意:`scripts/ci-disable-macos-qwen3.mjs` 控制 macOS CI 中 qwen3 的启用范围,升级后核对其仍符合当前 CI 策略。 +6. 行为验证:本地 ASR 下载/激活/取消与听写回归(见[桌面验收](2.0-desktop-acceptance.md)本地模型域)。 + +## 3. 纪律 + +- 不修改 vendored 引擎源码本身的问题;修复走 fork 上游,再 bump 指针。 +- 子模块指针升级必须与构建验证同批提交,不留"指针先行"状态。 diff --git a/docs/structure.md b/docs/structure.md new file mode 100644 index 000000000..e7a39fc72 --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,74 @@ +# 应用目录与工程结构 + +状态:canonical;更新:2026-09-08。分层与调用链见 [架构](architecture.md)。 + +## 仓库与应用工作目录 + +```text +1-app/ Git 仓库、分支与发布边界 +├── AGENTS.md / docs/ 规则、架构、合同说明和平台交接 +├── README.md / README.zh.md 面向使用者和贡献者的双语介绍 +├── RELEASING.md / USAGE.md 发布规则与使用说明 +├── .github/workflows/ CI、Tauri、Android、Linux 发布 +├── Casks/ Homebrew 分发定义 +├── Examples/ 示例数据 +├── assets/ / video-materials/ 产品展示材料 +├── scripts/ 仓库级辅助脚本 +└── openless-all/ + ├── design_handoff_openless/ 设计交接材料 + └── app/ npm 与 Core/Linux Cargo 工作目录 + ├── src/ React / TypeScript 界面 + ├── crates/openless-core/ 共享业务 Rust crate + ├── src-tauri/ Tauri Host,独立 Cargo manifest + ├── linux-egui/ Linux Host 和 egui UI + ├── android/ Kotlin / AIDL / manifest / 前端片段 + ├── windows-ime/ 原生 TSF/IME 工程 + ├── contract/ 机器可读 backend-2.0 合同 + ├── scripts/ 构建、平台检查与合同测试 + └── public/ Vite 静态资源 +``` + +## 按任务定位源码 + +路径以 `openless-all/app/` 为基准。 + +| 任务 | 入口 | 相关边界 | +| --- | --- | --- | +| 启动与窗口分支 | `src/main.tsx`、`src/App.tsx` | typed IPC 启动快照;Tauri 配置与运行时窗口 | +| 主界面、页面与设置 | `src/components/FloatingShell.tsx`、`src/pages/`、`src/pages/settings/` | `src/state/` 组织界面状态;业务规则归 Core | +| 多语言、主题、组件 | `src/i18n/`、`src/styles/`、`src/components/` | 五语言文案;tokens/global 样式 | +| 新增或调整 IPC | `src/lib/ipc/`、`src-tauri/src/commands/`、`src-tauri/src/lib.rs` | Rust/TypeScript 类型、注册、事件与 `contract/` 同步 | +| 共享业务入口 | `crates/openless-core/src/api.rs` | `events.rs`、`ports.rs`、`domains.rs`、`config.rs` | +| 听写和服务 | Core `dictation_engine.rs`、`provider_*`、`asr/`、`polish.rs` | Host 的录音、插入和本地模型适配 | +| 历史、词库、纠错、风格包 | Core `history.rs`、`vocabulary.rs`、`correction.rs`、`style_pack_store.rs` | Tauri `persistence/` 与对应 command | +| Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 | +| Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) | +| Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 | +| Windows 输入法 | `windows-ime/`、`src-tauri/src/windows_ime_*.rs` | 原生工程、IPC 协议、目标应用和安装检查 | + +Core 其余模块按领域列于 [架构模块地图](architecture.md)。平台缺口、事件签名与验收项由专项文档维护,本文件只提供定位。 + +## 构建清单与生成文件 + +| 文件或目录 | 作用与维护方式 | +| --- | --- | +| `package.json` / `package-lock.json` | npm 命令、前端依赖与锁定版本;脚本从应用目录执行 | +| `Cargo.toml` / `Cargo.lock` | Core 与 Linux workspace;不覆盖 `src-tauri` | +| `src-tauri/Cargo.toml` / `Cargo.lock` | Tauri Host 的独立依赖图;本地 path 子模块须在解析前就绪 | +| `src-tauri/backend-tests/Cargo.toml` | 独立 Rust 回归 crate,按 CI 选择平台执行 | +| `vite.config.ts` / `tsconfig.json` | WebView 构建、TypeScript 与 Android 别名 | +| `src-tauri/tauri.conf.json` / `src-tauri/capabilities/` | 应用元数据、初始窗口、打包与 Tauri 能力权限 | +| `src-tauri/vendor/` | 原生 ASR 引擎与子模块;升级按 [qwen-asr 清单](qwen-asr-submodule-upgrade-checklist.md) | +| `src/lib/ipc/provider-descriptors.generated.json` | Core 导出的公开 provider 目录;生成命令见 [架构](architecture.md) | +| `src-tauri/gen/` | Tauri 平台生成目录;Android 手写源与合成脚本保留在 `android/`、`scripts/` | +| `node_modules/`、`dist/`、各 `target/` | 依赖和构建产物,不作为源码或 docs 的事实来源 | + +检查命令集中在 [架构的验证入口](architecture.md),版本与发布流程集中在 [RELEASING.md](../RELEASING.md)。不要在目录说明中复制易变的命令数量、分支领先数或单次测试结果。 + +## 源码格式与注释 + +应用目录的 `.editorconfig` 定义基础缩进和换行。TypeScript、JavaScript、CSS、HTML、JSON 与 shell 使用锁定版本的 Prettier,执行 `npm run format` 修改格式,执行 `npm run format:check` 检查。配置见 `.prettierrc.json`;生成文件和第三方源码不参与格式化。 + +Rust 使用 rustfmt,分别覆盖根 workspace、`src-tauri/Cargo.toml` 与 `src-tauri/backend-tests/Cargo.toml`。C/C++ 使用 clang-format 23,遵循应用目录的 `.clang-format`。Android 手写 Kotlin 使用 ktfmt 0.64 的 `--kotlinlang-style --do-not-remove-unused-imports` 选项。 + +注释说明当前职责、调用约束、生命周期与失败处理;涉及 FFI 时写明所有权和 ABI 前提。已完成任务的过程说明、失效文档引用及重复代码含义的注释应删除。对协议兼容或平台限制的说明保留必要依据。 diff --git a/docs/tauri-csp.md b/docs/tauri-csp.md index 2ec050771..e6212a122 100644 --- a/docs/tauri-csp.md +++ b/docs/tauri-csp.md @@ -1,10 +1,36 @@ # Tauri CSP 边界 -OpenLess 的桌面 WebView 已在 `openless-all/app/src-tauri/tauri.conf.json` 启用最小 CSP。该策略只放开前端渲染和 Tauri IPC 必需的来源: +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 -- `script-src 'self'`:脚本只允许随应用打包的前端产物;Tauri 会在构建时为自身注入脚本补齐必要 nonce / hash。 -- `connect-src 'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420`:允许同源连接、Tauri IPC,以及 Vite 开发模式的本机 HTTP / HMR 连接。 -- `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com` 与 `font-src https://fonts.gstatic.com`:当前 React 页面大量使用 `style={{ ... }}` 保持设计稿像素对齐,因此短期保留 inline style;外部字体只允许 Google Fonts 相关域名。 -- `object-src 'none'`、`base-uri 'none'`、`form-action 'none'`、`frame-ancestors 'none'`:关闭插件对象、base URL、表单提交和嵌入入口。 +桌面 WebView 的 CSP 定义在 `openless-all/app/src-tauri/tauri.conf.json`(`app.security.csp`),当前值为: -Provider 校验、ASR/LLM 请求、更新检查、本地 ASR 模型下载等网络访问都在 Rust / Tauri plugin 侧执行,不通过 WebView 的 `fetch` 直连外部服务,因此不在 WebView CSP 中放开 provider 域名。QA markdown 的 sanitizer 仍是第一道防线;CSP 只作为纵深防御。 +``` +default-src 'self' customprotocol: asset: +script-src 'self' +style-src 'self' 'unsafe-inline' https://fonts.googleapis.com +font-src 'self' https://fonts.gstatic.com +img-src 'self' asset: http://asset.localhost blob: data: https://github.com https://avatars.githubusercontent.com +connect-src 'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420 +media-src 'self' data: asset: http://asset.localhost blob: +object-src 'none' +base-uri 'none' +form-action 'none' +frame-ancestors 'none' +``` + +## 放开项与用途 + +| 指令 | 用途 | +| --- | --- | +| `default-src 'self' customprotocol: asset:` | 仅打包产物与 Tauri asset 协议 | +| `script-src 'self'` | 脚本只允许应用自带前端产物(Tauri 构建时注入 nonce/hash) | +| `style-src` + Google Fonts | 界面样式与在线字体 | +| `img-src` 的 github/avatars | GitHub OAuth 头像(`GithubLoginModal`);`asset:/asset.localhost/blob:/data:` 服务于本地资源与音频波形 | +| `connect-src` 的 `ipc:/ipc.localhost` | Tauri IPC;`localhost:1420` 仅为 Vite 开发/HMR | +| `media-src` | 录音回放与提示音 | + +`object-src`、`base-uri`、`form-action`、`frame-ancestors` 全部关闭。 + +## 修改规则 + +新增外部来源必须先确认用途与最小化范围(只加到对应指令),不得放宽 `object-src/base-uri/form-action/frame-ancestors`;改动随 `npm test` 与安全审查一起验证。 diff --git a/docs/volcengine-setup.md b/docs/volcengine-setup.md index 868daaa6b..842a703f9 100644 --- a/docs/volcengine-setup.md +++ b/docs/volcengine-setup.md @@ -1,50 +1,19 @@ -# OpenLess 火山 ASR 配置 +# 火山引擎(volcengine)ASR 配置 -1. 登录火山引擎 - +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 - ![登录火山引擎](./images/volcengine-setup/01-login.png) +## 1. 代码中的定义 -2. 创建旧版应用 - 创建时勾选 `豆包流式语音识别模型2.0 小时版` - +- Provider:`volcengine`(labelKey `asrVolcengine`),定义于 Core `provider_rules.rs`;`authRequirement = Volcengine`(专用鉴权形态),无内置默认端点/模型(`defaultEndpoint` / `defaultModel` 为空,按通道配置)。 +- 验证探针:`asr_silence_allows_no_final`(静音段允许无 final 帧,验证以可取消的静音探测完成)。 +- 凭据字段(`provider_rules.rs:300-302`):`volcengine_auth_mode`(鉴权模式,如 ApiKey/官方端点模式)、`volcengine_app_key`、`volcengine_access_key`(布尔项 + 模式选择;具体取值在设置界面录入,凭据走系统安全存储,不落明文)。 - ![创建旧版应用并勾选小时版](./images/volcengine-setup/02-create-legacy-app.png) +## 2. 在应用内配置 -3. 打开豆包流式语音识别模型 2.0 管理页 - `APP ID` 和 `Access Token` 在页面最下方 - +设置 → AI 服务 → 语音识别 → 添加渠道,选择火山引擎;按界面提示填入鉴权字段,保存后执行“验证”得到真实验证结果(成功/失败与时间会记录在渠道列表)。 - ![流式语音识别模型 2.0 管理页](./images/volcengine-setup/03-streaming-asr-page.png) +## 3. 端点与排错 -4. 复制到 OpenLess 的 `Settings` 页面 - - 打开: - - `Settings -> Providers -> ASR` - - ![复制到 OpenLess 的 Settings 页面](./images/volcengine-setup/04-openless-settings.png) - - 填这两个: - - - `APP ID` - - `Access Token` - - 不用填: - - - `Secret Key` - -## 新版控制台(API Key 方式) - -新版豆包语音控制台统一使用单个 `API Key` 鉴权,无需 `APP ID` / `Access Token`(旧版应用方式见上文)。 - -1. 在新版语音控制台创建 API Key - - -2. 在 OpenLess 的 `Settings -> Providers -> ASR` 中: - - - 鉴权模式选择「新版控制台 API Key」 - - 填入上一步创建的 `API Key` - - `Resource ID` 保持默认 `volc.seedasr.sauc.duration`(豆包流式语音识别模型 2.0 · 小时版) - -新旧两种模式共享同一 WebSocket 端点(`wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async`),仅握手鉴权头不同(新版为 `X-Api-Key` 单头)。官方接口文档: +- ApiKey 模式使用火山官方实时 ASR 端点(历史修复 #931 后的行为,以 `crates/openless-core/src/asr/volcengine.rs` 当前实现为准)。 +- 弱网行为:连接超时与重试在 Host/Core 实现,失败信息展示在渠道验证结果中。 +- 开通服务、创建应用与获取密钥属火山控制台操作,以[火山官方文档](https://www.volcengine.com/docs)为准;本仓库只维护代码行为。 diff --git a/docs/windows-sherpa-onnx-asr-plan.md b/docs/windows-sherpa-onnx-asr-plan.md deleted file mode 100644 index bd85f8a74..000000000 --- a/docs/windows-sherpa-onnx-asr-plan.md +++ /dev/null @@ -1,421 +0,0 @@ -# Windows sherpa-onnx 本地 ASR 实施规划 - -> 状态:草案 / 待评审 -> 日期:2026-05-12 -> 范围:仅 Windows;不替换 macOS `local-qwen3`;不替换 Windows `foundry-local-whisper` - -按 OpenLess 现有架构(Coordinator 单一拥有者、ASR provider 独立模块、`AudioConsumer` -接口)来做,**不重写主链路、不动 macOS、不替换 Foundry**,新增一个 Windows 实验 -provider。 - ---- - -## 1. 目标与非目标 - -### 目标 - -- **Windows 新增本地 ASR provider**:`sherpa-onnx-local` -- **复用现有听写主链路**:Recorder / Coordinator / polish / insert / history -- **支持中文为主,中英混合可用** -- **第一阶段 batch,第二阶段流式** -- **可与 `foundry-local-whisper` 并存切换** - -### 非目标 - -- 不替换 macOS `local-qwen3` -- 不替换 Windows `foundry-local-whisper`,仅作为新选项 -- 不做 Linux 支持(本期) -- 不做语者分离、长会议转写、字幕导出 -- 不做云端模型,不做模型自训 - -### 明确边界 - -- **不动 Coordinator 的 phase enum / hotkey 流程** -- **不动 polish / insertion / history** -- **sherpa runtime 只通过 `AudioConsumer` + 转写函数对外暴露** -- **任何 sherpa 错误必须降级**:不能让用户的话丢失(与现有 ASR 失败语义一致) - ---- - -## 2. 架构定位 - -按现有结构对齐 Foundry 路径: - -``` -asr/local/ - mod.rs # 增加 sherpa provider id 与 helper - foundry_provider.rs # 保留 - foundry_runtime.rs # 保留 - sherpa_provider.rs # 新增:AudioConsumer + transcribe() - sherpa_runtime.rs # 新增:模型加载 / 推理调用 / 生命周期 - sherpa_models.rs # 新增:模型 catalog 静态表 -``` - -主要扩展点: - -- `ActiveAsr::SherpaOnnxLocal(Arc)` -- `coordinator/dictation.rs`:`begin_session` / `end_session` 增加 - `#[cfg(target_os = "windows")]` 分支 -- `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令 -- `types.rs`:增加 `UserPreferences` 字段 -- 前端 Settings 高级页:在 Windows 下新增第三个本地 ASR toggle - ---- - -## 3. 模型策略 - -### 第一批模型(重点是中文) - -| 模型 | 用途 | 备注 | -|---|---|---| -| **SenseVoice small (zh/en/ja/ko/yue, int8)** | 中文 + 多语言默认 | 体验通常优于 Whisper small;包小、速度快 | -| **Paraformer (zh, int8)** | 中文专用强力档 | 中文听写更稳;不擅长英文 | -| **Whisper small (multilingual, int8)** | 英文/通用 fallback | 与 Foundry Whisper 体验对齐基准 | - -模型形态全部用: - -- **ONNX** -- **量化 int8** -- **CPU 推理优先** - -后续可选: - -- **streaming Zipformer (zh)**:第二阶段流式使用 - -### 模型分发策略 - -- **不打进安装包** -- **首次启用时下载** -- **下载源带镜像**:HuggingFace / 镜像 / 自托管 CDN -- **校验 SHA-256** -- **存放路径**: - ``` - %APPDATA%\OpenLess\models\sherpa-onnx\\ - ``` - ---- - -## 4. 模块设计 - -### 4.1 `sherpa_models.rs` - -静态目录 + alias 解析,模仿 `foundry.rs::MODELS`: - -```rust -pub const PROVIDER_ID: &str = "sherpa-onnx-local"; -pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh"; - -pub struct SherpaModel { - pub alias: &'static str, - pub display_name: &'static str, - pub family: SherpaFamily, // SenseVoice / Paraformer / Whisper / Zipformer - pub languages: &'static [&'static str], - pub mode: SherpaMode, // Offline / Online - pub files: &'static [SherpaModelFile], // name + sha256 + size + url -} -``` - -边界: - -- **不在这里写下载逻辑** -- **不依赖 sherpa-onnx 类型**,纯描述 - -### 4.2 `sherpa_runtime.rs` - -只这一处依赖 `sherpa-onnx` crate。 - -职责: - -- **初始化 OfflineRecognizer / OnlineRecognizer** -- **缓存当前已加载的 recognizer** -- **暴露**: - - `ensure_loaded(alias) -> Result` - - `transcribe_pcm(pcm: &[i16]) -> Result`(offline) - - `create_stream() -> SherpaStream`(online,第二阶段) - - `release_now()` - - `status_snapshot()` -- **生命周期**: - - `lifecycle: AsyncMutex<()>`(与 Foundry 一致,串行化加载/释放) - - 闲时延迟释放(参考 `local_asr_keep_loaded_secs` 模式) - -边界: - -- **不知道 Coordinator** -- **不知道 Recorder** -- **不动 UI** -- **不发 Tauri 事件** - -错误统统返回 `anyhow::Error`,由上层翻译为前端文案。 - -### 4.3 `sherpa_provider.rs` - -形状与 `foundry_provider.rs` 完全对齐: - -```rust -pub struct SherpaOnnxAsr { - runtime: Arc, - model_alias: String, - language_hint: Option, - buffer: Mutex>, // PCM s16le 16kHz mono - cancel_generation: AtomicU64, -} - -impl AudioConsumer for SherpaOnnxAsr { - fn consume_pcm_chunk(&self, pcm: &[u8]) { ... } -} - -impl SherpaOnnxAsr { - pub async fn transcribe(&self, timeout: Duration) -> Result { ... } - pub fn cancel(&self) { ... } -} -``` - -边界: - -- **batch 阶段不做实时 token 回调** -- **流式阶段独立加 `transcribe_stream(on_token)`,不破坏 batch API** - -### 4.4 `coordinator/dictation.rs` 集成 - -新增分支,**完全 mirror 现有 foundry 分支**: - -`begin_session`: - -```rust -#[cfg(target_os = "windows")] -if sherpa::is_sherpa_onnx_local(&active_asr) { - let local = Arc::new(SherpaOnnxAsr::new(...)); - store_asr_for_session(inner, sid, ActiveAsr::SherpaOnnxLocal(Arc::clone(&local))); - let consumer: Arc = local; - start_recorder_and_enter_listening(inner, sid, &active_asr, consumer).await?; - return Ok(()); -} -``` - -`end_session`: - -```rust -#[cfg(target_os = "windows")] -ActiveAsr::SherpaOnnxLocal(local) => { - match local.transcribe(sherpa_transcribe_timeout()).await { - Ok(r) => { schedule_sherpa_release(...); r } - Err(e) => { /* 与 foundry 失败分支同形 */ } - } -} -``` - -边界: - -- **不修改 Foundry 分支** -- **不修改 macOS Qwen3 分支** -- **复用 `RawTranscript` / `polish` / `insertion`** - -### 4.5 `commands.rs` - -新增命令(与 Foundry 同形,方便前端代码复用模式): - -- `sherpa_asr_status` -- `sherpa_asr_prepare` -- `sherpa_asr_release` -- `sherpa_asr_catalog` -- `sherpa_asr_set_model` - -只在 `#[cfg(target_os = "windows")]` 下注册。 - -### 4.6 `types.rs` - -新增字段(默认值 Windows = SenseVoice 中文,其他平台不可用): - -```rust -#[serde(default = "default_sherpa_model_alias")] -pub sherpa_onnx_model: String, - -#[serde(default)] -pub sherpa_onnx_language_hint: String, - -#[serde(default = "default_local_asr_keep_loaded_secs")] -pub sherpa_onnx_keep_loaded_secs: u32, -``` - -**不改 `default_active_asr_provider()`**:Windows 默认仍是 -`foundry-local-whisper`,sherpa 通过高级开关启用。 - -### 4.7 前端 - -在 Windows 高级页加第三个 toggle 行: - -- Foundry Local Whisper -- **Sherpa-Onnx Local(新增,实验)** -- 模型选择 / 准备 / 删除 / 路径 - -复用现有 `LocalAsr` UI 模式。i18n key 用 zh-CN 源 + en 镜像(按 AGENTS.md 规则)。 - ---- - -## 5. 依赖与打包 - -### 5.1 Rust crate - -```toml -[target.'cfg(target_os = "windows")'.dependencies] -sherpa-onnx = "..." # 选最新稳定版,feature 关闭非必要后端 -``` - -注意: - -- **关掉 CUDA / DirectML 等 feature**(v1 只用 CPU) -- **避免依赖 dynamic ONNX Runtime**:优先静态或随包附带 DLL -- **不要引入新的 native build chain**:保证 GH Actions Windows runner 能编 - -### 5.2 DLL / native 资源 - -如果 sherpa-onnx crate 自带 `onnxruntime.dll` / `sherpa-onnx.dll`: - -- 通过 `build.rs` copy 到 target dir -- 由 Tauri bundler 一同打进 NSIS / MSI -- WiX 的 `Component` 落到 `INSTALLDIR` -- **严格遵守 AGENTS.md 的 Windows CI 红线**: - - 两轮 NSIS / MSI - - bash shell - - `-sice:ICE80` - - 不动 Repair 步骤 - -如果 crate 不带 DLL: - -- 第一次启用时从镜像下载,与模型同目录 -- 用 LoadLibrary delay-load - -### 5.3 模型下载 - -复用现有 `LocalAsr` 模型管理 UX: - -- 镜像选择 -- 进度 + 取消 -- SHA-256 校验 -- 失败重试 - ---- - -## 6. 实施里程碑 - -### M1 Provider 骨架 (0.5 周) - -- `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_models.rs` 文件结构 -- `ActiveAsr::SherpaOnnxLocal` -- `commands.rs` 桩函数 -- 前端 toggle + i18n -- **不实际推理**,先打通主链路(mock transcribe 返回空串或固定字符串) - -### M2 Batch 推理可用 (1.5 周) - -- 接 `sherpa-onnx` crate -- offline recognizer 加载 -- WAV/PCM → text -- 模型:先只接 **SenseVoice small zh** -- 错误降级:失败回到 Foundry / Volcengine -- Windows 本机 smoke test - -### M3 模型管理 + 多模型 (1 周) - -- 加 Paraformer / Whisper small -- 模型下载 / 校验 / 删除 -- 镜像源切换 -- 模型切换不需要重启 - -### M4 性能与稳定性 (1 周) - -- 启动时延、首次加载时延 -- 内存占用 -- 长录音稳定性 -- 取消(hotkey 再次按下)行为正确 -- DLL 缺失 / 模型损坏 / 路径含中文 / 路径含空格 全部覆盖 - -### M5 流式 ASR(可选,二阶段) - -- 接 OnlineRecognizer -- 边录边 partial → `local-asr-token` 事件 -- 与现有 macOS Qwen3 stream UX 对齐 - -### M6 发布 - -- 高级页打开为实验 -- 收集真实用户反馈 -- 满足质量门槛后再决定是否提升为 Windows 默认 - ---- - -## 7. 风险与对策 - -| 风险 | 对策 | -|---|---| -| sherpa-onnx Windows 打包带 native DLL,触发 WiX / NSIS 兼容问题 | 严格走 AGENTS.md 的两轮 bundle + `-sice:ICE80`;早期就在 CI 跑 | -| ONNX Runtime 版本冲突 | 锁版本;不和其他 crate 共享 ORT | -| 模型体积大,下载失败 | 强制镜像 + 断点续传 + SHA-256 + 明确错误文案 | -| 安装路径含中文/空格导致模型加载失败 | 用 `\\?\` 长路径前缀 + 单元测试覆盖 | -| 首次加载耗时长(用户以为卡死) | 加载阶段发 Tauri 进度事件;胶囊显示"准备模型"态 | -| CPU 性能不足机器卡顿 | 默认 SenseVoice small int8;提供更小模型;超时降级 | -| 推理 panic 干扰主进程 | 推理放 `spawn_blocking`,错误 → anyhow,绝不 panic 向上 | -| 与 Foundry / Qwen3 并存导致状态混乱 | 切换 provider 时强制 release 另一边;测试覆盖 | -| 取消语义不一致 | 严格按现有 `cancel_generation` 模式实现 | -| macOS / Linux 编译被影响 | 全部 sherpa 代码 `#[cfg(target_os = "windows")]` 包裹 | - ---- - -## 8. 验收标准 - -### 功能 - -- Windows 用户能在高级页启用 `sherpa-onnx-local` -- 默认模型 SenseVoice small zh 可下载、加载、转写 -- 中文短句听写质量明显优于 Foundry Whisper small(盲测) -- 失败时不丢用户的话(自动降级或留 raw) -- 取消、重复触发、连按热键不崩 - -### 工程 - -- 不动 macOS 编译产物 -- 不动 Foundry 路径 -- 不引入新的 CI 红线 -- Windows MSI / NSIS 两轮构建仍然通过 -- 包体增量在可接受范围(建议 < 50MB,不含模型) - -### 测试 - -- `cargo test` Windows 通过 -- 手测脚本: - - 中文短句 - - 中文长句(30s+) - - 中英混合 - - 安静 / 噪音 - - 取消 - - 切换模型 - - 切换 provider - - 卸载模型 - - 无网络再次启动 - ---- - -## 9. 不做什么(再次明确) - -- **不重构 ASR trait 体系** -- **不引入 ASR 中间层抽象** -- **不替换 Foundry** -- **不动 macOS Qwen3** -- **不做 Linux** -- **不做云端 fallback 改动** -- **不做模型微调** -- **不做多 provider 自动选择** - ---- - -## 10. 相关参考 - -- 现有 Windows 本地 ASR 实现: - - `openless-all/app/src-tauri/src/asr/local/foundry.rs` - - `openless-all/app/src-tauri/src/asr/local/foundry_provider.rs` - - `openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs` -- 现有 macOS 本地 ASR 实现: - - `openless-all/app/src-tauri/src/asr/local/local_provider.rs` -- 主听写链路集成点: - - `openless-all/app/src-tauri/src/coordinator/dictation.rs` -- Windows CI / 打包红线:见仓库根 `AGENTS.md`「Windows CI 红线」一节 diff --git a/docs/xfyun-asr.md b/docs/xfyun-asr.md index 84f6b1ae0..34be65196 100644 --- a/docs/xfyun-asr.md +++ b/docs/xfyun-asr.md @@ -1,39 +1,19 @@ -# OpenLess 讯飞 ASR 配置 +# 讯飞(iflytek)ASR 配置 -OpenLess 支持讯飞开放平台的**实时语音转写(RTASR,标准版)**,用于把录音实时转写成文字。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 -## 所需服务 +## 1. 代码中的定义 -- 服务:讯飞开放平台 **实时语音转写(标准版)** -- 接口文档: +- Provider:`iflytek`(labelKey `asrIflytek`),`authRequirement = Xfyun`;无内置默认端点/模型。 +- 凭据(`crates/openless-core/src/asr/xfyun.rs:62-69`):`app_id` + `api_key`(两者非空才视为已配置);连接时以 `app_id + timestamp` 计算 `signa` 签名(`compute_signa`,xfyun.rs:637)。 +- 验证探针:`asr_silence_allows_no_final`。 +- 对接的是讯飞开放平台实时语音转写(RTASR);协议细节以 `asr/xfyun.rs` 当前实现为准。 -> 说明:录音文件转写(IFASR)、实时语音转写大模型(202 方言 / 37 语种免切)目前 -> **未接入**。若你有这些需求,欢迎在 issue #842 留言。 +## 2. 在应用内配置 -## 凭证获取 +设置 → AI 服务 → 语音识别 → 添加渠道,选择讯飞;填入 AppID 与 API Key,保存后执行“验证”。验证结果(成功/失败与时间)显示在渠道列表。 -1. 登录讯飞开放平台: -2. 控制台 → 我的应用 → 创建应用,添加 **实时语音转写** 服务(需先在服务页领取免费额度或购买)。 -3. 在服务管理页查看并复制 **AppID** 与 **API Key**。 -4. 打开 OpenLess `Settings → Providers → ASR`,选择「讯飞实时语音转写」,填入 AppID 与 API Key。 +## 3. 开通与排错 -## 音频约束 - -- 采样率 16 kHz、位深 16 bit、单声道 PCM(与 OpenLess 录音输出完全一致,无需转码)。 -- 支持中文普通话、中英混合与英文;方言/小语种需在讯飞控制台开通后由 `lang` 参数指定, - 首期 OpenLess 固定使用中文普通话(`lang=cn`)。 -- IP 白名单:若在讯飞控制台开启了 IP 白名单,请把运行 OpenLess 机器的公网 IP 加入, - 否则握手会返回 `10105 illegal access`。 - -## 已知限制 - -- 标准版 RTASR **没有请求参数级热词**:OpenLess 的词典热词不会传给讯飞,个性化热词 - 请在讯飞控制台上传。 -- 实时出字由服务端驱动:OpenLess 的胶囊 UI 在收尾阶段一次性展示最终文本(与其它 - 流式 provider 行为一致)。 - -## 排障 - -- `10105` / `10110`:AppID / API Key 错误、IP 白名单未放行,或账号未开通实时语音转写服务。 -- `10800`:超过授权连接数(并发路数已满)。 -- `37005`:超过 15 秒未发送音频(弱网时偶发,可重试)。 +- 服务开通、AppID/API Key 获取在[讯飞开放平台控制台](https://www.xfyun.cn/)完成(外部平台操作,以官方文档为准)。 +- 鉴权失败多为 AppID 与 Key 不配套或时间偏差;渠道验证结果会给出可读错误。 diff --git a/openless-all/README.md b/openless-all/README.md index 0a2a0c23d..337abfed2 100644 --- a/openless-all/README.md +++ b/openless-all/README.md @@ -1,19 +1,20 @@ # OpenLess All-Platform -This is the current cross-platform OpenLess workspace. +This directory contains the cross-platform OpenLess application and design handoff material. Start with the repository [documentation index](../docs/index.md), [architecture](../docs/architecture.md), and [source structure](../docs/structure.md). ## App Directory -The runnable sources live in `app/` with three explicit layers: +The runnable sources live in `app/`: - `app/crates/openless-core`: framework-independent shared backend Interface and business rules; -- `app/src-tauri`: macOS, Windows, and Android Tauri Adapter plus the React frontend; -- `app/linux-egui`: Linux non-UI Adapter consumed by the separately developed egui frontend; it does not depend on Tauri or WebKitGTK. +- `app/src`: React frontend for the Tauri hosts; +- `app/src-tauri`: macOS, Windows, and Android Tauri Host and native adapters; +- `app/linux-egui`: Linux Host and egui/eframe UI; it does not depend on Tauri or WebKitGTK. -The macOS Tauri build links a vendored C ASR engine (`Open-Less/qwen-asr`, forked from `antirez/qwen-asr`) tracked as a git submodule under `app/src-tauri/vendor/qwen-asr/`. The root core/Linux workspace excludes `src-tauri`, so Linux checks do not need that submodule. +The Tauri manifest includes local ASR path dependencies under `app/src-tauri/vendor/`; initialize submodules before resolving it, including for non-macOS source builds. The root Core/Linux workspace excludes `src-tauri`, so its independent checks do not require those submodules. ```bash -# macOS Tauri development only — pull in vendored submodules +# Tauri source development — pull in vendored submodules git submodule update --init --recursive cd app @@ -23,17 +24,17 @@ npm run tauri dev ## Shared backend and Linux host -The egui UI is owned by another team. This repository supplies its typed Rust Interface, semantic events, fixtures, and Linux non-UI Adapters. The checked-in `linux-egui/src/main.rs` remains a stub until that team lands `eframe::App`; do not treat the current binary as a production application. +This repository supplies the shared typed Rust interface, semantic events, fixtures, and Linux adapters. `linux-egui/src/main.rs` now implements an egui/eframe application, with `LinuxHost` and `LinuxBackendBuilder` connecting it to Core. Remaining Host/UI work and product acceptance are tracked in the [Linux handoff](../docs/linux-egui-handoff/README.md). ```bash cd app -cargo test -p openless-core -cargo test -p openless-linux-egui --all-targets +cargo test -p openless-core --locked +cargo test -p openless-linux-egui --all-targets --locked pwsh ./scripts/check-core-deps.ps1 pwsh ./scripts/check-core-deps.ps1 openless-linux-egui ``` -The independent Linux package workflow is `.github/workflows/release-linux-egui.yml`. It builds deb/rpm/AppImage and the fcitx5 plugin without WebKitGTK, but deliberately has no automatic tag trigger until the real egui entry point is present. +The independent [Linux package workflow](../.github/workflows/release-linux-egui.yml) builds deb/rpm/AppImage and the fcitx5 plugin without WebKitGTK. It supports manual and reusable-workflow invocation; automatic tag triggering remains gated on real Ubuntu runtime, input, installation, upgrade, and rollback evidence. ## macOS Build diff --git a/openless-all/app/.clang-format b/openless-all/app/.clang-format new file mode 100644 index 000000000..4bcee5f29 --- /dev/null +++ b/openless-all/app/.clang-format @@ -0,0 +1,5 @@ +BasedOnStyle: LLVM +IndentWidth: 2 +ColumnLimit: 100 +SortIncludes: Never +ReflowComments: Never diff --git a/openless-all/app/.editorconfig b/openless-all/app/.editorconfig new file mode 100644 index 000000000..71b4c4298 --- /dev/null +++ b/openless-all/app/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{ts,tsx,js,mjs,css,json,html,sh,cpp,h}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/openless-all/app/.prettierignore b/openless-all/app/.prettierignore new file mode 100644 index 000000000..562b5b10d --- /dev/null +++ b/openless-all/app/.prettierignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +**/target/ +src-tauri/vendor/ +src-tauri/gen/ +**/*.generated.json +package-lock.json diff --git a/openless-all/app/.prettierrc.json b/openless-all/app/.prettierrc.json new file mode 100644 index 000000000..452b530d6 --- /dev/null +++ b/openless-all/app/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 100, + "endOfLine": "lf", + "plugins": ["prettier-plugin-sh"] +} diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 466b68731..4613808a7 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -28,10 +28,7 @@ import type { AndroidPreferenceKey, AndroidShizukuStatus, } from '../lib/androidTypes'; -import { - clampAndroidOverlaySize, - normalizeAndroidOverlayTrigger, -} from '../lib/androidTypes'; +import { clampAndroidOverlaySize, normalizeAndroidOverlayTrigger } from '../lib/androidTypes'; type AndroidPrefsSlice = Pick; @@ -50,7 +47,10 @@ let androidOverlaySaveQueue = Promise.resolve(); function enqueueAndroidOverlaySave(task: () => Promise): Promise { const run = androidOverlaySaveQueue.then(task); - androidOverlaySaveQueue = run.then(() => undefined, () => undefined); + androidOverlaySaveQueue = run.then( + () => undefined, + () => undefined, + ); return run; } @@ -78,12 +78,16 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane const conservative = useConservativeLayout(); const layoutStack = baseLayoutStack || conservative; const [androidOverlay, setAndroidOverlay] = useState(null); - const [androidAccessibility, setAndroidAccessibility] = useState(null); + const [androidAccessibility, setAndroidAccessibility] = + useState(null); const [androidShizuku, setAndroidShizuku] = useState(null); const [shizukuRecoveryMessageKey, setShizukuRecoveryMessageKey] = useState(null); const [shizukuActionMessageKey, setShizukuActionMessageKey] = useState(null); const [shizukuRecoveryPending, setShizukuRecoveryPending] = useState(false); - const [androidPrefs, setAndroidPrefs] = useState | null>(null); + const [androidPrefs, setAndroidPrefs] = useState | null>(null); const [sizeDraft, setSizeDraft] = useState(null); const sizeDebounceRef = useRef(null); const sizePendingRef = useRef(false); @@ -144,16 +148,19 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane } }; - const shizukuDisplayMessageKey = androidShizuku?.lastPermissionMessageKey - ?? shizukuRecoveryMessageKey - ?? shizukuActionMessageKey - ?? androidShizuku?.messageKey - ?? null; + const shizukuDisplayMessageKey = + androidShizuku?.lastPermissionMessageKey ?? + shizukuRecoveryMessageKey ?? + shizukuActionMessageKey ?? + androidShizuku?.messageKey ?? + null; useEffect(() => { void refreshAndroid(); const androidId = window.setInterval(refreshAndroid, 3000); - const onFocus = () => { void refreshAndroid(); }; + const onFocus = () => { + void refreshAndroid(); + }; window.addEventListener('focus', onFocus); return () => { window.clearInterval(androidId); @@ -227,16 +234,20 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane scheduleAndroidOverlaySizeSave(clamped); }; - const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { + const updateAndroidPref = async ( + key: K, + value: UserPreferences[K], + ) => { if (key !== 'androidOverlaySizeDp' && sizeDebounceRef.current) { clearTimeout(sizeDebounceRef.current); sizeDebounceRef.current = null; } const patch: Partial = { - [key]: key === 'androidOverlayTrigger' - ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) - : value, + [key]: + key === 'androidOverlayTrigger' + ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) + : value, }; if (key !== 'androidOverlaySizeDp') { @@ -279,248 +290,459 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane gap: 8, alignItems: conservative ? 'flex-start' : layoutStack ? 'flex-start' : 'center', justifyContent: rowJustify, - width: '100%', flexWrap: conservative ? 'nowrap' : 'wrap', minWidth: 0, + width: '100%', + flexWrap: conservative ? 'nowrap' : 'wrap', + minWidth: 0, }; - return ( <> {showOverlayPermission && ( - -
- {androidOverlay?.message && ( - - {androidOverlay.message} - - )} - - {androidOverlay?.permission !== 'granted' && ( - { void requestAndroidOverlayPermission().then(refreshAndroid); }}> - {t('settings.permissions.grant')} - - )} -
-
- )} - {showAccessibility && ( - -
-
- {resolveAccessibilityMessage(t, androidAccessibility?.messageKey) && ( - - {resolveAccessibilityMessage(t, androidAccessibility?.messageKey)} - - )} - - {(!androidAccessibility?.enabled || androidAccessibility?.operational === false) && ( - { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> - {t('settings.permissions.openSystem')} - - )} -
- - {t('settings.permissions.androidAccessibilityImpact')} - -
-
- )} - {showAccessibility && ( - -
+
- {resolveShizukuMessage(t, shizukuDisplayMessageKey) && ( - - {resolveShizukuMessage(t, shizukuDisplayMessageKey)} + {androidOverlay?.message && ( + + {androidOverlay.message} )} - - {(androidShizuku?.state === 'notInstalled' || androidShizuku?.state === 'notRunning' || androidShizuku?.state === 'binderDead') && ( - { - setShizukuRecoveryMessageKey(null); - setShizukuActionMessageKey(null); - void openShizukuApp().then((result) => { - if (!result.launched) { - setShizukuActionMessageKey(result.messageKey); - } - return refreshAndroid(); - }); - }}> - {t('settings.permissions.androidShizukuOpenApp')} - - )} - {androidShizuku?.state === 'notAuthorized' && ( - { - setShizukuRecoveryMessageKey(null); - setShizukuActionMessageKey(null); - void requestAndroidShizukuPermission().then((result) => { - if (!result.launched) { - setShizukuActionMessageKey(result.messageKey); - } - return refreshAndroid(); - }); - }}> - {t('settings.permissions.androidShizukuRequestPermission')} - - )} - {androidShizuku?.state === 'authorized' - && !androidShizuku.accessibility.operational && ( + + {androidOverlay?.permission !== 'granted' && ( { void handleRecoverAccessibility(); }} + onClick={() => { + void requestAndroidOverlayPermission().then(refreshAndroid); + }} > - {shizukuRecoveryPending - ? t('settings.permissions.checking') - : t('settings.permissions.androidShizukuRecover')} - - )} - {shizukuRecoveryMessageKey - && androidShizuku?.state === 'authorized' - && !androidShizuku.accessibility.operational - && (shizukuRecoveryMessageKey === 'partial_rollback' - || shizukuRecoveryMessageKey === 'manual_required' - || shizukuRecoveryMessageKey === 'oem_rollback' - || shizukuRecoveryMessageKey === 'concurrent_change') && ( - { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> - {t('settings.permissions.openSystem')} + {t('settings.permissions.grant')} )}
- {androidShizuku?.state === 'authorized' && ( - - {androidShizuku.accessibility.operational - ? t('settings.permissions.androidShizukuAccessibilityOperational') - : t('settings.permissions.androidShizukuAccessibilityRegistered', { - registered: androidShizuku.accessibility.registered - ? t('settings.permissions.androidShizukuYes') - : t('settings.permissions.androidShizukuNo'), - operational: androidShizuku.accessibility.operational - ? t('settings.permissions.androidShizukuYes') - : t('settings.permissions.androidShizukuNo'), - })} - - )} - - {t('settings.permissions.androidShizukuHint')} - -
-
+ )} - {showOverlayConfig && ( - <> - -
- - - {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)} - -
-
- -
- - - {t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)} - - - {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')} - -
-
- -
- - - {t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)} - -
-
- -
- - - {t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)} - -
-
- -
- - - {t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)} - -
-
- -
-
- { - handleAndroidOverlaySizeChange(Number(event.target.value)); +
+ {resolveShizukuMessage(t, shizukuDisplayMessageKey) && ( + + {resolveShizukuMessage(t, shizukuDisplayMessageKey)} + + )} + + {(androidShizuku?.state === 'notInstalled' || + androidShizuku?.state === 'notRunning' || + androidShizuku?.state === 'binderDead') && ( + { + setShizukuRecoveryMessageKey(null); + setShizukuActionMessageKey(null); + void openShizukuApp().then((result) => { + if (!result.launched) { + setShizukuActionMessageKey(result.messageKey); + } + return refreshAndroid(); + }); + }} + > + {t('settings.permissions.androidShizukuOpenApp')} + + )} + {androidShizuku?.state === 'notAuthorized' && ( + { + setShizukuRecoveryMessageKey(null); + setShizukuActionMessageKey(null); + void requestAndroidShizukuPermission().then((result) => { + if (!result.launched) { + setShizukuActionMessageKey(result.messageKey); + } + return refreshAndroid(); + }); + }} + > + {t('settings.permissions.androidShizukuRequestPermission')} + + )} + {androidShizuku?.state === 'authorized' && + !androidShizuku.accessibility.operational && ( + { + void handleRecoverAccessibility(); + }} + > + {shizukuRecoveryPending + ? t('settings.permissions.checking') + : t('settings.permissions.androidShizukuRecover')} + + )} + {shizukuRecoveryMessageKey && + androidShizuku?.state === 'authorized' && + !androidShizuku.accessibility.operational && + (shizukuRecoveryMessageKey === 'partial_rollback' || + shizukuRecoveryMessageKey === 'manual_required' || + shizukuRecoveryMessageKey === 'oem_rollback' || + shizukuRecoveryMessageKey === 'concurrent_change') && ( + { + void requestAndroidAccessibilityPermission().then(refreshAndroid); + }} + > + {t('settings.permissions.openSystem')} + + )} +
+ {androidShizuku?.state === 'authorized' && ( + + {androidShizuku.accessibility.operational + ? t('settings.permissions.androidShizukuAccessibilityOperational') + : t('settings.permissions.androidShizukuAccessibilityRegistered', { + registered: androidShizuku.accessibility.registered + ? t('settings.permissions.androidShizukuYes') + : t('settings.permissions.androidShizukuNo'), + operational: androidShizuku.accessibility.operational + ? t('settings.permissions.androidShizukuYes') + : t('settings.permissions.androidShizukuNo'), + })} + + )} + { - flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + > + {t('settings.permissions.androidShizukuHint')} + +
+ + )} + {showOverlayConfig && ( + <> + +
{ - flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + > + + + {t( + `settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`, + )} + +
+
+ +
{ - flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + > + + + {t( + `settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`, + )} + + + {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')} + +
+
+ +
- - {sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp - -
- - {t('settings.permissions.androidOverlaySizeHint')} - -
-
- + > + + + {t( + `settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`, + )} + + + + +
+ + + {t( + `settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`, + )} + +
+
+ +
+ + + {t( + `settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`, + )} + +
+
+ +
+
+ { + handleAndroidOverlaySizeChange(Number(event.target.value)); + }} + onPointerUp={(event) => { + flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + }} + onTouchEnd={(event) => { + flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + }} + onBlur={(event) => { + flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + }} + style={{ flex: 1, minWidth: 0 }} + /> + + {sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp + +
+ + {t('settings.permissions.androidOverlaySizeHint')} + +
+
+ )} ); @@ -530,7 +752,12 @@ function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | n const { t } = useTranslation(); if (!status) return {t('settings.permissions.checking')}; if (status.permission === 'granted') { - return {t('settings.permissions.granted')}; + return ( + + + {t('settings.permissions.granted')} + + ); } return {t('settings.permissions.denied')}; } @@ -542,7 +769,12 @@ function AndroidAccessibilityStatusPill({ status }: { status: AndroidAccessibili return {t('settings.permissions.androidAccessibilityGrantedStale')}; } if (status.enabled) { - return {t('settings.permissions.granted')}; + return ( + + + {t('settings.permissions.granted')} + + ); } return {t('settings.permissions.denied')}; } @@ -562,7 +794,12 @@ function AndroidShizukuStatusPill({ status }: { status: AndroidShizukuStatus | n if (!status) return {t('settings.permissions.checking')}; const labelKey = `settings.permissions.androidShizukuState.${status.state}` as const; if (status.state === 'authorized') { - return {t(labelKey)}; + return ( + + + {t(labelKey)} + + ); } if (status.state === 'notAndroid') { return {t(labelKey)}; diff --git a/openless-all/app/android/frontend/lib/androidIpc.ts b/openless-all/app/android/frontend/lib/androidIpc.ts index 60e7cab9a..af6baf0e7 100644 --- a/openless-all/app/android/frontend/lib/androidIpc.ts +++ b/openless-all/app/android/frontend/lib/androidIpc.ts @@ -39,7 +39,10 @@ export function getAndroidAccessibilityStatus(): Promise { +export function requestAndroidAccessibilityPermission(): Promise<{ + launched: boolean; + message: string; +}> { return invokeOrMock('request_android_accessibility_permission', undefined, () => ({ launched: false, message: 'Mock: accessibility settings unavailable in browser preview', @@ -72,7 +75,9 @@ export function openShizukuApp(): Promise { })); } -export function recoverAndroidAccessibility(confirmed: boolean): Promise { +export function recoverAndroidAccessibility( + confirmed: boolean, +): Promise { return invokeOrMock('recover_android_accessibility', { confirmed }, () => ({ outcome: confirmed ? 'shizukuUnavailable' : 'userNotConfirmed', messageKey: confirmed ? 'not_android' : 'user_not_confirmed', diff --git a/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts index d5fee5249..de8a7d387 100644 --- a/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts +++ b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts @@ -99,6 +99,6 @@ export async function requestAndroidMicrophoneAccess(): Promise track.stop()); + stream?.getTracks().forEach((track) => track.stop()); } } diff --git a/openless-all/app/android/frontend/lib/androidTypes.ts b/openless-all/app/android/frontend/lib/androidTypes.ts index 842a0982b..2ff3179ce 100644 --- a/openless-all/app/android/frontend/lib/androidTypes.ts +++ b/openless-all/app/android/frontend/lib/androidTypes.ts @@ -21,12 +21,7 @@ export interface AndroidAccessibilityStatus { } export type AndroidShizukuState = - | 'notInstalled' - | 'notRunning' - | 'notAuthorized' - | 'authorized' - | 'binderDead' - | 'notAndroid'; + 'notInstalled' | 'notRunning' | 'notAuthorized' | 'authorized' | 'binderDead' | 'notAndroid'; export interface AndroidAccessibilityDiagnosis { registered: boolean; diff --git a/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt b/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt index 746941f23..6837a45b4 100644 --- a/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt +++ b/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt @@ -9,7 +9,10 @@ import android.util.Log class MicrophonePermissionActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + if ( + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { OpenLessPermissionBridge.resolveRecordAudioPermission(true) finish() return @@ -26,8 +29,8 @@ class MicrophonePermissionActivity : Activity() { if (requestCode != REQUEST_RECORD_AUDIO) { return } - val granted = grantResults.isNotEmpty() && - grantResults[0] == PackageManager.PERMISSION_GRANTED + val granted = + grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED Log.i(TAG, "RECORD_AUDIO permission result granted=$granted") OpenLessPermissionBridge.resolveRecordAudioPermission(granted) finish() @@ -36,7 +39,8 @@ class MicrophonePermissionActivity : Activity() { override fun onDestroy() { if (isFinishing) { OpenLessPermissionBridge.resolveRecordAudioPermission( - checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED ) } super.onDestroy() diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt index 757a3d4ec..71e556b28 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt @@ -21,11 +21,12 @@ class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { } } ACTION_PING -> { - val result = if (OpenLessAccessibilityService.instance != null) { - AccessibilityPasteResult.SUCCESS - } else { - AccessibilityPasteResult.SERVICE_NOT_CONNECTED - } + val result = + if (OpenLessAccessibilityService.instance != null) { + AccessibilityPasteResult.SUCCESS + } else { + AccessibilityPasteResult.SERVICE_NOT_CONNECTED + } sendResult(receiver, result) } ACTION_CAPTURE_SELECTED_TEXT -> { @@ -59,17 +60,12 @@ class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { companion object { const val ACTION_PASTE = "com.openless.app.accessibility.PASTE" const val ACTION_PING = "com.openless.app.accessibility.PING" - const val ACTION_CAPTURE_SELECTED_TEXT = "com.openless.app.accessibility.CAPTURE_SELECTED_TEXT" + const val ACTION_CAPTURE_SELECTED_TEXT = + "com.openless.app.accessibility.CAPTURE_SELECTED_TEXT" const val EXTRA_RESULT_RECEIVER = "result_receiver" const val EXTRA_RESULT_REASON = "result_reason" const val EXTRA_PASTE_TEXT = "paste_text" const val EXTRA_SELECTED_TEXT = "selected_text" - /** @deprecated Use [AccessibilityPasteResult] codes */ - const val EXTRA_PASTE_RESULT = "paste_result" - /** @deprecated Use [AccessibilityPasteResult.SUCCESS.code] */ - const val RESULT_PASTE_SUCCESS = 1 - /** @deprecated Use failure codes from [AccessibilityPasteResult] */ - const val RESULT_PASTE_FAILED = 4 private const val TAG = "OpenLessA11yCommand" } } diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityComponentIds.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityComponentIds.kt index 1d761b75f..7817a3ab4 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityComponentIds.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityComponentIds.kt @@ -1,23 +1,20 @@ package com.openless.app /** - * Normalizes Android accessibility service component ids for comparison. - * Settings.Secure may store short forms (`pkg/.Class`) while callers often use full class names. + * Normalizes Android accessibility service component ids for comparison. Settings.Secure may store + * short forms (`pkg/.Class`) while callers often use full class names. */ internal object OpenLessAccessibilityComponentIds { internal fun parseServiceEntries(raw: String?): LinkedHashSet { val entries = LinkedHashSet() - raw - ?.split(':') + raw?.split(':') ?.map { it.trim() } ?.filter { it.isNotEmpty() && it != "null" } ?.forEach { entries.add(it) } return entries } - /** - * Mirrors Rust [normalize_component_key]: expands `pkg/.Class` to `pkg/pkg.Class`. - */ + /** Mirrors Rust [normalize_component_key]: expands `pkg/.Class` to `pkg/pkg.Class`. */ internal fun normalizeComponentKey(component: String): String? { val trimmed = component.trim() val slash = trimmed.indexOf('/') @@ -35,11 +32,12 @@ internal object OpenLessAccessibilityComponentIds { if (!isValidAndroidPackageName(packageName)) { return null } - val fullClassName = if (className.startsWith(".")) { - packageName + className - } else { - className - } + val fullClassName = + if (className.startsWith(".")) { + packageName + className + } else { + className + } if (fullClassName.any { it.isWhitespace() || it == '\n' || it == '\r' || it == '/' }) { return null } diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityResult.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityResult.kt index 0094d9b6e..e45f5970b 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityResult.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityResult.kt @@ -1,8 +1,8 @@ package com.openless.app /** - * Structured result for accessibility command IPC. - * [code] values are sent via [android.os.ResultReceiver]. + * Structured result for accessibility command IPC. [code] values are sent via + * [android.os.ResultReceiver]. */ enum class AccessibilityPasteResult(val code: Int) { SUCCESS(1), @@ -10,8 +10,7 @@ enum class AccessibilityPasteResult(val code: Int) { NO_FOCUSED_EDITOR(3), PASTE_REJECTED(4), TIMEOUT(5), - IPC_PROTOCOL_ERROR(6), - ; + IPC_PROTOCOL_ERROR(6); val reason: String get() = name diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index 474a61fe3..5b27cab04 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -19,9 +19,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -/** - * Detects IME windows for overlay keyboard trigger mode and performs paste insertion. - */ +/** Detects IME windows for overlay keyboard trigger mode and performs paste insertion. */ class OpenLessAccessibilityService : AccessibilityService() { private val mainHandler = Handler(Looper.getMainLooper()) private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } @@ -79,14 +77,15 @@ class OpenLessAccessibilityService : AccessibilityService() { return } val imeBounds = findInputMethodBounds() - val intent = Intent(this, OpenLessOverlayService::class.java).apply { - action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED - putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null) - imeBounds?.let { - putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top) - putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom) + val intent = + Intent(this, OpenLessOverlayService::class.java).apply { + action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null) + imeBounds?.let { + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top) + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom) + } } - } try { Log.i(TAG, "keyboard overlay event visible=${imeBounds != null} bounds=$imeBounds") startService(intent) @@ -117,7 +116,9 @@ class OpenLessAccessibilityService : AccessibilityService() { return OpenLessPermissionBridge.canDrawOverlaysSafely(this) } - private fun performPasteToFocusedFieldInternal(pasteText: String? = null): AccessibilityPasteResult { + private fun performPasteToFocusedFieldInternal( + pasteText: String? = null + ): AccessibilityPasteResult { val target = findEditableTarget() if (target == null) { return AccessibilityPasteResult.NO_FOCUSED_EDITOR @@ -225,7 +226,10 @@ class OpenLessAccessibilityService : AccessibilityService() { } } - private fun editableFocusedNode(root: AccessibilityNodeInfo, focusType: Int): AccessibilityNodeInfo? { + private fun editableFocusedNode( + root: AccessibilityNodeInfo, + focusType: Int, + ): AccessibilityNodeInfo? { val focused = root.findFocus(focusType) ?: return null return try { if (OpenLessAccessibilityTarget.isPasteTarget(focused)) { @@ -238,7 +242,10 @@ class OpenLessAccessibilityService : AccessibilityService() { } } - private fun findEditableInTree(node: AccessibilityNodeInfo, depth: Int): AccessibilityNodeInfo? { + private fun findEditableInTree( + node: AccessibilityNodeInfo, + depth: Int, + ): AccessibilityNodeInfo? { if (depth > MAX_EDITABLE_SEARCH_DEPTH) return null var firstCandidate: AccessibilityNodeInfo? = null if (OpenLessAccessibilityTarget.isPasteTarget(node)) { @@ -280,7 +287,10 @@ class OpenLessAccessibilityService : AccessibilityService() { lastEditableFocus = AccessibilityNodeInfo.obtain(target) } - private fun pasteWithRetryOrSetText(target: AccessibilityNodeInfo, pasteText: String? = null): Boolean { + private fun pasteWithRetryOrSetText( + target: AccessibilityNodeInfo, + pasteText: String? = null, + ): Boolean { val effectiveText = pasteText?.takeIf { it.isNotEmpty() } ?: clipboardText() if (effectiveText.isEmpty()) { return false @@ -290,7 +300,10 @@ class OpenLessAccessibilityService : AccessibilityService() { repeat(PASTE_RETRY_COUNT) { attempt -> if (target.performAction(AccessibilityNodeInfo.ACTION_PASTE)) { sleepQuietly(PASTE_VERIFY_DELAY_MS) - if (target.refresh() && pasteAppearsApplied(beforeText, nodeText(target), effectiveText)) { + if ( + target.refresh() && + pasteAppearsApplied(beforeText, nodeText(target), effectiveText) + ) { Log.i( TAG, "paste=true verified attempt=${attempt + 1} package=${target.packageName}", @@ -329,20 +342,25 @@ class OpenLessAccessibilityService : AccessibilityService() { return OpenLessPasteVerification.pasteAppearsApplied(beforeText, afterText, clipboardText) } - private fun appendClipboardTextWithSetText(target: AccessibilityNodeInfo, pasteText: String): Boolean { + private fun appendClipboardTextWithSetText( + target: AccessibilityNodeInfo, + pasteText: String, + ): Boolean { if (target.isPassword) return false val existingText = target.text?.toString().orEmpty() - val args = Bundle().apply { - putCharSequence( - AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, - existingText + pasteText, - ) - } + val args = + Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + existingText + pasteText, + ) + } return target.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) } private fun clipboardText(): String { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return "" + val clipboard = + getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return "" val clip = clipboard.primaryClip ?: return "" if (clip.itemCount <= 0) return "" return clip.getItemAt(0)?.coerceToText(this)?.toString().orEmpty() @@ -359,8 +377,9 @@ class OpenLessAccessibilityService : AccessibilityService() { private fun captureSelectedTextFromFocusedNode(): String { val root = rootInActiveWindow ?: return "" try { - val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) - ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + val focused = + root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) focused?.let { return try { selectedTextFromNode(it) @@ -376,11 +395,19 @@ class OpenLessAccessibilityService : AccessibilityService() { private fun selectedTextFromTree(node: AccessibilityNodeInfo?): String { if (node == null) return "" - selectedTextFromNode(node).takeIf { it.isNotBlank() }?.let { return it } + selectedTextFromNode(node) + .takeIf { it.isNotBlank() } + ?.let { + return it + } for (index in 0 until node.childCount) { val child = node.getChild(index) ?: continue try { - selectedTextFromTree(child).takeIf { it.isNotBlank() }?.let { return it } + selectedTextFromTree(child) + .takeIf { it.isNotBlank() } + ?.let { + return it + } } finally { child.recycle() } @@ -424,25 +451,29 @@ class OpenLessAccessibilityService : AccessibilityService() { @JvmStatic @Keep fun captureSelectedText(): String { - instance?.let { return it.captureSelectedTextFromFocusedNode() } + instance?.let { + return it.captureSelectedTextFromFocusedNode() + } return captureSelectedTextFromAccessibilityProcess() } @JvmStatic @Keep fun isEnabled(context: Context): Boolean { - val enabled = Settings.Secure.getInt( - context.contentResolver, - Settings.Secure.ACCESSIBILITY_ENABLED, - 0, - ) == 1 + val enabled = + Settings.Secure.getInt( + context.contentResolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 0, + ) == 1 if (!enabled) { return false } - val services = Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, - ) ?: return false + val services = + Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false return OpenLessAccessibilityComponentIds.enabledListContains( services, serviceComponentId(), @@ -456,19 +487,14 @@ class OpenLessAccessibilityService : AccessibilityService() { if (instance != null) { return true } - val pingResult = sendAccessibilityCommand( - OpenLessAccessibilityCommandReceiver.ACTION_PING, - PING_COMMAND_TIMEOUT_MS, - ) + val pingResult = + sendAccessibilityCommand( + OpenLessAccessibilityCommandReceiver.ACTION_PING, + PING_COMMAND_TIMEOUT_MS, + ) return pingResult == AccessibilityPasteResult.SUCCESS } - /** @deprecated Use [pingAccessibilityProcess] for UI; paste no longer gates on this. */ - @JvmStatic - fun isOperational(context: Context): Boolean { - return pingAccessibilityProcess(context) - } - internal fun performPasteFromCommand(pasteText: String? = null): AccessibilityPasteResult { return instance?.performPasteToFocusedFieldInternal(pasteText) ?: AccessibilityPasteResult.SERVICE_NOT_CONNECTED @@ -479,7 +505,9 @@ class OpenLessAccessibilityService : AccessibilityService() { } private fun pasteToFocusedFieldWithResult(pasteText: String): AccessibilityPasteResult { - instance?.let { return it.performPasteToFocusedFieldInternal(pasteText) } + instance?.let { + return it.performPasteToFocusedFieldInternal(pasteText) + } return sendAccessibilityCommand( OpenLessAccessibilityCommandReceiver.ACTION_PASTE, PASTE_COMMAND_TIMEOUT_MS, @@ -492,24 +520,33 @@ class OpenLessAccessibilityService : AccessibilityService() { timeoutMs: Long = PASTE_COMMAND_TIMEOUT_MS, pasteText: String? = null, ): AccessibilityPasteResult { - val context = OpenLessAppContext.context ?: return AccessibilityPasteResult.SERVICE_NOT_CONNECTED + val context = + OpenLessAppContext.context ?: return AccessibilityPasteResult.SERVICE_NOT_CONNECTED val latch = CountDownLatch(1) val resultHolder = AtomicReference(AccessibilityPasteResult.TIMEOUT) - val receiver = object : ResultReceiver(null) { - override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { - resultHolder.set(AccessibilityPasteResult.fromCode(resultCode)) - latch.countDown() + val receiver = + object : ResultReceiver(null) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + resultHolder.set(AccessibilityPasteResult.fromCode(resultCode)) + latch.countDown() + } } - } var broadcastSent = false return try { - val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { - this.action = action - putExtra(OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, receiver) - if (!pasteText.isNullOrEmpty()) { - putExtra(OpenLessAccessibilityCommandReceiver.EXTRA_PASTE_TEXT, pasteText) + val intent = + Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { + this.action = action + putExtra( + OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, + receiver, + ) + if (!pasteText.isNullOrEmpty()) { + putExtra( + OpenLessAccessibilityCommandReceiver.EXTRA_PASTE_TEXT, + pasteText, + ) + } } - } context.sendBroadcast(intent) broadcastSent = true try { @@ -521,7 +558,11 @@ class OpenLessAccessibilityService : AccessibilityService() { } } catch (error: InterruptedException) { Thread.currentThread().interrupt() - Log.w(TAG, "accessibility command interrupted after broadcast action=$action", error) + Log.w( + TAG, + "accessibility command interrupted after broadcast action=$action", + error, + ) AccessibilityPasteResult.IPC_PROTOCOL_ERROR } } catch (error: Throwable) { @@ -542,23 +583,30 @@ class OpenLessAccessibilityService : AccessibilityService() { val context = OpenLessAppContext.context ?: return "" val latch = CountDownLatch(1) val selectedText = AtomicReference("") - val receiver = object : ResultReceiver(null) { - override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { - if (resultCode == AccessibilityPasteResult.SUCCESS.code) { - selectedText.set( - resultData - ?.getString(OpenLessAccessibilityCommandReceiver.EXTRA_SELECTED_TEXT) - .orEmpty(), - ) + val receiver = + object : ResultReceiver(null) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + if (resultCode == AccessibilityPasteResult.SUCCESS.code) { + selectedText.set( + resultData + ?.getString( + OpenLessAccessibilityCommandReceiver.EXTRA_SELECTED_TEXT + ) + .orEmpty() + ) + } + latch.countDown() } - latch.countDown() } - } return try { - val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { - action = OpenLessAccessibilityCommandReceiver.ACTION_CAPTURE_SELECTED_TEXT - putExtra(OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, receiver) - } + val intent = + Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { + action = OpenLessAccessibilityCommandReceiver.ACTION_CAPTURE_SELECTED_TEXT + putExtra( + OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, + receiver, + ) + } context.sendBroadcast(intent) if (latch.await(SELECTION_COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { selectedText.get() diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityTarget.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityTarget.kt index 551f957d9..662e0a8bd 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityTarget.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityTarget.kt @@ -2,9 +2,7 @@ package com.openless.app import android.view.accessibility.AccessibilityNodeInfo -/** - * Pure helpers for validating editable focus targets (unit-testable without a live service). - */ +/** Pure helpers for validating editable focus targets (unit-testable without a live service). */ internal object OpenLessAccessibilityTarget { private const val ACTION_PASTE_ID = 0x00008000 private const val ACTION_SET_TEXT_ID = 0x00200000 @@ -74,8 +72,8 @@ internal object OpenLessAccessibilityTarget { } /** - * Limited cache validation without tree walks or pseudo node identity. - * Caller must prefer [AccessibilityNodeInfo.findFocus] first. + * Limited cache validation without tree walks or pseudo node identity. Caller must prefer + * [AccessibilityNodeInfo.findFocus] first. */ fun isValidCachedEditable( cached: AccessibilityNodeInfo, diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt index e98d5c08f..94d654cb7 100644 --- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -5,9 +5,7 @@ import android.util.Log import java.io.File import org.json.JSONObject -/** - * Reads Android-visible preferences without depending on the Rust coordinator. - */ +/** Reads Android-visible preferences without depending on the Rust coordinator. */ object OpenLessAndroidPreferences { private const val TAG = "OpenLessAndroidPrefs" private const val APP_DIR = "OpenLess" @@ -39,27 +37,26 @@ object OpenLessAndroidPreferences { } fun overlayActivationMode(context: Context): String { - return readPreferenceString(context, KEY_OVERLAY_ACTIVATION_MODE) - ?.takeIf { it in VALID_OVERLAY_ACTIVATION_MODES } - ?: "tap" + return readPreferenceString(context, KEY_OVERLAY_ACTIVATION_MODE)?.takeIf { + it in VALID_OVERLAY_ACTIVATION_MODES + } ?: "tap" } fun overlayLeftSwipeAction(context: Context): String { - return readPreferenceString(context, KEY_OVERLAY_LEFT_SWIPE_ACTION) - ?.takeIf { it in VALID_OVERLAY_LEFT_SWIPE_ACTIONS } - ?: "translation" + return readPreferenceString(context, KEY_OVERLAY_LEFT_SWIPE_ACTION)?.takeIf { + it in VALID_OVERLAY_LEFT_SWIPE_ACTIONS + } ?: "translation" } fun overlayCancelSwipeDirection(context: Context): String { - return readPreferenceString(context, KEY_OVERLAY_CANCEL_SWIPE_DIRECTION) - ?.takeIf { it in VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS } - ?: "up" + return readPreferenceString(context, KEY_OVERLAY_CANCEL_SWIPE_DIRECTION)?.takeIf { + it in VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS + } ?: "up" } fun overlaySizeDp(context: Context): Int { return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) - ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) - ?: DEFAULT_OVERLAY_SIZE_DP + ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) ?: DEFAULT_OVERLAY_SIZE_DP } private fun readPreferenceString(context: Context, key: String): String? { @@ -67,12 +64,13 @@ object OpenLessAndroidPreferences { if (!file.isFile) { continue } - val value = try { - JSONObject(file.readText()).optString(key, "") - } catch (error: Throwable) { - Log.w(TAG, "read ${file.absolutePath} failed", error) - "" - } + val value = + try { + JSONObject(file.readText()).optString(key, "") + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + "" + } if (value.isNotBlank()) { return value } @@ -85,13 +83,14 @@ object OpenLessAndroidPreferences { if (!file.isFile) { continue } - val value = try { - val json = JSONObject(file.readText()) - if (json.has(key)) json.optInt(key) else null - } catch (error: Throwable) { - Log.w(TAG, "read ${file.absolutePath} failed", error) - null - } + val value = + try { + val json = JSONObject(file.readText()) + if (json.has(key)) json.optInt(key) else null + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + null + } if (value != null) { return value } diff --git a/openless-all/app/android/kotlin/OpenLessApplication.kt b/openless-all/app/android/kotlin/OpenLessApplication.kt index d4a8300cb..6f077fd5c 100644 --- a/openless-all/app/android/kotlin/OpenLessApplication.kt +++ b/openless-all/app/android/kotlin/OpenLessApplication.kt @@ -8,9 +8,7 @@ import android.os.Build import android.os.Bundle import android.util.Log -/** - * Registers activity lifecycle hooks for overlay background trigger mode. - */ +/** Registers activity lifecycle hooks for overlay background trigger mode. */ class OpenLessApplication : Application() { override fun onCreate() { super.onCreate() @@ -18,29 +16,38 @@ class OpenLessApplication : Application() { if (isMainProcess()) { OpenLessShizukuBridge.initialize() } - registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit - override fun onActivityStarted(activity: Activity) { - if (activity.javaClass.name.endsWith("MainActivity")) { - maybeHideOverlayOnForeground() + registerActivityLifecycleCallbacks( + object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = + Unit + + override fun onActivityStarted(activity: Activity) { + if (activity.javaClass.name.endsWith("MainActivity")) { + maybeHideOverlayOnForeground() + } } - } - override fun onActivityResumed(activity: Activity) = Unit - override fun onActivityPaused(activity: Activity) = Unit - override fun onActivityStopped(activity: Activity) { - if (activity.javaClass.name.endsWith("MainActivity")) { - maybeShowOverlayOnBackground() + + override fun onActivityResumed(activity: Activity) = Unit + + override fun onActivityPaused(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) { + if (activity.javaClass.name.endsWith("MainActivity")) { + maybeShowOverlayOnBackground() + } } + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = + Unit + + override fun onActivityDestroyed(activity: Activity) = Unit } - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit - override fun onActivityDestroyed(activity: Activity) = Unit - }) + ) } private fun maybeShowOverlayOnBackground() { val configured = configuredOverlayTriggerMode() - val shouldShow = configured == "background" || - configured == "always" + val shouldShow = configured == "background" || configured == "always" if (!shouldShow) { return } @@ -70,9 +77,11 @@ class OpenLessApplication : Application() { private fun sendOverlayAction(action: String) { try { - startService(Intent(this, OpenLessOverlayService::class.java).apply { - this.action = action - }) + startService( + Intent(this, OpenLessOverlayService::class.java).apply { + this.action = action + } + ) } catch (error: Throwable) { Log.w(TAG, "overlay action failed: $action", error) } @@ -89,9 +98,7 @@ class OpenLessApplication : Application() { } val pid = android.os.Process.myPid() val activityManager = getSystemService(ACTIVITY_SERVICE) as? ActivityManager ?: return null - return activityManager.runningAppProcesses - ?.firstOrNull { it.pid == pid } - ?.processName + return activityManager.runningAppProcesses?.firstOrNull { it.pid == pid }?.processName } companion object { diff --git a/openless-all/app/android/kotlin/OpenLessContentReader.kt b/openless-all/app/android/kotlin/OpenLessContentReader.kt index af77bf615..a24ac82d6 100644 --- a/openless-all/app/android/kotlin/OpenLessContentReader.kt +++ b/openless-all/app/android/kotlin/OpenLessContentReader.kt @@ -21,10 +21,11 @@ object OpenLessContentReader { val uri = Uri.parse(uriString) context.contentResolver.openInputStream(uri)?.use { input -> readBounded(input, maxBytes) - } ?: run { - Log.w(TAG, "openInputStream returned null for selected document") - null } + ?: run { + Log.w(TAG, "openInputStream returned null for selected document") + null + } } catch (error: Throwable) { Log.e(TAG, "failed to read selected document", error) null diff --git a/openless-all/app/android/kotlin/OpenLessContentWriter.kt b/openless-all/app/android/kotlin/OpenLessContentWriter.kt index 905f73980..457f068fa 100644 --- a/openless-all/app/android/kotlin/OpenLessContentWriter.kt +++ b/openless-all/app/android/kotlin/OpenLessContentWriter.kt @@ -8,8 +8,8 @@ import androidx.annotation.Keep /** * Writes bytes to a SAF content:// URI via ContentResolver. * - * Prefer this over tauri-plugin-fs for exports: fs detaches the FD early and - * some providers finalize a 0-byte file before Rust finishes writing. + * Prefer this over tauri-plugin-fs for exports: fs detaches the FD early and some providers + * finalize a 0-byte file before Rust finishes writing. */ @Keep object OpenLessContentWriter { @@ -23,10 +23,11 @@ object OpenLessContentWriter { context.contentResolver.openOutputStream(uri)?.use { output -> output.write(bytes) output.flush() - } ?: run { - Log.w(TAG, "openOutputStream returned null for selected document") - return false } + ?: run { + Log.w(TAG, "openOutputStream returned null for selected document") + return false + } Log.i(TAG, "wrote ${bytes.size} bytes to selected document") true } catch (error: Throwable) { diff --git a/openless-all/app/android/kotlin/OpenLessCredentialCipher.kt b/openless-all/app/android/kotlin/OpenLessCredentialCipher.kt index ffc54c5c5..6f0dae3d9 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialCipher.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialCipher.kt @@ -29,10 +29,7 @@ internal object OpenLessCredentialCipher { throw IllegalArgumentException("malformed credential packet") } val nonceLength = packet[0].toInt() and 0xff - if ( - nonceLength != NONCE_BYTES || - packet.size < 1 + nonceLength + TAG_BYTES - ) { + if (nonceLength != NONCE_BYTES || packet.size < 1 + nonceLength + TAG_BYTES) { throw IllegalArgumentException("malformed credential packet") } val nonce = packet.copyOfRange(1, 1 + nonceLength) diff --git a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt index 4028bb0e5..e8e8dd1b9 100644 --- a/openless-all/app/android/kotlin/OpenLessCredentialVault.kt +++ b/openless-all/app/android/kotlin/OpenLessCredentialVault.kt @@ -60,7 +60,10 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { fun open(packet: ByteArray, aad: ByteArray): ByteArray { return try { val key = existingKey() ?: return credentialResponse(CREDENTIAL_STATUS_KEY_MISSING) - credentialResponse(CREDENTIAL_STATUS_OK, OpenLessCredentialCipher.open(key, packet, aad)) + credentialResponse( + CREDENTIAL_STATUS_OK, + OpenLessCredentialCipher.open(key, packet, aad), + ) } catch (error: KeyPermanentlyInvalidatedException) { credentialResponse(credentialStatusForKeyLoadFailure(error)) } catch (error: UnrecoverableKeyException) { @@ -134,18 +137,20 @@ internal class AndroidKeystoreCredentialVault(private val alias: String) { @Throws(GeneralSecurityException::class, IOException::class) private fun getOrCreateKey(): SecretKey { - existingKey()?.let { return it } + existingKey()?.let { + return it + } val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) generator.init( KeyGenParameterSpec.Builder( - alias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, - ) + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .setRandomizedEncryptionRequired(true) - .build(), + .build() ) return generator.generateKey() } @@ -170,15 +175,11 @@ object OpenLessCredentialVault { @JvmStatic fun seal(plaintext: ByteArray, aad: ByteArray): ByteArray = backend.seal(plaintext, aad) - @JvmStatic - fun open(packet: ByteArray, aad: ByteArray): ByteArray = backend.open(packet, aad) + @JvmStatic fun open(packet: ByteArray, aad: ByteArray): ByteArray = backend.open(packet, aad) - @JvmStatic - fun deleteKey(): ByteArray = backend.deleteKey() + @JvmStatic fun deleteKey(): ByteArray = backend.deleteKey() - @JvmStatic - fun migrationComplete(): ByteArray = migrationMarker.keyExists() + @JvmStatic fun migrationComplete(): ByteArray = migrationMarker.keyExists() - @JvmStatic - fun markMigrationComplete(): ByteArray = migrationMarker.ensureKey() + @JvmStatic fun markMigrationComplete(): ByteArray = migrationMarker.ensureKey() } diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index 45bf69706..745de9202 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -1,8 +1,6 @@ package com.openless.app -/** - * JNI bridge from Kotlin overlay / lifecycle code into Rust Coordinator. - */ +/** JNI bridge from Kotlin overlay / lifecycle code into Rust Coordinator. */ object OpenLessNative { private const val BACKEND_CONTRACT_VERSION = "2.0.0" @@ -26,7 +24,8 @@ object OpenLessNative { @JvmStatic external fun nativeBackendSnapshot(): String - @JvmStatic fun requireBackendContract() { + @JvmStatic + fun requireBackendContract() { val response = org.json.JSONObject(nativeBackendSnapshot()) val version = response.optString("contractVersion") check(version == BACKEND_CONTRACT_VERSION) { diff --git a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt index 213642bb0..7f0ed2b70 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt @@ -4,15 +4,12 @@ import android.os.Handler import android.os.Looper import androidx.annotation.Keep -/** - * Rust calls back into this object to refresh overlay UI state. - */ +/** Rust calls back into this object to refresh overlay UI state. */ @Keep object OpenLessOverlayBridge { private val mainHandler = Handler(Looper.getMainLooper()) - @Volatile - var listener: OverlayStateListener? = null + @Volatile var listener: OverlayStateListener? = null interface OverlayStateListener { fun onCapsuleStateChanged(state: String, message: String?) @@ -31,7 +28,12 @@ object OpenLessOverlayBridge { fun showToast(message: String) { mainHandler.post { val service = OpenLessOverlayService.instance ?: return@post - android.widget.Toast.makeText(service.applicationContext, message, android.widget.Toast.LENGTH_SHORT).show() + android.widget.Toast.makeText( + service.applicationContext, + message, + android.widget.Toast.LENGTH_SHORT, + ) + .show() } } } diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt index 939144981..fd2d2d7bc 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -23,9 +23,7 @@ import android.widget.ImageView import android.widget.Toast import kotlin.math.abs -/** - * Foreground service + TYPE_APPLICATION_OVERLAY floating dictation control. - */ +/** Foreground service + TYPE_APPLICATION_OVERLAY floating dictation control. */ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateListener { private var windowManager: WindowManager? = null @@ -82,8 +80,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) } else { - @Suppress("DEPRECATION") - stopForeground(true) + @Suppress("DEPRECATION") stopForeground(true) } stopSelf(startId) } @@ -123,7 +120,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } applyVisualState(OverlayVisualState.Recording) } - "transcribing", "polishing" -> { + "transcribing", + "polishing" -> { recording = false processing = true applyVisualState(OverlayVisualState.Processing) @@ -140,7 +138,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList applyVisualState(OverlayVisualState.Error) message?.takeIf { it.isNotBlank() }?.let { showToast(it) } } - "cancelled", "idle" -> { + "cancelled", + "idle" -> { recording = false processing = false setArmed(false) @@ -199,11 +198,12 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun clearAllOverlayRoots(): Int { windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager - val views = synchronized(overlayRoots) { - (overlayRoots + listOfNotNull(rootView)).distinct().also { - overlayRoots.clear() + val views = + synchronized(overlayRoots) { + (overlayRoots + listOfNotNull(rootView)).distinct().also { + overlayRoots.clear() + } } - } views.forEach { view -> removeOverlayRoot(view) } @@ -214,32 +214,34 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun attachNewOverlayRoot(): Boolean { val savedPosition = loadSavedPosition() - val params = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - } else { - @Suppress("DEPRECATION") - WindowManager.LayoutParams.TYPE_PHONE - }, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or - WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, - PixelFormat.TRANSLUCENT, - ).apply { - gravity = Gravity.TOP or Gravity.START - x = savedPosition.first - y = savedPosition.second - } + val params = + WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + @Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE + }, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT, + ) + .apply { + gravity = Gravity.TOP or Gravity.START + x = savedPosition.first + y = savedPosition.second + } layoutParams = params - val root = FrameLayout(this).apply { - contentDescription = "OpenLess" - isClickable = true - isFocusable = false - setOnClickListener { handleIconClick() } - } + val root = + FrameLayout(this).apply { + contentDescription = "OpenLess" + isClickable = true + isFocusable = false + setOnClickListener { handleIconClick() } + } iconContainer = root iconButton = buildIconButton() root.addView( @@ -261,7 +263,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList processing -> OverlayVisualState.Processing armed -> OverlayVisualState.Armed else -> OverlayVisualState.Idle - }, + } ) true } catch (error: Throwable) { @@ -308,12 +310,16 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun reconcileOverlayRoots() { - val roots = synchronized(overlayRoots) { - (overlayRoots + listOfNotNull(rootView)).distinct().filter { it.isAttachedToWindow }.also { - overlayRoots.clear() - overlayRoots.addAll(it) + val roots = + synchronized(overlayRoots) { + (overlayRoots + listOfNotNull(rootView)) + .distinct() + .filter { it.isAttachedToWindow } + .also { + overlayRoots.clear() + overlayRoots.addAll(it) + } } - } if (roots.isEmpty()) { rootView = null layoutParams = null @@ -427,20 +433,35 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList val dx = event.rawX.toInt() - dragStartX val dy = event.rawY.toInt() - dragStartY val verticalSwipe = detectVerticalSwipe(dx, dy) - if (recording && verticalSwipe != null && matchesConfiguredCancelSwipe(verticalSwipe) && !swipeConsumed) { + if ( + recording && + verticalSwipe != null && + matchesConfiguredCancelSwipe(verticalSwipe) && + !swipeConsumed + ) { pendingSwipe = verticalSwipe swipeConsumed = true applySwipePreview(verticalSwipe) return@setOnTouchListener true } val swipe = detectHorizontalSwipe(dx, dy) - if ((recording || armed || longPressRecording) && swipe != null && !swipeConsumed) { + if ( + (recording || armed || longPressRecording) && + swipe != null && + !swipeConsumed + ) { pendingSwipe = swipe swipeConsumed = true applySwipePreview(swipe) return@setOnTouchListener true } - if (!processing && !armed && !recording && !longPressRecording && (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX)) { + if ( + !processing && + !armed && + !recording && + !longPressRecording && + (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX) + ) { dragging = true params.x = paramStartX + dx params.y = paramStartY + dy @@ -485,43 +506,49 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList private fun applyVisualState(state: OverlayVisualState) { if (!::iconContainer.isInitialized || !::iconButton.isInitialized) return - val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { - OverlayVisualState.Idle -> VisualStyle( - alpha = 0.58f, - fill = Color.parseColor("#66202A36"), - stroke = Color.parseColor("#66FFFFFF"), - strokeWidth = 1, - enabled = true, - ) - OverlayVisualState.Armed -> VisualStyle( - alpha = 1f, - fill = Color.parseColor("#E6111827"), - stroke = Color.parseColor("#38BDF8"), - strokeWidth = 3, - enabled = true, - ) - OverlayVisualState.Recording -> VisualStyle( - alpha = 1f, - fill = Color.parseColor("#E6111827"), - stroke = Color.parseColor("#F43F5E"), - strokeWidth = 3, - enabled = true, - ) - OverlayVisualState.Processing -> VisualStyle( - alpha = 0.86f, - fill = Color.parseColor("#D1111827"), - stroke = Color.parseColor("#38BDF8"), - strokeWidth = 2, - enabled = true, - ) - OverlayVisualState.Error -> VisualStyle( - alpha = 0.95f, - fill = Color.parseColor("#E67F1D1D"), - stroke = Color.parseColor("#EF4444"), - strokeWidth = 2, - enabled = true, - ) - } + val (alpha, fill, stroke, strokeWidth, enabled) = + when (state) { + OverlayVisualState.Idle -> + VisualStyle( + alpha = 0.58f, + fill = Color.parseColor("#66202A36"), + stroke = Color.parseColor("#66FFFFFF"), + strokeWidth = 1, + enabled = true, + ) + OverlayVisualState.Armed -> + VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Recording -> + VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#F43F5E"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Processing -> + VisualStyle( + alpha = 0.86f, + fill = Color.parseColor("#D1111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 2, + enabled = true, + ) + OverlayVisualState.Error -> + VisualStyle( + alpha = 0.95f, + fill = Color.parseColor("#E67F1D1D"), + stroke = Color.parseColor("#EF4444"), + strokeWidth = 2, + enabled = true, + ) + } iconContainer.alpha = alpha iconContainer.isEnabled = enabled iconContainer.background = circleDrawable(fill, stroke, dp(strokeWidth)) @@ -702,7 +729,10 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } private fun tryPromoteRecordingForeground(): Boolean { - if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + if ( + checkSelfPermission(Manifest.permission.RECORD_AUDIO) != + PackageManager.PERMISSION_GRANTED + ) { showToast("请先授予麦克风权限") return false } @@ -730,7 +760,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val nm = getSystemService(NotificationManager::class.java) nm.createNotificationChannel( - NotificationChannel(channelId, "OpenLess Overlay", NotificationManager.IMPORTANCE_LOW), + NotificationChannel( + channelId, + "OpenLess Overlay", + NotificationManager.IMPORTANCE_LOW, + ) ) } return Notification.Builder(this, channelId) diff --git a/openless-all/app/android/kotlin/OpenLessPasteVerification.kt b/openless-all/app/android/kotlin/OpenLessPasteVerification.kt index fc8162881..27331db20 100644 --- a/openless-all/app/android/kotlin/OpenLessPasteVerification.kt +++ b/openless-all/app/android/kotlin/OpenLessPasteVerification.kt @@ -1,8 +1,6 @@ package com.openless.app -/** - * Pure helpers for verifying accessibility paste actually changed editor text. - */ +/** Pure helpers for verifying accessibility paste actually changed editor text. */ internal object OpenLessPasteVerification { fun pasteAppearsApplied( beforeText: String, diff --git a/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt b/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt index e7d9aa7f7..b4ba4f798 100644 --- a/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt +++ b/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt @@ -27,7 +27,10 @@ object OpenLessPermissionBridge { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return true } - if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + if ( + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { return true } if (!requestInFlight.compareAndSet(false, true)) { @@ -35,15 +38,17 @@ object OpenLessPermissionBridge { return false } return try { - val intent = Intent(context, MicrophonePermissionActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + val intent = + Intent(context, MicrophonePermissionActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } context.startActivity(intent) false } catch (error: Throwable) { requestInFlight.set(false) Log.w(TAG, "failed to launch RECORD_AUDIO permission activity", error) - context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED } } @@ -54,8 +59,8 @@ object OpenLessPermissionBridge { } /** - * Safe overlay permission query for Rust JNI / WebView IPC threads. - * HyperOS and some OEM skins may throw from Settings.canDrawOverlays off the main thread. + * Safe overlay permission query for Rust JNI / WebView IPC threads. HyperOS and some OEM skins + * may throw from Settings.canDrawOverlays off the main thread. */ @Keep @JvmStatic @@ -98,20 +103,21 @@ object OpenLessPermissionBridge { private fun queryCanDrawOverlaysViaAppOps(context: Context): Boolean { return try { val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - appOps.unsafeCheckOpNoThrow( - AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW, - android.os.Process.myUid(), - context.packageName, - ) - } else { - @Suppress("DEPRECATION") - appOps.checkOpNoThrow( - AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW, - android.os.Process.myUid(), - context.packageName, - ) - } + val mode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW, + android.os.Process.myUid(), + context.packageName, + ) + } else { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_SYSTEM_ALERT_WINDOW, + android.os.Process.myUid(), + context.packageName, + ) + } mode == AppOpsManager.MODE_ALLOWED } catch (error: Throwable) { Log.w(TAG, "AppOpsManager overlay check failed", error) diff --git a/openless-all/app/android/kotlin/OpenLessShizukuBridge.kt b/openless-all/app/android/kotlin/OpenLessShizukuBridge.kt index 5af153ef1..c9ce6020a 100644 --- a/openless-all/app/android/kotlin/OpenLessShizukuBridge.kt +++ b/openless-all/app/android/kotlin/OpenLessShizukuBridge.kt @@ -22,17 +22,13 @@ object OpenLessShizukuBridge { private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" private const val RECOVERY_BIND_TIMEOUT_MS = 5_000L private const val RECOVERY_BIND_POLL_MS = 250L - private val ANDROID_PACKAGE_REGEX = - Regex("^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*$") + private val ANDROID_PACKAGE_REGEX = Regex("^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*$") - @Volatile - private var binderWasAuthorized = false + @Volatile private var binderWasAuthorized = false - @Volatile - private var binderDead = false + @Volatile private var binderDead = false - @Volatile - private var lastPermissionMessageKey: String? = null + @Volatile private var lastPermissionMessageKey: String? = null @JvmStatic fun setLastPermissionMessageKey(key: String) { @@ -66,16 +62,17 @@ object OpenLessShizukuBridge { val state = detectState(context) val accessibility = diagnoseAccessibility(context) val messageKey = resolveStatusMessageKey(legacyBackend, state, accessibility) - val json = JSONObject() - .put("state", state.name) - .put("messageKey", messageKey) - .put( - "accessibility", - JSONObject() - .put("registered", accessibility.registered) - .put("operational", accessibility.operational) - .put("messageKey", accessibility.messageKey), - ) + val json = + JSONObject() + .put("state", state.name) + .put("messageKey", messageKey) + .put( + "accessibility", + JSONObject() + .put("registered", accessibility.registered) + .put("operational", accessibility.operational) + .put("messageKey", accessibility.messageKey), + ) consumeLastPermissionMessageKey()?.let { key -> json.put("lastPermissionMessageKey", key) } @@ -93,9 +90,10 @@ object OpenLessShizukuBridge { return false } return try { - val intent = Intent(context, ShizukuPermissionActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + val intent = + Intent(context, ShizukuPermissionActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } context.startActivity(intent) true } catch (error: Throwable) { @@ -120,10 +118,12 @@ object OpenLessShizukuBridge { } } return try { - val market = Intent( - Intent.ACTION_VIEW, - Uri.parse("market://details?id=$SHIZUKU_PACKAGE"), - ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val market = + Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=$SHIZUKU_PACKAGE"), + ) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(market) true } catch (error: Throwable) { @@ -150,28 +150,32 @@ object OpenLessShizukuBridge { } /** - * MTK/Xiaomi ROMs NPE in UserService app_process startup; Shizuku.newProcess is private - * but callable via reflection and does not spawn com.openless.app:* processes. + * MTK/Xiaomi ROMs NPE in UserService app_process startup; Shizuku.newProcess is private but + * callable via reflection and does not spawn com.openless.app:* processes. */ internal fun injectPasteKeyViaShizukuShell(): Boolean { if (!Shizuku.pingBinder()) { return false } return try { - val method = Shizuku::class.java.getDeclaredMethod( - "newProcess", - Array::class.java, - Array::class.java, - String::class.java, - ) + val method = + Shizuku::class + .java + .getDeclaredMethod( + "newProcess", + Array::class.java, + Array::class.java, + String::class.java, + ) method.isAccessible = true @Suppress("UNCHECKED_CAST") - val process = method.invoke( - null, - arrayOf("input", "keyevent", KEYCODE_PASTE), - null, - null, - ) as Process + val process = + method.invoke( + null, + arrayOf("input", "keyevent", KEYCODE_PASTE), + null, + null, + ) as Process val exitCode = process.waitFor() exitCode == 0 } catch (error: Throwable) { @@ -198,21 +202,26 @@ object OpenLessShizukuBridge { return recoveryJson(RecoveryOutcome.ShellFailed, "invalid_component") } - val recoveryPayload = OpenLessShizukuUserServiceClient.withRecoveryLock { - val raw = OpenLessShizukuUserServiceClient.withService(context) { service -> - service.recoverAccessibilityService(serviceComponent) - } ?: return@withRecoveryLock recoveryJson( - RecoveryOutcome.ShizukuUnavailable, - "service_connect_failed", - ) - raw - } ?: return recoveryJson( - RecoveryOutcome.ShellFailed, - "recovery_in_progress", - ) + val recoveryPayload = + OpenLessShizukuUserServiceClient.withRecoveryLock { + val raw = + OpenLessShizukuUserServiceClient.withService(context) { service -> + service.recoverAccessibilityService(serviceComponent) + } + ?: return@withRecoveryLock recoveryJson( + RecoveryOutcome.ShizukuUnavailable, + "service_connect_failed", + ) + raw + } + ?: return recoveryJson( + RecoveryOutcome.ShellFailed, + "recovery_in_progress", + ) - val (outcome, messageKey) = parseRecoveryPayload(recoveryPayload) - ?: return recoveryJson(RecoveryOutcome.ShellFailed, "parse_failed") + val (outcome, messageKey) = + parseRecoveryPayload(recoveryPayload) + ?: return recoveryJson(RecoveryOutcome.ShellFailed, "parse_failed") if (outcome != RecoveryOutcome.Success) { return recoveryJson(outcome, messageKey) @@ -271,26 +280,30 @@ object OpenLessShizukuBridge { internal fun diagnoseAccessibility(context: Context): AccessibilityDiagnosis { val registered = OpenLessAccessibilityService.isEnabled(context) - val operational = registered && OpenLessAccessibilityService.pingAccessibilityProcess(context) - val messageKey = when { - operational -> "operational" - registered -> "registered_stale" - else -> "not_registered" - } + val operational = + registered && OpenLessAccessibilityService.pingAccessibilityProcess(context) + val messageKey = + when { + operational -> "operational" + registered -> "registered_stale" + else -> "not_registered" + } return AccessibilityDiagnosis(registered, operational, messageKey) } internal fun parseServiceEntries(raw: String?): LinkedHashSet { val entries = LinkedHashSet() - raw - ?.split(':') + raw?.split(':') ?.map { it.trim() } ?.filter { it.isNotEmpty() && it != "null" } ?.forEach { entries.add(it) } return entries } - internal fun mergeEnabledAccessibilityServices(current: String?, serviceComponent: String): String { + internal fun mergeEnabledAccessibilityServices( + current: String?, + serviceComponent: String, + ): String { val normalizedComponent = serviceComponent.trim() if (normalizedComponent.isEmpty()) return "" val canonicalOpenLess = canonicalizeServiceEntry(normalizedComponent) @@ -347,12 +360,10 @@ object OpenLessShizukuBridge { } return when (servicesRollback) { ServicesRollbackResult.Restored, - ServicesRollbackResult.AlreadyBaseline, - -> true + ServicesRollbackResult.AlreadyBaseline -> true ServicesRollbackResult.Conflict, ServicesRollbackResult.ReadFailed, - ServicesRollbackResult.WriteFailed, - -> false + ServicesRollbackResult.WriteFailed -> false } } @@ -403,17 +414,17 @@ object OpenLessShizukuBridge { wroteEnabled: Boolean, baselineEnabled: String, ): Boolean { - val servicesComplete = rollback.services == ServicesRollbackResult.Restored || - rollback.services == ServicesRollbackResult.AlreadyBaseline + val servicesComplete = + rollback.services == ServicesRollbackResult.Restored || + rollback.services == ServicesRollbackResult.AlreadyBaseline if (!wroteEnabled) { return servicesComplete } if (baselineEnabled == "1") { - return servicesComplete && ( - rollback.enabled == EnabledRollbackResult.Restored || + return servicesComplete && + (rollback.enabled == EnabledRollbackResult.Restored || rollback.enabled == EnabledRollbackResult.AlreadyBaseline || - rollback.enabled == EnabledRollbackResult.Skipped - ) + rollback.enabled == EnabledRollbackResult.Skipped) } return false } @@ -430,17 +441,20 @@ object OpenLessShizukuBridge { } val packageName = trimmed.substring(0, slash) val className = trimmed.substring(slash + 1) - if (className.isEmpty() || className.any { it.isWhitespace() || it == '\n' || it == '\r' }) { + if ( + className.isEmpty() || className.any { it.isWhitespace() || it == '\n' || it == '\r' } + ) { return null } if (!isValidAndroidPackageName(packageName)) { return null } - val fullClassName = if (className.startsWith('.')) { - packageName + className - } else { - className - } + val fullClassName = + if (className.startsWith('.')) { + packageName + className + } else { + className + } if (fullClassName.any { it.isWhitespace() || it == '\n' || it == '\r' || it == '/' }) { return null } @@ -533,7 +547,9 @@ object OpenLessShizukuBridge { } val packageName = trimmed.substring(0, slash) val className = trimmed.substring(slash + 1) - if (className.isEmpty() || className.any { it.isWhitespace() || it == '\n' || it == '\r' }) { + if ( + className.isEmpty() || className.any { it.isWhitespace() || it == '\n' || it == '\r' } + ) { return false } return isValidAndroidPackageName(packageName) @@ -566,8 +582,7 @@ object OpenLessShizukuBridge { PackageManager.PackageInfoFlags.of(0), ) } else { - @Suppress("DEPRECATION") - context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) + @Suppress("DEPRECATION") context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0) } true } catch (_: PackageManager.NameNotFoundException) { @@ -592,11 +607,12 @@ object OpenLessShizukuBridge { ShizukuState.NotRunning -> "not_running" ShizukuState.NotAuthorized -> "not_authorized" ShizukuState.BinderDead -> "binder_dead" - ShizukuState.Authorized -> when { - accessibility.operational -> "authorized_operational" - accessibility.registered -> "authorized_registered_stale" - else -> "authorized_can_recover" - } + ShizukuState.Authorized -> + when { + accessibility.operational -> "authorized_operational" + accessibility.registered -> "authorized_registered_stale" + else -> "authorized_can_recover" + } } } @@ -617,10 +633,7 @@ object OpenLessShizukuBridge { } private fun recoveryJson(outcome: RecoveryOutcome, messageKey: String): String { - return JSONObject() - .put("outcome", outcome.name) - .put("messageKey", messageKey) - .toString() + return JSONObject().put("outcome", outcome.name).put("messageKey", messageKey).toString() } enum class ShizukuState { diff --git a/openless-all/app/android/kotlin/OpenLessShizukuUserService.kt b/openless-all/app/android/kotlin/OpenLessShizukuUserService.kt index 5d09a167c..1a5f4c5ac 100644 --- a/openless-all/app/android/kotlin/OpenLessShizukuUserService.kt +++ b/openless-all/app/android/kotlin/OpenLessShizukuUserService.kt @@ -4,23 +4,21 @@ import android.content.Context import android.os.Build import android.util.Log import androidx.annotation.Keep -import org.json.JSONObject import java.util.concurrent.Callable import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException +import org.json.JSONObject /** - * Runs in a Shizuku UserService process with shell/root identity. - * Best-effort accessibility recovery — Secure Settings writes are not compare-and-set. + * Runs in a Shizuku UserService process with shell/root identity. Best-effort accessibility + * recovery — Secure Settings writes are not compare-and-set. */ @Keep -class OpenLessShizukuUserService @JvmOverloads constructor( - private val appPackage: String = "", -) : IOpenLessShizukuUserService.Stub() { +class OpenLessShizukuUserService @JvmOverloads constructor(private val appPackage: String = "") : + IOpenLessShizukuUserService.Stub() { - @Keep - constructor(context: Context) : this(context.packageName) + @Keep constructor(context: Context) : this(context.packageName) override fun destroy() { Log.i(TAG, "destroy") @@ -66,11 +64,12 @@ class OpenLessShizukuUserService @JvmOverloads constructor( serviceComponent: String, attempt: Int, ): RecoveryAttemptResult { - val preWrite = readSnapshot() - ?: return RecoveryAttemptResult.Failure( - OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, - "read_failed", - ) + val preWrite = + readSnapshot() + ?: return RecoveryAttemptResult.Failure( + OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, + "read_failed", + ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && appPackage.isNotBlank()) { if (!allowRestrictedSettingsForApp()) { @@ -78,21 +77,23 @@ class OpenLessShizukuUserService @JvmOverloads constructor( } } - val immediate = readSnapshot() - ?: return RecoveryAttemptResult.Failure( - OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, - "read_failed", - ) + val immediate = + readSnapshot() + ?: return RecoveryAttemptResult.Failure( + OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, + "read_failed", + ) if (OpenLessShizukuBridge.preWriteSnapshotChanged(preWrite, immediate)) { Log.i(TAG, "pre-put snapshot changed attempt=$attempt") return RecoveryAttemptResult.Retry } - val prePut = readSnapshot() - ?: return RecoveryAttemptResult.Failure( - OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, - "read_failed", - ) + val prePut = + readSnapshot() + ?: return RecoveryAttemptResult.Failure( + OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, + "read_failed", + ) if (OpenLessShizukuBridge.preWriteSnapshotChanged(immediate, prePut)) { Log.i(TAG, "immediate pre-put snapshot changed attempt=$attempt") return RecoveryAttemptResult.Retry @@ -105,10 +106,11 @@ class OpenLessShizukuUserService @JvmOverloads constructor( ) } - val merged = OpenLessShizukuBridge.mergeEnabledAccessibilityServices( - prePut.services, - serviceComponent, - ) + val merged = + OpenLessShizukuBridge.mergeEnabledAccessibilityServices( + prePut.services, + serviceComponent, + ) if (merged.isBlank()) { return RecoveryAttemptResult.Failure( OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, @@ -123,20 +125,22 @@ class OpenLessShizukuUserService @JvmOverloads constructor( ) } - val postServicesPut = readEnabledServices() - ?: run { - val rollback = rollbackWrittenState( - WrittenState(merged, null), - prePut, - ) - return failureAfterRollback( - rollback, - prePut, - WrittenState(merged, null), - OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, - "readback_failed", - ) - } + val postServicesPut = + readEnabledServices() + ?: run { + val rollback = + rollbackWrittenState( + WrittenState(merged, null), + prePut, + ) + return failureAfterRollback( + rollback, + prePut, + WrittenState(merged, null), + OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, + "readback_failed", + ) + } if (!OpenLessShizukuBridge.servicesListsEqual(postServicesPut, merged)) { Log.w(TAG, "services changed immediately after write attempt=$attempt") rollbackWrittenState( @@ -148,10 +152,11 @@ class OpenLessShizukuUserService @JvmOverloads constructor( val writtenEnabled = "1" if (!putSecureSetting(KEY_ACCESSIBILITY_ENABLED, writtenEnabled)) { - val rollback = rollbackWrittenState( - WrittenState(merged, writtenEnabled), - prePut, - ) + val rollback = + rollbackWrittenState( + WrittenState(merged, writtenEnabled), + prePut, + ) return failureAfterRollback( rollback, prePut, @@ -161,31 +166,35 @@ class OpenLessShizukuUserService @JvmOverloads constructor( ) } - val readback = readEnabledServices() - ?: run { - val rollback = rollbackWrittenState( + val readback = + readEnabledServices() + ?: run { + val rollback = + rollbackWrittenState( + WrittenState(merged, writtenEnabled), + prePut, + ) + return failureAfterRollback( + rollback, + prePut, + WrittenState(merged, writtenEnabled), + OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, + "readback_failed", + ) + } + + if (!OpenLessShizukuBridge.verifyReadbackExact(readback, merged)) { + val rollback = + rollbackWrittenState( WrittenState(merged, writtenEnabled), prePut, ) - return failureAfterRollback( - rollback, - prePut, - WrittenState(merged, writtenEnabled), - OpenLessShizukuBridge.RecoveryOutcome.ShellFailed, - "readback_failed", - ) - } - - if (!OpenLessShizukuBridge.verifyReadbackExact(readback, merged)) { - val rollback = rollbackWrittenState( - WrittenState(merged, writtenEnabled), - prePut, - ) - val failureCause = if (!OpenLessShizukuBridge.readbackContainsComponent(readback, serviceComponent)) { - "oem_rollback" - } else { - "concurrent_change" - } + val failureCause = + if (!OpenLessShizukuBridge.readbackContainsComponent(readback, serviceComponent)) { + "oem_rollback" + } else { + "concurrent_change" + } return failureAfterRollback( rollback, prePut, @@ -203,24 +212,25 @@ class OpenLessShizukuUserService @JvmOverloads constructor( snapshot: OpenLessShizukuBridge.AccessibilitySettingsSnapshot, ): RollbackOutcome { val servicesResult = rollbackServicesIfUnchanged(written.services, snapshot.services) - val enabledResult = if (written.enabled == null) { - OpenLessShizukuBridge.EnabledRollbackResult.Skipped - } else if ( - OpenLessShizukuBridge.shouldRollbackEnabledAfterServices( - servicesResult, - written.enabled, - snapshot.enabled, - ) - ) { - rollbackEnabledIfUnchanged( - written.enabled, - snapshot.enabled, - snapshot.services, - ) - } else { - Log.w(TAG, "skip enabled rollback: services rollback=${servicesResult.name}") - OpenLessShizukuBridge.EnabledRollbackResult.SkippedDueToServicesConflict - } + val enabledResult = + if (written.enabled == null) { + OpenLessShizukuBridge.EnabledRollbackResult.Skipped + } else if ( + OpenLessShizukuBridge.shouldRollbackEnabledAfterServices( + servicesResult, + written.enabled, + snapshot.enabled, + ) + ) { + rollbackEnabledIfUnchanged( + written.enabled, + snapshot.enabled, + snapshot.services, + ) + } else { + Log.w(TAG, "skip enabled rollback: services rollback=${servicesResult.name}") + OpenLessShizukuBridge.EnabledRollbackResult.SkippedDueToServicesConflict + } return RollbackOutcome(servicesResult, enabledResult) } @@ -231,15 +241,16 @@ class OpenLessShizukuUserService @JvmOverloads constructor( outcome: OpenLessShizukuBridge.RecoveryOutcome, failureCause: String, ): RecoveryAttemptResult.Failure { - val messageKey = OpenLessShizukuBridge.recoveryFailureMessageKey( - OpenLessShizukuBridge.RecoveryRollbackStatus( - rollback.services, - rollback.enabled, - ), - wroteEnabled = written.enabled != null, - baselineEnabled = snapshot.enabled, - failureCause = failureCause, - ) + val messageKey = + OpenLessShizukuBridge.recoveryFailureMessageKey( + OpenLessShizukuBridge.RecoveryRollbackStatus( + rollback.services, + rollback.enabled, + ), + wroteEnabled = written.enabled != null, + baselineEnabled = snapshot.enabled, + failureCause = failureCause, + ) return RecoveryAttemptResult.Failure(outcome, messageKey) } @@ -247,8 +258,8 @@ class OpenLessShizukuUserService @JvmOverloads constructor( writtenServices: String, baselineServices: String, ): OpenLessShizukuBridge.ServicesRollbackResult { - val current = readEnabledServices() - ?: return OpenLessShizukuBridge.ServicesRollbackResult.ReadFailed + val current = + readEnabledServices() ?: return OpenLessShizukuBridge.ServicesRollbackResult.ReadFailed if (OpenLessShizukuBridge.servicesListsEqual(writtenServices, baselineServices)) { return OpenLessShizukuBridge.evaluateUnchangedServicesWriteRollback( @@ -280,14 +291,15 @@ class OpenLessShizukuUserService @JvmOverloads constructor( Log.w(TAG, "skip enabled rollback: refusing to auto-disable global accessibility") return OpenLessShizukuBridge.EnabledRollbackResult.SkippedDueToServicesConflict } - val currentEnabled = readAccessibilityEnabled() - ?: return OpenLessShizukuBridge.EnabledRollbackResult.ReadFailed + val currentEnabled = + readAccessibilityEnabled() + ?: return OpenLessShizukuBridge.EnabledRollbackResult.ReadFailed if (currentEnabled != writtenEnabled) { Log.w(TAG, "skip enabled rollback: current differs from written") return OpenLessShizukuBridge.EnabledRollbackResult.Skipped } - val currentServices = readEnabledServices() - ?: return OpenLessShizukuBridge.EnabledRollbackResult.ReadFailed + val currentServices = + readEnabledServices() ?: return OpenLessShizukuBridge.EnabledRollbackResult.ReadFailed if (!OpenLessShizukuBridge.servicesListsEqual(currentServices, baselineServices)) { Log.w(TAG, "skip enabled rollback: services changed before enabled put") return OpenLessShizukuBridge.EnabledRollbackResult.SkippedDueToServicesConflict @@ -331,8 +343,9 @@ class OpenLessShizukuUserService @JvmOverloads constructor( return false } return runProcess( - listOf("cmd", "appops", "set", appPackage, "ACCESS_RESTRICTED_SETTINGS", "allow"), - ) is ShellResult.Success + listOf("cmd", "appops", "set", appPackage, "ACCESS_RESTRICTED_SETTINGS", "allow") + ) is + ShellResult.Success } private fun runPasteKeyInjection(): ShellResult { @@ -373,11 +386,10 @@ class OpenLessShizukuUserService @JvmOverloads constructor( return ShellResult.Failure } return try { - val process = ProcessBuilder(command) - .redirectErrorStream(true) - .start() + val process = ProcessBuilder(command).redirectErrorStream(true).start() val reader = Executors.newSingleThreadExecutor() - val outputTask = reader.submit(Callable { process.inputStream.bufferedReader().readText() }) + val outputTask = + reader.submit(Callable { process.inputStream.bufferedReader().readText() }) try { if (!process.waitFor(SHELL_TIMEOUT_SEC, TimeUnit.SECONDS)) { process.destroyForcibly() @@ -403,10 +415,7 @@ class OpenLessShizukuUserService @JvmOverloads constructor( outcome: OpenLessShizukuBridge.RecoveryOutcome, messageKey: String, ): String { - return JSONObject() - .put("outcome", outcome.name) - .put("messageKey", messageKey) - .toString() + return JSONObject().put("outcome", outcome.name).put("messageKey", messageKey).toString() } private data class WrittenState( @@ -421,7 +430,9 @@ class OpenLessShizukuUserService @JvmOverloads constructor( private sealed class RecoveryAttemptResult { data object Success : RecoveryAttemptResult() + data object Retry : RecoveryAttemptResult() + data class Failure( val outcome: OpenLessShizukuBridge.RecoveryOutcome, val messageKey: String, @@ -430,6 +441,7 @@ class OpenLessShizukuUserService @JvmOverloads constructor( private sealed class ShellResult { data class Success(val value: String) : ShellResult() + data object Failure : ShellResult() } diff --git a/openless-all/app/android/kotlin/OpenLessShizukuUserServiceClient.kt b/openless-all/app/android/kotlin/OpenLessShizukuUserServiceClient.kt index 168b56b8d..767606379 100644 --- a/openless-all/app/android/kotlin/OpenLessShizukuUserServiceClient.kt +++ b/openless-all/app/android/kotlin/OpenLessShizukuUserServiceClient.kt @@ -5,15 +5,15 @@ import android.content.Context import android.content.ServiceConnection import android.os.IBinder import android.util.Log -import rikka.shizuku.Shizuku import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.locks.ReentrantLock +import rikka.shizuku.Shizuku /** - * Binds the Shizuku UserService for synchronous privileged operations. - * Recovery calls are serialized; unbind removes the UserService after each operation. + * Binds the Shizuku UserService for synchronous privileged operations. Recovery calls are + * serialized; unbind removes the UserService after each operation. */ internal object OpenLessShizukuUserServiceClient { private const val TAG = "OpenLessShizukuClient" @@ -24,8 +24,7 @@ internal object OpenLessShizukuUserServiceClient { private val recoveryLock = ReentrantLock() - @Volatile - private var recoveryInProgress = false + @Volatile private var recoveryInProgress = false fun withService(context: Context, block: (IOpenLessShizukuUserService) -> T): T? { return bindUserService( @@ -61,25 +60,28 @@ internal object OpenLessShizukuUserServiceClient { if (!Shizuku.pingBinder()) { return null } - val component = ComponentName(context.packageName, OpenLessShizukuUserService::class.java.name) - val args = Shizuku.UserServiceArgs(component) - .daemon(daemon) - .processNameSuffix(processNameSuffix) - .version(SERVICE_VERSION) - .tag(tag) + val component = + ComponentName(context.packageName, OpenLessShizukuUserService::class.java.name) + val args = + Shizuku.UserServiceArgs(component) + .daemon(daemon) + .processNameSuffix(processNameSuffix) + .version(SERVICE_VERSION) + .tag(tag) val latch = CountDownLatch(1) val binderRef = AtomicReference(null) - val connection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - binderRef.set(IOpenLessShizukuUserService.Stub.asInterface(binder)) - latch.countDown() - } + val connection = + object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + binderRef.set(IOpenLessShizukuUserService.Stub.asInterface(binder)) + latch.countDown() + } - override fun onServiceDisconnected(name: ComponentName?) { - binderRef.set(null) + override fun onServiceDisconnected(name: ComponentName?) { + binderRef.set(null) + } } - } return try { Shizuku.bindUserService(args, connection) diff --git a/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt b/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt index 1605511b4..e88b7b294 100644 --- a/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt +++ b/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt @@ -9,9 +9,7 @@ import androidx.annotation.Keep import androidx.core.content.FileProvider import java.io.File -/** - * Triggers system package installer for a downloaded APK via FileProvider. - */ +/** Triggers system package installer for a downloaded APK via FileProvider. */ @Keep object OpenLessUpdateInstaller { @Keep @@ -21,20 +19,25 @@ object OpenLessUpdateInstaller { if (!apkFile.exists()) { return false } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) { - val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { - data = Uri.parse("package:${context.packageName}") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls() + ) { + val intent = + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } context.startActivity(intent) return false } val authority = "${context.packageName}.fileprovider" val uri: Uri = FileProvider.getUriForFile(context, authority, apkFile) - val install = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - } + val install = + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } context.startActivity(install) return true } diff --git a/openless-all/app/android/kotlin/OverlayPermissionActivity.kt b/openless-all/app/android/kotlin/OverlayPermissionActivity.kt index 89f5fd189..36f989a72 100644 --- a/openless-all/app/android/kotlin/OverlayPermissionActivity.kt +++ b/openless-all/app/android/kotlin/OverlayPermissionActivity.kt @@ -8,20 +8,21 @@ import android.os.Bundle import android.provider.Settings /** - * 引导用户授权 SYSTEM_ALERT_WINDOW。 - * Rust 命令 request_android_overlay_permission 通过 Intent 启动本 Activity。 + * 引导用户授权 SYSTEM_ALERT_WINDOW。 Rust 命令 request_android_overlay_permission 通过 Intent 启动本 Activity。 */ class OverlayPermissionActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && - !OpenLessPermissionBridge.canDrawOverlaysSafely(this) + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + !OpenLessPermissionBridge.canDrawOverlaysSafely(this) ) { - val intent = Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:$packageName"), - ) + val intent = + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName"), + ) startActivity(intent) } finish() diff --git a/openless-all/app/android/kotlin/ShizukuPermissionActivity.kt b/openless-all/app/android/kotlin/ShizukuPermissionActivity.kt index eb3f280e5..f8e707433 100644 --- a/openless-all/app/android/kotlin/ShizukuPermissionActivity.kt +++ b/openless-all/app/android/kotlin/ShizukuPermissionActivity.kt @@ -7,9 +7,7 @@ import android.os.Bundle import android.util.Log import rikka.shizuku.Shizuku -/** - * Translucent activity that requests Shizuku binder permission from the user. - */ +/** Translucent activity that requests Shizuku binder permission from the user. */ class ShizukuPermissionActivity : Activity(), Shizuku.OnRequestPermissionResultListener { override fun onCreate(savedInstanceState: Bundle?) { @@ -21,7 +19,10 @@ class ShizukuPermissionActivity : Activity(), Shizuku.OnRequestPermissionResultL return } try { - if (Shizuku.pingBinder() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { + if ( + Shizuku.pingBinder() && + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + ) { OpenLessShizukuBridge.setLastPermissionMessageKey("already_granted") finish() return @@ -37,15 +38,21 @@ class ShizukuPermissionActivity : Activity(), Shizuku.OnRequestPermissionResultL .setMessage(R.string.openless_shizuku_permission_blocked) .setPositiveButton(R.string.openless_shizuku_open_manager) { _, _ -> OpenLessShizukuBridge.openShizukuApp(this) - OpenLessShizukuBridge.setLastPermissionMessageKey("permission_permanently_denied") + OpenLessShizukuBridge.setLastPermissionMessageKey( + "permission_permanently_denied" + ) finish() } .setNegativeButton(android.R.string.cancel) { _, _ -> - OpenLessShizukuBridge.setLastPermissionMessageKey("permission_permanently_denied") + OpenLessShizukuBridge.setLastPermissionMessageKey( + "permission_permanently_denied" + ) finish() } .setOnCancelListener { - OpenLessShizukuBridge.setLastPermissionMessageKey("permission_permanently_denied") + OpenLessShizukuBridge.setLastPermissionMessageKey( + "permission_permanently_denied" + ) finish() } .show() @@ -63,9 +70,7 @@ class ShizukuPermissionActivity : Activity(), Shizuku.OnRequestPermissionResultL if (requestCode == REQUEST_CODE) { val granted = grantResult == PackageManager.PERMISSION_GRANTED Log.i(TAG, "Shizuku permission result granted=$granted") - OpenLessShizukuBridge.setLastPermissionMessageKey( - if (granted) "granted" else "denied", - ) + OpenLessShizukuBridge.setLastPermissionMessageKey(if (granted) "granted" else "denied") finish() } } diff --git a/openless-all/app/android/kotlin/test/OpenLessAccessibilityComponentIdsTest.kt b/openless-all/app/android/kotlin/test/OpenLessAccessibilityComponentIdsTest.kt index 874919776..1bd4e5b2d 100644 --- a/openless-all/app/android/kotlin/test/OpenLessAccessibilityComponentIdsTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessAccessibilityComponentIdsTest.kt @@ -9,8 +9,7 @@ class OpenLessAccessibilityComponentIdsTest { private val full = "com.openless.app/com.openless.app.OpenLessAccessibilityService" private val shortForm = "com.openless.app/.OpenLessAccessibilityService" private val thirdParty = "com.example/.OtherService" - private val similarClass = - "com.openless.app/com.openless.app.OpenLessAccessibilityServiceFake" + private val similarClass = "com.openless.app/com.openless.app.OpenLessAccessibilityServiceFake" @Test fun componentIdsEqualTreatsShortAndFullFormsAsEqual() { diff --git a/openless-all/app/android/kotlin/test/OpenLessAccessibilityTargetTest.kt b/openless-all/app/android/kotlin/test/OpenLessAccessibilityTargetTest.kt index e429f816d..126c7cdc5 100644 --- a/openless-all/app/android/kotlin/test/OpenLessAccessibilityTargetTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessAccessibilityTargetTest.kt @@ -14,7 +14,7 @@ class OpenLessAccessibilityTargetTest { isFocused = true, nodePackage = "com.example.app", activePackage = "com.example.app", - ), + ) ) assertFalse( OpenLessAccessibilityTarget.passesEditableFocusChecks( @@ -22,7 +22,7 @@ class OpenLessAccessibilityTargetTest { isFocused = true, nodePackage = "com.example.app", activePackage = "com.example.app", - ), + ) ) assertFalse( OpenLessAccessibilityTarget.passesEditableFocusChecks( @@ -30,7 +30,7 @@ class OpenLessAccessibilityTargetTest { isFocused = false, nodePackage = "com.example.app", activePackage = "com.example.app", - ), + ) ) assertFalse( OpenLessAccessibilityTarget.passesEditableFocusChecks( @@ -38,7 +38,7 @@ class OpenLessAccessibilityTargetTest { isFocused = true, nodePackage = "com.other.app", activePackage = "com.example.app", - ), + ) ) } @@ -75,7 +75,7 @@ class OpenLessAccessibilityTargetTest { isPassword = false, className = "android.widget.EditText", actions = emptyList(), - ), + ) ) assertTrue( OpenLessAccessibilityTarget.isPasteTarget( @@ -83,7 +83,7 @@ class OpenLessAccessibilityTargetTest { isPassword = false, className = "android.view.View", actionIds = listOf(0x00008000), - ), + ) ) assertFalse( OpenLessAccessibilityTarget.isPasteTarget( @@ -91,7 +91,7 @@ class OpenLessAccessibilityTargetTest { isPassword = true, className = "android.widget.EditText", actions = emptyList(), - ), + ) ) } } diff --git a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt index 648f29ea8..a3d0a9fdd 100644 --- a/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessCredentialCipherTest.kt @@ -40,9 +40,9 @@ class OpenLessCredentialCipherTest { assertFalse(first.contentEquals(second)) assertFalse( - first.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES).contentEquals( - second.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES), - ), + first + .copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES) + .contentEquals(second.copyOfRange(1, 1 + OpenLessCredentialCipher.NONCE_BYTES)) ) } @@ -73,11 +73,12 @@ class OpenLessCredentialCipherTest { @Test fun tamperedAad() { val key = key() - val packet = OpenLessCredentialCipher.seal( - key, - "secret".toByteArray(), - "account-a".toByteArray(), - ) + val packet = + OpenLessCredentialCipher.seal( + key, + "secret".toByteArray(), + "account-a".toByteArray(), + ) assertThrows(GeneralSecurityException::class.java) { OpenLessCredentialCipher.open(key, packet, "account-b".toByteArray()) @@ -87,13 +88,14 @@ class OpenLessCredentialCipherTest { @Test fun facadeMethodsExposeExactStaticJniSignatures() { val facade = OpenLessCredentialVault::class.java - val signatures: List>>> = listOf( - "seal" to arrayOf>(ByteArray::class.java, ByteArray::class.java), - "open" to arrayOf>(ByteArray::class.java, ByteArray::class.java), - "deleteKey" to emptyArray(), - "migrationComplete" to emptyArray(), - "markMigrationComplete" to emptyArray(), - ) + val signatures: List>>> = + listOf( + "seal" to arrayOf>(ByteArray::class.java, ByteArray::class.java), + "open" to arrayOf>(ByteArray::class.java, ByteArray::class.java), + "deleteKey" to emptyArray(), + "migrationComplete" to emptyArray(), + "markMigrationComplete" to emptyArray(), + ) for ((name, parameters) in signatures) { val method = facade.getDeclaredMethod(name, *parameters) assertTrue("$name must be static for JNI", Modifier.isStatic(method.modifiers)) diff --git a/openless-all/app/android/kotlin/test/OpenLessPasteVerificationTest.kt b/openless-all/app/android/kotlin/test/OpenLessPasteVerificationTest.kt index cb9970dfd..11a6f014c 100644 --- a/openless-all/app/android/kotlin/test/OpenLessPasteVerificationTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessPasteVerificationTest.kt @@ -12,7 +12,7 @@ class OpenLessPasteVerificationTest { beforeText = "hello ", afterText = "hello world", clipboardText = "world", - ), + ) ) } @@ -23,7 +23,7 @@ class OpenLessPasteVerificationTest { beforeText = "", afterText = "dictation", clipboardText = "dictation", - ), + ) ) } @@ -34,7 +34,7 @@ class OpenLessPasteVerificationTest { beforeText = "still empty", afterText = "still empty", clipboardText = "new words", - ), + ) ) } } diff --git a/openless-all/app/android/kotlin/test/OpenLessShizukuBridgeTest.kt b/openless-all/app/android/kotlin/test/OpenLessShizukuBridgeTest.kt index ace51cc87..bfeb447cb 100644 --- a/openless-all/app/android/kotlin/test/OpenLessShizukuBridgeTest.kt +++ b/openless-all/app/android/kotlin/test/OpenLessShizukuBridgeTest.kt @@ -1,7 +1,7 @@ package com.openless.app -import org.junit.Assert.assertFalse import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -12,7 +12,8 @@ class OpenLessShizukuBridgeTest { @Test fun mergePreservesThirdPartyServices() { val current = "$thirdParty:com.foo/.AnotherService" - val merged = OpenLessShizukuBridge.mergeEnabledAccessibilityServices(current, serviceComponent) + val merged = + OpenLessShizukuBridge.mergeEnabledAccessibilityServices(current, serviceComponent) assertTrue(merged.contains(thirdParty)) assertTrue(merged.contains("com.foo/.AnotherService")) assertTrue(merged.contains(serviceComponent)) @@ -27,7 +28,8 @@ class OpenLessShizukuBridgeTest { @Test fun mergeDoesNotDuplicateOpenLess() { val current = serviceComponent - val merged = OpenLessShizukuBridge.mergeEnabledAccessibilityServices(current, serviceComponent) + val merged = + OpenLessShizukuBridge.mergeEnabledAccessibilityServices(current, serviceComponent) assertEquals(serviceComponent, merged) } @@ -47,14 +49,19 @@ class OpenLessShizukuBridgeTest { fun verifyReadbackPreservesOriginalServices() { val original = OpenLessShizukuBridge.parseServiceEntries(thirdParty) val readback = serviceComponent - assertFalse(OpenLessShizukuBridge.verifyReadbackPreserves(readback, serviceComponent, original)) + assertFalse( + OpenLessShizukuBridge.verifyReadbackPreserves(readback, serviceComponent, original) + ) } @Test fun verifyReadbackPreservesWhenAllOriginalServicesRemain() { - val original = OpenLessShizukuBridge.parseServiceEntries("$thirdParty:com.foo/.AnotherService") + val original = + OpenLessShizukuBridge.parseServiceEntries("$thirdParty:com.foo/.AnotherService") val readback = "$thirdParty:com.foo/.AnotherService:$serviceComponent" - assertTrue(OpenLessShizukuBridge.verifyReadbackPreserves(readback, serviceComponent, original)) + assertTrue( + OpenLessShizukuBridge.verifyReadbackPreserves(readback, serviceComponent, original) + ) } @Test @@ -78,28 +85,28 @@ class OpenLessShizukuBridgeTest { OpenLessShizukuBridge.ServicesRollbackResult.Conflict, "1", "0", - ), + ) ) assertFalse( OpenLessShizukuBridge.shouldRollbackEnabledAfterServices( OpenLessShizukuBridge.ServicesRollbackResult.Restored, "1", "0", - ), + ) ) assertFalse( OpenLessShizukuBridge.shouldRollbackEnabledAfterServices( OpenLessShizukuBridge.ServicesRollbackResult.AlreadyBaseline, "1", "0", - ), + ) ) assertFalse( OpenLessShizukuBridge.shouldRollbackEnabledAfterServices( OpenLessShizukuBridge.ServicesRollbackResult.AlreadyBaseline, "1", "1", - ), + ) ) } @@ -119,31 +126,33 @@ class OpenLessShizukuBridgeTest { @Test fun requiresManualRecoveryWhenGlobalDisabledWithThirdPartyServices() { - val snapshot = OpenLessShizukuBridge.AccessibilitySettingsSnapshot( - "$thirdParty:$serviceComponent", - "0", - ) + val snapshot = + OpenLessShizukuBridge.AccessibilitySettingsSnapshot( + "$thirdParty:$serviceComponent", + "0", + ) assertTrue(OpenLessShizukuBridge.requiresManualRecovery(snapshot, serviceComponent)) assertFalse( OpenLessShizukuBridge.requiresManualRecovery( OpenLessShizukuBridge.AccessibilitySettingsSnapshot(serviceComponent, "0"), serviceComponent, - ), + ) ) assertFalse( OpenLessShizukuBridge.requiresManualRecovery( OpenLessShizukuBridge.AccessibilitySettingsSnapshot(thirdParty, "1"), serviceComponent, - ), + ) ) } @Test fun recoveryFailureMessageKeyPrefersPartialRollbackWhenEnabledLeftOn() { - val rollback = OpenLessShizukuBridge.RecoveryRollbackStatus( - OpenLessShizukuBridge.ServicesRollbackResult.Restored, - OpenLessShizukuBridge.EnabledRollbackResult.SkippedDueToServicesConflict, - ) + val rollback = + OpenLessShizukuBridge.RecoveryRollbackStatus( + OpenLessShizukuBridge.ServicesRollbackResult.Restored, + OpenLessShizukuBridge.EnabledRollbackResult.SkippedDueToServicesConflict, + ) assertEquals( "partial_rollback", OpenLessShizukuBridge.recoveryFailureMessageKey( @@ -189,19 +198,19 @@ class OpenLessShizukuBridgeTest { OpenLessShizukuBridge.preWriteSnapshotChanged( baseline, OpenLessShizukuBridge.AccessibilitySettingsSnapshot("a/.A", "0"), - ), + ) ) assertTrue( OpenLessShizukuBridge.preWriteSnapshotChanged( baseline, OpenLessShizukuBridge.AccessibilitySettingsSnapshot("a/.A:b/.B", "0"), - ), + ) ) assertTrue( OpenLessShizukuBridge.preWriteSnapshotChanged( baseline, OpenLessShizukuBridge.AccessibilitySettingsSnapshot("a/.A", "1"), - ), + ) ) } @@ -235,7 +244,8 @@ class OpenLessShizukuBridgeTest { @Test fun mergeDoesNotDuplicateOpenLessShortForm() { val shortForm = "com.openless.app/.OpenLessAccessibilityService" - val merged = OpenLessShizukuBridge.mergeEnabledAccessibilityServices(shortForm, serviceComponent) + val merged = + OpenLessShizukuBridge.mergeEnabledAccessibilityServices(shortForm, serviceComponent) assertEquals(shortForm, merged) } @@ -243,7 +253,8 @@ class OpenLessShizukuBridgeTest { fun mergeTreatsEquivalentComponentIdsAsSame() { val shortForm = "com.openless.app/.OpenLessAccessibilityService" val merged = OpenLessShizukuBridge.mergeEnabledAccessibilityServices(null, shortForm) - val mergedAgain = OpenLessShizukuBridge.mergeEnabledAccessibilityServices(merged, serviceComponent) + val mergedAgain = + OpenLessShizukuBridge.mergeEnabledAccessibilityServices(merged, serviceComponent) assertTrue(OpenLessShizukuBridge.servicesListsEqual(mergedAgain, shortForm)) assertEquals(1, OpenLessShizukuBridge.parseServiceEntries(mergedAgain).size) } @@ -264,7 +275,8 @@ class OpenLessShizukuBridgeTest { @Test fun verifyReadbackExactAcceptsExpectedMergedSet() { - val merged = OpenLessShizukuBridge.mergeEnabledAccessibilityServices(thirdParty, serviceComponent) + val merged = + OpenLessShizukuBridge.mergeEnabledAccessibilityServices(thirdParty, serviceComponent) assertTrue(OpenLessShizukuBridge.verifyReadbackExact(merged, merged)) val shortFormReadback = "$thirdParty:com.openless.app/.OpenLessAccessibilityService" assertTrue(OpenLessShizukuBridge.verifyReadbackExact(shortFormReadback, merged)) @@ -272,11 +284,12 @@ class OpenLessShizukuBridgeTest { @Test fun resolveStatusMessageKeyUsesUnsupportedBackendForLegacyShizuku() { - val accessibility = OpenLessShizukuBridge.AccessibilityDiagnosis( - registered = false, - operational = false, - messageKey = "not_registered", - ) + val accessibility = + OpenLessShizukuBridge.AccessibilityDiagnosis( + registered = false, + operational = false, + messageKey = "not_registered", + ) assertEquals( "unsupported_backend", OpenLessShizukuBridge.resolveStatusMessageKey( diff --git a/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs b/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs new file mode 100644 index 000000000..ee90a44c5 --- /dev/null +++ b/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs @@ -0,0 +1,24 @@ +//! Regenerate the public provider metadata used by the browser-only UI preview. +//! Run from the app directory: cargo run --locked -p openless-core --example export_provider_descriptors + +use openless_core::{domains::ProviderKind, provider_rules::provider_descriptors}; +use std::{fs, path::Path}; + +fn main() -> anyhow::Result<()> { + let catalog = serde_json::json!({ + "_generatedFrom": "openless-core::provider_rules::provider_descriptors", + "asr": provider_descriptors(ProviderKind::Asr), + "llm": provider_descriptors(ProviderKind::Llm), + "omni": provider_descriptors(ProviderKind::Omni), + }); + let destination = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../src/lib/ipc/provider-descriptors.generated.json"); + let mut bytes = serde_json::to_vec_pretty(&catalog)?; + bytes.push(b'\n'); + fs::write(&destination, bytes)?; + println!( + "Provider preview catalog exported to {}", + destination.display() + ); + Ok(()) +} diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 75f5f7f61..68b88c637 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3632,6 +3632,34 @@ impl OpenLessBackend { self.preferences.get() } + /// 消费「本大版本首启」开屏 PV 标记:配置里的 `splash_seen_version` 与传入的 + /// 当前主版本一致时返回 false(不再播放);不一致时写回主版本并返回 true, + /// 前端据此播放随包发行的开屏动画(同世代 2.x 升级与重启都不重播)。 + /// 磁盘写入失败时仍返回 true——宁可多播一次,也不静默吞掉首启体验;标记留待 + /// 下次启动重试。成功写回后走 publish_preferences_changed 递增 revision, + /// 让并发中的设置页乐观提交重新对账,不会拿着旧档把标记冲掉。 + pub fn take_splash_playback(&self, current_major: &str) -> bool { + match self.preferences.update(|preferences| { + if preferences.splash_seen_version == current_major { + false + } else { + preferences.splash_seen_version = current_major.to_string(); + true + } + }) { + Ok(should_play) => { + if should_play { + self.publish_preferences_changed(); + } + should_play + } + Err(error) => { + log::warn!("[splash] failed to persist splash marker: {error}"); + true + } + } + } + #[cfg(test)] pub(crate) fn set_preferences( &self, @@ -3715,6 +3743,10 @@ impl OpenLessBackend { let mut previous = self.preferences.get(); crate::sync_dictation_hotkey_legacy_fields(&mut previous); crate::sync_dictation_hotkey_legacy_fields(&mut preferences); + // 开屏标记只能由 take_splash_playback 推进:整档提交的客户端(旧前端或 + // 尚未回读标记的请求)不带此字段时,serde 默认会把空串写回,导致下次 + // 启动重播开屏 PV。这里永远沿用盘上的当前值。 + preferences.splash_seen_version = previous.splash_seen_version.clone(); if options.preserve_current_style { preferences.preserve_style_preferences_from(&previous); } @@ -3914,6 +3946,20 @@ impl OpenLessBackend { Ok(pack) } + pub fn set_style_pack_icon( + &self, + id: &str, + png: Option<&[u8]>, + ) -> Result { + let pack = self.style_packs.update_icon(id, png)?; + self.publish_style_packs_changed(); + Ok(pack) + } + + pub fn read_style_pack_icon(&self, id: &str) -> Result, BackendError> { + self.style_packs.icon_data_url(id) + } + pub fn set_style_pack_enabled( &self, id: &str, @@ -4354,6 +4400,12 @@ impl OpenLessBackend { Ok(()) } + pub fn update_vocabulary_phrase(&self, id: &str, phrase: String) -> Result<(), BackendError> { + self.vocabulary.update_phrase(id, phrase)?; + self.publish_vocabulary_changed(); + Ok(()) + } + pub fn list_correction_rules(&self) -> Result, BackendError> { self.correction_rules.list() } @@ -10610,6 +10662,36 @@ mod tests { assert_eq!(backend.snapshot().dictation.session_id, Some(active)); } + #[test] + fn take_splash_playback_marks_major_once_and_survives_restart() { + let data_dir = TestDataDir::new("splash-playback"); + let make = || { + OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies::unsupported(), + ) + .unwrap() + }; + + let backend = make(); + // 首启:标记缺失 → 播放一次并写回主版本。 + assert!(backend.take_splash_playback("2")); + assert_eq!(backend.get_preferences().splash_seen_version, "2"); + // 同一世代内再次启动不再播放。 + assert!(!backend.take_splash_playback("2")); + + // 模拟进程重启:标记已从 preferences.json 读回。 + let reopened = make(); + assert!(!reopened.take_splash_playback("2")); + // 新一代大版本:播一次新 PV 后同样收口。 + assert!(reopened.take_splash_playback("3")); + assert!(!reopened.take_splash_playback("3")); + assert_eq!(reopened.get_preferences().splash_seen_version, "3"); + } + #[tokio::test] async fn engine_failure_publishes_failed_state_and_preserves_session_identity() { let host = Arc::new(FakeHost::default()); diff --git a/openless-all/app/crates/openless-core/src/asr/volcengine.rs b/openless-all/app/crates/openless-core/src/asr/volcengine.rs index f2f987b4b..12f35c771 100644 --- a/openless-all/app/crates/openless-core/src/asr/volcengine.rs +++ b/openless-all/app/crates/openless-core/src/asr/volcengine.rs @@ -1,9 +1,9 @@ #![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Volcengine SAUC bigmodel streaming ASR client. //! -//! Direct port of the Swift `VolcengineStreamingASR`. Battle-tested protocol -//! quirks are preserved verbatim — see comments tagged with `[asr]` for the -//! original learnings (especially the "definite=true is NOT stream end" bug). +//! Sends PCM frames, combines recognition updates and waits for the protocol's +//! final response. A `definite=true` utterance commits that segment only; it does +//! not end the stream or prevent later audio from being sent. use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/openless-all/app/crates/openless-core/src/coding_agent.rs b/openless-all/app/crates/openless-core/src/coding_agent.rs index cc7ad26d5..297f40b54 100644 --- a/openless-all/app/crates/openless-core/src/coding_agent.rs +++ b/openless-all/app/crates/openless-core/src/coding_agent.rs @@ -2891,13 +2891,19 @@ broken: npx broken - ✗ Failed to connect\n"; let before = run(&[]); let after = run(&hardening); let roots = |output: &str| { - output - .split("writable root") - .nth(1) - .unwrap_or_default() - .chars() - .take(400) - .collect::() + // The debug output also contains app/tool instructions with backticks. + // Count paths only in the permissions block, not a fixed-length excerpt. + let (_, permissions) = output + .split_once("") + .expect("Codex debug output must include permissions instructions"); + let (permissions, _) = permissions + .split_once("") + .expect("Codex permissions instructions must be closed"); + permissions + .split_once("writable root") + .expect("Codex permissions must declare writable roots") + .1 + .to_string() }; let before = roots(&before); let after = roots(&after); 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 6266a809b..c80c57c12 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -677,6 +677,12 @@ pub struct UserPreferences { /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, + /// 开屏 PV 的主版本世代标记(如 "2")。空串 = 从未播过。启动时 Rust 比较 + /// 此标记与当前应用主版本:不一致则写回并播一次开屏动画,之后同一世代内 + /// (2.x 补丁/小版本升级、重启)不再播放。由 `take_splash_playback` 消费, + /// `update_settings` 保存时永远沿用当前值,防止客户端整档提交把它冲掉。 + #[serde(default)] + pub splash_seen_version: String, } impl UserPreferences { @@ -933,6 +939,8 @@ struct UserPreferencesWire { android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, + #[serde(default)] + splash_seen_version: String, } fn deserialize_selection_polish_hotkey<'de, D>( @@ -1077,6 +1085,7 @@ impl Default for UserPreferencesWire { android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, android_overlay_size_dp: prefs.android_overlay_size_dp, + splash_seen_version: prefs.splash_seen_version, } } } @@ -1243,6 +1252,7 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), + splash_seen_version: wire.splash_seen_version, }) } } @@ -1580,6 +1590,7 @@ impl Default for UserPreferences { android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), android_overlay_size_dp: default_android_overlay_size_dp(), + splash_seen_version: String::new(), } } } diff --git a/openless-all/app/crates/openless-core/src/style_pack_archive.rs b/openless-all/app/crates/openless-core/src/style_pack_archive.rs index d45a164fe..201d0d49c 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_archive.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_archive.rs @@ -614,7 +614,7 @@ pub(super) fn read_stream_bounded( Ok(bytes) } -fn validate_icon_content(extension: &str, bytes: &[u8]) -> Result<()> { +pub(super) fn validate_icon_content(extension: &str, bytes: &[u8]) -> Result<()> { let (width, height) = match extension { "png" => validate_png(bytes)?, "jpg" | "jpeg" => validate_jpeg(bytes)?, diff --git a/openless-all/app/crates/openless-core/src/style_pack_store.rs b/openless-all/app/crates/openless-core/src/style_pack_store.rs index b61b0d092..f1f14b013 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_store.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_store.rs @@ -1,7 +1,8 @@ //! Shared style-pack repository and lifecycle rules. +use base64::Engine; use std::fs; -use std::io::{Cursor, Write}; +use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -10,7 +11,8 @@ use crate::persistence::{atomic_write, persistence_error, read_or_default}; use crate::shared_types::UserPreferences; use crate::style_pack_archive::{ cleanup_style_pack_asset_dir, persist_style_pack_icon, read_style_pack_archive, - read_style_pack_archive_bytes, ParsedStylePackArchive, StylePackArchiveManifest, + read_style_pack_archive_bytes, validate_icon_content, ParsedStylePackArchive, + StylePackArchiveManifest, MAX_ICON_BYTES, }; use crate::style_packs::{ builtin_style_pack_for_mode, builtin_style_pack_id, builtin_style_packs, @@ -171,6 +173,141 @@ impl StylePackStore { Ok(updated) } + /// Save a PNG icon in this pack's owned asset directory, or clear it with `None`. + /// + /// Metadata is committed before the old file is removed. A failed metadata + /// write leaves the previous image and in-memory pack unchanged. + /// + /// # Errors + /// Returns an error for an unknown/invalid pack ID, an invalid PNG, an image + /// over 64 KiB, an unavailable asset directory, or a failed filesystem write. + pub fn update_icon(&self, id: &str, png: Option<&[u8]>) -> Result { + if id.is_empty() + || id == "." + || id == ".." + || !id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + { + return Err(invalid_icon("invalid style pack id")); + } + let mut packs = self.lock()?; + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + let old_path = packs[index].icon_path.clone(); + let new_path = if let Some(bytes) = png { + if bytes.len() > MAX_ICON_BYTES { + return Err(invalid_icon("style pack icon exceeds 64 KiB")); + } + validate_icon_content("png", bytes).map_err(archive_error)?; + let root = self + .asset_root + .as_ref() + .filter(|root| !root.as_os_str().is_empty()) + .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; + fs::create_dir_all(root) + .map_err(|_| persistence_error("create style pack asset root"))?; + let root = root + .canonicalize() + .map_err(|_| persistence_error("resolve style pack asset root"))?; + let directory = root.join(id); + fs::create_dir_all(&directory) + .map_err(|_| persistence_error("create style pack icon directory"))?; + let directory = directory + .canonicalize() + .map_err(|_| persistence_error("resolve style pack icon directory"))?; + if !directory.starts_with(&root) { + return Err(invalid_icon( + "style pack icon directory is outside its asset root", + )); + } + // A new filename keeps the previous image valid until metadata commits. + let target = directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())); + atomic_write(&target, bytes)?; + Some(target) + } else { + None + }; + let mut next = packs.clone(); + next[index].icon_path = new_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + next[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); + if let Err(error) = self.persist_locked(&next) { + if let Some(path) = new_path { + let _ = fs::remove_file(path); + } + return Err(error); + } + let saved = next[index].clone(); + *packs = next; + if let (Some(root), Some(old)) = (&self.asset_root, old_path) { + let old = Path::new(&old); + if let (Ok(owned), Some(parent)) = (root.join(id).canonicalize(), old.parent()) { + if parent.canonicalize().ok().as_ref() == Some(&owned) { + let _ = fs::remove_file(old); + } + } + } + Ok(saved) + } + + /// Read a pack icon as a PNG/JPEG/WebP data URL without exposing filesystem access. + /// + /// Missing icons return `None`. Reads are limited to 64 KiB and must resolve + /// inside the repository's asset directory. + /// + /// # Errors + /// Returns an error for an unknown pack, an invalid image or path, or a read failure. + pub fn icon_data_url(&self, id: &str) -> Result, BackendError> { + let pack = self.get(id)?; + let Some(path) = pack.icon_path else { + return Ok(None); + }; + let root = self + .asset_root + .as_ref() + .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; + let root = root + .canonicalize() + .map_err(|_| persistence_error("resolve style pack asset root"))?; + let source = match Path::new(&path).canonicalize() { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(persistence_error("resolve style pack icon")), + }; + if !source.starts_with(&root) || !source.is_file() { + return Err(invalid_icon("style pack icon is outside its asset root")); + } + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let mime = match extension.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => return Err(invalid_icon("unsupported style pack icon type")), + }; + let mut bytes = Vec::new(); + fs::File::open(&source) + .map_err(|_| persistence_error("open style pack icon"))? + .take((MAX_ICON_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| persistence_error("read style pack icon"))?; + if bytes.len() > MAX_ICON_BYTES { + return Err(invalid_icon("style pack icon exceeds 64 KiB")); + } + validate_icon_content(&extension, &bytes).map_err(archive_error)?; + Ok(Some(format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ))) + } + pub fn set_origin( &self, id: &str, @@ -724,6 +861,10 @@ fn required_text(value: &str, field: &str) -> Result { } } +fn invalid_icon(message: &str) -> BackendError { + BackendError::new(BackendErrorCode::InvalidArgument, message) +} + fn unique_imported_id(packs: &[StylePack], requested: &str) -> String { let mut base = requested .trim() diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index bf175a472..6c2dcf053 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -149,6 +149,43 @@ impl DictionaryStore { Ok(()) } + /// Rename an entry in place so its id / hits / enabled state survive the edit. + /// Empty phrases and phrases colliding with another entry are rejected. + pub fn update_phrase(&self, id: &str, phrase: String) -> Result<(), BackendError> { + let phrase = phrase.trim().to_string(); + if phrase.is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase is empty", + )); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + if entries + .iter() + .any(|entry| entry.id != id && entry.phrase == phrase) + { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase already exists", + )); + } + let entry = entries + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary entry not found", + ) + })?; + if entry.phrase != phrase { + entry.phrase = phrase; + self.write_locked(&entries)?; + } + Ok(()) + } + /// Count case-insensitive, non-overlapping occurrences in final output. pub fn record_hits(&self, text: &str) -> Result { if text.is_empty() { diff --git a/openless-all/app/crates/openless-core/tests/style_icon_contract.rs b/openless-all/app/crates/openless-core/tests/style_icon_contract.rs new file mode 100644 index 000000000..400c0299a --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/style_icon_contract.rs @@ -0,0 +1,100 @@ +use base64::Engine; +use openless_core::{builtin_style_pack_for_mode, PolishMode, StylePackStore}; +use std::path::PathBuf; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-style-icon-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn store(&self) -> StylePackStore { + StylePackStore::at_data_dir(&self.0).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn png() -> Vec { + base64::engine::general_purpose::STANDARD.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3c3b0AAAAASUVORK5CYII=").unwrap() +} + +#[test] +fn uploaded_icon_survives_restart_export_and_clear() { + let fixture = Fixture::new(); + let store = fixture.store(); + let saved = store.update_icon("builtin.light", Some(&png())).unwrap(); + let path = PathBuf::from(saved.icon_path.unwrap()); + assert!(path.is_file()); + let expected = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png()) + ); + let reopened = fixture.store(); + assert_eq!( + reopened.icon_data_url("builtin.light").unwrap(), + Some(expected.clone()) + ); + let exported = reopened.export_zip_bytes("builtin.light").unwrap(); + let imported = reopened.import_from_zip_bytes(&exported).unwrap(); + assert_eq!( + reopened.icon_data_url(&imported.id).unwrap(), + Some(expected) + ); + reopened.update_icon("builtin.light", None).unwrap(); + assert!(!path.exists()); + assert_eq!( + fixture.store().icon_data_url("builtin.light").unwrap(), + None + ); + assert!(reopened.icon_data_url(&imported.id).unwrap().is_some()); +} + +#[test] +fn invalid_icon_preserves_the_previous_image() { + let fixture = Fixture::new(); + let store = fixture.store(); + let saved = store.update_icon("builtin.light", Some(&png())).unwrap(); + for invalid in [b"".to_vec(), vec![0; 65_537]] { + assert!(store.update_icon("builtin.light", Some(&invalid)).is_err()); + assert_eq!( + store.get("builtin.light").unwrap().icon_path, + saved.icon_path + ); + assert!(PathBuf::from(saved.icon_path.as_ref().unwrap()).is_file()); + } +} + +#[test] +fn icon_reader_cannot_read_paths_outside_the_asset_directory() { + let fixture = Fixture::new(); + let store = fixture.store(); + let outside = fixture.0.join("outside.png"); + std::fs::write(&outside, png()).unwrap(); + let mut template = builtin_style_pack_for_mode(PolishMode::Light); + template.icon_path = Some(outside.to_string_lossy().into_owned()); + let created = store.create(template).unwrap(); + assert!(store.icon_data_url(&created.id).is_err()); + store.update_icon(&created.id, None).unwrap(); + assert!(outside.is_file()); +} + +#[test] +fn failed_metadata_write_preserves_old_image_and_memory() { + let fixture = Fixture::new(); + let store = fixture.store(); + let saved = store.update_icon("builtin.light", Some(&png())).unwrap(); + let path = fixture.0.join("style-packs.json"); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(store.update_icon("builtin.light", Some(&png())).is_err()); + assert_eq!( + store.get("builtin.light").unwrap().icon_path, + saved.icon_path + ); + assert!(PathBuf::from(saved.icon_path.unwrap()).is_file()); +} diff --git a/openless-all/app/docs/cursor-context-test-plan.md b/openless-all/app/docs/cursor-context-test-plan.md deleted file mode 100644 index 3a2543294..000000000 --- a/openless-all/app/docs/cursor-context-test-plan.md +++ /dev/null @@ -1,186 +0,0 @@ -# 光标上下文 + 手改学习 —— 装机测试案例 - -装的版本:`local/daily`。开关在 **设置 → 隐私 → 数据存储 → 光标上下文(实验)**,默认关。 - -盯日志: - -```bash -tail -f ~/Library/Logs/OpenLess/openless.log | grep -E "cursor-context|cursor context|vocab" -``` - -重装后辅助功能授权会短暂失效(ad-hoc 签名每次构建 cdhash 都变),但 app 自己会恢复——实测重试到第 23 次时自己起来,约 70 秒。看到 `hotkey listener installed` 就能用了;万一一直刷 `CGEventTapCreate 失败`,去 系统设置 → 隐私与安全性 → 辅助功能 → OpenLess 关掉再打开。 - ---- - -## 这个功能干什么 - -开关打开后,每次听写会读**你当时正在写的那个文档里、光标附近的几百个字**,跟着请求一起发给 LLM。这样它知道你在写什么,「接口」不会写成「借口」。 - -落字之后它还会盯一小会儿:如果你手动改了它插进去的某个词,那个词可能进你的词汇表。 - -**只写词汇表,不写纠正规则。** 词条是提示(送给 ASR、进润色 prompt 让 LLM 带上下文判断),错了最多是没帮上忙;纠正规则是字面替换,错了是静默的、全局的。学来的东西配不上后者那份权力。 - ---- - -## 1. AX 覆盖率 —— 不用开口说话 - -**设置 → 高级 → 调试工具 → 光标上下文探针** - -点「探测(5 秒后)」→ 切到目标 app → 在正文里点一下让光标进去 → 切回来看结果。 - -``` -ok · 11ms -备忘录 (com.apple.Notes) -我们这个模块的接口设计得不太好,⟦光标⟧ -``` - -`⟦光标⟧` 是光标位置,左边上文右边下文。 - -| app | 预期 | -|---|---| -| 备忘录 / 文本编辑 | `ok` | -| VS Code / Notion / Claude 桌面版(Electron) | 能读到,但光标位置常常不准 | -| 微信 / 飞书 | `ok` | -| 浏览器普通输入框 | `ok` | -| 浏览器**密码框** | **必须 `blocked` / `secure_text_field`** | -| 终端 / iTerm / Warp | **必须 `blocked` / `blocked_app`** | -| 1Password | **必须 `blocked` / `blocked_app`** | - -后三行是安全验收,任何一条没拦住立刻停下来说。 - -**屏幕上显示出来的原文,就是会发给 LLM 的内容。** 哪个 app 里蹦出了你不希望离开这台机器的东西,那是必须知道的发现。 - ---- - -## 2. 开关关闭时行为不变 - -关掉开关 → 听写几句 → 日志里**不该有任何 `cursor-context` 行**。 - -关着的时候一次 AX 都不发,prompt 也和这个功能不存在时逐字节相同(有单测钉死)。 - ---- - -## 3. 上下文真的进 prompt 了吗 - -同一个 app、同一个位置,开/关各听一次同样的话: - -```bash -grep "effective_prompt_chars" ~/Library/Logs/OpenLess/openless.log | tail -2 -``` - -开着的那次应该多 **427 + 读到的字数**(427 = 上下文块固定措辞 314 + 注入防御条款 113)。 - -必须同一个 app 比——prompt 里带了前台应用名,「备忘录 (com.apple.Notes)」和「Claude (com.anthropic.claudefordesktop)」差 19 个字符,换 app 比会对不上账。 - -**更该盯的是 LLM 拿它干了什么。** 最容易翻车的是把上文复述进输出:光标前写着「这个模块的接口设计得不太好,」,你口述「还得再改」,它输出「这个模块的接口设计得不太好,还得再改」——把你已有的字又插了一遍。prompt 里明令禁止了,但那是软约束。 - ---- - -## 4. 手改 → 卡片 → 词条 - -前提:开关开着,**在备忘录这类原生 app 里测**(Electron 的通知不稳)。 - -**没有自动入库这条路了。** 任何一处手改都只会变成卡片上的一条建议,你点勾它才进词汇表。 - -早期版本让「中文改成英文」这一档静默入库,理由是「没人为了换语气把中文改成英文」。 -真机跑两天,自动收进去 5 条只有 1 条是对的: - -| 自动收的 | 实际是什么 | -|---|---| -| `Tailscale`(was telskill) | ✅ 唯一一条真纠错 | -| `ype`(was ap) | 逐字敲 `Type` 的中间态 | -| `ess`(was ice) | 同上 | -| `typeless`(was TypeScript) | 用户本来就要打这个词 | -| ` claude`(was cloud) | 带前导空格,永远匹配不上 | - -观察器看到的是**编辑过程中的每一帧**,而中间态和一次真纠错在文本上没有区别。分不出来就别猜。 - -### 4a. 卡片长什么样 - -1. 口述一句,把其中某个词改掉 -2. **把光标点到别处**(这是判定「你改完了」的信号) -3. 屏幕**右下角**弹出卡片: - -``` -要记住这个词吗? -扣德克斯 → Codex ✓ ✗ -大禹 → 大鱼 ✓ ✗ -``` - -- 点 **✓** → 进词汇表,落在分割线下面的「自动收集」区 -- 点 **✗** → 丢掉,什么都不记(没有拒绝名单,下次再改同一个词它还会问) -- 等 10 秒不动 → 整张卡片消失,同样什么都不记 -- 连着改两个词 → 合并到同一张卡片,倒计时重置 -- 逐条点完最后一条 → 卡片自己收起 - -**位置必须是右下角,不是屏幕正下方居中。** 居中那块正是你在写字的地方,卡片停十秒会 -直接盖住正在编辑的那一行 —— 这是真机上退回来的。 - -卡片只挡住它自己那一块的鼠标(窗口会缩到卡片大小,逐条点完还会跟着重算),周围照常能点。 - -### 4c. 该拒绝的时候确实拒绝了 - -| 操作 | 期待 | -|---|---| -| 改完切到别的 app 再改 | 无输出(观察器已解除) | -| 落字后等 60 秒再改 | 无输出(硬上限) | -| 落字后再听写一次,回头改第一段 | 无输出(新会话解除旧观察器) | -| 改你自己之前写的内容(不是它插的) | 无输出(只认落在插入文本里的改动) | -| 只是补几个字(纯插入) | 不学 | -| 把一个词删掉(纯删除) | 检测到但不入库 | -| 在聊天框里按回车发送 | 不该产生任何建议 ← 这条曾经翻过车 | -| 慢慢逐字打出一个英文词 | **可能**会弹出 `ap → ype` 这种半截建议 —— 点 ✗ 就行 | - -最后一条不是 bug,是这个设计的已知代价:半截和真纠错在文本上没有区别,粗筛拦不住。 -以前它会**静默入库**,现在最多是让你多点一次叉。 - ---- - -## 5. 词汇表页 - -``` -词汇表 - [你自己加的...] - ───────── 自动收集(N) [全部删除] - [自动收的...] -``` - -试一下「全部删除」,确认**手动加的不会被一起删掉**。 - -学来的词条在 `dictionary.json` 里是**追加到最后**的,手动添加的才插到最前。这条顺序是 -跟 ASR 词表预算的接口约定:预算把最前面的几条当保底席位(理由是「你刚手动加它,多半刚 -被它坑过」),点一下勾不该享受这个待遇。学来的词在 **LLM 热词块里立刻生效**(那一侧没有 -名额限制),ASR 侧则靠命中次数自己爬进预算。 - -早期版本往**纠正规则**里写过 learned 条目(现在不写了)。如果你的 `correction-rules.json` 里还有,纠正规则区有个「只看自动收集的」筛选可以把它们挑出来删。 - ---- - -## 6. 不会冻住界面 - -对着一个卡死的 app 触发听写。AX 调用 200ms 超时、整次读取 1.2 秒封顶、跑在独立线程,不占 tokio worker。 - ---- - -## 已知限制 - -1. **建议只在内存里**,OpenLess 重启就没了。卡片消失即当没发生——下次改同一个词会再问。 -2. **Electron 类 app 的光标位置常常不准**,日志里表现为 `before=0 after=N`(上文读成空)。上下文对润色的价值主要在上文,那种情况下收益有限。 -3. **风格包预览里看不到 ``**,跟 `front_app` 一样是运行时才有值的东西。 - ---- - -## 出问题时给我这些 - -```bash -# 相关日志(诊断细节是 debug 级别,日常不记;要更细的得改 LevelFilter 重编译) -grep -E "cursor-context|cursor context|vocab" ~/Library/Logs/OpenLess/openless.log | tail -100 - -# 学到的词条 -python3 -c "import json,os;[print(' ',e['phrase']) for e in json.load(open(os.path.expanduser('~/Library/Application Support/OpenLess/dictionary.json'))) if e.get('note')=='从手改中自动收集']" - -# 开关状态 -python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/Library/Application Support/OpenLess/preferences.json'))).get('cursorContextEnabled'))" -``` - -`edit watch disarmed` 那一行带两个数字——收到几次通知、学到几处改动。这两个数字足够判断某个 app 到底发不发通知,也就是逐 app 的覆盖率数据。 diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 076611e9f..228339b2e 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -1,9 +1,8 @@ -//! Linux host seam for the egui frontend. +//! Linux platform adapters and runtime coordination for the egui frontend. //! -//! The egui team owns the `eframe::App` and all visual/UI code. This crate is -//! intentionally a small host adapter: it re-exports the core contract and -//! provides a place for Linux window, tray, input and resource adapters to be -//! added without making the core depend on egui or Tauri. +//! `main.rs` owns the egui UI. This library constructs Core with Linux audio, +//! credentials, input, settings and resource adapters, then forwards commands +//! and semantic events between that backend and the UI. mod audio; mod backend; @@ -48,11 +47,10 @@ pub use single_instance::{ pub use openless_core::contract::*; -/// Construction seam reserved for the Linux host implementation. +/// Coordinates Core operations with Linux settings and recording lifecycles. /// -/// Keeping this as a named type gives the egui package a stable home for -/// platform adapters while the UI is developed independently. No window or -/// egui object is stored here. +/// Capture state binds recorder callbacks to their owning Core session. Window +/// objects and egui widgets stay in the frontend. pub struct LinuxHost { backend: std::sync::Arc, settings_runtime: std::sync::Arc, diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ce7002bde..35cb4b657 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1,3 +1,6 @@ +#[cfg(any(target_os = "linux", test))] +mod ui_state; + #[cfg(not(target_os = "linux"))] fn main() { eprintln!("openless-linux-egui is only available on Linux"); @@ -5,6 +8,7 @@ fn main() { #[cfg(target_os = "linux")] mod linux_app { + use super::ui_state::{Navigation, Page}; use std::future::Future; use std::sync::mpsc; use std::sync::Arc; @@ -20,11 +24,12 @@ mod linux_app { use openless_linux_egui::{ drain_events, ensure_fcitx5_plugin_installed, EventDrainOutcome, Fcitx5HotkeyListener, FcitxPluginInstallPlan, FcitxPluginStatus, LinuxBackendBuilder, LinuxCapabilitySnapshot, - LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, LinuxResourceLayout, - SingleInstanceBroker, SingleInstanceRole, + LinuxDesktopSession, LinuxLaunchIntent, LinuxNativeRuntime, LinuxPackageKind, + LinuxResourceLayout, SingleInstanceBroker, SingleInstanceRole, }; enum UiResult { + Environment(LinuxCapabilitySnapshot), Message(String), Models(Result, String>), Remote(Result<(openless_core::RemoteInputStatus, String), String>), @@ -92,6 +97,12 @@ mod linux_app { } pub struct OpenLessEguiApp { + navigation: Navigation, + environment: Option, + environment_refreshing: bool, + plugin_check: Option>, + less_computer_running: bool, + remote_error: Option, tokio: Arc, native: Option, subscription: Option, @@ -141,6 +152,12 @@ mod linux_app { let preferences = backend.get_preferences(); let subscription = backend.subscribe(); let app = Self { + navigation: Navigation::default(), + environment: None, + environment_refreshing: false, + plugin_check: None, + less_computer_running: false, + remote_error: None, tokio, native: Some(native), subscription: Some(subscription), @@ -182,6 +199,12 @@ mod linux_app { app } Err(error) => Self { + navigation: Navigation::default(), + environment: None, + environment_refreshing: false, + plugin_check: None, + less_computer_running: false, + remote_error: None, tokio, native: None, subscription: None, @@ -371,6 +394,7 @@ mod linux_app { let session_id = event.session_id; match event.kind { BackendEventKind::DictationStateChanged(state) => { + self.navigation.notify(Page::Dictation); if state.phase == DictationPhase::Starting { self.transcript_state = TranscriptAccumulator::default(); self.transcript.clear(); @@ -389,6 +413,7 @@ mod linux_app { self.transcript = delta.text; } BackendEventKind::DictationCompleted(result) => { + self.navigation.notify(Page::Dictation); self.transcript = result.polished_text; self.status = format!("听写完成:{:?}", result.inserted); } @@ -408,6 +433,13 @@ mod linux_app { } } BackendEventKind::LessComputerEvent(event) => { + // Voice capture has its own session, preceding a chat User + // turn. Keep a navigation notice without assigning it to + // the current chat or inventing microphone readiness. + if matches!(&event.kind, LessComputerEventKind::VoiceState { .. }) { + self.navigation.notify(Page::Agent); + return; + } // Less Computer events may complete after a newer turn has // already started. Session ownership, not arrival time, // decides whether a delta/terminal may mutate this view. @@ -416,6 +448,7 @@ mod linux_app { // continuation. `fresh` describes conversation history, // never whether this turn is allowed to receive output. self.less_computer_session = session_id; + self.less_computer_running = true; self.pending_approval = None; if *fresh { self.less_computer_output.clear(); @@ -427,11 +460,13 @@ mod linux_app { } else if session_id != self.less_computer_session { return; } + self.navigation.notify(Page::Agent); match event.kind { // Linux已有独立录音显示;新typed反馈供接手Host/UI团队继续接入。 LessComputerEventKind::VoiceState { .. } => {} LessComputerEventKind::User { .. } => {} LessComputerEventKind::Started => { + self.less_computer_running = true; self.status = "Less Computer 正在运行".to_string(); } LessComputerEventKind::Delta { text } => { @@ -444,6 +479,7 @@ mod linux_app { self.status = "Less Computer 已压缩上下文".to_string(); } LessComputerEventKind::Completed { text, .. } => { + self.less_computer_running = false; // A terminal is authoritative even for final-only // providers or after a missed partial event. self.less_computer_output @@ -457,16 +493,19 @@ mod linux_app { self.status = "Less Computer 等待审批".to_string(); } LessComputerEventKind::Error { message } => { + self.less_computer_running = false; self.pending_approval = None; self.status = message; } LessComputerEventKind::Cancelled => { + self.less_computer_running = false; self.pending_approval = None; self.status = "Less Computer 已取消".to_string(); } } } BackendEventKind::LocalAsrDownloadProgress(progress) => { + self.navigation.notify(Page::Models); self.status = format!( "模型 {}:{:?} {}/{}", progress.model_id, @@ -497,6 +536,7 @@ mod linux_app { .as_mut() .filter(|current| current.session_id == state.session_id) { + self.navigation.notify(Page::Qa); // Core deltas deliberately omit messages. Preserve // the conversation and append only this turn's text; // the following Answer replaces it with Core history. @@ -517,10 +557,12 @@ mod linux_app { .as_ref() .is_none_or(|current| current.session_id == state.session_id) { + self.navigation.notify(Page::Qa); self.qa_state = Some(state); } } BackendEventKind::SelectionStateChanged(snapshot) => { + self.navigation.notify(Page::Selection); if snapshot.phase == SelectionPhase::Preview { self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); self.selection_preview_visible = true; @@ -528,7 +570,10 @@ mod linux_app { self.selection = Some(snapshot); } BackendEventKind::RemoteInputStatusChanged(_) - | BackendEventKind::RemoteInputFailed(_) => self.load_remote_status(), + | BackendEventKind::RemoteInputFailed(_) => { + self.navigation.notify(Page::Remote); + self.load_remote_status(); + } _ => {} } } @@ -562,7 +607,11 @@ mod linux_app { // back through sequenced Core events handled above. for action in actions { match action { - HostAction::ShowMain | HostAction::ShowLessComputer => { + HostAction::ShowMain => { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + } + HostAction::ShowLessComputer => { + self.navigation.open(Page::Agent); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); } HostAction::FocusMain => { @@ -578,6 +627,7 @@ mod linux_app { self.status = "请手动重启 OpenLess".to_string(); } HostAction::ShowSelectionPreview => { + self.navigation.open(Page::Selection); self.selection_preview_visible = true; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); } @@ -585,6 +635,7 @@ mod linux_app { self.selection_preview_visible = false; } HostAction::ShowQa => { + self.navigation.open(Page::Qa); self.qa_visible = true; ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); } @@ -619,6 +670,7 @@ mod linux_app { self.less_computer_output.clear(); self.less_computer_turn_start = 0; self.less_computer_session = None; + self.less_computer_running = false; self.pending_approval = None; self.qa_state = None; self.qa_visible = false; @@ -639,14 +691,25 @@ mod linux_app { } while let Ok(result) = self.rx.try_recv() { match result { + UiResult::Environment(environment) => { + self.environment = Some(environment); + self.environment_refreshing = false; + } UiResult::Message(message) => self.status = message, UiResult::Models(Ok(models)) => self.models = ModelsState::Loaded(models), UiResult::Models(Err(error)) => { self.models = ModelsState::Failed(error.clone()); self.status = error; } - UiResult::Remote(Ok(remote)) => self.remote_access = Some(remote), - UiResult::Remote(Err(error)) => self.status = error, + UiResult::Remote(Ok(remote)) => { + self.remote_error = None; + self.remote_access = Some(remote); + } + UiResult::Remote(Err(error)) => { + self.remote_access = None; + self.remote_error = Some(error.clone()); + self.status = error; + } UiResult::Providers(Ok(panel)) => { if panel.kind != self.provider_kind { continue; @@ -833,8 +896,21 @@ mod linux_app { } } }); + ui.label(if self.less_computer_output.is_empty() { + "尚无 Agent 输出" + } else { + &self.less_computer_output + }); + } + + fn agent_approval_ui(&mut self, ui: &mut egui::Ui) { if let Some((token, command)) = self.pending_approval.clone() { - ui.label(format!("请求执行:{command}")); + egui::ScrollArea::vertical() + .id_salt("approval_command") + .max_height(72.0) + .show(ui, |ui| { + ui.label(format!("请求执行:{command}")); + }); ui.horizontal(|ui| { for (label, approved) in [("允许", true), ("拒绝", false)] { if ui.button(label).clicked() { @@ -854,18 +930,22 @@ mod linux_app { } }); } - ui.label(if self.less_computer_output.is_empty() { - "尚无 Agent 输出" - } else { - &self.less_computer_output - }); } fn qa_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("问答"); if !self.qa_visible { + ui.label("打开问答后可文字提问或语音提问。切换页面会保留当前会话;关闭会话使用下方的关闭操作。"); + if ui.button("打开问答").clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.show().await?; + Ok("问答已打开".to_string()) + }); + } + } return; } - ui.heading("问答"); if let Some(state) = &self.qa_state { if let Some(messages) = &state.messages { for message in messages { @@ -917,15 +997,33 @@ mod linux_app { }); } } + if ui.button("取消本轮").clicked() { + if let Some(backend) = self.backend() { + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(openless_core::SessionId::from_uuid); + self.spawn(async move { + backend.services().qa.cancel(session_id).await?; + Ok("问答本轮已取消".to_string()) + }); + } + } }); } fn selection_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("选区润色"); let Some(selection) = self.selection.clone() else { + ui.label("先在目标应用中选中文字,再使用已配置的选区润色快捷键。预览会在此显示,确认前可以编辑或取消。"); + ui.small("此入口是现有 Selection polish;Selection Voice 的完整意图路由尚未接入。"); return; }; + ui.label(format!("当前状态:{:?}", selection.phase)); if self.selection_preview_visible && selection.phase == SelectionPhase::Preview { - ui.heading("选区预览"); + ui.strong("选区预览"); ui.text_edit_multiline(&mut self.selection_draft); ui.horizontal(|ui| { if ui.button("确认替换").clicked() { @@ -1384,8 +1482,9 @@ mod linux_app { } } - fn settings_ui(&mut self, ui: &mut egui::Ui) { - ui.heading("Provider 与设置"); + fn services_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("AI 服务"); + ui.label("选择 ASR 语音识别、LLM 文本处理或 Omni 服务,再编辑并校验渠道。已配置不代表网络请求已通过。"); if let Some(snapshot) = &self.snapshot { let credentials = &snapshot.credentials; ui.label(format!( @@ -1408,72 +1507,446 @@ mod linux_app { )); } self.provider_management_ui(ui); - ui.separator(); - let mut remote_update = None; + } + + fn save_preferences(&mut self) { + let (Some(native), Some(snapshot), Some(preferences)) = + (&self.native, &self.snapshot, &self.preferences) + else { + return; + }; + match native + .host() + .save_settings(preferences.clone(), snapshot.preferences_revision) + { + Ok(_) => { + self.status = "设置已保存".to_string(); + let config = openless_core::RemoteInputConfig { + enabled: preferences.remote_input_enabled, + port: preferences.remote_input_port, + }; + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().remote_input.configure(config).await?; + Ok("远程输入状态已更新".to_string()) + }); + } + } + Err(error) => self.status = error.to_string(), + } + } + + fn settings_actions_ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + if ui.button("保存设置").clicked() { + self.save_preferences(); + } + if ui.button("放弃修改并重新读取").clicked() { + if let Some(backend) = self.backend() { + self.preferences = Some(backend.get_preferences()); + self.snapshot = Some(backend.snapshot()); + self.status = "已重新读取设置".to_string(); + } + } + }); + ui.small( + "环境与设置、手机输入共用设置草稿;保存会一起应用。保存冲突时可重新读取后再修改。", + ); + } + + fn settings_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("环境与设置"); + ui.strong("现有功能设置"); if let Some(preferences) = self.preferences.as_mut() { ui.checkbox(&mut preferences.streaming_insert, "流式插入"); + ui.small("将转写逐步发送到原输入目标,实际结果以听写与历史反馈为准。"); ui.checkbox(&mut preferences.coding_agent_enabled, "启用 Less Computer"); + ui.small("使用已有 Agent 配置与 CLI;进程执行仍遵循 Core 的审批规则。"); + self.settings_actions_ui(ui); + } + ui.horizontal_wrapped(|ui| { + if ui.button("配置 AI 服务").clicked() { + self.navigation.open(Page::Services); + } + if ui.button("设置手机输入").clicked() { + self.navigation.open(Page::Remote); + } + }); + ui.small( + "托盘、自启、自动更新、系统静音与额外全局热键尚未完整接入,此页没有对应开关。", + ); + ui.separator(); + self.environment_ui(ui); + } + + fn remote_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("手机输入"); + ui.label( + "先启用并保存,再让手机连接同一局域网,打开本机提供的 HTTPS 地址并输入配对码。", + ); + ui.label("首次连接需要确认并信任本服务的证书;服务运行不代表手机已连接。"); + if let Some(preferences) = self.preferences.as_mut() { ui.checkbox(&mut preferences.remote_input_enabled, "启用远程输入"); ui.add( egui::DragValue::new(&mut preferences.remote_input_port) .range(1..=u16::MAX) .prefix("端口 "), ); - if ui.button("保存设置").clicked() { - if let (Some(native), Some(snapshot)) = (&self.native, &self.snapshot) { - match native - .host() - .save_settings(preferences.clone(), snapshot.preferences_revision) - { - Ok(_) => { - self.status = "设置已保存".to_string(); - remote_update = Some(openless_core::RemoteInputConfig { - enabled: preferences.remote_input_enabled, - port: preferences.remote_input_port, - }); - } - Err(error) => self.status = error.to_string(), - } - } - } + self.settings_actions_ui(ui); } - if let (Some(config), Some(backend)) = (remote_update, self.backend()) { - self.spawn(async move { - backend.services().remote_input.configure(config).await?; - Ok("远程输入状态已更新".to_string()) - }); + ui.separator(); + if ui.button("刷新连接状态").clicked() { + self.load_remote_status(); + } + if let Some(error) = &self.remote_error { + ui.colored_label( + egui::Color32::YELLOW, + format!("暂时无法读取连接状态:{error}"), + ); + ui.label("检查桌面密钥环、网络和端口后刷新;旧地址与配对码已隐藏。"); + } else if self.remote_access.is_none() { + ui.label("尚未取得手机输入状态。"); } if let Some((remote, pin)) = &self.remote_access { ui.label(if remote.running { - "远程输入:运行中" + "远程输入服务:运行中" } else if remote.starting { - "远程输入:启动中" + "远程输入服务:启动中" } else { - "远程输入:已停止" + "远程输入服务:已停止" }); - if remote.enabled { + ui.label(format!("当前连接数:{}", remote.connection_count)); + if remote.active_session_id.is_some() { + ui.label("手机语音会话进行中,可使用顶部的语音取消。"); + } + if remote.urls_stale { + ui.colored_label( + egui::Color32::YELLOW, + "网络地址已过期,请检查网络后刷新状态。", + ); + } + if remote.enabled && remote.running && !remote.urls_stale { ui.monospace(format!("PIN:{pin}")); for url in &remote.urls { ui.monospace(url); } - if ui.button("重置配对码").clicked() { + if remote.urls.is_empty() { + ui.label("服务已启动,但尚未提供可用地址;请检查本机局域网连接。"); + } + } + if remote.enabled && ui.button("重置配对码").clicked() { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .remote_input + .regenerate_pairing_pin() + .await?; + Ok("远程输入配对码已重置".to_string()) + }); + } + } + } + } + + fn environment_ui(&mut self, ui: &mut egui::Ui) { + ui.strong("Linux 环境准备"); + ui.label(if self.native.is_some() { + "Core 已连接。下面的环境检查不代表录音、落字或服务调用已经实测成功。" + } else { + "Core 未连接。可查看准备步骤;修复启动问题后,请退出并重新启动 OpenLess。" + }); + if let Some(environment) = &self.environment { + ui.label(match environment.session { + LinuxDesktopSession::X11 => "桌面会话:检测到 X11 环境", + LinuxDesktopSession::Wayland => "桌面会话:检测到 Wayland 环境", + LinuxDesktopSession::Headless => "桌面会话:未检测到 DISPLAY / WAYLAND_DISPLAY", + }); + ui.label(if environment.fcitx5_ready { + "fcitx5:D-Bus 探测有响应,插件加载、快捷键与目标应用落字仍需实际操作确认。" + } else { + "fcitx5:D-Bus 探测未通过,可能是会话总线、服务或插件未就绪。" + }); + ui.label(match environment.permissions.microphone { + openless_core::PermissionState::Unsupported => { + "麦克风:当前探测环境不支持;请进入图形桌面会话。" + } + _ => "麦克风:尚未验证录音。请在系统声音设置选择输入设备,再进行一次短听写。", + }); + } else { + ui.label("尚未取得桌面环境探测结果。"); + } + ui.label(match &self.plugin_check { + Some(Ok(FcitxPluginStatus::Ready)) => { + "本次启动插件检查:找到插件文件;文件存在不代表 fcitx5 已加载它。" + } + Some(Ok(FcitxPluginStatus::Updated)) => { + "本次启动插件检查:插件文件已安装或更新,需要重载配置并重新启动 fcitx5。" + } + Some(Ok(FcitxPluginStatus::Missing)) => { + "本次启动插件检查:未找到插件文件,请重新安装含 OpenLess 插件的软件包。" + } + Some(Err(_)) => "本次启动插件检查:检查失败,请查看下方具体原因。", + None => "本次启动插件检查:未执行。", + }); + if let Some(Err(error)) = &self.plugin_check { + ui.colored_label(egui::Color32::YELLOW, error); + } + if ui + .add_enabled( + !self.environment_refreshing, + egui::Button::new(if self.environment_refreshing { + "正在检测…" + } else { + "重新检测会话与 D-Bus" + }), + ) + .clicked() + { + self.environment_refreshing = true; + let tx = self.tx.clone(); + self.tokio.spawn_blocking(move || { + let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); + let _ = tx.send(UiResult::Environment(environment)); + }); + } + ui.small("重新检测只更新上面的会话与 D-Bus 信息,不安装插件,也不重新连接 Core。本次启动检查结果保留到退出。"); + egui::CollapsingHeader::new("准备步骤与官方指南") + .default_open(self.native.is_none()) + .show(ui, |ui| { + ui.separator(); + ui.strong("1 · 准备输入法与桌面会话"); + ui.label("在当前图形桌面安装并启用 fcitx5,再安装含 OpenLess 插件的当前软件包。先在普通编辑器中确认输入法可以输入。"); + ui.label("在终端运行以下诊断,查看输入法环境与插件加载信息:"); + command_ui(ui, "fcitx5-diagnose"); + ui.label("插件安装或更新后可先重载配置;若插件仍未加载,退出并重新登录桌面,再启动 OpenLess:"); + command_ui(ui, "fcitx5-remote -r"); + ui.horizontal_wrapped(|ui| { + ui.hyperlink_to( + "Fcitx 5 官方设置指南", + "https://fcitx-im.org/wiki/Setup_Fcitx_5", + ); + ui.hyperlink_to( + "Wayland 桌面配置差异", + "https://fcitx-im.org/wiki/Using_Fcitx_5_on_Wayland", + ); + }); + ui.small("Wayland 的输入法配置取决于桌面和应用工具包,请按官方对应章节配置;检测到 Wayland 不代表所有目标应用都支持替换。X11 的 overlay 能力标记也不代表本应用已接入录音浮层。"); + ui.separator(); + ui.strong("2 · 准备密钥环与识别服务"); + ui.label("Secret Service:当前没有独立的服务连接或解锁状态检测;渠道显示“已配置”也不能证明密钥环现在可读写。"); + ui.label("打开桌面的密码/密钥环管理器,确认当前登录会话的密钥环已解锁。然后到 AI 服务选择渠道,填写所需凭据、保存并校验;若返回锁定或访问失败,解锁后重试。"); + ui.hyperlink_to( + "Secret Service 官方规范", + "https://specifications.freedesktop.org/secret-service/latest/", + ); + ui.small("API 密钥输入只用于写入,不回显已有密钥。本地识别可在本地模型页下载并激活 Generic Qwen。"); + ui.separator(); + ui.strong("3 · 做一次短听写"); + ui.label("在系统声音设置确认输入设备有电平。配置识别服务后,在目标编辑器聚焦输入框,用已有听写快捷键录制一句话并结束,检查转写和落字结果。问答、选区润色与 Agent 分别从导航进入。"); + ui.small("请分别验证你使用的 X11/Wayland、GTK/Qt/浏览器/终端。托盘、自启和应用内自动更新仍未完整接入。"); + }); + } + + fn start_ui(&mut self, ui: &mut egui::Ui) { + ui.heading("从一次听写开始"); + ui.label("先准备 Linux 输入环境,再选择识别服务。切换页面不会停止正在进行的任务。"); + if let Some(error) = &self.startup_error { + ui.colored_label(egui::Color32::YELLOW, format!("启动未完成:{error}")); + } + if let Some(snapshot) = &self.snapshot { + ui.label(if snapshot.running { + "Core:运行中" + } else { + "Core:未运行" + }); + let credentials = &snapshot.credentials; + match credentials.pipeline_mode { + openless_core::shared_types::PipelineMode::Multimodal => { + ui.label("当前管线:多模态(Omni)"); + ui.label(if credentials.omni_configured { + "Omni:已配置。" + } else { + "Omni:尚未配置,请到 AI 服务配置 Omni。" + }); + } + openless_core::shared_types::PipelineMode::Traditional => { + ui.label("当前管线:传统(ASR + LLM)"); + ui.label(if credentials.asr_configured { + "ASR 语音识别:已配置。" + } else { + "ASR 语音识别:尚未配置,请配置 AI 服务或激活本地模型。" + }); + ui.label(if credentials.llm_configured { + "LLM 润色:已配置。" + } else { + "LLM 润色:尚未配置。" + }); + } + } + ui.small("已配置不代表校验通过;请到 AI 服务验证连接。"); + } + ui.horizontal_wrapped(|ui| { + for (page, label) in [ + (Page::Settings, "1. 准备环境"), + (Page::Services, "2. 配置 AI 服务"), + (Page::Models, "使用本地模型"), + (Page::Dictation, "3. 打开听写"), + ] { + if ui + .add_enabled( + self.native.is_some() || page == Page::Settings, + egui::Button::new(label), + ) + .clicked() + { + self.navigation.open(page); + } + } + }); + ui.separator(); + if self.native.is_none() { + self.environment_ui(ui); + } else { + ui.strong("继续其他任务"); + ui.horizontal_wrapped(|ui| { + for page in [ + Page::Qa, + Page::Selection, + Page::Agent, + Page::Remote, + Page::History, + ] { + if ui.button(page.label()).clicked() { + self.navigation.open(page); + } + } + }); + ui.label("问答支持文字与语音;选区润色保留确认、取消与撤销;Less Computer 的工具执行继续使用原有审批。"); + ui.small( + "Linux 当前提供已有 Core / Host 能力的入口,完整原生支持与发布验收仍在继续。", + ); + } + } + + fn page_activity(&self, page: Page) -> Option<&'static str> { + match page { + Page::Dictation + if self.snapshot.as_ref().is_some_and(|snapshot| { + matches!( + snapshot.dictation.phase, + DictationPhase::Starting + | DictationPhase::Recording + | DictationPhase::Transcribing + | DictationPhase::Polishing + | DictationPhase::Inserting + ) + }) => + { + Some("进行中") + } + Page::Qa if self.qa_visible => Some("会话"), + Page::Selection + if self.selection_preview_visible + && self.selection.as_ref().is_some_and(|selection| { + selection.phase == SelectionPhase::Preview + }) => + { + Some("待确认") + } + Page::Agent if self.pending_approval.is_some() => Some("待审批"), + Page::Agent if self.less_computer_running => Some("进行中"), + _ if self.navigation.has_update(page) => Some("有更新"), + _ => None, + } + } + + fn navigation_button(&mut self, ui: &mut egui::Ui, page: Page) { + let label = match self.page_activity(page) { + Some(activity) => format!("{} · {activity}", page.label()), + None => page.label().to_string(), + }; + if ui + .selectable_label(self.navigation.page == page, label) + .clicked() + { + self.navigation.open(page); + } + } + + fn activity_ui(&mut self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + for page in Page::ALL { + if let Some(activity) = self.page_activity(page) { + if ui + .link(format!("{} · {activity} →", page.label())) + .clicked() + { + self.navigation.open(page); + } + } + } + if self.native.is_some() && ui.button("取消当前语音 · Esc").clicked() { + self.cancel_voice(); + } + if self.qa_visible && ui.button("取消问答").clicked() { + if let Some(backend) = self.backend() { + let session_id = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(openless_core::SessionId::from_uuid); + self.spawn(async move { + backend.services().qa.cancel(session_id).await?; + Ok("问答本轮已取消".to_string()) + }); + } + } + if let Some(session_id) = self + .selection + .as_ref() + .filter(|selection| selection.phase == SelectionPhase::Preview) + .and_then(|selection| selection.session_id) + { + if ui.button("取消选区预览").clicked() { if let Some(backend) = self.backend() { self.spawn(async move { backend .services() - .remote_input - .regenerate_pairing_pin() + .selection + .cancel(Some(session_id)) .await?; - Ok("远程输入配对码已重置".to_string()) + Ok("选区替换已取消".to_string()) }); } } } + if (self.less_computer_running || self.pending_approval.is_some()) + && ui.button("取消 Agent").clicked() + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_less_computer(None).await?; + Ok("Less Computer 已取消".to_string()) + }); + } + } + }); + } + + fn cancel_voice(&self) { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.cancel_active_voice_session(None).await?; + Ok("语音会话已取消".to_string()) + }); } } fn history_ui(&mut self, ui: &mut egui::Ui) { ui.heading("历史"); + ui.label("最近 20 条,只读。插入、复制回退与已发送粘贴分别显示实际结果。"); let Some(backend) = self.backend() else { return; }; @@ -1505,50 +1978,96 @@ mod linux_app { impl eframe::App for OpenLessEguiApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Poll every frame before routing pages. Hidden pages retain their + // drafts, session ownership, event replay and async completion paths. self.poll(ctx); if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.cancel_active_voice_session(None).await?; - Ok("语音会话已取消".to_string()) - }); - } + self.cancel_voice(); } egui::TopBottomPanel::top("status").show(ctx, |ui| { ui.horizontal(|ui| { ui.strong("OpenLess 2.0"); ui.separator(); - ui.label(&self.status); + ui.add(egui::Label::new(&self.status).truncate()) + .on_hover_text(&self.status); }); - }); - egui::CentralPanel::default().show(ctx, |ui| { - if let Some(error) = &self.startup_error { - ui.heading("启动失败"); - ui.colored_label(egui::Color32::RED, error); - return; + self.activity_ui(ui); + if self.pending_approval.is_some() { + ui.strong("Less Computer 等待审批"); + self.agent_approval_ui(ui); } - egui::ScrollArea::vertical().show(ui, |ui| { - self.dictation_ui(ui); - ui.separator(); - self.qa_ui(ui); - if self.qa_visible { - ui.separator(); - } - self.selection_ui(ui); - ui.separator(); - self.less_computer_ui(ui); - ui.separator(); - self.models_ui(ui); - ui.separator(); - self.settings_ui(ui); - ui.separator(); - self.history_ui(ui); + }); + if ctx.screen_rect().width() < 760.0 { + egui::TopBottomPanel::top("compact_navigation").show(ctx, |ui| { + ui.horizontal_wrapped(|ui| { + for page in Page::ALL { + self.navigation_button(ui, page); + } + }); }); + } else { + egui::SidePanel::left("navigation") + .resizable(false) + .default_width(176.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .id_salt("navigation_scroll") + .show(ui, |ui| { + ui.strong("工作空间"); + ui.add_space(8.0); + for page in Page::ALL { + if page == Page::Services { + ui.separator(); + ui.strong("准备与管理"); + } + self.navigation_button(ui, page); + } + }); + }); + } + egui::CentralPanel::default().show(ctx, |ui| { + let page = self.navigation.page; + egui::ScrollArea::vertical() + .id_salt(("page", page)) + .show(ui, |ui| { + if self.native.is_none() && !matches!(page, Page::Start | Page::Settings) { + ui.heading(page.label()); + ui.label("Core 尚未连接,请先完成 Linux 环境准备并重新启动应用。"); + if let Some(error) = &self.startup_error { + ui.colored_label(egui::Color32::YELLOW, error); + } + if ui.button("查看环境准备步骤").clicked() { + self.navigation.open(Page::Settings); + } + return; + } + match page { + Page::Start => self.start_ui(ui), + Page::Dictation => self.dictation_ui(ui), + Page::Qa => self.qa_ui(ui), + Page::Selection => self.selection_ui(ui), + Page::Agent => self.less_computer_ui(ui), + Page::Services => self.services_ui(ui), + Page::Models => self.models_ui(ui), + Page::Remote => self.remote_ui(ui), + Page::History => self.history_ui(ui), + Page::Settings => self.settings_ui(ui), + } + }); }); ctx.request_repaint_after(Duration::from_millis(50)); } } + fn command_ui(ui: &mut egui::Ui, command: &str) { + ui.horizontal_wrapped(|ui| { + ui.monospace(command); + if ui.button("复制命令").clicked() { + ui.ctx().copy_text(command.to_string()); + } + }); + } + impl Drop for OpenLessEguiApp { fn drop(&mut self) { if let Some(native) = self.native.take() { @@ -2046,7 +2565,7 @@ mod linux_app { }) } - fn ensure_fcitx5_ready(config: &BackendConfig) -> Result<(), String> { + fn ensure_fcitx5_ready(config: &BackendConfig) -> Result { let home = config .home_dir .as_deref() @@ -2054,16 +2573,7 @@ mod linux_app { let layout = LinuxResourceLayout::detect(None).map_err(|error| error.to_string())?; let plan = FcitxPluginInstallPlan::for_layout(&layout, home).map_err(|error| error.to_string())?; - match ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string())? { - FcitxPluginStatus::Ready => Ok(()), - FcitxPluginStatus::Updated => Err( - "fcitx5 插件已安装或更新;请先重载 fcitx5(fcitx5-remote -r)再重启 OpenLess" - .to_string(), - ), - FcitxPluginStatus::Missing => { - Err("未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string()) - } - } + ensure_fcitx5_plugin_installed(&plan).map_err(|error| error.to_string()) } pub fn run() -> Result<(), String> { @@ -2083,12 +2593,23 @@ mod linux_app { SingleInstanceRole::Primary(broker) => broker, SingleInstanceRole::Forwarded => return Ok(()), }; + let plugin_check = ensure_fcitx5_ready(&config); + let environment = LinuxCapabilitySnapshot::detect(false, package_kind()); let native = (|| { // AppImage may need to materialize its bundled plugin into the // per-user fcitx5 search path. Do that before opening the DBus // listener: otherwise the first run can wait forever for signals // from a plugin fcitx5 has never loaded. - ensure_fcitx5_ready(&config)?; + match &plugin_check { + Ok(FcitxPluginStatus::Ready) => {} + Ok(FcitxPluginStatus::Updated) => return Err( + "fcitx5 插件已安装或更新;请重载配置(fcitx5-remote -r),重新启动 fcitx5 或重新登录桌面,再启动 OpenLess".to_string() + ), + Ok(FcitxPluginStatus::Missing) => return Err( + "未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string() + ), + Err(error) => return Err(error.clone()), + } let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?; let backend = { // Construction captures the existing executor for cpal/native @@ -2109,13 +2630,20 @@ mod linux_app { .map_err(|error| error.to_string()) })(); let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default().with_inner_size([960.0, 720.0]), + viewport: egui::ViewportBuilder::default() + .with_inner_size([1040.0, 760.0]) + .with_min_inner_size([420.0, 400.0]), ..Default::default() }; eframe::run_native( "OpenLess", options, - Box::new(move |_| Ok(Box::new(OpenLessEguiApp::new(tokio, native)))), + Box::new(move |_| { + let mut app = OpenLessEguiApp::new(tokio, native); + app.environment = Some(environment); + app.plugin_check = Some(plugin_check); + Ok(Box::new(app)) + }), ) .map_err(|error| error.to_string()) } @@ -2124,6 +2652,279 @@ mod linux_app { mod tests { use super::*; + fn disconnected_app() -> OpenLessEguiApp { + OpenLessEguiApp::new( + Arc::new(tokio::runtime::Runtime::new().unwrap()), + Err("fixture: plugin unavailable".into()), + ) + } + + fn rendered_text(mut draw: impl FnMut(&mut egui::Ui)) -> String { + let ctx = egui::Context::default(); + let output = ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(720.0, 1800.0), + )), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + draw(ui); + }); + }, + ); + output + .shapes + .into_iter() + .filter_map(|shape| match shape.shape { + egui::epaint::Shape::Text(text) => Some(text.galley.job.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") + } + + #[test] + fn start_page_pipeline_multimodal_uses_only_omni_configuration() { + use openless_core::shared_types::PipelineMode; + + let mut app = disconnected_app(); + // A pending settings draft must not override Core's effective mode. + app.preferences = Some(UserPreferences { + multimodal_pipeline_enabled: false, + pipeline_mode: PipelineMode::Traditional, + ..Default::default() + }); + for omni_configured in [true, false] { + for (asr_configured, llm_configured) in + [(false, false), (true, false), (false, true), (true, true)] + { + app.snapshot = Some(BackendSnapshot { + credentials: openless_core::shared_types::CredentialsStatus { + pipeline_mode: PipelineMode::Multimodal, + omni_configured, + asr_configured, + llm_configured, + ..Default::default() + }, + ..Default::default() + }); + let text = rendered_text(|ui| app.start_ui(ui)); + let expected = if omni_configured { + "Omni:已配置" + } else { + "Omni:尚未配置" + }; + assert!(text.contains(expected), "missing {expected}: {text}"); + assert!(!text.contains("语音识别:尚未配置"), "{text}"); + assert!(!text.contains("ASR 语音识别:"), "{text}"); + assert!(!text.contains("LLM 润色:"), "{text}"); + assert!(text.contains("已配置不代表校验通过"), "{text}"); + } + } + } + + #[test] + fn start_page_pipeline_traditional_reports_asr_and_llm_independently() { + use openless_core::shared_types::PipelineMode; + + let mut app = disconnected_app(); + // Conversely, a multimodal draft must not hide the effective + // traditional pipeline's missing ASR or LLM configuration. + app.preferences = Some(UserPreferences { + multimodal_pipeline_enabled: true, + pipeline_mode: PipelineMode::Multimodal, + ..Default::default() + }); + for omni_configured in [true, false] { + for (asr_configured, llm_configured) in + [(false, false), (true, false), (false, true), (true, true)] + { + app.snapshot = Some(BackendSnapshot { + credentials: openless_core::shared_types::CredentialsStatus { + pipeline_mode: PipelineMode::Traditional, + omni_configured, + asr_configured, + llm_configured, + ..Default::default() + }, + ..Default::default() + }); + let text = rendered_text(|ui| app.start_ui(ui)); + for expected in [ + if asr_configured { + "ASR 语音识别:已配置" + } else { + "ASR 语音识别:尚未配置" + }, + if llm_configured { + "LLM 润色:已配置" + } else { + "LLM 润色:尚未配置" + }, + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + assert!(!text.contains("Omni:"), "{text}"); + assert!(text.contains("已配置不代表校验通过"), "{text}"); + } + } + } + + #[test] + fn background_approval_survives_navigation_and_stale_terminals() { + let mut app = disconnected_app(); + let session = openless_core::SessionId::new(); + for (sequence, kind) in [ + ( + 1, + LessComputerEventKind::User { + text: "task".into(), + fresh: true, + }, + ), + ( + 2, + LessComputerEventKind::Approval { + token: "approval".into(), + command: "echo test".into(), + reason: "fixture".into(), + }, + ), + ] { + app.apply_event(BackendEvent { + sequence, + session_id: Some(session), + kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { + seq: None, + kind, + }), + }); + } + for page in Page::ALL { + app.navigation.open(page); + assert_eq!(app.page_activity(Page::Agent), Some("待审批")); + let text = rendered_text(|ui| { + app.activity_ui(ui); + app.agent_approval_ui(ui); + }); + for control in ["允许", "拒绝", "取消 Agent"] { + assert!( + text.contains(control), + "missing {control} on {page:?}: {text}" + ); + } + } + app.apply_event(BackendEvent { + sequence: 3, + session_id: Some(openless_core::SessionId::new()), + kind: BackendEventKind::LessComputerEvent(openless_core::LessComputerEvent { + seq: None, + kind: LessComputerEventKind::Cancelled, + }), + }); + assert_eq!( + app.pending_approval, + Some(("approval".into(), "echo test".into())) + ); + assert!(app.less_computer_running); + } + + #[test] + fn qa_and_selection_events_on_settings_keep_drafts_and_action_notices() { + let mut app = disconnected_app(); + app.navigation.open(Page::Settings); + app.qa_input = "unsent question".into(); + let qa_session = openless_core::SessionId::new(); + let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); + thinking.session_id = Some(qa_session.to_string()); + app.apply_event(BackendEvent { + sequence: 1, + session_id: Some(qa_session), + kind: BackendEventKind::QaState(thinking), + }); + let selection_session = openless_core::SessionId::new(); + app.apply_event(BackendEvent { + sequence: 2, + session_id: Some(selection_session), + kind: BackendEventKind::SelectionStateChanged(SelectionSnapshot { + phase: SelectionPhase::Preview, + session_id: Some(selection_session), + preview_text: Some("editable preview".into()), + ..Default::default() + }), + }); + assert_eq!(app.navigation.page, Page::Settings); + assert!(app.navigation.has_update(Page::Qa)); + assert_eq!(app.page_activity(Page::Selection), Some("待确认")); + app.selection_draft = "user edited preview".into(); + app.navigation.open(Page::Qa); + app.navigation.open(Page::Selection); + app.navigation.open(Page::Models); + assert_eq!(app.qa_input, "unsent question"); + assert_eq!(app.selection_draft, "user edited preview"); + assert_eq!( + app.selection.as_ref().unwrap().session_id, + Some(selection_session) + ); + let text = rendered_text(|ui| app.activity_ui(ui)); + assert!(text.contains("取消选区预览"), "{text}"); + } + + #[test] + fn startup_failure_keeps_preparation_steps_without_claiming_connection() { + let mut app = disconnected_app(); + app.environment = Some(LinuxCapabilitySnapshot::from_environment( + Some("wayland-0"), + None, + false, + false, + LinuxPackageKind::AppImage, + )); + app.plugin_check = Some(Ok(FcitxPluginStatus::Updated)); + let text = rendered_text(|ui| app.start_ui(ui)); + for expected in [ + "Core 未连接", + "Wayland", + "D-Bus 探测未通过", + "尚未验证录音", + "fcitx5-diagnose", + "fcitx5-remote -r", + "Secret Service", + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + assert!(!text.contains("Core 已连接")); + assert!(!text.contains("Core:运行中")); + } + + #[test] + fn stopped_or_stale_remote_status_never_shows_pairing_secrets_or_old_urls() { + let mut app = disconnected_app(); + for (running, urls_stale) in [(false, false), (true, true)] { + app.remote_access = Some(( + openless_core::RemoteInputStatus { + enabled: true, + running, + starting: false, + port: 8443, + urls: vec!["https://old.example.invalid".into()], + urls_stale, + locale: "en".into(), + connection_count: 0, + active_session_id: None, + }, + "fixture-pin".into(), + )); + let text = rendered_text(|ui| app.remote_ui(ui)); + assert!(!text.contains("fixture-pin"), "{text}"); + assert!(!text.contains("https://old.example.invalid"), "{text}"); + assert!(text.contains("当前连接数:0"), "{text}"); + } + } + #[test] fn continuation_turn_keeps_receiving_output_and_approval() { let mut app = OpenLessEguiApp::new( diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs new file mode 100644 index 000000000..c22ced65e --- /dev/null +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -0,0 +1,119 @@ +//! Presentation state only: changing pages never owns or cancels a Core session. + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub(super) enum Page { + #[default] + Start, + Dictation, + Qa, + Selection, + Agent, + Services, + Models, + Remote, + History, + Settings, +} + +impl Page { + pub const ALL: [Self; 10] = [ + Self::Start, + Self::Dictation, + Self::Qa, + Self::Selection, + Self::Agent, + Self::Services, + Self::Models, + Self::Remote, + Self::History, + Self::Settings, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Start => "开始", + Self::Dictation => "听写", + Self::Qa => "问答", + Self::Selection => "选区润色", + Self::Agent => "Less Computer", + Self::Services => "AI 服务", + Self::Models => "本地模型", + Self::Remote => "手机输入", + Self::History => "历史", + Self::Settings => "环境与设置", + } + } +} + +#[derive(Default)] +pub(super) struct Navigation { + pub page: Page, + unread: [bool; Page::ALL.len()], +} + +impl Navigation { + pub fn open(&mut self, page: Page) { + self.page = page; + self.unread[page as usize] = false; + } + + pub fn notify(&mut self, page: Page) { + if self.page != page { + self.unread[page as usize] = true; + } + } + + pub fn has_update(&self, page: Page) -> bool { + self.unread[page as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn background_work_keeps_its_notice_until_its_own_page_is_opened() { + let mut navigation = Navigation::default(); + navigation.notify(Page::Qa); + navigation.notify(Page::Selection); + navigation.notify(Page::Agent); + navigation.open(Page::Settings); + for page in [Page::Qa, Page::Selection, Page::Agent] { + assert!(navigation.has_update(page)); + } + navigation.open(Page::Qa); + assert!(!navigation.has_update(Page::Qa)); + assert!(navigation.has_update(Page::Selection)); + assert!(navigation.has_update(Page::Agent)); + } + + #[test] + fn reading_a_page_does_not_create_an_unread_notice() { + let mut navigation = Navigation::default(); + navigation.open(Page::Agent); + navigation.notify(Page::Agent); + assert!(!navigation.has_update(Page::Agent)); + navigation.open(Page::Start); + navigation.notify(Page::Agent); + assert!(navigation.has_update(Page::Agent)); + assert_eq!(navigation.page, Page::Start); + } + + #[test] + fn every_destination_can_be_opened_without_clearing_other_destinations() { + let mut navigation = Navigation::default(); + for page in Page::ALL { + assert!(!page.label().is_empty()); + navigation.notify(page); + } + for (index, page) in Page::ALL.into_iter().enumerate() { + navigation.open(page); + assert_eq!(navigation.page, page); + assert!(!navigation.has_update(page)); + for remaining in &Page::ALL[index + 1..] { + assert!(navigation.has_update(*remaining)); + } + } + } +} diff --git a/openless-all/app/linux-egui/tests/host_contract.rs b/openless-all/app/linux-egui/tests/host_contract.rs index ce2b2396d..adcfb5e6c 100644 --- a/openless-all/app/linux-egui/tests/host_contract.rs +++ b/openless-all/app/linux-egui/tests/host_contract.rs @@ -1,5 +1,7 @@ use std::sync::{Arc, Mutex}; +use openless_core::shared_types::WindowsInsertionMode; + use openless_linux_egui::{ drain_events, BackendConfig, BackendDependencies, BackendErrorCode, BackendEventKind, BackendServices, CliDispatchOutcome, CliIntent, DictationSession, EventDrainOutcome, @@ -415,14 +417,31 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( assert_eq!(backend.snapshot().preferences_revision, revision + 1); assert_eq!(events.try_recv(), Err(EventRecvError::Empty)); + // Windows-only effects must stay explicit on the Linux host: since the + // Windows keyboard target is derived from the insertion mode, a raw + // keyboard-list toggle under the default TSF mode has no effective target + // and is accepted, while a change that would drive the Windows language + // profile is rejected as Unsupported. + let mut no_windows_effect = backend.get_preferences(); + no_windows_effect.windows_show_openless_in_keyboard_list = + !no_windows_effect.windows_show_openless_in_keyboard_list; + host.update_settings_strict(no_windows_effect, revision + 1) + .expect("toggling the keyboard-list pref under TSF must not produce a Windows effect"); + assert_eq!(backend.snapshot().preferences_revision, revision + 2); + assert!(matches!( + events.try_recv().unwrap().kind, + BackendEventKind::PreferencesChanged(_) + )); + assert_eq!(events.try_recv(), Err(EventRecvError::Empty)); + let mut windows_only = backend.get_preferences(); - windows_only.windows_show_openless_in_keyboard_list = - !windows_only.windows_show_openless_in_keyboard_list; + windows_only.windows_insertion_mode = WindowsInsertionMode::SendInput; + windows_only.windows_show_openless_in_keyboard_list = false; let unsupported = host - .update_settings_strict(windows_only, revision + 1) + .update_settings_strict(windows_only, backend.snapshot().preferences_revision) .expect_err("Windows-only effects must be explicit on the Linux host"); assert_eq!(unsupported.code, BackendErrorCode::Unsupported); - assert_eq!(backend.snapshot().preferences_revision, revision + 1); + assert_eq!(backend.snapshot().preferences_revision, revision + 2); assert_eq!(events.try_recv(), Err(EventRecvError::Empty)); for enabled in [true, false] { let mut preferences = backend.get_preferences(); diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 76fb2f0e2..534d75287 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -9,11 +9,9 @@ "version": "2.0.0-Beta.1", "dependencies": { "@base-ui/react": "^1.6.0", - "@formkit/auto-animate": "^0.9.0", "@shadcn/react": "^0.2.0", "@tailwindcss/vite": "^4.3.2", "@tauri-apps/api": "^2.1.1", - "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", @@ -36,6 +34,8 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", + "prettier": "3.9.6", + "prettier-plugin-sh": "0.19.0", "shadcn": "^4.13.0", "tsx": "4.23.1", "tw-animate-css": "^1.4.0", @@ -1226,12 +1226,6 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, - "node_modules/@formkit/auto-animate": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.9.0.tgz", - "integrity": "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==", - "license": "MIT" - }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -1369,6 +1363,81 @@ "node": ">= 8" } }, + "node_modules/@reteps/dockerfmt": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz", + "integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==", + "dev": true, + "license": "MIT", + "bin": { + "dockerfmt": "dist/launcher.js" + }, + "engines": { + "node": "^v12.20.0 || ^14.13.0 || >=16.0.0" + }, + "optionalDependencies": { + "@reteps/dockerfmt-darwin-arm64": "0.5.4", + "@reteps/dockerfmt-darwin-x64": "0.5.4", + "@reteps/dockerfmt-linux-arm64": "0.5.4", + "@reteps/dockerfmt-linux-x64": "0.5.4" + } + }, + "node_modules/@reteps/dockerfmt-darwin-arm64": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz", + "integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@reteps/dockerfmt-darwin-x64": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz", + "integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@reteps/dockerfmt-linux-arm64": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz", + "integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@reteps/dockerfmt-linux-x64": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz", + "integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2223,15 +2292,6 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/plugin-autostart": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz", - "integrity": "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", @@ -5308,6 +5368,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-sh": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz", + "integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@reteps/dockerfmt": "^0.5.4", + "sh-syntax": "^0.6.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + }, + "peerDependencies": { + "prettier": "^3.6.0" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -5757,6 +5853,19 @@ "dev": true, "license": "ISC" }, + "node_modules/sh-syntax": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz", + "integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/sh-syntax" + } + }, "node_modules/shadcn": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.13.0.tgz", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 2eb0ca1d2..dc11da883 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -30,15 +30,15 @@ "check:windows-ui-config": "node scripts/windows-ui-config.test.mjs", "check:pin-persistence-security": "node scripts/pin-persistence-security-contract.test.mjs", "check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs", - "check:android-shizuku-scaffolding": "node scripts/merge-android-shizuku-manifest.test.mjs && node scripts/patch-android-shizuku-deps.test.mjs" + "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}\" \"src-tauri/src/remote_server/assets/*.{js,css,html}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"android/frontend/**/*.{ts,tsx}\" \"scripts/**/*.{js,mjs,sh}\" \"*.{ts,json,html,sh}\" \"src-tauri/src/remote_server/assets/*.{js,css,html}\"" }, "dependencies": { "@base-ui/react": "^1.6.0", - "@formkit/auto-animate": "^0.9.0", "@shadcn/react": "^0.2.0", "@tailwindcss/vite": "^4.3.2", "@tauri-apps/api": "^2.1.1", - "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-shell": "^2.3.5", "@tauri-apps/plugin-updater": "^2.10.1", @@ -61,6 +61,8 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", + "prettier": "3.9.6", + "prettier-plugin-sh": "0.19.0", "shadcn": "^4.13.0", "tsx": "4.23.1", "tw-animate-css": "^1.4.0", diff --git a/openless-all/app/public/openless-2.0-splash.mp4 b/openless-all/app/public/openless-2.0-splash.mp4 new file mode 100644 index 000000000..acda74833 Binary files /dev/null and b/openless-all/app/public/openless-2.0-splash.mp4 differ diff --git a/openless-all/app/scripts/android-accessibility-enabled-detection-contract.test.mjs b/openless-all/app/scripts/android-accessibility-enabled-detection-contract.test.mjs index 7395ec9ab..6a0ece741 100644 --- a/openless-all/app/scripts/android-accessibility-enabled-detection-contract.test.mjs +++ b/openless-all/app/scripts/android-accessibility-enabled-detection-contract.test.mjs @@ -1,9 +1,6 @@ import { readFile } from 'node:fs/promises'; -const serviceUrl = new URL( - '../android/kotlin/OpenLessAccessibilityService.kt', - import.meta.url, -); +const serviceUrl = new URL('../android/kotlin/OpenLessAccessibilityService.kt', import.meta.url); const jniUrl = new URL('../src-tauri/src/android/jni.rs', import.meta.url); const panelUrl = new URL( '../android/frontend/components/AndroidPermissionsPanel.tsx', @@ -60,9 +57,7 @@ if (!/enabled_services_contain/.test(jniSource)) { throw new Error('jni.accessibility_enabled must call enabled_services_contain'); } -if ( - !/status\.enabled\s*&&\s*status\.operational\s*===\s*false/.test(panelSource) -) { +if (!/status\.enabled\s*&&\s*status\.operational\s*===\s*false/.test(panelSource)) { throw new Error( 'AndroidAccessibilityStatusPill must retain enabled=true && operational=false branch', ); diff --git a/openless-all/app/scripts/android-accessibility-paste-cache-contract.test.mjs b/openless-all/app/scripts/android-accessibility-paste-cache-contract.test.mjs index ec768e21a..3737c40a1 100644 --- a/openless-all/app/scripts/android-accessibility-paste-cache-contract.test.mjs +++ b/openless-all/app/scripts/android-accessibility-paste-cache-contract.test.mjs @@ -9,7 +9,13 @@ const servicePath = fileURLToPath( const source = readFileSync(servicePath, 'utf8'); function kotlinFunctionBody(functionSignature) { - const signatureIndex = source.indexOf(functionSignature); + // Formatting may wrap parameters; keep the function name and signature checks. + const pattern = functionSignature + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\s+/g, '\\s+') + .replaceAll('\\(', '\\(\\s*') + .replaceAll('\\)', '\\s*\\)'); + const signatureIndex = source.search(new RegExp(pattern)); assert.notEqual(signatureIndex, -1, `missing Kotlin function: ${functionSignature}`); const openBrace = source.indexOf('{', signatureIndex); assert.notEqual(openBrace, -1, `missing opening brace: ${functionSignature}`); @@ -23,7 +29,9 @@ function kotlinFunctionBody(functionSignature) { assert.fail(`missing closing brace: ${functionSignature}`); } -const pasteBody = kotlinFunctionBody('private fun performPasteToFocusedFieldInternal(pasteText: String? = null)'); +const pasteBody = kotlinFunctionBody( + 'private fun performPasteToFocusedFieldInternal(pasteText: String? = null)', +); assert.match( pasteBody, /finally\s*\{\s*target\.recycle\(\)\s*\}/s, @@ -72,7 +80,9 @@ assert.match(source, /pasteAppearsApplied/, 'paste must verify editor text chang assert.match(source, /paste=unverified/, 'paste must log unverified ACTION_PASTE results'); assert.match( readFileSync( - fileURLToPath(new URL('../android/kotlin/OpenLessAccessibilityCommandReceiver.kt', import.meta.url)), + fileURLToPath( + new URL('../android/kotlin/OpenLessAccessibilityCommandReceiver.kt', import.meta.url), + ), 'utf8', ), /EXTRA_PASTE_TEXT/, diff --git a/openless-all/app/scripts/android-accessibility-selection-ipc-contract.test.mjs b/openless-all/app/scripts/android-accessibility-selection-ipc-contract.test.mjs index ca8d2e941..bf07b54cb 100644 --- a/openless-all/app/scripts/android-accessibility-selection-ipc-contract.test.mjs +++ b/openless-all/app/scripts/android-accessibility-selection-ipc-contract.test.mjs @@ -13,7 +13,13 @@ const serviceSource = readFileSync(servicePath, 'utf8'); const receiverSource = readFileSync(receiverPath, 'utf8'); function kotlinFunctionBody(source, functionSignature) { - const signatureIndex = source.indexOf(functionSignature); + // Formatting may wrap parameters; keep the function name and signature checks. + const pattern = functionSignature + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\s+/g, '\\s+') + .replaceAll('\\(', '\\(\\s*') + .replaceAll('\\)', '\\s*\\)'); + const signatureIndex = source.search(new RegExp(pattern)); assert.notEqual(signatureIndex, -1, `missing Kotlin function: ${functionSignature}`); const openBrace = source.indexOf('{', signatureIndex); assert.notEqual(openBrace, -1, `missing opening brace: ${functionSignature}`); @@ -41,7 +47,7 @@ assert.match( assert.match( receiverSource, - /const val ACTION_CAPTURE_SELECTED_TEXT = "com\.openless\.app\.accessibility\.CAPTURE_SELECTED_TEXT"/, + /const val ACTION_CAPTURE_SELECTED_TEXT\s*=\s*"com\.openless\.app\.accessibility\.CAPTURE_SELECTED_TEXT"/, 'receiver must expose a dedicated selection action', ); assert.match( @@ -50,10 +56,7 @@ assert.match( 'receiver must expose a stable selected-text Bundle key', ); -const receiverActionBody = kotlinFunctionBody( - receiverSource, - 'ACTION_CAPTURE_SELECTED_TEXT ->', -); +const receiverActionBody = kotlinFunctionBody(receiverSource, 'ACTION_CAPTURE_SELECTED_TEXT ->'); assert.match( receiverActionBody, /OpenLessAccessibilityService\.captureSelectedTextFromCommand\(\)/, @@ -81,7 +84,7 @@ assert.match( ); assert.match( ipcBody, - /getString\(OpenLessAccessibilityCommandReceiver\.EXTRA_SELECTED_TEXT\)/, + /getString\(\s*OpenLessAccessibilityCommandReceiver\.EXTRA_SELECTED_TEXT\s*\)/, 'selection IPC sender must read the receiver Bundle key', ); assert.match( diff --git a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs index afcaaef72..a2554e9e3 100644 --- a/openless-all/app/scripts/android-credential-keystore-contract.test.mjs +++ b/openless-all/app/scripts/android-credential-keystore-contract.test.mjs @@ -6,18 +6,12 @@ const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); const paths = { cipher: new URL('../android/kotlin/OpenLessCredentialCipher.kt', import.meta.url), vault: new URL('../android/kotlin/OpenLessCredentialVault.kt', import.meta.url), - unitTest: new URL( - '../android/kotlin/test/OpenLessCredentialCipherTest.kt', - import.meta.url, - ), + unitTest: new URL('../android/kotlin/test/OpenLessCredentialCipherTest.kt', import.meta.url), instrumentedTest: new URL( '../android/kotlin/androidTest/OpenLessCredentialVaultInstrumentedTest.kt', import.meta.url, ), - rustStore: new URL( - '../src-tauri/src/persistence/android_credentials.rs', - import.meta.url, - ), + rustStore: new URL('../src-tauri/src/persistence/android_credentials.rs', import.meta.url), credentials: new URL('../src-tauri/src/persistence/credentials.rs', import.meta.url), jni: new URL('../src-tauri/src/android/jni.rs', import.meta.url), copyScript: new URL('./copy-android-scaffolding.mjs', import.meta.url), @@ -151,7 +145,7 @@ requirePattern( requirePattern( jni, /fn\s+with_tao_android_env[\s\S]*?main_android_context/, - 'startup persistence must use Tao\'s non-panicking Android context registry', + "startup persistence must use Tao's non-panicking Android context registry", ); const androidCredentialPath = credentials.match( /fn\s+android_credentials_path\s*\([^)]*\)\s*->\s*Result[\s\S]*?\r?\n}\r?\n/, @@ -165,7 +159,9 @@ requirePattern( 'Android credentials must use the app-private files directory', ); if (/TAURI_ANDROID_APP_DATA_DIR|temp_dir/.test(androidCredentialPath[0])) { - throw new Error('Android credential storage must not fall back to environment or temporary storage'); + throw new Error( + 'Android credential storage must not fall back to environment or temporary storage', + ); } for (const pattern of [ /android_legacy_credentials_paths/, @@ -227,11 +223,7 @@ requirePattern( /connectedX86_64DebugAndroidTest/, 'PR CI must execute x86_64 Android Keystore instrumentation tests on a device', ); -if ( - /:app:(?:testDebugUnitTest|assembleDebugAndroidTest|connectedDebugAndroidTest)\b/.test( - ci, - ) -) { +if (/:app:(?:testDebugUnitTest|assembleDebugAndroidTest|connectedDebugAndroidTest)\b/.test(ci)) { throw new Error('PR CI must not use Android tasks without the required ABI flavor'); } requirePattern( diff --git a/openless-all/app/scripts/android-insert-tier-fallback-contract.test.mjs b/openless-all/app/scripts/android-insert-tier-fallback-contract.test.mjs index cab524894..14b1ad755 100644 --- a/openless-all/app/scripts/android-insert-tier-fallback-contract.test.mjs +++ b/openless-all/app/scripts/android-insert-tier-fallback-contract.test.mjs @@ -3,15 +3,11 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -const insertPath = fileURLToPath( - new URL('../src-tauri/src/android/insert.rs', import.meta.url), -); +const insertPath = fileURLToPath(new URL('../src-tauri/src/android/insert.rs', import.meta.url)); const tiersPath = fileURLToPath( new URL('../src-tauri/src/android/insert_tiers.rs', import.meta.url), ); -const shizukuPath = fileURLToPath( - new URL('../src-tauri/src/android/shizuku.rs', import.meta.url), -); +const shizukuPath = fileURLToPath(new URL('../src-tauri/src/android/shizuku.rs', import.meta.url)); const bridgePath = fileURLToPath( new URL('../android/kotlin/OpenLessShizukuBridge.kt', import.meta.url), ); diff --git a/openless-all/app/scripts/build-mac.sh b/openless-all/app/scripts/build-mac.sh index 32e3c4d7e..a7fe0d5ee 100755 --- a/openless-all/app/scripts/build-mac.sh +++ b/openless-all/app/scripts/build-mac.sh @@ -59,9 +59,9 @@ APP_VERSION="$(node -p "require('./package.json').version")" DMG_PATH="$DMG_DIR/OpenLess_${APP_VERSION}_${MAC_BUNDLE_ARCH}.dmg" echo "▶ 校验 Info.plist / 签名" -/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" >/dev/null +/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" > /dev/null bash scripts/check-macos-speech-usage-description.sh "$INFO" -codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "com.apple.security.device.audio-input" +codesign -d --entitlements :- "$APP" 2> /dev/null | grep -q "com.apple.security.device.audio-input" codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -2 if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then @@ -84,11 +84,11 @@ if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then fi DMG_MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/openless-dmg-verify.XXXXXX")" cleanup_dmg_mount() { - hdiutil detach "$DMG_MOUNT" >/dev/null 2>&1 || true - rmdir "$DMG_MOUNT" >/dev/null 2>&1 || true + hdiutil detach "$DMG_MOUNT" > /dev/null 2>&1 || true + rmdir "$DMG_MOUNT" > /dev/null 2>&1 || true } trap cleanup_dmg_mount EXIT - hdiutil attach "$DMG_PATH" -readonly -nobrowse -mountpoint "$DMG_MOUNT" >/dev/null + hdiutil attach "$DMG_PATH" -readonly -nobrowse -mountpoint "$DMG_MOUNT" > /dev/null DMG_METALLIB="$DMG_MOUNT/OpenLess.app/Contents/Resources/mlx.metallib" if [ ! -s "$DMG_METALLIB" ]; then echo "✗ DMG 中缺少 OpenLess.app/Contents/Resources/mlx.metallib" @@ -132,8 +132,8 @@ if [ -n "${APPLE_CERTIFICATE:-}" ] \ fi HAS_NOTARIZATION_CREDENTIALS=0 if { [ -n "${APPLE_ID:-}" ] \ - && [ -n "${APPLE_PASSWORD:-}" ] \ - && [ -n "${APPLE_TEAM_ID:-}" ]; } \ + && [ -n "${APPLE_PASSWORD:-}" ] \ + && [ -n "${APPLE_TEAM_ID:-}" ]; } \ || { [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; }; then HAS_NOTARIZATION_CREDENTIALS=1 fi @@ -147,16 +147,16 @@ fi echo "▶ 清理发布产物扩展属性" # 这只能保证 CI/本机构建产物本身干净;浏览器下载仍可能重新加 quarantine。 # 用户免手工 xattr 的根本方案是 Developer ID 签名 + Apple notarization。 -xattr -cr "$APP" 2>/dev/null || true -find "$DMG_DIR" -maxdepth 1 -name '*.dmg' -exec xattr -c {} \; 2>/dev/null || true +xattr -cr "$APP" 2> /dev/null || true +find "$DMG_DIR" -maxdepth 1 -name '*.dmg' -exec xattr -c {} \; 2> /dev/null || true echo "▶ 校验 quarantine 属性" -if xattr -pr com.apple.quarantine "$APP" >/dev/null 2>&1; then +if xattr -pr com.apple.quarantine "$APP" > /dev/null 2>&1; then echo "✗ $APP 仍包含 com.apple.quarantine" exit 1 fi while IFS= read -r dmg; do - if xattr -p com.apple.quarantine "$dmg" >/dev/null 2>&1; then + if xattr -p com.apple.quarantine "$dmg" > /dev/null 2>&1; then echo "✗ $dmg 仍包含 com.apple.quarantine" exit 1 fi @@ -164,15 +164,15 @@ done < <(find "$DMG_DIR" -maxdepth 1 -name '*.dmg' -print) if [ "$INSTALL" = "1" ]; then echo "▶ 装到 /Applications" - pkill -f "OpenLess.app/Contents/MacOS/openless" 2>/dev/null || true + pkill -f "OpenLess.app/Contents/MacOS/openless" 2> /dev/null || true sleep 1 # 每次重装前重置 TCC:ad-hoc 签名 hash 每次构建都会变,旧授权立即失效, # 不重置就会出现"系统设置里看着已勾选实际不生效"。 - tccutil reset Accessibility com.openless.app 2>/dev/null || true - tccutil reset Microphone com.openless.app 2>/dev/null || true + tccutil reset Accessibility com.openless.app 2> /dev/null || true + tccutil reset Microphone com.openless.app 2> /dev/null || true rm -rf /Applications/OpenLess.app cp -R "$APP" /Applications/ - xattr -dr com.apple.quarantine /Applications/OpenLess.app 2>/dev/null || true + xattr -dr com.apple.quarantine /Applications/OpenLess.app 2> /dev/null || true echo "✓ 装好了:/Applications/OpenLess.app" echo " 打开方式:open /Applications/OpenLess.app" fi diff --git a/openless-all/app/scripts/check-android-updater-pubkey.mjs b/openless-all/app/scripts/check-android-updater-pubkey.mjs index 7493e8662..44c9bae0c 100644 --- a/openless-all/app/scripts/check-android-updater-pubkey.mjs +++ b/openless-all/app/scripts/check-android-updater-pubkey.mjs @@ -18,9 +18,7 @@ if (!confPubkey) { } const logicSource = readFileSync(logicPath, 'utf8'); -const match = logicSource.match( - /pub const UPDATER_PUBKEY_B64: &str\s*=\s*"([^"]+)"/, -); +const match = logicSource.match(/pub const UPDATER_PUBKEY_B64: &str\s*=\s*"([^"]+)"/); if (!match) { console.error('check-android-updater-pubkey: UPDATER_PUBKEY_B64 not found in updater_logic.rs'); process.exit(1); diff --git a/openless-all/app/scripts/check-hotkey-injection.mjs b/openless-all/app/scripts/check-hotkey-injection.mjs index 7cc60e2d1..3b7d866f0 100644 --- a/openless-all/app/scripts/check-hotkey-injection.mjs +++ b/openless-all/app/scripts/check-hotkey-injection.mjs @@ -18,15 +18,21 @@ const result = spawnSync( ); const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; -for (const chunk of (result.stdout ?? '').match(/[\s\S]{1,8192}/g) ?? []) process.stdout.write(chunk); -for (const chunk of (result.stderr ?? '').match(/[\s\S]{1,8192}/g) ?? []) process.stderr.write(chunk); +for (const chunk of (result.stdout ?? '').match(/[\s\S]{1,8192}/g) ?? []) + process.stdout.write(chunk); +for (const chunk of (result.stderr ?? '').match(/[\s\S]{1,8192}/g) ?? []) + process.stderr.write(chunk); if (result.status !== 0) { if (result.error) console.error(result.error); process.exit(result.status ?? 1); } -if (!output.includes('test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics ... ok')) { +if ( + !output.includes( + 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics ... ok', + ) +) { console.error('Core hotkey edge gate did not execute the expected test.'); process.exit(1); } diff --git a/openless-all/app/scripts/check-hotkey-recorder.mjs b/openless-all/app/scripts/check-hotkey-recorder.mjs deleted file mode 100644 index f14aff3a2..000000000 --- a/openless-all/app/scripts/check-hotkey-recorder.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import * as esbuild from 'esbuild'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const tmp = await mkdtemp(join(tmpdir(), 'openless-hotkey-recorder-')); -const outfile = join(tmp, 'hotkey-recorder-test.mjs'); - -try { - await esbuild.build({ - entryPoints: [fileURLToPath(new URL('../src/lib/hotkeyRecorder.test.ts', import.meta.url))], - outfile, - bundle: true, - platform: 'node', - format: 'esm', - logLevel: 'silent', - }); - await import(pathToFileURL(outfile).href); -} finally { - await rm(tmp, { recursive: true, force: true }); -} diff --git a/openless-all/app/scripts/check-window-hotkey-fallback.mjs b/openless-all/app/scripts/check-window-hotkey-fallback.mjs index 0891d4b26..43dae3c73 100644 --- a/openless-all/app/scripts/check-window-hotkey-fallback.mjs +++ b/openless-all/app/scripts/check-window-hotkey-fallback.mjs @@ -9,7 +9,9 @@ const outfile = join(tmp, 'window-hotkey-fallback-test.mjs'); try { await esbuild.build({ - entryPoints: [fileURLToPath(new URL('../src/lib/windowHotkeyFallback.test.ts', import.meta.url))], + entryPoints: [ + fileURLToPath(new URL('../src/lib/windowHotkeyFallback.test.ts', import.meta.url)), + ], outfile, bundle: true, platform: 'node', diff --git a/openless-all/app/scripts/ci-disable-macos-qwen3.mjs b/openless-all/app/scripts/ci-disable-macos-qwen3.mjs index 34fcf2c99..e0dda1e5b 100644 --- a/openless-all/app/scripts/ci-disable-macos-qwen3.mjs +++ b/openless-all/app/scripts/ci-disable-macos-qwen3.mjs @@ -1,37 +1,37 @@ -import { spawnSync } from "node:child_process" -import { readFileSync, writeFileSync } from "node:fs" -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") -const cargoPath = resolve(appRoot, "src-tauri/Cargo.toml") -const lockPath = resolve(appRoot, "src-tauri/Cargo.lock") -const cargo = readFileSync(cargoPath, "utf8") -const dependency = /^qwen3-asr-rs\s*=\s*\{[^\n]+\}\r?\n/m +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const cargoPath = resolve(appRoot, 'src-tauri/Cargo.toml'); +const lockPath = resolve(appRoot, 'src-tauri/Cargo.lock'); +const cargo = readFileSync(cargoPath, 'utf8'); +const dependency = /^qwen3-asr-rs\s*=\s*\{[^\n]+\}\r?\n/m; if (!dependency.test(cargo)) { - throw new Error(`未找到 macOS-only qwen3-asr-rs 依赖:${cargoPath}`) + throw new Error(`未找到 macOS-only qwen3-asr-rs 依赖:${cargoPath}`); } -writeFileSync(cargoPath, cargo.replace(dependency, "")) -const lock = readFileSync(lockPath, "utf8") +writeFileSync(cargoPath, cargo.replace(dependency, '')); +const lock = readFileSync(lockPath, 'utf8'); if (!lock.includes('name = "qwen3-asr-rs"')) { - throw new Error(`openless Cargo.lock package 未包含 qwen3-asr-rs:${lockPath}`) + throw new Error(`openless Cargo.lock package 未包含 qwen3-asr-rs:${lockPath}`); } -const cargoResult = spawnSync("cargo", ["generate-lockfile", "--manifest-path", cargoPath], { - cwd: appRoot, - stdio: "inherit", -}) +const cargoResult = spawnSync('cargo', ['generate-lockfile', '--manifest-path', cargoPath], { + cwd: appRoot, + stdio: 'inherit', +}); if (cargoResult.error) { - throw cargoResult.error + throw cargoResult.error; } if (cargoResult.status !== 0) { - throw new Error(`cargo generate-lockfile 失败,退出码:${cargoResult.status}`) + throw new Error(`cargo generate-lockfile 失败,退出码:${cargoResult.status}`); } -const regeneratedLock = readFileSync(lockPath, "utf8") +const regeneratedLock = readFileSync(lockPath, 'utf8'); if (regeneratedLock.includes('name = "qwen3-asr-rs"')) { - throw new Error(`cargo generate-lockfile 后仍包含 qwen3-asr-rs:${lockPath}`) + throw new Error(`cargo generate-lockfile 后仍包含 qwen3-asr-rs:${lockPath}`); } -console.log("[ci] disabled macOS-only qwen3-asr-rs dependency for this target") +console.log('[ci] disabled macOS-only qwen3-asr-rs dependency for this target'); diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 4a49aed2c..a03055b82 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -1,5 +1,13 @@ #!/usr/bin/env node -import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; import { dirname, join } from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -134,10 +142,7 @@ export function mergeMissingStringResources(xml, stringsByName) { if (!content.includes('')) { throw new Error('strings.xml is missing '); } - content = content.replace( - '', - `${formatStringResource(name, value)}\n`, - ); + content = content.replace('', `${formatStringResource(name, value)}\n`); changed = true; } return { content, changed }; @@ -333,20 +338,13 @@ function main() { copyDirectoryContents(androidIconRoot, resDest, dryRun); copyNamedFiles(KOTLIN_FILES, kotlinRoot, kotlinDest, dryRun); copyNamedFiles(KOTLIN_TEST_FILES, kotlinTestRoot, kotlinTestDest, dryRun); - copyNamedFiles( - KOTLIN_ANDROID_TEST_FILES, - kotlinAndroidTestRoot, - kotlinAndroidTestDest, - dryRun, - ); + copyNamedFiles(KOTLIN_ANDROID_TEST_FILES, kotlinAndroidTestRoot, kotlinAndroidTestDest, dryRun); ensureInstrumentationRunner(dryRun); for (const [relSrc, destName] of XML_FILES) { const src = join(manifestsRoot, relSrc); const dest = join(resXmlDest, destName); - const content = existsSync(src) - ? readFileSync(src, 'utf8') - : GENERATED_ACCESSIBILITY_CONFIG; + const content = existsSync(src) ? readFileSync(src, 'utf8') : GENERATED_ACCESSIBILITY_CONFIG; if (dryRun) { console.log(`[dry-run] Would write ${dest}`); continue; diff --git a/openless-all/app/scripts/copy-android-scaffolding.test.mjs b/openless-all/app/scripts/copy-android-scaffolding.test.mjs index db3b9f7ec..c026e6585 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.test.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.test.mjs @@ -26,9 +26,16 @@ assert.ok(hasStringResource(mergedAll.content, 'openless_shizuku_open_manager')) const mergedPartial = mergeMissingStringResources(partialXml, SHIZUKU_STRINGS_BY_LOCALE.values); assert.equal(mergedPartial.changed, true); assert.ok(hasStringResource(mergedPartial.content, 'openless_shizuku_open_manager')); -assert.ok(!mergedPartial.content.includes('exists\n ')); +assert.ok( + !mergedPartial.content.includes( + 'exists\n ', + ), +); -const mergedTwice = mergeMissingStringResources(mergedAll.content, SHIZUKU_STRINGS_BY_LOCALE.values); +const mergedTwice = mergeMissingStringResources( + mergedAll.content, + SHIZUKU_STRINGS_BY_LOCALE.values, +); assert.equal(mergedTwice.changed, false); for (const [locale, stringsByName] of Object.entries(SHIZUKU_STRINGS_BY_LOCALE)) { diff --git a/openless-all/app/scripts/frontend-test-runner.mjs b/openless-all/app/scripts/frontend-test-runner.mjs index d0715586d..976dcf95f 100644 --- a/openless-all/app/scripts/frontend-test-runner.mjs +++ b/openless-all/app/scripts/frontend-test-runner.mjs @@ -34,12 +34,7 @@ export function discoverTestFiles(appRoot = DEFAULT_APP_ROOT) { export function runTestFiles( testFiles, - { - appRoot = DEFAULT_APP_ROOT, - log = console.log, - spawn = spawnSync, - tsxCli, - } = {}, + { appRoot = DEFAULT_APP_ROOT, log = console.log, spawn = spawnSync, tsxCli } = {}, ) { let resolvedTsxCli = tsxCli; diff --git a/openless-all/app/scripts/frontend-test-runner.test.mjs b/openless-all/app/scripts/frontend-test-runner.test.mjs index 97e4f0bf3..44a1ebe25 100644 --- a/openless-all/app/scripts/frontend-test-runner.test.mjs +++ b/openless-all/app/scripts/frontend-test-runner.test.mjs @@ -13,21 +13,21 @@ for (const expected of [ 'scripts/macos-speech-usage-description-contract.test.mjs', 'scripts/repository-owner-contract.test.mjs', 'scripts/windows-ui-config.test.mjs', - 'src/lib/hotkeyRecorder.test.ts', + 'src/lib/hotkeySideModifiers.test.ts', 'src/lib/windowHotkeyFallback.test.ts', ]) { assert(discovered.includes(expected), `aggregate discovery omitted ${expected}`); } -assert.equal(new Set(discovered).size, discovered.length, 'aggregate discovery must not duplicate tests'); +assert.equal( + new Set(discovered).size, + discovered.length, + 'aggregate discovery must not duplicate tests', +); const invocations = []; const statuses = [0, 7, 0]; const exitCode = runTestFiles( - [ - 'src/lib/passes.test.ts', - 'scripts/fails.test.mjs', - 'scripts/must-not-run.test.mjs', - ], + ['src/lib/passes.test.ts', 'scripts/fails.test.mjs', 'scripts/must-not-run.test.mjs'], { appRoot: '/app', log: () => {}, diff --git a/openless-all/app/scripts/history-detail-sibling-keys.test.mjs b/openless-all/app/scripts/history-detail-sibling-keys.test.mjs index 3ad44d7a3..c4cf9b8e5 100644 --- a/openless-all/app/scripts/history-detail-sibling-keys.test.mjs +++ b/openless-all/app/scripts/history-detail-sibling-keys.test.mjs @@ -11,10 +11,7 @@ import { readFile } from 'node:fs/promises'; -const historyTsx = await readFile( - new URL('../src/pages/History.tsx', import.meta.url), - 'utf-8', -); +const historyTsx = await readFile(new URL('../src/pages/History.tsx', import.meta.url), 'utf-8'); /** 从 `key={` 之后开始按花括号配对取出完整表达式(模板串里的 `${}` 不会截断)。 */ function readKeyExpressions(source) { @@ -86,10 +83,10 @@ for (const itemId of ['history-key-contract-a', 'history-key-contract-b']) { const key = evaluateKey(expression, itemId); if (seen.has(key)) { throw new Error( - `历史详情面板出现重复的运行时 key:\`${key}\`(表达式 \`${expression}\` 与 ` - + `\`${seen.get(key)}\` 冲突)。重复 key 会让 React 无法正确删除旧节点,` - + '切换条目时残留重复的「播放录音」按钮,请给每个组件加上各自的前缀(如 ' - + '`audio-${item.id}` / `repolish-${item.id}`)。', + `历史详情面板出现重复的运行时 key:\`${key}\`(表达式 \`${expression}\` 与 ` + + `\`${seen.get(key)}\` 冲突)。重复 key 会让 React 无法正确删除旧节点,` + + '切换条目时残留重复的「播放录音」按钮,请给每个组件加上各自的前缀(如 ' + + '`audio-${item.id}` / `repolish-${item.id}`)。', ); } seen.set(key, expression); diff --git a/openless-all/app/scripts/layout-extreme-h5.mjs b/openless-all/app/scripts/layout-extreme-h5.mjs index d4002a6aa..a7b113e82 100644 --- a/openless-all/app/scripts/layout-extreme-h5.mjs +++ b/openless-all/app/scripts/layout-extreme-h5.mjs @@ -5,7 +5,8 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; const APP_URL = process.env.OPENLESS_H5_URL || 'http://127.0.0.1:1420/'; -const CHROME_PATH = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; +const CHROME_PATH = + process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'; const OUTPUT_DIR = process.env.OPENLESS_LAYOUT_ARTIFACT_DIR || join(tmpdir(), 'openless-layout-h5'); const VIEWPORTS = [ { width: 360, height: 640 }, @@ -16,7 +17,7 @@ const ZOOMS = [1.1, 2]; mkdirSync(OUTPUT_DIR, { recursive: true }); function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } class CdpClient { @@ -24,7 +25,7 @@ class CdpClient { this.socket = socket; this.nextId = 1; this.pending = new Map(); - socket.addEventListener('message', event => { + socket.addEventListener('message', (event) => { const message = JSON.parse(String(event.data)); if (!message.id) return; const waiter = this.pending.get(message.id); @@ -63,7 +64,7 @@ async function waitForPageTarget(port) { try { const response = await fetch('http://127.0.0.1:' + port + '/json/list'); const targets = await response.json(); - const target = targets.find(item => item.type === 'page' && item.url.startsWith(APP_URL)); + const target = targets.find((item) => item.type === 'page' && item.url.startsWith(APP_URL)); if (target?.webSocketDebuggerUrl) return target; } catch { // Chrome is still starting. @@ -76,15 +77,19 @@ async function waitForPageTarget(port) { async function launchChrome() { const port = 9300 + Math.floor(Math.random() * 400); const profile = join(tmpdir(), 'openless-layout-cdp-' + Date.now()); - const child = spawn(CHROME_PATH, [ - '--headless=new', - '--disable-gpu', - '--no-first-run', - '--no-default-browser-check', - '--remote-debugging-port=' + port, - '--user-data-dir=' + profile, - APP_URL, - ], { stdio: 'ignore' }); + const child = spawn( + CHROME_PATH, + [ + '--headless=new', + '--disable-gpu', + '--no-first-run', + '--no-default-browser-check', + '--remote-debugging-port=' + port, + '--user-data-dir=' + profile, + APP_URL, + ], + { stdio: 'ignore' }, + ); const target = await waitForPageTarget(port); return { child, client: await CdpClient.connect(target.webSocketDebuggerUrl) }; } @@ -97,14 +102,17 @@ async function evaluate(client, expression) { }); if (response.exceptionDetails) { throw new Error( - response.exceptionDetails.exception?.description || response.exceptionDetails.text || 'Runtime.evaluate failed', + response.exceptionDetails.exception?.description || + response.exceptionDetails.text || + 'Runtime.evaluate failed', ); } return response.result.value; } async function evaluateFn(client, fn, ...args) { - const expression = '(' + fn.toString() + ')(' + args.map(value => JSON.stringify(value)).join(',') + ')'; + const expression = + '(' + fn.toString() + ')(' + args.map((value) => JSON.stringify(value)).join(',') + ')'; return evaluate(client, expression); } @@ -115,38 +123,51 @@ async function waitForFn(client, fn, args = [], timeoutMs = 10000) { await sleep(100); } const diagnostics = await evaluateFn(client, function collectDiagnostics() { - const visible = element => element.getBoundingClientRect().width > 0; + const visible = (element) => element.getBoundingClientRect().width > 0; return { - buttons: Array.from(document.querySelectorAll('button')).filter(visible) - .map(button => button.innerText.trim()).filter(Boolean).slice(0, 40), - leaves: Array.from(document.querySelectorAll('div,span')).filter(element => ( - visible(element) && element.children.length === 0 && element.textContent.trim() - )).map(element => element.textContent.trim()).slice(0, 80), - layoutTexts: document.body.innerText.split('\n') - .map(text => text.trim()).filter(text => /布局|排版|易读/.test(text)), + buttons: Array.from(document.querySelectorAll('button')) + .filter(visible) + .map((button) => button.innerText.trim()) + .filter(Boolean) + .slice(0, 40), + leaves: Array.from(document.querySelectorAll('div,span')) + .filter( + (element) => + visible(element) && element.children.length === 0 && element.textContent.trim(), + ) + .map((element) => element.textContent.trim()) + .slice(0, 80), + layoutTexts: document.body.innerText + .split('\n') + .map((text) => text.trim()) + .filter((text) => /布局|排版|易读/.test(text)), }; }); throw new Error( - 'Timed out waiting for browser state: ' + fn.name + ' diagnostics=' + JSON.stringify(diagnostics), + 'Timed out waiting for browser state: ' + + fn.name + + ' diagnostics=' + + JSON.stringify(diagnostics), ); } function hasExactButton(text) { - return Array.from(document.querySelectorAll('button')).some(button => ( - button.getBoundingClientRect().width > 0 && button.innerText.trim() === text - )); + return Array.from(document.querySelectorAll('button')).some( + (button) => button.getBoundingClientRect().width > 0 && button.innerText.trim() === text, + ); } function clickExactButton(text) { - const button = Array.from(document.querySelectorAll('button')).find(candidate => ( - candidate.getBoundingClientRect().width > 0 && candidate.innerText.trim() === text - )); + const button = Array.from(document.querySelectorAll('button')).find( + (candidate) => + candidate.getBoundingClientRect().width > 0 && candidate.innerText.trim() === text, + ); if (!button) { return { ok: false, buttons: Array.from(document.querySelectorAll('button')) - .filter(candidate => candidate.getBoundingClientRect().width > 0) - .map(candidate => candidate.innerText.trim()) + .filter((candidate) => candidate.getBoundingClientRect().width > 0) + .map((candidate) => candidate.innerText.trim()) .filter(Boolean), }; } @@ -156,16 +177,21 @@ function clickExactButton(text) { async function clickButton(client, text) { const result = await evaluateFn(client, clickExactButton, text); - assert.equal(result.ok, true, '找不到按钮 ' + text + ',当前按钮:' + JSON.stringify(result.buttons)); + assert.equal( + result.ok, + true, + '找不到按钮 ' + text + ',当前按钮:' + JSON.stringify(result.buttons), + ); await sleep(180); } function clickCompositeRow(text) { - const button = Array.from(document.querySelectorAll('button.ol-nav-btn')).find(candidate => ( - candidate.children.length === 3 && - candidate.getBoundingClientRect().width > 0 && - candidate.innerText.trim() === text - )); + const button = Array.from(document.querySelectorAll('button.ol-nav-btn')).find( + (candidate) => + candidate.children.length === 3 && + candidate.getBoundingClientRect().width > 0 && + candidate.innerText.trim() === text, + ); if (!button) return false; button.click(); return true; @@ -185,8 +211,9 @@ async function openSettings(client) { } function closeCurrentOverlay() { - const buttons = Array.from(document.querySelectorAll('button[aria-label="关闭"]')) - .filter(button => button.getBoundingClientRect().width > 0); + const buttons = Array.from(document.querySelectorAll('button[aria-label="关闭"]')).filter( + (button) => button.getBoundingClientRect().width > 0, + ); if (!buttons.length) return false; buttons[0].click(); return true; @@ -198,8 +225,9 @@ async function closeOverlay(client) { } function clickSettingToggle(label) { - const labelNode = Array.from(document.querySelectorAll('div,span')) - .find(node => node.textContent.trim() === label); + const labelNode = Array.from(document.querySelectorAll('div,span')).find( + (node) => node.textContent.trim() === label, + ); let row = labelNode; while (row && !row.querySelector('button')) row = row.parentElement; const button = row?.querySelector('button'); @@ -218,15 +246,24 @@ function rootPreferenceState() { async function setPreference(client, label, key, desired) { const state = await evaluateFn(client, rootPreferenceState); if (state[key] !== desired) { - assert.equal(await evaluateFn(client, clickSettingToggle, label), true, '找不到布局开关:' + label); + assert.equal( + await evaluateFn(client, clickSettingToggle, label), + true, + '找不到布局开关:' + label, + ); } - await waitForFn(client, function waitPreference(prefKey, expected) { - const root = document.documentElement.dataset; - const actual = prefKey === 'readable' - ? root.olStackedLayout === 'true' - : root.olConservativeLayout === 'true'; - return actual === expected; - }, [key, desired]); + await waitForFn( + client, + function waitPreference(prefKey, expected) { + const root = document.documentElement.dataset; + const actual = + prefKey === 'readable' + ? root.olStackedLayout === 'true' + : root.olConservativeLayout === 'true'; + return actual === expected; + }, + [key, desired], + ); await sleep(180); } @@ -238,9 +275,10 @@ async function setPreferences(client, readable, conservative) { } function inspectGeneralLayout(zoom) { - const inspectToggle = label => { - const labelNode = Array.from(document.querySelectorAll('div,span')) - .find(node => node.textContent.trim() === label); + const inspectToggle = (label) => { + const labelNode = Array.from(document.querySelectorAll('div,span')).find( + (node) => node.textContent.trim() === label, + ); let row = labelNode; while (row && !row.querySelector('button')) row = row.parentElement; const button = row?.querySelector('button'); @@ -258,28 +296,36 @@ function inspectGeneralLayout(zoom) { }; const settingsRoot = document.querySelector('.ol-settings-surface') || document.body; - const isScrollContained = element => { - for (let parent = element.parentElement; parent && parent !== settingsRoot; parent = parent.parentElement) { + const isScrollContained = (element) => { + for ( + let parent = element.parentElement; + parent && parent !== settingsRoot; + parent = parent.parentElement + ) { const overflowX = getComputedStyle(parent).overflowX; if (overflowX === 'auto' || overflowX === 'scroll') return true; } return false; }; - const outliers = Array.from(settingsRoot.querySelectorAll('button,input,select,textarea,[role="button"],.ol-inline-composite')) - .filter(element => { + const outliers = Array.from( + settingsRoot.querySelectorAll( + 'button,input,select,textarea,[role="button"],.ol-inline-composite', + ), + ) + .filter((element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && (rect.left < -1 || rect.right > innerWidth + 1); }) - .filter(element => !isScrollContained(element)) + .filter((element) => !isScrollContained(element)) .slice(0, 12) - .map(element => ({ + .map((element) => ({ tag: element.tagName, text: element.textContent.trim().slice(0, 30), rect: Array.from([ element.getBoundingClientRect().left, element.getBoundingClientRect().right, innerWidth, - ]).map(value => Number(value.toFixed(1))), + ]).map((value) => Number(value.toFixed(1))), })); return { @@ -294,16 +340,17 @@ function inspectGeneralLayout(zoom) { } function inspectServiceActions() { - const edit = Array.from(document.querySelectorAll('button')).find(button => ( - button.getBoundingClientRect().width > 0 && - (button.getAttribute('aria-label') || '').startsWith('编辑') - )); + const edit = Array.from(document.querySelectorAll('button')).find( + (button) => + button.getBoundingClientRect().width > 0 && + (button.getAttribute('aria-label') || '').startsWith('编辑'), + ); const group = edit?.parentElement; if (!group) return null; const style = getComputedStyle(group); const childTops = Array.from(group.children) - .filter(child => child.getBoundingClientRect().height > 0) - .map(child => Math.round(child.getBoundingClientRect().top)); + .filter((child) => child.getBoundingClientRect().height > 0) + .map((child) => Math.round(child.getBoundingClientRect().top)); return { className: group.className, flexDirection: style.flexDirection, @@ -315,8 +362,9 @@ function inspectServiceActions() { function inspectPermissionActions() { const surface = document.querySelector('.ol-settings-surface'); - const labelNode = Array.from(surface?.querySelectorAll('div,span') || []) - .find(node => node.textContent.trim() === '麦克风'); + const labelNode = Array.from(surface?.querySelectorAll('div,span') || []).find( + (node) => node.textContent.trim() === '麦克风', + ); let row = labelNode; while (row && getComputedStyle(row).display !== 'grid') row = row.parentElement; const group = row?.children[1]?.firstElementChild; @@ -333,7 +381,7 @@ function inspectAboutComposite() { const row = document.querySelector('.ol-inline-composite'); if (!row) return null; const rowRect = row.getBoundingClientRect(); - const children = Array.from(row.children).map(child => { + const children = Array.from(row.children).map((child) => { const rect = child.getBoundingClientRect(); return { left: Number(rect.left.toFixed(1)), @@ -345,42 +393,55 @@ function inspectAboutComposite() { const style = getComputedStyle(row); return { flexDirection: style.flexDirection, - row: [rowRect.left, rowRect.right, rowRect.top, rowRect.bottom].map(value => Number(value.toFixed(1))), + row: [rowRect.left, rowRect.right, rowRect.top, rowRect.bottom].map((value) => + Number(value.toFixed(1)), + ), flexWrap: style.flexWrap, childCount: children.length, - withinRow: children.every(rect => rect.left >= rowRect.left - 1 && rect.right <= rowRect.right + 1), + withinRow: children.every( + (rect) => rect.left >= rowRect.left - 1 && rect.right <= rowRect.right + 1, + ), children, }; } function inspectCompositeSheet(expectedRows) { - const rows = Array.from(document.querySelectorAll('button.ol-nav-btn')).filter(button => ( - button.children.length === 3 && - button.getBoundingClientRect().width > 0 && - getComputedStyle(button).flexDirection === 'row' - )); + const rows = Array.from(document.querySelectorAll('button.ol-nav-btn')).filter( + (button) => + button.children.length === 3 && + button.getBoundingClientRect().width > 0 && + getComputedStyle(button).flexDirection === 'row', + ); return { count: rows.length, expectedRows, - valid: rows.every(row => { + valid: rows.every((row) => { const rect = row.getBoundingClientRect(); - const children = Array.from(row.children).map(child => child.getBoundingClientRect()); - return rect.left >= -1 && + const children = Array.from(row.children).map((child) => child.getBoundingClientRect()); + return ( + rect.left >= -1 && rect.right <= innerWidth + 1 && - children.every(child => child.top < rect.bottom && child.bottom > rect.top); + children.every((child) => child.top < rect.bottom && child.bottom > rect.top) + ); }), - rows: rows.map(row => { + rows: rows.map((row) => { const rect = row.getBoundingClientRect(); const parentRect = row.parentElement.getBoundingClientRect(); const style = getComputedStyle(row); return { text: row.innerText.trim(), - rect: [rect.left, rect.right, rect.top, rect.bottom, innerWidth].map(value => Number(value.toFixed(1))), - parentRect: [parentRect.left, parentRect.right, parentRect.width].map(value => Number(value.toFixed(1))), + rect: [rect.left, rect.right, rect.top, rect.bottom, innerWidth].map((value) => + Number(value.toFixed(1)), + ), + parentRect: [parentRect.left, parentRect.right, parentRect.width].map((value) => + Number(value.toFixed(1)), + ), computed: { boxSizing: style.boxSizing, width: style.width, padding: style.padding }, - children: Array.from(row.children).map(child => { + children: Array.from(row.children).map((child) => { const childRect = child.getBoundingClientRect(); - return [childRect.left, childRect.right, childRect.top, childRect.bottom].map(value => Number(value.toFixed(1))); + return [childRect.left, childRect.right, childRect.top, childRect.bottom].map((value) => + Number(value.toFixed(1)), + ); }), }; }), @@ -390,30 +451,30 @@ function inspectCompositeSheet(expectedRows) { function inspectPageOutliers() { const root = document.querySelector('main'); if (!root) return [{ tag: 'MISSING_MAIN' }]; - const isScrollContained = element => { - for (let parent = element.parentElement; parent && parent !== root; parent = parent.parentElement) { + const isScrollContained = (element) => { + for ( + let parent = element.parentElement; + parent && parent !== root; + parent = parent.parentElement + ) { const overflowX = getComputedStyle(parent).overflowX; if (overflowX === 'auto' || overflowX === 'scroll') return true; } return false; }; return Array.from(root.querySelectorAll('h1,p,button,input,select,textarea')) - .filter(element => { + .filter((element) => { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && (rect.left < -1 || rect.right > innerWidth + 1); }) - .filter(element => !isScrollContained(element)) + .filter((element) => !isScrollContained(element)) .slice(0, 12) - .map(element => { + .map((element) => { const rect = element.getBoundingClientRect(); return { tag: element.tagName, text: element.textContent.trim().slice(0, 30), - rect: [ - Number(rect.left.toFixed(1)), - Number(rect.right.toFixed(1)), - innerWidth, - ], + rect: [Number(rect.left.toFixed(1)), Number(rect.right.toFixed(1)), innerWidth], }; }); } @@ -429,14 +490,23 @@ function pageGridFingerprint() { async function openStylePage(client, label) { await clickButton(client, '风格'); - await waitForFn(client, function hasComposite(labelText) { - return Array.from(document.querySelectorAll('button.ol-nav-btn')).some(button => ( - button.children.length === 3 && - button.getBoundingClientRect().width > 0 && - button.innerText.trim() === labelText - )); - }, [label]); - assert.equal(await evaluateFn(client, clickCompositeRow, label), true, '找不到风格抽屉入口:' + label); + await waitForFn( + client, + function hasComposite(labelText) { + return Array.from(document.querySelectorAll('button.ol-nav-btn')).some( + (button) => + button.children.length === 3 && + button.getBoundingClientRect().width > 0 && + button.innerText.trim() === labelText, + ); + }, + [label], + ); + assert.equal( + await evaluateFn(client, clickCompositeRow, label), + true, + '找不到风格抽屉入口:' + label, + ); await waitForFn(client, function hasGrid() { return Boolean(document.querySelector('.ol-grid-auto-cards')); }); @@ -478,9 +548,13 @@ async function runCase(client, viewport, zoom) { }); await client.send('Page.navigate', { url: APP_URL }); await waitForFn(client, hasExactButton, ['更多'], 15000); - await evaluateFn(client, function applyTestZoom(value) { - document.documentElement.style.zoom = String(value); - }, zoom); + await evaluateFn( + client, + function applyTestZoom(value) { + document.documentElement.style.zoom = String(value); + }, + zoom, + ); await sleep(250); await openSettings(client); @@ -510,25 +584,35 @@ async function runCase(client, viewport, zoom) { assert.deepEqual(general.root, { readable, conservative }); for (const toggle of [general.readableToggle, general.conservativeToggle]) { assert.ok(toggle, '布局开关未渲染'); - assert.ok(Math.abs(toggle.normalizedWidth - 36) <= 0.75, '开关宽度不再是 36px:' + JSON.stringify(toggle)); + assert.ok( + Math.abs(toggle.normalizedWidth - 36) <= 0.75, + '开关宽度不再是 36px:' + JSON.stringify(toggle), + ); assert.equal(toggle.justifyContent, 'flex-start', '小尺寸布局开关应靠左'); - assert.ok(toggle.buttonLeft >= -1 && toggle.buttonLeft <= toggle.rowRight, '布局开关超出设置行'); + assert.ok( + toggle.buttonLeft >= -1 && toggle.buttonLeft <= toggle.rowRight, + '布局开关超出设置行', + ); } assert.deepEqual(general.outliers, [], '设置页存在未被滚动容器承接的横向溢出'); if (!readable && conservative) { await clickButton(client, '服务'); await waitForFn(client, function hasEditAction() { - return Array.from(document.querySelectorAll('button')).some(button => ( - button.getBoundingClientRect().width > 0 && - (button.getAttribute('aria-label') || '').startsWith('编辑') - )); + return Array.from(document.querySelectorAll('button')).some( + (button) => + button.getBoundingClientRect().width > 0 && + (button.getAttribute('aria-label') || '').startsWith('编辑'), + ); }); const service = await evaluateFn(client, inspectServiceActions); assert.ok(service, '服务卡片动作组未渲染'); assert.equal(service.flexDirection, 'column'); assert.equal(service.alignItems, 'flex-start'); - assert.ok(service.childCount === 3 && service.uniqueRows === 3, '服务卡片应有验证、开关、编辑三个纵排动作'); + assert.ok( + service.childCount === 3 && service.uniqueRows === 3, + '服务卡片应有验证、开关、编辑三个纵排动作', + ); await clickButton(client, '隐私'); await waitForFn(client, hasSettingLabel, ['麦克风']); @@ -552,10 +636,18 @@ async function runCase(client, viewport, zoom) { await closeOverlay(client); await openStylePage(client, '润色模式'); const styleOutliers = await evaluateFn(client, inspectPageOutliers); - assert.deepEqual(styleOutliers, [], '保守排版下风格页存在横向溢出:' + JSON.stringify(styleOutliers)); + assert.deepEqual( + styleOutliers, + [], + '保守排版下风格页存在横向溢出:' + JSON.stringify(styleOutliers), + ); await openStylePage(client, '风格市场'); const marketplaceOutliers = await evaluateFn(client, inspectPageOutliers); - assert.deepEqual(marketplaceOutliers, [], '保守排版下风格市场存在横向溢出:' + JSON.stringify(marketplaceOutliers)); + assert.deepEqual( + marketplaceOutliers, + [], + '保守排版下风格市场存在横向溢出:' + JSON.stringify(marketplaceOutliers), + ); await openSettings(client); } @@ -574,7 +666,11 @@ async function runCase(client, viewport, zoom) { assert.deepEqual(restoredStyle, baselineStyle, '关闭两个偏好后风格页未恢复基线布局'); await openStylePage(client, '风格市场'); const restoredMarketplace = await evaluateFn(client, pageGridFingerprint); - assert.deepEqual(restoredMarketplace, baselineMarketplace, '关闭两个偏好后风格市场未恢复基线布局'); + assert.deepEqual( + restoredMarketplace, + baselineMarketplace, + '关闭两个偏好后风格市场未恢复基线布局', + ); await assertCompositeSheets(client); await openSettings(client); @@ -583,7 +679,13 @@ async function runCase(client, viewport, zoom) { await openStylePage(client, '风格市场'); const screenshot = await captureScreenshot( client, - 'layout-' + viewport.width + 'x' + viewport.height + '-zoom-' + String(zoom).replace('.', '_') + '.png', + 'layout-' + + viewport.width + + 'x' + + viewport.height + + '-zoom-' + + String(zoom).replace('.', '_') + + '.png', ); return { diff --git a/openless-all/app/scripts/layout-mode-contract.test.mjs b/openless-all/app/scripts/layout-mode-contract.test.mjs index 9e8437f7e..427bb0132 100644 --- a/openless-all/app/scripts/layout-mode-contract.test.mjs +++ b/openless-all/app/scripts/layout-mode-contract.test.mjs @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const read = relativePath => readFileSync(join(appRoot, relativePath), 'utf8'); +const read = (relativePath) => readFileSync(join(appRoot, relativePath), 'utf8'); const css = read('src/styles/global.css'); const stackedLayout = read('src/lib/stackedLayout.ts'); @@ -26,7 +26,10 @@ assert.match(css, /\.ol-conservative-stack/); assert.match(stackedLayout, /stackedRowLayout\?: boolean/); assert.doesNotMatch(stackedLayout, /mobile\s*\|\|/); -assert.match(shared, /flex:\s*"0 0 36px"/); +// Toggle 尺寸契约:宽 36 由 width/minWidth/maxWidth 锁定,flex 只声明不伸缩。 +// flex-basis 必须为 auto,避免在列方向容器中把开关高度扩成 36px。 +assert.match(shared, /flex:\s*['"]0 0 auto['"]/); +assert.match(shared, /width:\s*36/); assert.match(shared, /minWidth:\s*36/); assert.match(shared, /maxWidth:\s*36/); assert.match(pageAtoms, /preferenceStack\s*\?/); diff --git a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs index 338ce741d..1a7d39c9a 100644 --- a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs +++ b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs @@ -9,11 +9,36 @@ const read = (relativePath) => readFile(join(appRoot, relativePath), 'utf8'); const shortcuts = await read('src/pages/settings/ShortcutsSection.tsx'); const agentSettings = await read('src/pages/settings/CodingAgentSection.tsx'); const settingsTabs = await read('src/pages/settings/tabs.tsx'); -assert(!/if \(os === 'win'/.test(agentSettings), 'Windows must expose Less Computer configuration and its text entry point'); -assert(shortcuts.includes("(os === 'mac' || os === 'win') && ("), 'Windows must expose the Less Computer voice shortcut'); -assert(settingsTabs.includes("(os === 'mac' || os === 'win') && "), 'the settings tab must mount Less Computer on Windows'); +assert( + !/if \(os === 'win'/.test(agentSettings), + 'Windows must expose Less Computer configuration and its text entry point', +); +assert( + shortcuts.includes("(os === 'mac' || os === 'win') && ("), + 'Windows must expose the Less Computer voice shortcut', +); +// Platform visibility is covered by navigation.test.ts; the detail route must +// still mount the existing settings consumer. +assert( + settingsTabs.includes("item.id === 'lessComputer' && "), + 'the Less Computer subpage must mount its settings consumer', +); -const [settings, ipc, opencode, dictation, lib, onboarding, lessComputerIpc, qaCommands, credentialCommands, miscCommands, coreAdapters, coordinatorHost, coreApi] = await Promise.all([ +const [ + settings, + ipc, + opencode, + dictation, + lib, + onboarding, + lessComputerIpc, + qaCommands, + credentialCommands, + miscCommands, + coreAdapters, + coordinatorHost, + coreApi, +] = await Promise.all([ read('src/pages/settings/CodingAgentSection.tsx'), read('src/lib/ipc/coding-agent.ts'), read('crates/openless-core/src/coding_agent.rs'), @@ -38,11 +63,14 @@ assert( 'OpenCode settings must expose the localized manual refresh action', ); assert( - ipc.includes('"coding_agent_list_opencode_models"'), + /['"]coding_agent_list_opencode_models['"]/.test(ipc), 'frontend IPC must call the OpenCode model-list command', ); assert(opencode.includes('"--refresh"'), 'OpenCode model discovery must refresh its model cache'); -assert(opencode.includes('"--auto"'), 'OpenCode runs must use the current automatic permission flag'); +assert( + opencode.includes('"--auto"'), + 'OpenCode runs must use the current automatic permission flag', +); assert(opencode.includes('"--continue"'), 'OpenCode runs must preserve follow-up session context'); assert( !opencode.includes('--dangerously-skip-permissions'), @@ -54,7 +82,9 @@ assert( 'Less Computer must resolve model defaults per provider', ); assert( - settings.includes("const SANDBOX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']") && + settings.includes( + "const SANDBOX_PERMISSION_MODES: CodingAgentPermissionMode[] = ['plan', 'acceptEdits']", + ) && settings.includes("provider === 'codex-cli' || provider === 'dsh-cli'") && settings.includes('normalizePermissionMode'), 'Codex and dsh settings must expose only read-only/plan and workspace-write permission modes and normalize legacy values', @@ -94,7 +124,9 @@ assert( miscCommands.includes('pub async fn list_microphone_devices(') && miscCommands.includes('.platform') && miscCommands.includes('.microphone_devices()') && - coreAdapters.includes('tauri::async_runtime::spawn_blocking(crate::recorder::list_input_devices'), + coreAdapters.includes( + 'tauri::async_runtime::spawn_blocking(crate::recorder::list_input_devices', + ), 'settings microphone enumeration must not block the AppKit main thread', ); assert( @@ -103,7 +135,7 @@ assert( ); assert( settings.includes('lessComputerWindowOpen()') && - lessComputerIpc.includes('"less_computer_window_open"') && + /['"]less_computer_window_open['"]/.test(lessComputerIpc) && qaCommands.includes('window.label() != "main"'), 'Advanced settings must expose a main-window-only text entry point for Less Computer', ); diff --git a/openless-all/app/scripts/local-asr-polling-contract.test.mjs b/openless-all/app/scripts/local-asr-polling-contract.test.mjs index 94f0c8133..bbb507f84 100644 --- a/openless-all/app/scripts/local-asr-polling-contract.test.mjs +++ b/openless-all/app/scripts/local-asr-polling-contract.test.mjs @@ -1,14 +1,10 @@ import { readFile } from 'node:fs/promises'; import ts from 'typescript'; -const source = await readFile( - new URL('../src/pages/LocalAsr/index.tsx', import.meta.url), - 'utf-8', -); +const source = await readFile(new URL('../src/pages/LocalAsr/index.tsx', import.meta.url), 'utf-8'); -const refreshPolling = source.match( - /window\.setInterval\(\(\) => \{\s*void refresh\(\)\s*\}, 3000\)/g, -) ?? []; +const refreshPolling = + source.match(/window\.setInterval\(\(\) => \{\s*void refresh\(\);?\s*\}, 3000\)/g) ?? []; const sourceFile = ts.createSourceFile( 'LocalAsr/index.tsx', @@ -18,8 +14,7 @@ const sourceFile = ts.createSourceFile( ts.ScriptKind.TSX, ); const localAsr = sourceFile.statements.find( - (statement) => - ts.isFunctionDeclaration(statement) && statement.name?.text === 'LocalAsr', + (statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === 'LocalAsr', ); if (!localAsr?.body) { throw new Error('LocalAsr component declaration is missing'); @@ -66,7 +61,11 @@ if (refreshPolling.length !== 1) { throw new Error(`LocalAsr should have one refresh poller, found ${refreshPolling.length}`); } -if (!/if \(downloadDialogOpen\) return[\s\S]{0,200}window\.setInterval\(\(\) => \{\s*void refresh\(\)/.test(source)) { +if ( + !/if \(downloadDialogOpen\) return[\s\S]{0,200}window\.setInterval\(\(\) => \{\s*void refresh\(\)/.test( + source, + ) +) { throw new Error('LocalAsr refresh polling must stop while the download dialog is open'); } diff --git a/openless-all/app/scripts/macos-compiler-runtime-contract.test.mjs b/openless-all/app/scripts/macos-compiler-runtime-contract.test.mjs index 6c1e4c6eb..afab088a6 100644 --- a/openless-all/app/scripts/macos-compiler-runtime-contract.test.mjs +++ b/openless-all/app/scripts/macos-compiler-runtime-contract.test.mjs @@ -1,11 +1,7 @@ import { readFile } from 'node:fs/promises'; const buildScript = await readFile(new URL('../src-tauri/build.rs', import.meta.url), 'utf8'); -const requiredFragments = [ - '-print-resource-dir', - 'rustc-link-search=native=', - 'clang_rt.osx', -]; +const requiredFragments = ['-print-resource-dir', 'rustc-link-search=native=', 'clang_rt.osx']; for (const fragment of requiredFragments) { if (!buildScript.includes(fragment)) { diff --git a/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs b/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs index 15029e1e9..d5f00c58b 100644 --- a/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs +++ b/openless-all/app/scripts/macos-mlx-bundle-contract.test.mjs @@ -1,30 +1,27 @@ -import assert from "node:assert/strict" -import { readFileSync } from "node:fs" -import { resolve } from "node:path" -import { fileURLToPath } from "node:url" +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url))) +const appRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); const overlay = JSON.parse( - readFileSync(resolve(appRoot, "src-tauri/tauri.macos-mlx.conf.json"), "utf8"), -) -const buildScript = readFileSync(resolve(appRoot, "scripts/build-mac.sh"), "utf8") + readFileSync(resolve(appRoot, 'src-tauri/tauri.macos-mlx.conf.json'), 'utf8'), +); +const buildScript = readFileSync(resolve(appRoot, 'scripts/build-mac.sh'), 'utf8'); +assert.equal(overlay.build.beforeBundleCommand, 'node scripts/stage-macos-mlx-metallib.mjs'); assert.equal( - overlay.build.beforeBundleCommand, - "node scripts/stage-macos-mlx-metallib.mjs", -) -assert.equal( - overlay.bundle.macOS.files["Resources/mlx.metallib"], - "target/release/openless-mlx/mlx.metallib", -) -assert.equal(overlay.bundle.macOS.files["MacOS/mlx.metallib"], undefined) -assert.match(buildScript, /arm64\)[\s\S]*tauri\.macos-mlx\.conf\.json/) -assert.doesNotMatch(buildScript, /--bundles app/) -assert.doesNotMatch(buildScript, /codesign --force/) -assert.doesNotMatch(buildScript, /hdiutil create/) -assert.match(buildScript, /OpenLess\.app\.tar\.gz/) -assert.match(buildScript, /stapler validate/) -assert.match(buildScript, /Contents\/Resources\/mlx\.metallib/) -assert.match(buildScript, /Contents\/MacOS\/mlx\.metallib/) + overlay.bundle.macOS.files['Resources/mlx.metallib'], + 'target/release/openless-mlx/mlx.metallib', +); +assert.equal(overlay.bundle.macOS.files['MacOS/mlx.metallib'], undefined); +assert.match(buildScript, /arm64\)[\s\S]*tauri\.macos-mlx\.conf\.json/); +assert.doesNotMatch(buildScript, /--bundles app/); +assert.doesNotMatch(buildScript, /codesign --force/); +assert.doesNotMatch(buildScript, /hdiutil create/); +assert.match(buildScript, /OpenLess\.app\.tar\.gz/); +assert.match(buildScript, /stapler validate/); +assert.match(buildScript, /Contents\/Resources\/mlx\.metallib/); +assert.match(buildScript, /Contents\/MacOS\/mlx\.metallib/); -console.log("macOS MLX bundle contract tests passed") +console.log('macOS MLX bundle contract tests passed'); diff --git a/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs b/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs index c62e7c8e4..ce2b216fd 100644 --- a/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs +++ b/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs @@ -39,7 +39,9 @@ function expectSuccessSilent(name, path) { throw new Error(`${name}: expected exit 0, got ${result.status}\n${result.stderr}`); } if (result.stdout !== '' || result.stderr !== '') { - throw new Error(`${name}: success must be silent, got ${JSON.stringify(result.stdout + result.stderr)}`); + throw new Error( + `${name}: success must be silent, got ${JSON.stringify(result.stdout + result.stderr)}`, + ); } } @@ -61,7 +63,10 @@ try { expectFailure('missing key', writeFixture('missing-key.plist', '')); expectFailure( 'empty string', - writeFixture('empty-string.plist', 'NSSpeechRecognitionUsageDescription'), + writeFixture( + 'empty-string.plist', + 'NSSpeechRecognitionUsageDescription', + ), ); expectFailure( 'whitespace-only string', diff --git a/openless-all/app/scripts/merge-android-shizuku-manifest.mjs b/openless-all/app/scripts/merge-android-shizuku-manifest.mjs index 6a7aa2b7e..1da22a4bc 100644 --- a/openless-all/app/scripts/merge-android-shizuku-manifest.mjs +++ b/openless-all/app/scripts/merge-android-shizuku-manifest.mjs @@ -133,15 +133,16 @@ function scanXmlTags(xml) { } const tagQName = parseQName(name); for (const attribute of attributes) { - attribute.namespaceUri = attribute.prefix === 'xmlns' - ? 'http://www.w3.org/2000/xmlns/' - : attribute.name === 'xmlns' + attribute.namespaceUri = + attribute.prefix === 'xmlns' ? 'http://www.w3.org/2000/xmlns/' - // XML default namespaces apply to elements only. An unprefixed - // attribute is always in no namespace (except xmlns above). - : attribute.prefix === null - ? null - : scope.get(attribute.prefix) ?? null; + : attribute.name === 'xmlns' + ? 'http://www.w3.org/2000/xmlns/' + : // XML default namespaces apply to elements only. An unprefixed + // attribute is always in no namespace (except xmlns above). + attribute.prefix === null + ? null + : (scope.get(attribute.prefix) ?? null); } const entry = { name, @@ -229,9 +230,11 @@ function findManifestTag(manifestXml) { function findAndroidNamespacePrefix(manifestXml) { const manifestTag = findManifestTag(manifestXml); - return manifestTag.attributes.find( - (attribute) => attribute.prefix === 'xmlns' && attribute.value === ANDROID_NAMESPACE_URI, - )?.localName ?? null; + return ( + manifestTag.attributes.find( + (attribute) => attribute.prefix === 'xmlns' && attribute.value === ANDROID_NAMESPACE_URI, + )?.localName ?? null + ); } function ensureAndroidNamespace(manifestXml) { @@ -249,13 +252,15 @@ function ensureAndroidNamespace(manifestXml) { function hasNamedTag(manifestXml, tagName, androidName) { return scanXmlTags(manifestXml).some( - (tag) => !tag.closing - && tag.localName === tagName - && tag.namespaceUri === null - && tag.attributes.some( - (attribute) => attribute.namespaceUri === ANDROID_NAMESPACE_URI - && attribute.localName === 'name' - && attribute.value === androidName, + (tag) => + !tag.closing && + tag.localName === tagName && + tag.namespaceUri === null && + tag.attributes.some( + (attribute) => + attribute.namespaceUri === ANDROID_NAMESPACE_URI && + attribute.localName === 'name' && + attribute.value === androidName, ), ); } @@ -263,13 +268,15 @@ function hasNamedTag(manifestXml, tagName, androidName) { function findProviderTagBounds(manifestXml) { const tags = scanXmlTags(manifestXml); const providerIndex = tags.findIndex( - (tag) => !tag.closing - && tag.localName === 'provider' - && tag.namespaceUri === null - && tag.attributes.some( - (attribute) => attribute.namespaceUri === ANDROID_NAMESPACE_URI - && attribute.localName === 'name' - && attribute.value === SHIZUKU_PROVIDER_CLASS, + (tag) => + !tag.closing && + tag.localName === 'provider' && + tag.namespaceUri === null && + tag.attributes.some( + (attribute) => + attribute.namespaceUri === ANDROID_NAMESPACE_URI && + attribute.localName === 'name' && + attribute.value === SHIZUKU_PROVIDER_CLASS, ), ); if (providerIndex === -1) return null; @@ -290,9 +297,10 @@ function findProviderTagBounds(manifestXml) { function selectAndroidPrefix(tag) { const nameAttribute = tag.attributes.find( - (attribute) => attribute.namespaceUri === ANDROID_NAMESPACE_URI - && attribute.localName === 'name' - && attribute.prefix, + (attribute) => + attribute.namespaceUri === ANDROID_NAMESPACE_URI && + attribute.localName === 'name' && + attribute.prefix, ); if (nameAttribute) return nameAttribute.prefix; return [...tag.scope.entries()].find(([, uri]) => uri === ANDROID_NAMESPACE_URI)?.[0] || null; @@ -333,7 +341,8 @@ function fixProviderOpeningTag(openingTag, provider, fallbackAndroidPrefix) { const replacements = []; const present = new Set(); for (const attribute of provider.attributes) { - if (attribute.namespaceUri !== ANDROID_NAMESPACE_URI || !expected.has(attribute.localName)) continue; + if (attribute.namespaceUri !== ANDROID_NAMESPACE_URI || !expected.has(attribute.localName)) + continue; if (present.has(attribute.localName)) { throw new Error(`duplicate Android ${attribute.localName} in Shizuku provider tag`); } @@ -452,8 +461,7 @@ function mergeQueries(manifestXml, androidPrefix) { } const manifestOpen = findManifestTag(manifestXml); const insertAt = manifestOpen.end; - const content = - `${manifestXml.slice(0, insertAt)}\n ${replaceAndroidAttributePrefix(QUERIES_SNIPPET, androidPrefix)}\n${manifestXml.slice(insertAt)}`; + const content = `${manifestXml.slice(0, insertAt)}\n ${replaceAndroidAttributePrefix(QUERIES_SNIPPET, androidPrefix)}\n${manifestXml.slice(insertAt)}`; return { content, changed: true }; } diff --git a/openless-all/app/scripts/merge-android-shizuku-manifest.test.mjs b/openless-all/app/scripts/merge-android-shizuku-manifest.test.mjs index 644c10233..762aaf85d 100644 --- a/openless-all/app/scripts/merge-android-shizuku-manifest.test.mjs +++ b/openless-all/app/scripts/merge-android-shizuku-manifest.test.mjs @@ -4,14 +4,20 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { mergeShizukuManifest } from './merge-android-shizuku-manifest.mjs'; -const manifestScript = fileURLToPath(new URL('./merge-android-shizuku-manifest.mjs', import.meta.url)); +const manifestScript = fileURLToPath( + new URL('./merge-android-shizuku-manifest.mjs', import.meta.url), +); const depsScript = fileURLToPath(new URL('./patch-android-shizuku-deps.mjs', import.meta.url)); const ANDROID_NAMESPACE_URI = 'http://schemas.android.com/apk/res/android'; const manifestSource = readFileSync(manifestScript, 'utf8'); const depsSource = readFileSync(depsScript, 'utf8'); -assert.match(manifestSource, /android:multiprocess="false"/, 'Shizuku provider must set multiprocess=false'); +assert.match( + manifestSource, + /android:multiprocess="false"/, + 'Shizuku provider must set multiprocess=false', +); assert.match( manifestSource, /moe\.shizuku\.privileged\.api/, @@ -23,8 +29,16 @@ assert.match( 'merge script must upgrade legacy multiprocess=true manifests', ); -assert.match(depsSource, /dev\.rikka\.shizuku:api:13\.1\.5/, 'Shizuku API dependency must be pinned'); -assert.match(depsSource, /dev\.rikka\.shizuku:provider:13\.1\.5/, 'Shizuku provider dependency must be pinned'); +assert.match( + depsSource, + /dev\.rikka\.shizuku:api:13\.1\.5/, + 'Shizuku API dependency must be pinned', +); +assert.match( + depsSource, + /dev\.rikka\.shizuku:provider:13\.1\.5/, + 'Shizuku provider dependency must be pinned', +); assert.match(depsSource, /aidl = true/, 'Gradle patch must enable AIDL build feature'); const fixture = ` @@ -223,8 +237,7 @@ assert.match(singleQuoteProviderTag, /android:exported="true"/); assert.match(singleQuoteProviderTag, /android:multiprocess="false"/); assertParsableManifest(singleQuoteMerged.content); -const compactManifestFixture = - ``; +const compactManifestFixture = ``; const compactMerged = mergeShizukuManifest(compactManifestFixture); assert.equal(compactMerged.changed, true); @@ -280,8 +293,14 @@ assert.equal((partialAliasNamespaceMerged.content.match(/ `; const regexPrefixMerged = mergeShizukuManifest(regexPrefixFixture); -assert.match(regexPrefixMerged.content, /axb:enabled="keep"/, 'non-Android axb attribute must remain unchanged'); +assert.match( + regexPrefixMerged.content, + /axb:enabled="keep"/, + 'non-Android axb attribute must remain unchanged', +); assert.match(regexPrefixMerged.content, /a\.b:enabled="true"/); assert.equal((regexPrefixMerged.content.match(/a\.b:enabled=/g) || []).length, 1); assert.equal(mergeShizukuManifest(regexPrefixMerged.content).changed, false); @@ -411,8 +434,16 @@ for (const [label, rootPrefix, applicationDeclaration] of [ `; const applicationShadowMerged = mergeShizukuManifest(applicationShadowFixture); assert.equal(applicationShadowMerged.changed, true, label); - assert.equal((applicationShadowMerged.content.match(/ @@ -435,10 +470,25 @@ const unprefixedNameFixture = ` `; const unprefixedNameMerged = mergeShizukuManifest(unprefixedNameFixture); -assert.equal((unprefixedNameMerged.content.match(/ `; const foreignProviderMerged = mergeShizukuManifest(foreignProviderFixture); -assert.match(foreignProviderMerged.content, //, 'foreign provider must remain untouched'); -assert.match(foreignProviderMerged.content, //, + 'foreign provider must remain untouched', +); +assert.match( + foreignProviderMerged.content, + /]*android:name="([^"]+)"[^>]*\/?>/g; +const PERMISSION_LINE_RE = /]*android:name="([^"]+)"[^>]*\/?>/g; function printHelp() { console.log(`Usage: node scripts/merge-android-v1-manifest.mjs [options] @@ -63,12 +62,15 @@ function permissionExists(manifestXml, permissionName) { } function mergePermissionLines(manifestXml, permissionLines) { - const missing = permissionLines.filter((permission) => !permissionExists(manifestXml, permission.name)); + const missing = permissionLines.filter( + (permission) => !permissionExists(manifestXml, permission.name), + ); if (missing.length === 0) { return { changed: false, content: manifestXml }; } - const insertionBlock = (indent) => missing.map((permission) => `${indent}${permission.line}`).join('\n') + '\n'; + const insertionBlock = (indent) => + missing.map((permission) => `${indent}${permission.line}`).join('\n') + '\n'; const applicationIdx = manifestXml.indexOf('/dev/null -command -v appimagetool >/dev/null +command -v fpm > /dev/null +command -v appimagetool > /dev/null mkdir -p "$OUTPUT" @@ -76,7 +76,7 @@ install -Dm644 "$PLUGIN_ROOT/openless.conf" \ QWEN_APPDIR="$APPDIR/usr/lib/openless/resources/qwen-asr" while read -r library; do case "$(basename "$library")" in - libc.so.*|libm.so.*|libpthread.so.*|libdl.so.*|librt.so.*|ld-linux-*.so.*) continue ;; + libc.so.* | libm.so.* | libpthread.so.* | libdl.so.* | librt.so.* | ld-linux-*.so.*) continue ;; esac install -Dm755 "$library" "$QWEN_APPDIR/$(basename "$library")" done < <(ldd "$QWEN_RUNTIME" | awk '$2 == "=>" && $3 ~ /^\// { print $3 }') diff --git a/openless-all/app/scripts/package-lock-root-contract.test.mjs b/openless-all/app/scripts/package-lock-root-contract.test.mjs index e303aad2e..55aa622ad 100644 --- a/openless-all/app/scripts/package-lock-root-contract.test.mjs +++ b/openless-all/app/scripts/package-lock-root-contract.test.mjs @@ -1,9 +1,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; -const packageJson = JSON.parse( - await readFile(new URL('../package.json', import.meta.url), 'utf8'), -); +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); const packageLock = JSON.parse( await readFile(new URL('../package-lock.json', import.meta.url), 'utf8'), ); diff --git a/openless-all/app/scripts/patch-android-shizuku-deps.mjs b/openless-all/app/scripts/patch-android-shizuku-deps.mjs index f3833cd9f..6d53a6499 100644 --- a/openless-all/app/scripts/patch-android-shizuku-deps.mjs +++ b/openless-all/app/scripts/patch-android-shizuku-deps.mjs @@ -85,8 +85,7 @@ export function patchAidlBuildFeature(gradleContent) { throw new Error('android block not found in Gradle file'); } const indent = androidMatch[1]; - const insertion = - `${androidMatch[0]}\n${indent} buildFeatures {\n${indent} ${AIDL_FEATURE}\n${indent} }`; + const insertion = `${androidMatch[0]}\n${indent} buildFeatures {\n${indent} ${AIDL_FEATURE}\n${indent} }`; const updated = gradleContent.replace(androidMatch[0], insertion); return { content: updated, changed: true }; } diff --git a/openless-all/app/scripts/pin-persistence-security-contract.test.mjs b/openless-all/app/scripts/pin-persistence-security-contract.test.mjs index 1a2242c0e..9fc5781e7 100644 --- a/openless-all/app/scripts/pin-persistence-security-contract.test.mjs +++ b/openless-all/app/scripts/pin-persistence-security-contract.test.mjs @@ -1,90 +1,98 @@ -import assert from "node:assert/strict" -import { readFile } from "node:fs/promises" +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; -const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8") +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); const [pinModule, coreRemote, coreContract, tauriAdapter, coordinator, command, appCargo, ci] = - await Promise.all([ - read("src-tauri/src/remote_server/pin_persistence.rs"), - read("crates/openless-core/src/remote_input_service.rs"), - read("crates/openless-core/tests/remote_input_contract.rs"), - read("src-tauri/src/core_adapters.rs"), - read("src-tauri/src/coordinator.rs"), - read("src-tauri/src/commands/remote_input.rs"), - read("src-tauri/Cargo.toml"), - read("../../.github/workflows/ci.yml"), - ]) + await Promise.all([ + read('src-tauri/src/remote_server/pin_persistence.rs'), + read('crates/openless-core/src/remote_input_service.rs'), + read('crates/openless-core/tests/remote_input_contract.rs'), + read('src-tauri/src/core_adapters.rs'), + read('src-tauri/src/coordinator.rs'), + read('src-tauri/src/commands/remote_input.rs'), + read('src-tauri/Cargo.toml'), + read('../../.github/workflows/ci.yml'), + ]); -for (const token of ["O_NOFOLLOW", "O_NONBLOCK", "O_CLOEXEC"]) { - assert.match(pinModule, new RegExp(`custom_flags\\([^)]*${token}`), `Unix open must use ${token}`) +for (const token of ['O_NOFOLLOW', 'O_NONBLOCK', 'O_CLOEXEC']) { + assert.match( + pinModule, + new RegExp(`custom_flags\\([^)]*${token}`), + `Unix open must use ${token}`, + ); } -assert.match(pinModule, /file\.metadata\(\)/, "validation must fstat the opened file") -assert.match(pinModule, /file\.set_permissions\(/, "permission repair must use the opened file") -assert.match(pinModule, /\.take\(MAX_PIN_FILE_BYTES \+ 1\)/, "reads must remain bounded after fstat") +assert.match(pinModule, /file\.metadata\(\)/, 'validation must fstat the opened file'); +assert.match(pinModule, /file\.set_permissions\(/, 'permission repair must use the opened file'); +assert.match( + pinModule, + /\.take\(MAX_PIN_FILE_BYTES \+ 1\)/, + 'reads must remain bounded after fstat', +); for (const token of [ - "FILE_FLAG_OPEN_REPARSE_POINT", - "GetFileInformationByHandle", - "GetFileType", - "FILE_ATTRIBUTE_REPARSE_POINT", - "nNumberOfLinks", - "ReplaceFileW", - "MoveFileExW", + 'FILE_FLAG_OPEN_REPARSE_POINT', + 'GetFileInformationByHandle', + 'GetFileType', + 'FILE_ATTRIBUTE_REPARSE_POINT', + 'nNumberOfLinks', + 'ReplaceFileW', + 'MoveFileExW', ]) { - assert.match(pinModule, new RegExp(token), `Windows implementation must use ${token}`) + assert.match(pinModule, new RegExp(token), `Windows implementation must use ${token}`); } assert.doesNotMatch( - pinModule, - /remove_file\(path\)/, - "replacement must never delete the destination before installing the new PIN", -) -assert.match(pinModule, /backup_path/, "Windows replacement must retain a rollback path") + pinModule, + /remove_file\(path\)/, + 'replacement must never delete the destination before installing the new PIN', +); +assert.match(pinModule, /backup_path/, 'Windows replacement must retain a rollback path'); -assert.match(appCargo, /"Win32_Storage_FileSystem"/, "Windows file APIs must be enabled") +assert.match(appCargo, /"Win32_Storage_FileSystem"/, 'Windows file APIs must be enabled'); assert.match( - pinModule, - /#\[cfg\(test\)\][\s\S]*mod tests[\s\S]*hard_link_pin_path_is_rejected/, - "PIN persistence security tests must remain in the owning Tauri module", -) + pinModule, + /#\[cfg\(test\)\][\s\S]*mod tests[\s\S]*hard_link_pin_path_is_rejected/, + 'PIN persistence security tests must remain in the owning Tauri module', +); assert.match( - ci, - /if: runner\.os != 'Windows'[\s\S]*cargo test --locked --manifest-path src-tauri\/Cargo\.toml --lib/, - "non-Windows CI must execute the owning Tauri module tests", -) + ci, + /if: runner\.os != 'Windows'[\s\S]*cargo test --locked --manifest-path src-tauri\/Cargo\.toml --lib/, + 'non-Windows CI must execute the owning Tauri module tests', +); assert.match( - ci, - /if: runner\.os == 'Windows'[\s\S]*cargo test --locked --manifest-path src-tauri\/Cargo\.toml --lib --no-run/, - "Windows CI must at least compile the owning Tauri module tests when native runtime DLLs prevent execution", -) + ci, + /if: runner\.os == 'Windows'[\s\S]*cargo test --locked --manifest-path src-tauri\/Cargo\.toml --lib --no-run/, + 'Windows CI must at least compile the owning Tauri module tests when native runtime DLLs prevent execution', +); const regenerate = coreRemote.match( - /async fn regenerate_pairing_pin_inner[\s\S]*?async fn authenticate_inner/, -)?.[0] -assert.ok(regenerate, "Core pairing PIN transaction must be present") + /async fn regenerate_pairing_pin_inner[\s\S]*?async fn authenticate_inner/, +)?.[0]; +assert.ok(regenerate, 'Core pairing PIN transaction must be present'); assert.match( - regenerate, - /persist_pairing_pin\(pin\.clone\(\)\)[\s\S]*?\.await[\s\S]*?state\.pairing_pin = Some\(pin\);[\s\S]*?if restart[\s\S]*?stop_server_and_sessions\(\)\.await\?;[\s\S]*?start_server\(port\)\.await\?;/, - "Core must persist the new PIN before committing memory and restarting the transport", -) + regenerate, + /persist_pairing_pin\(pin\.clone\(\)\)[\s\S]*?\.await[\s\S]*?state\.pairing_pin = Some\(pin\);[\s\S]*?if restart[\s\S]*?stop_server_and_sessions\(\)\.await\?;[\s\S]*?start_server\(port\)\.await\?;/, + 'Core must persist the new PIN before committing memory and restarting the transport', +); assert.match( - coreContract, - /failed_pin_persistence_keeps_the_committed_pin_and_server_state[\s\S]*?reject_persist\.store\(true[\s\S]*?regenerate_pairing_pin\(\)\.await\.unwrap_err\(\)[\s\S]*?old_pin[\s\S]*?status\(\)\.unwrap\(\)\.running[\s\S]*?start_count\.load\(Ordering::Acquire\), 1/, - "Core contract tests must prove persistence failure preserves the committed PIN and running server", -) + coreContract, + /failed_pin_persistence_keeps_the_committed_pin_and_server_state[\s\S]*?reject_persist\.store\(true[\s\S]*?regenerate_pairing_pin\(\)\.await\.unwrap_err\(\)[\s\S]*?old_pin[\s\S]*?status\(\)\.unwrap\(\)\.running[\s\S]*?start_count\.load\(Ordering::Acquire\), 1/, + 'Core contract tests must prove persistence failure preserves the committed PIN and running server', +); assert.match( - tauriAdapter, - /impl openless_core::RemoteInputRuntimeAdapter for TauriRemoteInputRuntimeAdapter[\s\S]*?fn persist_pairing_pin[\s\S]*?crate::remote_server::save_pin/, - "Tauri adapter must delegate pairing PIN persistence to the hardened atomic file implementation", -) + tauriAdapter, + /impl openless_core::RemoteInputRuntimeAdapter for TauriRemoteInputRuntimeAdapter[\s\S]*?fn persist_pairing_pin[\s\S]*?crate::remote_server::save_pin/, + 'Tauri adapter must delegate pairing PIN persistence to the hardened atomic file implementation', +); assert.doesNotMatch( - coordinator, - /regenerate_remote_pin|persist_and_commit_remote_pin|remote_server_handle|pairing_pin/, - "Coordinator must not regain remote input PIN or transport ownership", -) + coordinator, + /regenerate_remote_pin|persist_and_commit_remote_pin|remote_server_handle|pairing_pin/, + 'Coordinator must not regain remote input PIN or transport ownership', +); assert.match( - command, - /pub async fn regenerate_remote_pin[\s\S]*?-> Result[\s\S]*?regenerate_pairing_pin\(\)[\s\S]*?\.await[\s\S]*?read_pairing_pin\(\)/, - "Tauri command must surface Core reset failures before returning the committed PIN", -) + command, + /pub async fn regenerate_remote_pin[\s\S]*?-> Result[\s\S]*?regenerate_pairing_pin\(\)[\s\S]*?\.await[\s\S]*?read_pairing_pin\(\)/, + 'Tauri command must surface Core reset failures before returning the committed PIN', +); -console.log("PIN persistence security contract passed") +console.log('PIN persistence security contract passed'); diff --git a/openless-all/app/scripts/remote-input-audio-queue.test.mjs b/openless-all/app/scripts/remote-input-audio-queue.test.mjs index 86c2e88e3..99f4017ed 100644 --- a/openless-all/app/scripts/remote-input-audio-queue.test.mjs +++ b/openless-all/app/scripts/remote-input-audio-queue.test.mjs @@ -26,8 +26,12 @@ function fakeElement() { toggle: (name, enabled) => (enabled ? classes.add(name) : classes.delete(name)), contains: (name) => classes.has(name), }, - addEventListener(type, listener) { this.listeners[type] = listener; }, - querySelectorAll() { return []; }, + addEventListener(type, listener) { + this.listeners[type] = listener; + }, + querySelectorAll() { + return []; + }, focus() {}, select() {}, }; @@ -58,8 +62,12 @@ async function openRemotePage({ defaultMode, savedMode } = {}) { this.readyState = 1; socket = this; } - send(value) { sent.push(value); } - close() { this.readyState = 3; } + send(value) { + sent.push(value); + } + close() { + this.readyState = 3; + } } class FakeAudioWorkletNode { @@ -77,9 +85,15 @@ async function openRemotePage({ defaultMode, savedMode } = {}) { this.sampleRate = 48_000; this.audioWorklet = { addModule: () => Promise.resolve() }; } - resume() { return Promise.resolve(); } - suspend() { this.state = 'suspended'; } - createMediaStreamSource() { return { connect() {}, disconnect() {} }; } + resume() { + return Promise.resolve(); + } + suspend() { + this.state = 'suspended'; + } + createMediaStreamSource() { + return { connect() {}, disconnect() {} }; + } } const document = { @@ -89,7 +103,9 @@ async function openRemotePage({ defaultMode, savedMode } = {}) { getElementById: element, querySelectorAll: () => [], createElement: fakeElement, - addEventListener(type, listener) { documentListeners[type] = listener; }, + addEventListener(type, listener) { + documentListeners[type] = listener; + }, removeEventListener() {}, execCommand() {}, }; @@ -127,7 +143,8 @@ async function openRemotePage({ defaultMode, savedMode } = {}) { context.window = context; // Exercise the embedded HTML script too, so a missing template variable cannot // be hidden by setting window properties directly in the test harness. - const injectedScript = html.match(/ - - -
- -
-
- OpenLess -

OpenLess 远程输入

-

在手机上录音,实时输入到电脑

-
- -
- - - - -
+ + + + + + OpenLess + + + + + +
+ +
+
+ OpenLess +

OpenLess 远程输入

+

在手机上录音,实时输入到电脑

+
- -
- -
-
- OpenLess - OpenLess -
- - + + -
+
-
- + +
+
+ OpenLess + OpenLess +
+ + +
+
- +
+ -
- - - 准备就绪 -
+ + +
+ + + 准备就绪 +
- - -
-

点击大按钮开始录音,再次点击结束并识别。

+

点击大按钮开始录音,再次点击结束并识别。

- - -
+ + +
- -
-
-
📵
-

连接已断开

-

与电脑的连接已中断。

- -
-
-
+ +
+
+
📵
+

连接已断开

+

与电脑的连接已中断。

+ +
+
+ - - + + diff --git a/openless-all/app/src-tauri/src/remote_server/assets/style.css b/openless-all/app/src-tauri/src/remote_server/assets/style.css index 3b3ab4fd7..92c310c2e 100644 --- a/openless-all/app/src-tauri/src/remote_server/assets/style.css +++ b/openless-all/app/src-tauri/src/remote_server/assets/style.css @@ -35,8 +35,12 @@ /* 阴影 */ --shadow-sm: 0 1px 2px rgba(15, 17, 22, 0.04), 0 0 0 0.5px rgba(0, 0, 0, 0.04); - --shadow-md: 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.04); - --shadow-lg: 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06); + --shadow-md: + 0 1px 2px rgba(15, 17, 22, 0.05), 0 6px 24px -12px rgba(15, 17, 22, 0.1), + 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-lg: + 0 20px 60px -20px rgba(15, 17, 22, 0.18), 0 8px 32px -16px rgba(15, 17, 22, 0.1), + 0 0 0 0.5px rgba(0, 0, 0, 0.06); /* 圆角 */ --control-radius: 8px; @@ -50,7 +54,8 @@ --r-pill: 999px; /* 字体 */ - --font-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + --font-sans: + system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', Roboto, Helvetica, Arial, sans-serif; --safe-bottom: env(safe-area-inset-bottom, 0px); } @@ -68,7 +73,7 @@ --blue: #60a5fa; --blue-hover: #3b82f6; --blue-soft: rgba(96, 165, 250, 0.14); - --blue-ring: rgba(96, 165, 250, 0.30); + --blue-ring: rgba(96, 165, 250, 0.3); --on-accent: #f8fbff; --accent-solid-bg: #2563eb; --accent-solid-bg-hover: #3b82f6; @@ -82,9 +87,12 @@ /* 关键:很多元素用 hidden 属性控制显隐,但元素自带 display(flex/inline-flex)会覆盖浏览器 默认的 [hidden]{display:none},导致空框照常显示。这条强制 hidden 优先(结果框/三点/图标都靠它)。 */ -[hidden] { display: none !important; } +[hidden] { + display: none !important; +} -html, body { +html, +body { margin: 0; padding: 0; height: 100%; @@ -119,15 +127,21 @@ body { flex: 1; flex-direction: column; padding: 24px 20px; - animation: fadeIn .25s ease; + animation: fadeIn 0.25s ease; } .screen.active { display: flex; } @keyframes fadeIn { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } } /* ===== 品牌头 ===== */ @@ -146,7 +160,7 @@ body { font-size: 22px; font-weight: 700; margin: 16px 0 4px; - letter-spacing: .2px; + letter-spacing: 0.2px; color: var(--ink); } .brand-sub { @@ -187,7 +201,9 @@ body { border-radius: var(--r-lg); outline: none; font-variant-numeric: tabular-nums; - transition: border-color .15s ease, box-shadow .15s ease; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; } .pin-input::placeholder { color: var(--ink-4); @@ -212,16 +228,26 @@ body { border: none; border-radius: var(--r-lg); cursor: pointer; - transition: transform .08s ease, background .15s ease, opacity .15s ease; + transition: + transform 0.08s ease, + background 0.15s ease, + opacity 0.15s ease; +} +.btn:active { + transform: scale(0.98); +} +.btn:disabled { + opacity: 0.5; + cursor: default; } -.btn:active { transform: scale(.98); } -.btn:disabled { opacity: .5; cursor: default; } .btn-primary { background: var(--accent-solid-bg); box-shadow: 0 6px 18px -6px var(--blue-ring); } -.btn-primary:active { background: var(--accent-solid-bg-hover); } +.btn-primary:active { + background: var(--accent-solid-bg-hover); +} .hint-error { color: var(--danger); @@ -271,13 +297,17 @@ body { -webkit-appearance: none; appearance: none; } -.help-link:active { background: var(--accent-solid-bg-hover); } +.help-link:active { + background: var(--accent-solid-bg-hover); +} .help-link-ghost { background: var(--surface); color: var(--blue); border: 1px solid var(--blue); } -.help-link-ghost:active { background: var(--blue-soft); } +.help-link-ghost:active { + background: var(--blue-soft); +} /* ===== 录音屏头部 ===== */ .rec-header { @@ -296,7 +326,7 @@ body { .rec-header-title { font-weight: 700; font-size: 16px; - letter-spacing: .2px; + letter-spacing: 0.2px; color: var(--ink); } .mode-switch { @@ -319,7 +349,9 @@ body { padding: 7px 14px; border-radius: var(--control-radius); cursor: pointer; - transition: background .15s ease, color .15s ease; + transition: + background 0.15s ease, + color 0.15s ease; } .mode-btn.active { background: var(--accent-solid-bg); @@ -347,24 +379,29 @@ body { color: var(--accent-solid-ink); background: linear-gradient(180deg, var(--accent-solid-bg-hover) 0%, var(--accent-solid-bg) 100%); box-shadow: - 0 16px 36px -10px rgba(37, 99, 235, .5), - inset 0 1px 0 rgba(255, 255, 255, .25); + 0 16px 36px -10px rgba(37, 99, 235, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.25); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; - transition: transform .1s ease, box-shadow .2s ease, background .2s ease; + transition: + transform 0.1s ease, + box-shadow 0.2s ease, + background 0.2s ease; user-select: none; -webkit-user-select: none; } -.record-btn:active { transform: scale(.97); } +.record-btn:active { + transform: scale(0.97); +} .record-btn-ring { position: absolute; inset: -6px; border-radius: 50%; - border: 2px solid rgba(37, 99, 235, .35); + border: 2px solid rgba(37, 99, 235, 0.35); opacity: 0; pointer-events: none; } @@ -373,41 +410,57 @@ body { height: 60px; object-fit: contain; line-height: 1; - transition: transform .15s ease; + transition: transform 0.15s ease; } .record-btn-label { font-size: 14px; font-weight: 600; - color: rgba(255, 255, 255, .92); - letter-spacing: .3px; + color: rgba(255, 255, 255, 0.92); + letter-spacing: 0.3px; } /* 录音中:红色 + 呼吸脉冲动画 */ .record-btn.recording { background: linear-gradient(180deg, #f87171 0%, #dc2626 100%); - box-shadow: 0 16px 36px -10px rgba(220, 38, 38, .5); + box-shadow: 0 16px 36px -10px rgba(220, 38, 38, 0.5); animation: breathe 1.6s ease-in-out infinite; } -.record-btn.recording .record-btn-label { color: var(--on-accent); } +.record-btn.recording .record-btn-label { + color: var(--on-accent); +} .record-btn.recording .record-btn-ring { opacity: 1; - border-color: rgba(220, 38, 38, .4); + border-color: rgba(220, 38, 38, 0.4); animation: pulseRing 1.6s ease-out infinite; } @keyframes breathe { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.04); } + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.04); + } } @keyframes pulseRing { - 0% { transform: scale(1); opacity: .7; } - 70% { transform: scale(1.28); opacity: 0; } - 100% { transform: scale(1.28); opacity: 0; } + 0% { + transform: scale(1); + opacity: 0.7; + } + 70% { + transform: scale(1.28); + opacity: 0; + } + 100% { + transform: scale(1.28); + opacity: 0; + } } /* 忙/禁用态 */ .record-btn.busy { - opacity: .5; + opacity: 0.5; cursor: default; animation: none; } @@ -419,11 +472,11 @@ body { height: 8px; border-radius: var(--r-pill); background: #e9ebf0; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, .07); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.07); overflow: hidden; /* 平时淡化成一个浅凹槽;录音时才高亮(下方规则),避免像一根无意义的白条 */ - opacity: .45; - transition: opacity .2s ease; + opacity: 0.45; + transition: opacity 0.2s ease; } .record-btn.recording ~ .level-wrap { opacity: 1; @@ -433,7 +486,7 @@ body { width: 0%; border-radius: var(--r-pill); background: linear-gradient(90deg, var(--ok), var(--blue)); - transition: width .08s linear; + transition: width 0.08s linear; } /* ===== 状态条 ===== */ @@ -450,9 +503,18 @@ body { text-align: center; max-width: 90%; } -.status-bar.is-error { color: var(--danger); border-color: rgba(220, 38, 38, .35); } -.status-bar.is-ok { color: var(--ok); border-color: rgba(22, 163, 74, .35); } -.status-bar.is-work { color: var(--blue); border-color: var(--blue-ring); } +.status-bar.is-error { + color: var(--danger); + border-color: rgba(220, 38, 38, 0.35); +} +.status-bar.is-ok { + color: var(--ok); + border-color: rgba(22, 163, 74, 0.35); +} +.status-bar.is-work { + color: var(--blue); + border-color: var(--blue-ring); +} /* 状态图标(如完成对勾) */ .status-icon { @@ -480,11 +542,23 @@ body { display: inline-block; animation: dotPulse 1.2s infinite ease-in-out both; } -.dots i:nth-child(1) { animation-delay: -.32s; } -.dots i:nth-child(2) { animation-delay: -.16s; } +.dots i:nth-child(1) { + animation-delay: -0.32s; +} +.dots i:nth-child(2) { + animation-delay: -0.16s; +} @keyframes dotPulse { - 0%, 80%, 100% { transform: scale(.5); opacity: .35; } - 40% { transform: scale(1); opacity: 1; } + 0%, + 80%, + 100% { + transform: scale(0.5); + opacity: 0.35; + } + 40% { + transform: scale(1); + opacity: 1; + } } /* ===== 识别结果文字(电脑回传) ===== */ @@ -494,7 +568,7 @@ body { display: flex; flex-direction: column; gap: 8px; - animation: fadeIn .25s ease; + animation: fadeIn 0.25s ease; } .result-text { padding: 12px 16px; @@ -525,10 +599,19 @@ body { padding: 8px 18px; border-radius: var(--r-lg); cursor: pointer; - transition: background .15s ease, color .15s ease; + transition: + background 0.15s ease, + color 0.15s ease; +} +.result-copy:active { + background: var(--accent-solid-bg); + color: var(--accent-solid-ink); +} +.result-copy.copied { + background: var(--ok-soft); + border-color: var(--ok); + color: var(--ok); } -.result-copy:active { background: var(--accent-solid-bg); color: var(--accent-solid-ink); } -.result-copy.copied { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); } /* ===== 提示文字 ===== */ .rec-tip { @@ -562,7 +645,7 @@ body { height: 24px; border-radius: 999px; background: var(--line-strong); - transition: background .2s ease; + transition: background 0.2s ease; flex: none; } .insert-track::after { @@ -574,8 +657,8 @@ body { height: 20px; border-radius: 50%; background: #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, .25); - transition: transform .2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transition: transform 0.2s ease; } .insert-switch:checked ~ .insert-track { background: var(--blue); @@ -585,9 +668,19 @@ body { } /* ===== 断线屏 ===== */ -.offline-icon { font-size: 48px; } -.offline-title { font-size: 20px; margin: 12px 0 6px; color: var(--ink); } -.offline-sub { color: var(--ink-3); font-size: 14px; margin: 0 0 8px; } +.offline-icon { + font-size: 48px; +} +.offline-title { + font-size: 20px; + margin: 12px 0 6px; + color: var(--ink); +} +.offline-sub { + color: var(--ink-3); + font-size: 14px; + margin: 0 0 8px; +} /* ===== 底部证书提示(固定) ===== */ .cert-tip { @@ -599,7 +692,7 @@ body { font-size: 12px; line-height: 1.5; color: var(--ink-4); - background: rgba(255, 255, 255, .92); + background: rgba(255, 255, 255, 0.92); backdrop-filter: blur(12px) saturate(160%); -webkit-backdrop-filter: blur(12px) saturate(160%); border-top: 0.5px solid var(--line); @@ -608,8 +701,19 @@ body { /* 小屏微调 */ @media (max-height: 640px) { - .brand { margin: 14px 0; } - .brand-logo-img { width: 56px; height: 56px; } - .record-btn { width: 148px; height: 148px; } - .record-btn-icon { width: 52px; height: 52px; } + .brand { + margin: 14px 0; + } + .brand-logo-img { + width: 56px; + height: 56px; + } + .record-btn { + width: 148px; + height: 148px; + } + .record-btn-icon { + width: 52px; + height: 52px; + } } diff --git a/openless-all/app/src-tauri/src/remote_server/lan_addresses.rs b/openless-all/app/src-tauri/src/remote_server/lan_addresses.rs deleted file mode 100644 index 23370a308..000000000 --- a/openless-all/app/src-tauri/src/remote_server/lan_addresses.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Read, Write}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; -use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -use uuid::Uuid; - -const CACHE_FILE: &str = "remote-input-ips-v1.txt"; -const MAX_CACHE_BYTES: u64 = 1024; -const MAX_CACHE_IPS: usize = 32; -const ENUMERATION_TIMEOUT: Duration = Duration::from_secs(1); -const ROUTE_DESTINATIONS: [&str; 4] = [ - "8.8.8.8:80", - "1.1.1.1:80", - "192.168.8.1:80", - "192.168.1.1:80", -]; - -static ENUMERATION_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct LanAddressSnapshot { - pub(crate) ips: Vec, - pub(crate) stale: bool, -} - -pub(crate) fn normalize_lan_ips(ips: I) -> Vec -where - I: IntoIterator, -{ - let mut normalized = ips.into_iter().filter(is_private_lan).collect::>(); - normalized.sort_unstable(); - normalized.dedup(); - normalized.truncate(MAX_CACHE_IPS); - normalized -} - -pub(crate) fn discover_lan_addresses(config_dir: Option<&Path>) -> LanAddressSnapshot { - let live_ips = enumerate_interfaces_with_timeout(); - if let Some(ips) = live_ips.as_ref().filter(|ips| !ips.is_empty()) { - if let Some(dir) = config_dir { - if let Err(error) = persist_cached_ips(dir, ips) { - log::warn!("[remote-input] persist LAN IP cache failed: {error}"); - } - } - } - let cached_ips = if live_ips.as_ref().is_some_and(|ips| !ips.is_empty()) { - None - } else { - config_dir.and_then(load_cached_ips) - }; - let route_ips = if live_ips.as_ref().is_some_and(|ips| !ips.is_empty()) - || cached_ips.as_ref().is_some_and(|ips| !ips.is_empty()) - { - Vec::new() - } else { - local_lan_ipv4s_from_route() - }; - let snapshot = choose_snapshot(live_ips, cached_ips, route_ips); - if snapshot.stale { - log::info!("[remote-input] using cached LAN IPs after interface discovery timeout"); - } - snapshot -} - -fn choose_snapshot( - live_ips: Option>, - cached_ips: Option>, - route_ips: Vec, -) -> LanAddressSnapshot { - if let Some(ips) = live_ips.filter(|ips| !ips.is_empty()) { - return LanAddressSnapshot { ips, stale: false }; - } - if let Some(ips) = cached_ips.filter(|ips| !ips.is_empty()) { - return LanAddressSnapshot { ips, stale: true }; - } - LanAddressSnapshot { - ips: normalize_lan_ips(route_ips), - stale: false, - } -} - -fn is_private_lan(ip: &Ipv4Addr) -> bool { - let octets = ip.octets(); - !ip.is_loopback() - && !ip.is_link_local() - && ((octets[0] == 192 && octets[1] == 168) - || octets[0] == 10 - || (octets[0] == 172 && (16..=31).contains(&octets[1]))) -} - -fn enumerate_interfaces_with_timeout() -> Option> { - if ENUMERATION_IN_FLIGHT - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_err() - { - return None; - } - - let (tx, rx) = mpsc::sync_channel(1); - let worker = thread::Builder::new() - .name("openless-lan-addresses".to_string()) - .spawn(move || { - struct ResetInFlight; - - impl Drop for ResetInFlight { - fn drop(&mut self) { - ENUMERATION_IN_FLIGHT.store(false, Ordering::Release); - } - } - - let _reset = ResetInFlight; - let result = enumerate_interfaces(); - let _ = tx.send(result); - }); - - if worker.is_err() { - ENUMERATION_IN_FLIGHT.store(false, Ordering::Release); - return None; - } - - match rx.recv_timeout(ENUMERATION_TIMEOUT) { - Ok(Ok(ips)) if !ips.is_empty() => Some(ips), - Ok(Ok(_)) | Ok(Err(_)) | Err(_) => None, - } -} - -fn enumerate_interfaces() -> Result, String> { - let ifaces = local_ip_address::list_afinet_netifas().map_err(|error| error.to_string())?; - let ips = ifaces.into_iter().filter_map(|(_, ip)| match ip { - IpAddr::V4(ip) => Some(ip), - IpAddr::V6(_) => None, - }); - Ok(normalize_lan_ips(ips)) -} - -fn local_lan_ipv4s_from_route() -> Vec { - let mut ips = Vec::new(); - for destination in ROUTE_DESTINATIONS { - let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else { - continue; - }; - let _ = socket.set_write_timeout(Some(Duration::from_millis(200))); - if socket.connect(destination).is_err() { - continue; - } - if let Ok(SocketAddr::V4(address)) = socket.local_addr() { - ips.push(*address.ip()); - } - } - normalize_lan_ips(ips) -} - -fn cache_path(config_dir: &Path) -> std::path::PathBuf { - config_dir.join(CACHE_FILE) -} - -fn load_cached_ips(config_dir: &Path) -> Option> { - let path = cache_path(config_dir); - let mut file = File::open(path).ok()?; - let mut contents = Vec::new(); - std::io::Read::by_ref(&mut file) - .take(MAX_CACHE_BYTES + 1) - .read_to_end(&mut contents) - .ok()?; - if contents.len() as u64 > MAX_CACHE_BYTES { - return None; - } - parse_cached_ips(&contents).ok() -} - -fn parse_cached_ips(contents: &[u8]) -> Result, String> { - let text = std::str::from_utf8(contents).map_err(|_| "cache is not UTF-8".to_string())?; - let mut ips = Vec::new(); - for line in text.lines() { - if line.trim().is_empty() { - return Err("cache contains an empty line".to_string()); - } - if ips.len() >= MAX_CACHE_IPS { - return Err("cache contains too many addresses".to_string()); - } - let ip = line - .trim() - .parse::() - .map_err(|_| "cache contains an invalid IPv4 address".to_string())?; - if !is_private_lan(&ip) { - return Err("cache contains a non-private IPv4 address".to_string()); - } - ips.push(ip); - } - let normalized = normalize_lan_ips(ips); - if normalized.is_empty() { - return Err("cache contains no LAN addresses".to_string()); - } - Ok(normalized) -} - -fn persist_cached_ips(config_dir: &Path, ips: &[Ipv4Addr]) -> io::Result<()> { - fs::create_dir_all(config_dir)?; - let path = cache_path(config_dir); - let temp_path = path.with_file_name(format!(".{CACHE_FILE}.tmp-{}", Uuid::new_v4().simple())); - let result = (|| { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(&temp_path)?; - for ip in ips { - writeln!(file, "{ip}")?; - } - file.sync_all()?; - drop(file); - replace_cache_file(&temp_path, &path) - })(); - if result.is_err() { - let _ = fs::remove_file(&temp_path); - } - result -} - -#[cfg(unix)] -fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> { - fs::rename(temp_path, path) -} - -#[cfg(target_os = "windows")] -fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> { - use windows::core::PCWSTR; - use windows::Win32::Storage::FileSystem::{ - MoveFileExW, ReplaceFileW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - REPLACE_FILE_FLAGS, - }; - - let replacement = windows_wide_path(temp_path); - let destination = windows_wide_path(path); - if path.exists() { - let backup = - path.with_file_name(format!(".{CACHE_FILE}.backup-{}", Uuid::new_v4().simple())); - let backup_wide = windows_wide_path(&backup); - let replaced = unsafe { - ReplaceFileW( - PCWSTR(destination.as_ptr()), - PCWSTR(replacement.as_ptr()), - PCWSTR(backup_wide.as_ptr()), - REPLACE_FILE_FLAGS(0), - None, - None, - ) - }; - replaced.map_err(windows_io_error)?; - let _ = fs::remove_file(backup); - Ok(()) - } else { - unsafe { - MoveFileExW( - PCWSTR(replacement.as_ptr()), - PCWSTR(destination.as_ptr()), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - } - .map_err(windows_io_error) - } -} - -#[cfg(not(any(unix, target_os = "windows")))] -fn replace_cache_file(temp_path: &Path, path: &Path) -> io::Result<()> { - fs::rename(temp_path, path) -} - -#[cfg(target_os = "windows")] -fn windows_wide_path(path: &Path) -> Vec { - use std::os::windows::ffi::OsStrExt; - - path.as_os_str().encode_wide().chain(Some(0)).collect() -} - -#[cfg(target_os = "windows")] -fn windows_io_error(error: windows::core::Error) -> io::Error { - io::Error::new(io::ErrorKind::Other, error) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_lan_ips_filters_private_addresses_and_deduplicates() { - let actual = normalize_lan_ips([ - Ipv4Addr::new(192, 168, 1, 20), - Ipv4Addr::new(8, 8, 8, 8), - Ipv4Addr::new(10, 0, 0, 2), - Ipv4Addr::new(192, 168, 1, 20), - Ipv4Addr::new(172, 16, 4, 9), - Ipv4Addr::new(127, 0, 0, 1), - ]); - - assert_eq!( - actual, - vec![ - Ipv4Addr::new(10, 0, 0, 2), - Ipv4Addr::new(172, 16, 4, 9), - Ipv4Addr::new(192, 168, 1, 20), - ] - ); - } - - #[test] - fn parse_cached_ips_rejects_public_or_malformed_entries() { - assert!(parse_cached_ips(b"8.8.8.8\n").is_err()); - assert!(parse_cached_ips(b"not-an-ip\n").is_err()); - assert!(parse_cached_ips(b"10.0.0.2\n\n10.0.0.3\n").is_err()); - } - - #[test] - fn parse_cached_ips_accepts_bounded_private_entries() { - assert_eq!( - parse_cached_ips(b"192.168.1.20\n10.0.0.2\n192.168.1.20\n").unwrap(), - vec![Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(192, 168, 1, 20)] - ); - } - - #[test] - fn choose_snapshot_prefers_live_then_cache_then_route() { - let live = vec![Ipv4Addr::new(10, 0, 0, 2)]; - let cached = vec![Ipv4Addr::new(192, 168, 1, 20)]; - let route = vec![Ipv4Addr::new(172, 16, 0, 3)]; - - assert_eq!( - choose_snapshot(Some(live.clone()), Some(cached.clone()), route.clone()), - LanAddressSnapshot { - ips: live, - stale: false, - } - ); - assert_eq!( - choose_snapshot(None, Some(cached.clone()), route.clone()), - LanAddressSnapshot { - ips: cached, - stale: true, - } - ); - assert_eq!( - choose_snapshot(None, None, route.clone()), - LanAddressSnapshot { - ips: route, - stale: false, - } - ); - assert_eq!( - choose_snapshot(None, None, Vec::new()), - LanAddressSnapshot { - ips: Vec::new(), - stale: false, - } - ); - } - - #[test] - fn cache_rejects_more_than_the_maximum_number_of_entries() { - let contents = (0..=MAX_CACHE_IPS) - .map(|_| "10.0.0.2") - .collect::>() - .join("\n"); - assert!(parse_cached_ips(contents.as_bytes()).is_err()); - } - - #[test] - fn oversized_cache_file_is_rejected_before_parsing() { - let directory = std::env::temp_dir().join(format!( - "openless-lan-addresses-oversized-{}", - Uuid::new_v4().simple() - )); - fs::create_dir_all(&directory).unwrap(); - fs::write( - cache_path(&directory), - vec![b'1'; (MAX_CACHE_BYTES + 1) as usize], - ) - .unwrap(); - - assert_eq!(load_cached_ips(&directory), None); - let _ = fs::remove_dir_all(directory); - } - - #[test] - fn cached_ips_round_trip_through_atomic_file() { - let directory = std::env::temp_dir().join(format!( - "openless-lan-addresses-test-{}", - Uuid::new_v4().simple() - )); - let ips = vec![Ipv4Addr::new(10, 0, 0, 2), Ipv4Addr::new(192, 168, 1, 20)]; - - persist_cached_ips(&directory, &ips).unwrap(); - - assert_eq!(load_cached_ips(&directory), Some(ips)); - let _ = fs::remove_dir_all(directory); - } -} diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 7eb784034..12909add9 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -963,7 +963,7 @@ mod macos_ax { buffer: *mut c_char, buffer_size: isize, encoding: u32, - ) -> bool; + ) -> u8; fn CFStringGetLength(s: CFStringRef) -> isize; fn CFStringGetMaximumSizeForEncoding(length: isize, encoding: u32) -> isize; } @@ -1053,7 +1053,8 @@ mod macos_ax { max_bytes, K_CF_STRING_ENCODING_UTF8, ); - if !ok { + // CoreFoundation's Boolean is an unsigned byte in the SDK ABI. + if ok == 0 { return None; } let cstr = CStr::from_ptr(buf.as_ptr() as *const c_char); diff --git a/openless-all/app/src-tauri/src/windows_ime_target.rs b/openless-all/app/src-tauri/src/windows_ime_target.rs index 7ada4a242..050593a80 100644 --- a/openless-all/app/src-tauri/src/windows_ime_target.rs +++ b/openless-all/app/src-tauri/src/windows_ime_target.rs @@ -55,8 +55,6 @@ mod tests { #[test] fn null_window_is_not_a_capture_target() { - assert!(!hwnd_is_present( - windows::Win32::Foundation::HWND::default() - )); + assert!(!hwnd_is_present(windows::Win32::Foundation::HWND::default())); } } diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index bc56288ff..e2e639c68 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ "shadow": true, "hiddenTitle": true, "titleBarStyle": "Overlay", - "trafficLightPosition": { "x": 14, "y": 20 }, + "trafficLightPosition": { "x": 20, "y": 20 }, "visible": false, "acceptFirstMouse": true }, diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 55629f36d..ad111ce67 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useState } from 'react'; import { Capsule } from './components/Capsule'; +import { CoreStartupScreen } from './components/CoreStartupScreen'; import { GlobalDownloadProgress } from './components/GlobalDownloadProgress'; import { detectOS, type OS } from './components/WindowChrome'; import { @@ -14,42 +15,41 @@ import { qaWindowDismiss, } from './lib/ipc'; import type { PlatformCapabilities } from './lib/types'; -import { - isWindowHotkeyKeyboardCandidate, - windowMouseHotkeyCode, -} from './lib/windowHotkeyFallback'; +import { isWindowHotkeyKeyboardCandidate, windowMouseHotkeyCode } from './lib/windowHotkeyFallback'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; -// 各窗口/重页面懒加载,让每个 webview 只下载并解析自己用到的那部分代码。原本所有窗口 -// (主设置 / 胶囊 / QA / Less Computer / glow)共用一个打包产物,导致 5 个常驻 WebKit -// 进程都把整套设置 UI(FloatingShell + Style/Marketplace/LocalAsr…)和聊天面板加载进来, -// 常驻内存离谱。拆开后胶囊/glow 这类轻窗口不再加载设置/聊天代码。胶囊保持 eager: -// 它是听写实时反馈、对首帧延迟敏感,且体积很小。 +// 各 WebView 按窗口用途懒加载页面,减少常驻内存。 +// 胶囊承担录音的即时反馈,体积较小且对首帧延迟敏感,因此保持直接导入。 const AutoUpdateGate = lazy(() => - import('./components/AutoUpdateGate').then(m => ({ default: m.AutoUpdateGate })), + import('./components/AutoUpdateGate').then((m) => ({ default: m.AutoUpdateGate })), ); const FloatingShell = lazy(() => - import('./components/FloatingShell').then(m => ({ default: m.FloatingShell })), + import('./components/FloatingShell').then((m) => ({ default: m.FloatingShell })), ); const Onboarding = lazy(() => - import('./components/Onboarding').then(m => ({ default: m.Onboarding })), + import('./components/Onboarding').then((m) => ({ default: m.Onboarding })), +); +const QaPanel = lazy(() => import('./pages/QaPanel').then((m) => ({ default: m.QaPanel }))); +const SelectionPolishPreview = lazy(() => + import('./pages/SelectionPolishPreview').then((m) => ({ default: m.SelectionPolishPreview })), +); +const SelectionVoiceIntentPicker = lazy(() => + import('./pages/SelectionVoiceIntentPicker').then((m) => ({ + default: m.SelectionVoiceIntentPicker, + })), ); -const QaPanel = lazy(() => import('./pages/QaPanel').then(m => ({ default: m.QaPanel }))); -const SelectionPolishPreview = lazy(() => import('./pages/SelectionPolishPreview').then(m => ({ default: m.SelectionPolishPreview }))); -const SelectionVoiceIntentPicker = lazy(() => import('./pages/SelectionVoiceIntentPicker').then(m => ({ default: m.SelectionVoiceIntentPicker }))); // Tauri 的 Less Computer 面板同时面向 macOS 和 Windows;Linux 由原生 egui 提供。 // TAURI_ENV_PLATFORM 是编译期字面量,不支持该 WebView 的平台可裁掉对应 import, // 避免把不能显示的面板 chunk 带入移动端构建。 // 纯浏览器 vite 环境(预览/调样式)没有该变量 → 保持可加载。 const TAURI_BUILD_PLATFORM: string | undefined = import.meta.env.TAURI_ENV_PLATFORM; -const LESS_COMPUTER_BUNDLED = !TAURI_BUILD_PLATFORM - || TAURI_BUILD_PLATFORM === 'darwin' - || TAURI_BUILD_PLATFORM === 'windows'; +const LESS_COMPUTER_BUNDLED = + !TAURI_BUILD_PLATFORM || TAURI_BUILD_PLATFORM === 'darwin' || TAURI_BUILD_PLATFORM === 'windows'; const LessComputerPanel = LESS_COMPUTER_BUNDLED - ? lazy(() => import('./pages/LessComputerPanel').then(m => ({ default: m.LessComputerPanel }))) + ? lazy(() => import('./pages/LessComputerPanel').then((m) => ({ default: m.LessComputerPanel }))) : null; const LessComputerGlow = LESS_COMPUTER_BUNDLED - ? lazy(() => import('./pages/LessComputerGlow').then(m => ({ default: m.LessComputerGlow }))) + ? lazy(() => import('./pages/LessComputerGlow').then((m) => ({ default: m.LessComputerGlow }))) : null; interface AppProps { @@ -78,7 +78,7 @@ export function App(props: AppProps) { if (!isTauri) return; void getStartupSnapshot() .then(() => setReady(true)) - .catch(reason => { + .catch((reason) => { const detail = reason instanceof Error ? reason.message : String(reason); console.error('[startup] backend contract handshake failed', reason); setError(detail); @@ -86,15 +86,25 @@ export function App(props: AppProps) { }, []); if (error) { - return
OpenLess Core 无法启动或版本不兼容。 {error}
; + return ( + + ); } if (!ready) { - return
正在检查 OpenLess Core 兼容性…
; + return ; } return ; } -function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceIntent, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) { +function ReadyApp({ + isCapsule, + isQa, + isSelectionPolishPreview, + isSelectionVoiceIntent, + isLessComputer, + isLessComputerGlow, + forcedOs, +}: AppProps) { if (isCapsule) { return ; } @@ -106,10 +116,18 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI ); } if (isSelectionPolishPreview) { - return ; + return ( + + + + ); } if (isSelectionVoiceIntent) { - return ; + return ( + + + + ); } if (isLessComputer) { return LessComputerPanel ? ( @@ -143,7 +161,7 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI void getStartupSnapshot() .then(() => getPlatformCapabilities()) .then(setPlatformCaps) - .catch(error => { + .catch((error) => { const detail = error instanceof Error ? error.message : String(error); console.error('[startup] backend contract handshake failed', error); setStartupError(detail); @@ -190,7 +208,9 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI window.history.pushState({ openlessQa: true }, '', window.location.href); const onPopState = () => { setMobileQaOpen(false); - void qaWindowDismiss().catch(error => console.warn('[qa] mobile back dismiss failed', error)); + void qaWindowDismiss().catch((error) => + console.warn('[qa] mobile back dismiss failed', error), + ); }; window.addEventListener('popstate', onPopState); return () => { @@ -223,7 +243,11 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI // / 透明主窗口。首次安装的"prefs 不存在"场景不走这里 —— Rust 端会返回 // 默认 UserPreferences。 const detail = err instanceof Error ? err.message : String(err); - console.warn('[startup] read startMinimized failed; staying hidden to avoid #468:', detail, err); + console.warn( + '[startup] read startMinimized failed; staying hidden to avoid #468:', + detail, + err, + ); return; } const { getCurrentWindow } = await import('@tauri-apps/api/window'); @@ -232,7 +256,7 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI if (!(await currentWindow.isVisible())) { await currentWindow.show(); } - })().catch(error => console.warn('[startup] show main window failed', error)); + })().catch((error) => console.warn('[startup] show main window failed', error)); }); return () => { cancelled = true; @@ -275,11 +299,11 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI setGate('ready'); return; } - await new Promise(resolve => window.setTimeout(resolve, POLL_INTERVAL_MS)); + await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS)); } if (!cancelled) { console.warn( - `[startup] hotkey gate timed out after ${POLL_MAX_ATTEMPTS * POLL_INTERVAL_MS}ms; forcing ready so user can reach Permissions page` + `[startup] hotkey gate timed out after ${POLL_MAX_ATTEMPTS * POLL_INTERVAL_MS}ms; forcing ready so user can reach Permissions page`, ); setGate('ready'); } @@ -296,7 +320,7 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI // 让用户进应用后在权限页看到“未检测到麦克风”的明确提示。见 issue #779。 const mOk = m === 'granted' || m === 'notApplicable' || m === 'noDevice'; setGate(aOk && mOk ? 'ready' : 'onboarding'); - })().catch(error => { + })().catch((error) => { console.warn('[startup] permission gate failed', error); if (!cancelled) { setGate('ready'); @@ -317,7 +341,7 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI event.key, event.code, event.repeat, - ).catch(error => console.warn('[window-hotkey] forward failed', error)); + ).catch((error) => console.warn('[window-hotkey] forward failed', error)); }; const forwardMouse = (event: MouseEvent) => { const code = windowMouseHotkeyCode(event.button); @@ -327,7 +351,7 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI code, code, false, - ).catch(error => console.warn('[window-hotkey] mouse forward failed', error)); + ).catch((error) => console.warn('[window-hotkey] mouse forward failed', error)); }; window.addEventListener('keydown', forwardKey, true); window.addEventListener('keyup', forwardKey, true); @@ -342,14 +366,10 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI }, [os]); if (gate === 'checking') { - return
正在检查 OpenLess Core 兼容性…
; + return ; } if (gate === 'incompatible') { - return ( -
- OpenLess Core 无法启动或版本不兼容。{startupError ? ` ${startupError}` : ''} -
- ); + return ; } return ( @@ -370,11 +390,12 @@ function ReadyApp({ isCapsule, isQa, isSelectionPolishPreview, isSelectionVoiceI /> )} - {!mobileQaOpen && (gate === 'onboarding' ? ( - - ) : ( - - ))} + {!mobileQaOpen && + (gate === 'onboarding' ? ( + + ) : ( + + ))} {gate === 'ready' && platformCaps?.supportsAutoUpdate === true && } diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index ddcbd4a66..84e835dd7 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -35,7 +35,9 @@ export function AudioCueListener() { // 读取失败保持默认 true } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [audioCueRuntimeEnabled]); // 监听设置变更 @@ -48,9 +50,17 @@ export function AudioCueListener() { listen('prefs:changed', (event) => { const next = event.payload; if (next) audioCueEnabledRef.current = next.audioCueOnRecord !== false; - }).then(fn => { if (cancelled) fn(); else unlisten = fn; }).catch(() => {}); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => {}); })(); - return () => { cancelled = true; unlisten?.(); }; + return () => { + cancelled = true; + unlisten?.(); + }; }, [audioCueRuntimeEnabled]); // 预热 AudioContext @@ -75,9 +85,17 @@ export function AudioCueListener() { } else if (state !== 'recording' && prev === 'recording') { stopAudioCue(); } - }).then(fn => { if (cancelled) fn(); else unlisten = fn; }).catch(() => {}); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => {}); })(); - return () => { cancelled = true; unlisten?.(); }; + return () => { + cancelled = true; + unlisten?.(); + }; }, [audioCueRuntimeEnabled]); return null; diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 11faeead8..c91f65422 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -79,9 +79,10 @@ export function useAutoUpdate(): UseAutoUpdate { const checking = status === 'checking'; const busy = status === 'downloading' || status === 'installing'; - const progress = contentLength && contentLength > 0 - ? Math.min(100, Math.round((downloaded / contentLength) * 100)) - : null; + const progress = + contentLength && contentLength > 0 + ? Math.min(100, Math.round((downloaded / contentLength) * 100)) + : null; const closeUpdate = async () => { const current = updateRef.current; @@ -97,7 +98,9 @@ export function useAutoUpdate(): UseAutoUpdate { }; useEffect(() => { - return () => { void closeUpdate(); }; + return () => { + void closeUpdate(); + }; }, []); useEffect(() => { @@ -149,10 +152,7 @@ export function useAutoUpdate(): UseAutoUpdate { setStatus('none'); return; } - const metadata = await appCheckUpdateWithChannel( - UPDATE_CHECK_TIMEOUT_MS, - channel ?? null, - ); + const metadata = await appCheckUpdateWithChannel(UPDATE_CHECK_TIMEOUT_MS, channel ?? null); if (!metadata) { setStatus('none'); return; @@ -167,7 +167,9 @@ export function useAutoUpdate(): UseAutoUpdate { const url = typeof raw.url === 'string' ? raw.url : ''; const signature = typeof raw.signature === 'string' ? raw.signature : ''; if (!url || !signature) { - console.warn('[auto-update] android manifest missing url/signature, falling back to manual update'); + console.warn( + '[auto-update] android manifest missing url/signature, falling back to manual update', + ); setStatus('available'); return; } @@ -232,7 +234,7 @@ export function useAutoUpdate(): UseAutoUpdate { resetProgress(); setContentLength(event.data.contentLength ?? null); } else if (event.event === 'Progress') { - setDownloaded(value => value + event.data.chunkLength); + setDownloaded((value) => value + event.data.chunkLength); } else if (event.event === 'Finished') { setStatus('installing'); } @@ -274,8 +276,16 @@ export function useAutoUpdate(): UseAutoUpdate { }; } -export function isDialogStatus(status: UpdateStatus): status is 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError' { - return status === 'available' || status === 'downloading' || status === 'installing' || status === 'downloaded' || status === 'installError'; +export function isDialogStatus( + status: UpdateStatus, +): status is 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError' { + return ( + status === 'available' || + status === 'downloading' || + status === 'installing' || + status === 'downloaded' || + status === 'installError' + ); } export function UpdateDialog({ @@ -307,38 +317,125 @@ export function UpdateDialog({ // 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。 // portal 出去后遮罩铺满整窗,灰度均匀。0.05 极淡遮罩因此可以恢复正常遮罩透明度。 return createPortal( -
-
-
{t(`settings.about.updateDialog.${status}.title`)}
-
+
+
+
+ {t(`settings.about.updateDialog.${status}.title`)} +
+
{androidInstalled - ? t('settings.about.updateDialog.androidInstalled.desc', { version, defaultValue: '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。' }) + ? t('settings.about.updateDialog.androidInstalled.desc', { + version, + defaultValue: + '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。', + }) : installError - ? t('settings.about.updateDialog.installError.desc', { error: errorMessage || t('settings.about.updateError') }) + ? t('settings.about.updateDialog.installError.desc', { + error: errorMessage || t('settings.about.updateError'), + }) : t(`settings.about.updateDialog.${status}.desc`, { version })}
{(downloading || installing || status === 'downloaded') && (
-
-
+
+
{installing ? t('settings.about.updateDialog.installingLabel') : progress === null - ? t('settings.about.updateDialog.progressUnknown', { downloaded: formatBytes(downloaded) }) - : t('settings.about.updateDialog.progress', { progress, downloaded: formatBytes(downloaded), total: formatBytes(contentLength ?? 0) })} + ? t('settings.about.updateDialog.progressUnknown', { + downloaded: formatBytes(downloaded), + }) + : t('settings.about.updateDialog.progress', { + progress, + downloaded: formatBytes(downloaded), + total: formatBytes(contentLength ?? 0), + })}
)}
- {status === 'available' && {t('common.cancel')}} - {status === 'available' && {t('settings.about.updateDialog.install')}} - {(downloading || installing) && {installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}} - {status === 'downloaded' && {t('settings.about.updateDialog.later')}} - {status === 'downloaded' && !androidInstalled && {t('settings.about.updateDialog.restartNow')}} - {installError && {t('common.cancel')}} - {installError && void openExternal(RELEASE_DOWNLOAD_URL)}>{t('settings.about.updateDialog.manualDownload')}} + {status === 'available' && ( + + {t('common.cancel')} + + )} + {status === 'available' && ( + + {t('settings.about.updateDialog.install')} + + )} + {(downloading || installing) && ( + + {installing + ? t('settings.about.updateDialog.installingLabel') + : t('settings.about.updateDialog.downloadingLabel')} + + )} + {status === 'downloaded' && ( + + {t('settings.about.updateDialog.later')} + + )} + {status === 'downloaded' && !androidInstalled && ( + + {t('settings.about.updateDialog.restartNow')} + + )} + {installError && ( + + {t('common.cancel')} + + )} + {installError && ( + void openExternal(RELEASE_DOWNLOAD_URL)}> + {t('settings.about.updateDialog.manualDownload')} + + )}
, diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index e4051d9d2..0e349d889 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -33,9 +33,11 @@ export function AutoUpdateGate() { if (cancelled) return; const current = uRef.current; if (current.checking || current.busy || isDialogStatus(current.status)) return; - void current.checkForUpdates(undefined, { autoInstallAndroid: isAndroid() }).catch(error => { - console.warn('[auto-update] background check failed', error); - }); + void current + .checkForUpdates(undefined, { autoInstallAndroid: isAndroid() }) + .catch((error) => { + console.warn('[auto-update] background check failed', error); + }); }; const startupTimer = window.setTimeout(tick, STARTUP_DELAY_MS); diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index af89d3893..aa5befcd6 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -1,3 +1,4 @@ +import { Icon } from './Icon'; import { memo, useCallback, @@ -167,9 +168,7 @@ function VoiceOrbStage({ os, state, level, warming, warmupMs, message }: VoiceOr }} /> )} - {state === 'error' && ( - {message || t('capsule.error')} - )} + {state === 'error' && {message || t('capsule.error')}}
); } @@ -206,8 +205,9 @@ function SelectionPolishNotice({ state, message }: SelectionPolishNoticeProps) { const failed = state === 'error'; const completed = state === 'done'; const cancelled = state === 'cancelled'; - const label = message - ?? (processing + const label = + message ?? + (processing ? t('capsule.selectionPolish.polishing') : completed ? t('capsule.selectionPolish.replaced') @@ -323,7 +323,10 @@ function AudioBars({ level }: { level: number }) { const voice = Math.min(1, Math.max(0, level)); const silenceGate = 0.012; const responseCeiling = 0.34; - const gatedVoice = Math.min(1, Math.max(0, (voice - silenceGate) / (responseCeiling - silenceGate))); + const gatedVoice = Math.min( + 1, + Math.max(0, (voice - silenceGate) / (responseCeiling - silenceGate)), + ); const easedVoice = gatedVoice * gatedVoice * (3 - 2 * gatedVoice); const visualVoice = Math.pow(easedVoice, 0.42); return ( @@ -429,18 +432,11 @@ const CircleButton = memo(function CircleButton({ variant, enabled, onClick }: C flexShrink: 0, padding: 0, boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)', - transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', + transition: + 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }} > - {isCancel ? ( - - - - ) : ( - - - - )} + ); }); @@ -456,7 +452,16 @@ interface ClassicPillProps { onConfirm: () => void; } -function ClassicPill({ os, state, level, insertedChars, message, operating, onCancel, onConfirm }: ClassicPillProps) { +function ClassicPill({ + os, + state, + level, + insertedChars, + message, + operating, + onCancel, + onConfirm, +}: ClassicPillProps) { const { t } = useTranslation(); const metrics = classicPillMetrics(os); const processingLayout = useMemo(() => getCapsuleMessageLayout(os, 'processing'), [os]); @@ -536,13 +541,26 @@ function ClassicPill({ os, state, level, insertedChars, message, operating, onCa ); break; case 'done': - center = ; + center = ( + + ); break; case 'cancelled': center = ; break; case 'error': - center = ; + center = ( + + ); break; default: center = ; @@ -550,7 +568,7 @@ function ClassicPill({ os, state, level, insertedChars, message, operating, onCa const ambient = state === 'recording' ? Math.min(1, Math.max(0, level)) : 0; const scale = os === 'win' ? 1 : 1 + ambient * 0.018; - const shadowAlpha = 0.20 + ambient * 0.10; + const shadowAlpha = 0.2 + ambient * 0.1; return ( // 非 Linux 走假毛玻璃;Linux 禁用透明窗口后由 .ol-frost 平台规则退成不透明面。 @@ -573,12 +591,21 @@ function ClassicPill({ os, state, level, insertedChars, message, operating, onCa fontFamily: 'var(--ol-font-sans)', transform: `scale(${scale.toFixed(4)})`, transformOrigin: 'center', - transition: 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)', + transition: + 'transform 0.08s var(--ol-motion-quick), box-shadow 0.08s var(--ol-motion-quick)', willChange: 'transform, box-shadow', }} > -
+
{center}
@@ -631,9 +658,10 @@ function ClassicCapsule({ left: '50%', // macOS / Linux:pill 居中在 460×180 host,badge 锚到 pill 中线上方 21+8。 // Windows:pill 更高(52),badge 锚到 pill 上沿(bottomInset + height + gap)。 - bottom: os === 'win' - ? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px` - : 'calc(50% + 21px + 8px)', + bottom: + os === 'win' + ? `${hostMetrics.bottomInset + metrics.height + hostMetrics.badgeGap}px` + : 'calc(50% + 21px + 8px)', transform: 'translateX(-50%)', pointerEvents: 'none', }} @@ -736,7 +764,8 @@ function getPreviewCapsulePayload() { warming: params.get('warming') === '1', selectionPolish: params.get('selectionPolish') === '1', // 浏览器预览:?style=classic 直接看经典药丸。 - style: params.get('style') === 'classic' ? ('classic' as CapsuleStyle) : ('siri' as CapsuleStyle), + style: + params.get('style') === 'classic' ? ('classic' as CapsuleStyle) : ('siri' as CapsuleStyle), }; } @@ -792,8 +821,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { // Space 贴附、多屏定位一整串逻辑,加一个非会话状态进去只会污染它。 const [suggestions, setSuggestions] = useState([]); // 落字失败兜底卡片。与词条卡片同一套路:独立事件通道,不进会话状态机。 - const [insertFallback, setInsertFallback] = - useState(null); + const [insertFallback, setInsertFallback] = useState(null); // 前端 host 与原生窗口保持同一份透明语音 orb 舞台尺寸。 const hostMetrics = getCapsuleHostMetrics(os, translation); // Windows 端 host 用「host 高 − pill 高」把 pill 垂直居中;Siri 舞台 460×180 与 host @@ -819,7 +847,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { let cancelled = false; (async () => { const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen('capsule:state', event => { + const handle = await listen('capsule:state', (event) => { const p = event.payload; if (!capsuleStateFirstLogged) { capsuleStateFirstLogged = true; @@ -838,17 +866,17 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { if (p.insertedChars != null) insertedCharsRef.current = p.insertedChars; operatingRef.current = p.operating === true; }); - const transcriptHandle = await listen('backend:event', event => { + const transcriptHandle = await listen('backend:event', (event) => { const next = applyTranscriptEvent(transcriptViewRef.current, event.payload); transcriptViewRef.current = next; setLocalAsrText(next.text); }); - const suggestHandle = await listen('vocab:suggested', event => { + const suggestHandle = await listen('vocab:suggested', (event) => { setSuggestions(event.payload ?? []); }); const fallbackHandle = await listen( 'insert:fallback', - event => { + (event) => { setInsertFallback(event.payload ?? null); }, ); @@ -882,7 +910,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { let cancelled = false; (async () => { const { listen } = await import('@tauri-apps/api/event'); - const handle = await listen<{ capsuleStyle?: CapsuleStyle }>('prefs:changed', event => { + const handle = await listen<{ capsuleStyle?: CapsuleStyle }>('prefs:changed', (event) => { const next = event.payload?.capsuleStyle; if (next === 'siri' || next === 'classic') setCapsuleStyle(next); }); @@ -951,9 +979,13 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { warmStartRef.current = null; // 过滤异常样本:<20ms 多半不是真入场;>3s 多半首次 TCC / 卡顿,不代表常态。 if (loadMs < 20 || loadMs > 3000) return; - setWarmupMs(prev => { + setWarmupMs((prev) => { const next = Math.min(600, Math.max(60, prev * 0.7 + loadMs * 0.3)); - try { localStorage.setItem(WARMUP_MS_KEY, String(Math.round(next))); } catch { /* ignore */ } + try { + localStorage.setItem(WARMUP_MS_KEY, String(Math.round(next))); + } catch { + /* ignore */ + } return next; }); }, [warming]); @@ -978,14 +1010,13 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) { // 离场时用 lastVisibleState 渲染最后一帧内容,避免把 idle 当作无波形状态。 const renderedState: CapsuleState = state === 'idle' ? lastVisibleState : state; - const renderedSelectionPolish = state === 'idle' - ? lastVisibleSelectionPolish - : selectionPolish; - const renderedMessage = state === 'idle' - ? lastVisibleMessage - : state === 'transcribing' && localAsrText - ? localAsrText - : message; + const renderedSelectionPolish = state === 'idle' ? lastVisibleSelectionPolish : selectionPolish; + const renderedMessage = + state === 'idle' + ? lastVisibleMessage + : state === 'transcribing' && localAsrText + ? localAsrText + : message; return (
- {!renderedSelectionPolish && (isClassic ? ( - - ) : ( - <> - {/* "正在翻译" 徽章 — 嵌套两层: + {!renderedSelectionPolish && + (isClassic ? ( + + ) : ( + <> + {/* "正在翻译" 徽章 — 嵌套两层: 外层只负责"绝对定位 + 水平居中(translateX(-50%))",不参与动画; 内层只负责"垂直位移 + 渐变透明度"——这样不会跟 translateX(-50%) 冲突, 也不存在 keyframe 与 inline transform 互相覆盖导致的视觉跳变。 */} -
-
- - {t('capsule.translating')} -
-
- - - ))} +
+
+ + {t('capsule.translating')} +
+
+ + + ))} {renderedSelectionPolish && ( )} diff --git a/openless-all/app/src/components/CoreStartupScreen.tsx b/openless-all/app/src/components/CoreStartupScreen.tsx new file mode 100644 index 000000000..d7c6ab18f --- /dev/null +++ b/openless-all/app/src/components/CoreStartupScreen.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next'; +import { Icon } from './Icon'; + +/** All windows keep the same contract gate; only its presentation varies by available space. */ +export function CoreStartupScreen({ + error, + compact = false, +}: { + error?: string | null; + compact?: boolean; +}) { + const { t } = useTranslation(); + return ( +
+
+
+ {!compact && } + {t(error ? 'startup.failed' : 'startup.loading')} +
+ {!compact && ( + <> +

+ {t(error ? 'startup.recovery' : 'startup.loadingDesc')} +

+ {error && ( + <> + +
+ {t('startup.details')} +
+                    {error}
+                  
+
+ + )} + + )} +
+
+ ); +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 522a371ce..24c978ad8 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -1,15 +1,12 @@ -// FloatingShell.tsx — frosted outer frame + raised inner console. -// Sidebar lives INSIDE the console card. -// Settings opens as a centered modal sheet from the sidebar bottom entry. -// -// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell. +// 主窗口外壳:组织侧栏导航、页面切换、移动端面板和设置弹窗。 +// 各页面共享偏好状态;业务读写通过 typed IPC 交给 Core。 -import { useEffect, useMemo, useState, type ComponentType, type CSSProperties } from 'react'; +import { useEffect, useRef, useState, type ComponentType, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { Tooltip } from './Tooltip'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; -import { AudioCueListener } from "./AudioCue"; +import { AudioCueListener } from './AudioCue'; import { SettingsModal } from './SettingsModal'; import { Overview } from '../pages/Overview'; import { History } from '../pages/History'; @@ -18,9 +15,7 @@ import { Style } from '../pages/Style'; import { Marketplace } from '../pages/Marketplace'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; -// 风格市场(Marketplace)现在是侧栏「风格」展开组下的独立页面(不再是 Style 页面内 modal)。 -// LocalAsr 不再作为主 nav tab——本地 ASR 模型管理已合并到 Settings → Advanced 中 -// 通过 渲染。这里之前的 import 与 NAV_BASE 条目都已移除。 +import { Corrections } from '../pages/Corrections'; import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, @@ -28,6 +23,7 @@ import { shouldShowHotkeyModeMigrationPrompt, } from '../lib/hotkeyMigration'; import { applyFontScale, readFontScale } from '../lib/fontScale'; +import { useExitMount } from '../lib/useExitMount'; import { getCredentials } from '../lib/ipc'; import { PROVIDER_SETUP_PROMPT_DEFERRED_KEY, @@ -43,12 +39,13 @@ import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; -const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk']; +const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections']; const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace']; -/** macOS 上侧栏顶部需让开原生红绿灯的高度(红绿灯竖直落在 ~6–22px)。 */ -const MAC_TRAFFIC_LIGHT_CLEARANCE = 30; -const SIDEBAR_WIDTH = 188; +/** macOS 上侧栏顶部需让开原生红绿灯的高度。红绿灯在 (20,20),按钮组高约 14px, + * 上下留等距 20px → 避让带总高 54px,红绿灯恰好竖直居中、左缘与导航图标列对齐。 */ +const MAC_TRAFFIC_LIGHT_CLEARANCE = 54; +const SIDEBAR_WIDTH = 282; /** tab → 页面组件映射(渲染主内容用;与侧栏树解耦,含未直接列在树上的页)。 */ const PAGE_CMP: Record, ComponentType> = { @@ -59,12 +56,10 @@ const PAGE_CMP: Record, ComponentType> = { marketplace: Marketplace, translation: Translation, selectionAsk: SelectionAsk, + corrections: Corrections, }; -/** 侧栏导航树:扁平项 + 可展开分组(用户拍板的结构)。 - * - 概览 / 历史 / 词汇:扁平项,位置不变。 - * - 风格:展开组 → 润色模式(=style 页) + 风格市场(=marketplace 页,原为 Style 内 modal)。 - * - 工具:展开组 → 划词追问 + 翻译(原本两个扁平项合并成一个可展开分组)。 */ +/** 主导航分为直接入口与可展开分组;页面组件由 PAGE_CMP 统一解析。 */ type NavNode = | { kind: 'item'; id: AppTab; icon: string } | { kind: 'group'; key: string; icon: string; children: Array<{ id: AppTab }> }; @@ -73,8 +68,18 @@ const NAV_TREE: NavNode[] = [ { kind: 'item', id: 'overview', icon: 'overview' }, { kind: 'item', id: 'history', icon: 'history' }, { kind: 'item', id: 'vocab', icon: 'vocab' }, - { kind: 'group', key: 'style', icon: 'style', children: [{ id: 'style' }, { id: 'marketplace' }] }, - { kind: 'group', key: 'tools', icon: 'selectionAsk', children: [{ id: 'selectionAsk' }, { id: 'translation' }] }, + { + kind: 'group', + key: 'style', + icon: 'style', + children: [{ id: 'style' }, { id: 'marketplace' }], + }, + { + kind: 'group', + key: 'tools', + icon: 'selectionAsk', + children: [{ id: 'selectionAsk' }, { id: 'translation' }, { id: 'corrections' }], + }, ]; interface FloatingShellProps { @@ -83,7 +88,11 @@ interface FloatingShellProps { initialSettings?: boolean; } -export function FloatingShell({ os: osProp, initialTab = 'overview', initialSettings = false }: FloatingShellProps) { +export function FloatingShell({ + os: osProp, + initialTab = 'overview', + initialSettings = false, +}: FloatingShellProps) { const os = osProp ?? detectOS(); return ( @@ -92,17 +101,40 @@ export function FloatingShell({ os: osProp, initialTab = 'overview', initialSett ); } -function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initialTab: AppTab; initialSettings: boolean }) { +function FloatingShellBody({ + os, + initialTab, + initialSettings, +}: { + os: OS; + initialTab: AppTab; + initialSettings: boolean; +}) { const { t } = useTranslation(); const mobile = useMobileLayout(); const conservative = useConservativeLayout(); const { prefs } = useHotkeySettings(); - const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState(initialTab, initialSettings); - const [settingsInitialSection, setSettingsInitialSection] = useState(); + const { currentTab, setCurrentTab, settingsOpen, setSettingsOpen } = useAppState( + initialTab, + initialSettings, + ); + const [settingsInitialSection, setSettingsInitialSection] = useState< + SettingsSectionId | undefined + >(); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); + // 退出动画门:关闭时先反向播放入场动画再卸载。 + const settingsMount = useExitMount(settingsOpen, 220); + const providerPromptMount = useExitMount(providerPromptOpen); + const hotkeyPromptMount = useExitMount(hotkeyModePromptOpen); const [moreOpen, setMoreOpen] = useState(false); const [styleOpen, setStyleOpen] = useState(false); + const shellRef = useRef(null); + + // The dialog records and moves focus before its background becomes inert. + useEffect(() => { + if (shellRef.current) shellRef.current.inert = settingsOpen; + }, [settingsOpen]); // tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。 // displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。 @@ -133,7 +165,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia // 分组展开态:默认展开「当前所在页所属的分组」。用户点分组标题手动切换。 const groupOfTab = (tab: AppTab): string | null => { for (const node of NAV_TREE) { - if (node.kind === 'group' && node.children.some(c => c.id === tab)) return node.key; + if (node.kind === 'group' && node.children.some((c) => c.id === tab)) return node.key; } return null; }; @@ -144,9 +176,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia // 切到某分组内的页时自动展开该组(例如从胶囊直接跳到 marketplace)。 useEffect(() => { const active = groupOfTab(currentTab); - if (active) setOpenGroups(prev => (prev[active] ? prev : { ...prev, [active]: true })); + if (active) setOpenGroups((prev) => (prev[active] ? prev : { ...prev, [active]: true })); }, [currentTab]); - const toggleGroup = (key: string) => setOpenGroups(prev => ({ ...prev, [key]: !prev[key] })); + const toggleGroup = (key: string) => setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] })); useEffect(() => { let cancelled = false; @@ -171,7 +203,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia }, []); // 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线, - // 模型管理 UI 现在通过 Settings → Advanced 的 渲染, + // 模型管理 UI 现在通过 Settings → Services 的 渲染, // 用户在 Settings 内即可一站式管理,无需跨页跳转。 const rememberProviderPrompt = () => { @@ -191,17 +223,17 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia setStyleOpen(false); }; - // ⌘, 打开设置页面 + // 跟随平台的设置快捷键。 useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.metaKey && e.key === ',') { + if ((os === 'mac' ? e.metaKey : e.ctrlKey) && e.key === ',') { e.preventDefault(); openSettings(); } }; window.addEventListener('keydown', onKeyDown, true); return () => window.removeEventListener('keydown', onKeyDown, true); - }, []); + }, [os]); const openProviderSettings = () => { rememberProviderPrompt(); @@ -214,195 +246,229 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia openSettings('general'); }; - const mobileTitle = settingsOpen - ? t('shell.footer.settings') - : t(subItemLabelKey(currentTab)); + const mobileTitle = settingsOpen ? t('shell.footer.settings') : t(subItemLabelKey(currentTab)); const moreTabActive = MORE_TAB_IDS.includes(currentTab); const styleTabActive = STYLE_TAB_IDS.includes(currentTab); return ( - // 不再为 macOS 红绿灯预留顶部 28px 空条(用户反馈「块上方多一条丑横条」): - // 侧栏与内容块都顶到窗口最上沿,原生红绿灯直接浮在侧栏左上角的块面上。 - // 侧栏内 brand 行在 mac 上加 topClearance 让「OpenLess」避开红绿灯。 -
- + // 窗口内容从顶端铺开;macOS 仅在侧栏内部预留红绿灯区域。 +
{mobile && ( - openSettings()} - settingsActive={settingsOpen} - /> +
{ + if (element) element.inert = settingsOpen; + }} + style={{ display: 'contents' }} + > + openSettings()} + settingsActive={settingsOpen} + /> +
)} {/* Main shell — flush with the frosted backplate (no separate float). */}
- + }} + > {/* Sidebar — desktop / wide only。 */} {!mobile && ( - )} {/* Main content — 平铺实底块(用户反馈:右侧要一整块、不要圆角、别浮成卡)。 @@ -412,7 +478,8 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{displayTab === 'overview' ? ( - setCurrentTab('history')} /> + setCurrentTab('history')} + onOpenSettings={openSettings} + /> ) : (
- + {displayTab === 'selectionAsk' ? ( + openSettings('shortcuts')} /> + ) : ( + + )}
)}
@@ -478,33 +565,33 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia {mobile && ( <> {!settingsOpen && !styleOpen && !moreOpen && ( - { - setMoreOpen(false); - setStyleOpen(false); - setCurrentTab(id); - }} - onOpenStyle={() => { - setMoreOpen(false); - setStyleOpen(true); - }} - onOpenMore={() => { - setStyleOpen(false); - setMoreOpen(true); - }} - /> + { + setMoreOpen(false); + setStyleOpen(false); + setCurrentTab(id); + }} + onOpenStyle={() => { + setMoreOpen(false); + setStyleOpen(true); + }} + onOpenMore={() => { + setStyleOpen(false); + setMoreOpen(true); + }} + /> )} setStyleOpen(false)} - onSelectTab={id => { + onSelectTab={(id) => { setStyleOpen(false); setCurrentTab(id); }} @@ -519,23 +606,26 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia )} - {/* Settings modal — rendered inside this window */} - {settingsOpen && + {/* Settings modal — rendered inside this window;settingsMount 门控退场动画 */} + {settingsMount.mounted && ( setSettingsOpen(false)} /> - } + )} - {providerPromptOpen ? ( + {providerPromptMount.mounted ? ( - ) : hotkeyModePromptOpen ? ( + ) : hotkeyPromptMount.mounted ? ( @@ -718,7 +808,7 @@ function MobileBottomNav({ background: 'var(--ol-surface)', }} > - {MOBILE_BOTTOM_TABS.map(tab => { + {MOBILE_BOTTOM_TABS.map((tab) => { const active = !settingsOpen && currentTab === tab.id; return ( ); })} @@ -740,7 +832,9 @@ function MobileBottomNav({ style={mobileNavBtnStyle} > - {t('nav.group.style')} + + {t('nav.group.style')} + + > + × +
{/* 固定最小高度 —— 各阶段共用,窗口尺寸恒定。 */}
{phase.kind === 'starting' && ( -
+
{t('marketplace.oauth.generating')}
)} @@ -196,35 +230,66 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
{t('marketplace.oauth.browserHint', { uri: phase.verificationUri })}
-
- {phase.userCode} +
+ + {phase.userCode} + void copyCode()}> {copied ? t('marketplace.oauth.copied') : t('marketplace.oauth.copyBtn')}
- void openExternal(phase.verificationUri)}> + void openExternal(phase.verificationUri)} + > {t('marketplace.oauth.openBrowserBtn')} {t('marketplace.oauth.cancelBtn')}
-
- +
+ {t('marketplace.oauth.waiting')}
@@ -244,19 +309,27 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) {phase.kind === 'error' && (
-
+
{phase.message}
- {t('marketplace.oauth.closeBtn')} - void begin()}>{t('marketplace.oauth.retryBtn')} + + {t('marketplace.oauth.closeBtn')} + + void begin()}> + {t('marketplace.oauth.retryBtn')} +
)} diff --git a/openless-all/app/src/components/GlobalDownloadProgress.tsx b/openless-all/app/src/components/GlobalDownloadProgress.tsx index 2e4c370bd..5fcfb9d00 100644 --- a/openless-all/app/src/components/GlobalDownloadProgress.tsx +++ b/openless-all/app/src/components/GlobalDownloadProgress.tsx @@ -6,7 +6,7 @@ // 重渲染不会拖累它,它也不会让页面跟随每个进度事件重渲染。 // // 事件在 Core ModelStore 内已按 ≥150ms 节流, -// 进度条不会因高频 IPC 抽搐;这里只做展示 + 取消入口,不参与模型状态管理。 +// 进度条不会因高频 IPC 抖动;这里只做展示 + 取消入口,不参与模型状态管理。 // // portal 到 body 的原因与 DownloadDialog 相同:WindowChrome 根节点的常驻 // transform / will-change 会为 position:fixed 后代创建 containing block,不 @@ -49,33 +49,27 @@ export function GlobalDownloadProgress() { let cancelled = false; void (async () => { const { listen } = await import('@tauri-apps/api/event'); - const qwenOff = await listen( - 'local-asr-download-progress', - (e) => { - const p = e.payload; - const key = `qwen3:${p.modelId}`; - setItems((prev) => { - if (DOWNLOAD_TERMINAL_PHASES.has(p.phase)) { - const next = { ...prev }; - delete next[key]; - return next; - } - return { - ...prev, - [key]: { - key, - id: p.modelId, - name: p.modelId, - percent: - p.bytesTotal > 0 - ? (p.bytesDownloaded / p.bytesTotal) * 100 - : null, - engine: 'qwen3' as const, - }, - }; - }); - }, - ); + const qwenOff = await listen('local-asr-download-progress', (e) => { + const p = e.payload; + const key = `qwen3:${p.modelId}`; + setItems((prev) => { + if (DOWNLOAD_TERMINAL_PHASES.has(p.phase)) { + const next = { ...prev }; + delete next[key]; + return next; + } + return { + ...prev, + [key]: { + key, + id: p.modelId, + name: p.modelId, + percent: p.bytesTotal > 0 ? (p.bytesDownloaded / p.bytesTotal) * 100 : null, + engine: 'qwen3' as const, + }, + }; + }); + }); const sherpaOff = await listen( 'sherpa-onnx-asr-download-progress', (e) => { @@ -93,10 +87,7 @@ export function GlobalDownloadProgress() { key, id: p.modelId, name: p.modelId, - percent: - p.bytesTotal > 0 - ? (p.bytesDownloaded / p.bytesTotal) * 100 - : null, + percent: p.bytesTotal > 0 ? (p.bytesDownloaded / p.bytesTotal) * 100 : null, engine: 'sherpa' as const, }, }; @@ -136,9 +127,7 @@ export function GlobalDownloadProgress() { } else { unlistens = [qwenOff, sherpaOff, foundryOff]; } - })().catch((err) => - console.warn('[global-download-progress] subscribe failed', err), - ); + })().catch((err) => console.warn('[global-download-progress] subscribe failed', err)); return () => { cancelled = true; for (const off of unlistens) off(); @@ -209,9 +198,7 @@ export function GlobalDownloadProgress() { }} > - {item.percent != null - ? `${Math.round(item.percent)}%` - : t('localAsr.downloading')} + {item.percent != null ? `${Math.round(item.percent)}%` : t('localAsr.downloading')}
- {MORE_TABS.map(item => { + {MORE_TABS.map((item) => { const active = currentTab === item.id; return (
- {STYLE_TABS.map(item => { + {STYLE_TABS.map((item) => { const active = currentTab === item.id; return ( + )} +
+ ); return (
- + animation: mobile + ? undefined + : closing + ? 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft) reverse both' + : 'ol-modal-backdrop-in 0.2s var(--ol-motion-soft) both', + }} + >
e.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onKeyDown={handleKeyDown} style={{ width: '100%', - maxWidth: mobile ? undefined : 880, + maxWidth: mobile ? undefined : 1020, height: '100%', - maxHeight: mobile ? undefined : 600, + maxHeight: mobile ? undefined : 760, + minHeight: 0, background: 'var(--ol-settings-content-bg)', borderRadius: mobile ? 0 : 14, border: mobile ? 'none' : '0.5px solid var(--ol-line)', boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', display: 'flex', - flexDirection: mobile ? 'column' : 'row', + flexDirection: 'column', overflow: 'hidden', - animation: mobile ? undefined : 'ol-modal-card-in 0.24s var(--ol-motion-spring)', - position: 'relative', - }}> - - {mobile ? ( -
+ animation: mobile + ? closing + ? 'ol-mobile-sheet-up 0.22s var(--ol-motion-soft) reverse both' + : 'ol-mobile-sheet-up 0.26s var(--ol-motion-spring) both' + : closing + ? 'ol-modal-card-in 0.2s var(--ol-motion-soft) reverse both' + : 'ol-modal-card-in 0.28s var(--ol-motion-spring) both', + }} + > + {/* 桌面端不再有横跨两栏的标题栏; + 左侧栏与右侧内容各自通到顶,标题/自动保存/关闭并入右栏顶部。 + 移动端保留整宽 header(标题 + 关闭 + 整行搜索)。 */} + {mobile && ( +
+
+ {t('shell.footer.settings')} +
-
{searchBox}
+
+ )} +
+
- ) : ( - - )} - - {/* ─── 内容区 ────────────────────────────────────────────── */} -
- - {!mobile && ( - - )} - - {!mobile && ( -

- {t(`modal.sections.${section}`)} -

- )} - + + {!mobile && } +
- {/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */} -
- {section === 'general' && } - {section === 'services' && } - {section === 'privacy' && } - {section === 'advanced' && } - {section === 'about' && } -
- {mobile && ( -
- {LINK_ITEMS.map(it => ( - - ))} + {activeAdvancedPage && ( + + )} +

+ {contentTitle} +

+ {!mobile && ( + <> + + {t('modal.autoSaveHint')} + + + + )} +
+ )} +
+ {mobile && !activeAdvancedPage && ( +

+ {contentTitle} +

+ )} +

+ {contentDescription} +

+
+
+ {searching && ( +
+ {results.map((item) => ( + + ))} + {results.length === 0 && ( +
+

+ {t('modal.noResults')} +

+ +
+ )} +
+ )} + {/* key={section} 重挂载 → 每次切换分类播放轻微淡入(ol-tab-fade), + 与 tab 切换动画语言一致。 */} +
+ {section === 'general' && } + {section === 'shortcuts' && } + {section === 'appearance' && } + {section === 'services' && } + {section === 'privacy' && } + {section === 'advanced' && ( + + )} + {section === 'about' && } +
+ {mobile && !searching && } +
- )} +
@@ -291,41 +686,61 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett ); } -const mobileHeaderBtnStyle: CSSProperties = { - width: 36, - height: 36, +function HelpLinks() { + const { t } = useTranslation(); + return ( +
+ {LINKS.map((link) => ( + + ))} +
+ ); +} + +const iconButtonStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', flexShrink: 0, + width: 30, + height: 30, + padding: 0, border: 0, - borderRadius: 10, + borderRadius: 8, background: 'transparent', color: 'var(--ol-ink-3)', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'default', + cursor: 'pointer', }; - -function mobileTabChipStyle(active: boolean): CSSProperties { - return { - flexShrink: 0, - padding: '6px 12px', - borderRadius: 999, - fontFamily: 'inherit', - fontSize: 12, - fontWeight: active ? 600 : 500, - cursor: 'default', - ...chipSelectedStyle(active), - }; -} - -const navBtnStyle = { - display: 'flex', alignItems: 'center', gap: 10, - padding: '7px 10px', - borderRadius: 8, border: 0, +const navButtonStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '8px 10px', + border: 0, + borderRadius: 8, background: 'transparent', - fontFamily: 'inherit', fontSize: 13, - cursor: 'default', textAlign: 'left' as const, - position: 'relative' as const, - zIndex: 1, - transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)', + color: 'var(--ol-ink-2)', + font: 'inherit', + fontSize: 14, + cursor: 'pointer', + textAlign: 'left', }; diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index 497949251..1c5195856 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -11,7 +11,7 @@ import type { ShortcutBinding } from '../lib/types'; const SLIDE_DISTANCE = 48; /** 下拉菜单展开后的固定高度(px):菜单按钮行高恒定,用固定值动画避免每次测量。 */ const MENU_HEIGHT = 34; -/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抽搐。 */ +/** 滑动切换用 spring(与 Style.tsx 编辑抽屉同款)。只动 transform/opacity,不驱动布局,避免抖动。 */ const slideSpring = { type: 'spring' as const, damping: 26, stiffness: 280 }; /** 下拉菜单展开/收起缓动,与 --ol-motion-soft 一致。 */ const menuEase = [0.22, 0.8, 0.22, 1] as const; @@ -59,15 +59,21 @@ export function ShortcutRecorder({ useEffect(() => { if (!menuOpen) return; const onKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'Escape') setMenuOpen(false); + if (e.key === 'Escape' && !e.isComposing) { + e.preventDefault(); + e.stopPropagation(); + setMenuOpen(false); + rootRef.current?.querySelector('[aria-expanded]')?.focus(); + } }; const onPointerDown = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setMenuOpen(false); }; - window.addEventListener('keydown', onKeyDown); + // Capture Escape before the surrounding settings dialog handles it. + window.addEventListener('keydown', onKeyDown, true); window.addEventListener('mousedown', onPointerDown); return () => { - window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('mousedown', onPointerDown); }; }, [menuOpen]); @@ -89,9 +95,12 @@ export function ShortcutRecorder({ clearPressedCodes(); }; - useEffect(() => () => { - resetRecordingState(); - }, []); + useEffect( + () => () => { + resetRecordingState(); + }, + [], + ); useEffect(() => { if (!disabled || !recording) return; @@ -187,7 +196,10 @@ export function ShortcutRecorder({ clearPendingModifier(); const primary = primaryFromKeyboardEvent(e); if (primary) { - void finish({ primary, modifiers: modifiersFromPressedCodes(pressedCodes.current, sideSpecificModifiers) }); + void finish({ + primary, + modifiers: modifiersFromPressedCodes(pressedCodes.current, sideSpecificModifiers), + }); } }; @@ -225,6 +237,9 @@ export function ShortcutRecorder({ flexDirection: 'column', gap: 6, width: '100%', + // 设置行里的快捷键录制控件不再拉满整行(此前「Right ⌃」值贴左、 + // 下拉箭头甩到最右缘),与输入框同宽上限,紧凑地跟在标签列之后。 + maxWidth: 360, }; const recorderRowStyle: CSSProperties = { display: 'flex', @@ -286,7 +301,7 @@ export function ShortcutRecorder({ return (
{/* mode="wait":主行与「正在录入」面板不重叠渲染;切换只做 transform/opacity 动画, - 不驱动布局,面板运动过程不抽搐。所有滑入/滑出统一向右。 */} + 不驱动布局,面板运动过程不抖动。所有滑入/滑出统一向右。 */} {recording ? ( el?.focus()} + ref={(el) => el?.focus()} style={{ minHeight: 36, display: 'flex', @@ -331,7 +346,7 @@ export function ShortcutRecorder({
setMenuOpen(open => !open)} + onClick={() => setMenuOpen((open) => !open)} aria-label={t('settings.recording.comboMenuToggle', 'More options')} aria-expanded={menuOpen} title={t('settings.recording.comboMenuToggle', 'More options')} diff --git a/openless-all/app/src/components/SiriGL.tsx b/openless-all/app/src/components/SiriGL.tsx index bc4b1f38f..6b7cca5ef 100644 --- a/openless-all/app/src/components/SiriGL.tsx +++ b/openless-all/app/src/components/SiriGL.tsx @@ -282,7 +282,17 @@ function visualVoice(raw: number): number { return Math.pow(eased, 0.42); } -export function SiriGL({ mode, level, resolved, warming, warmupMs, speed, merging, className, style }: SiriGLProps) { +export function SiriGL({ + mode, + level, + resolved, + warming, + warmupMs, + speed, + merging, + className, + style, +}: SiriGLProps) { const hostRef = useRef(null); // 60Hz 的 level 更新走 ref 桥接:render 只同步数值,绘制循环在 rAF 里读, // 不因 props 变化重建 GL 管线。 diff --git a/openless-all/app/src/components/SplashVideo.tsx b/openless-all/app/src/components/SplashVideo.tsx new file mode 100644 index 000000000..b870c698f --- /dev/null +++ b/openless-all/app/src/components/SplashVideo.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; +import { takeSplashPlayback } from '../lib/ipc'; + +/** 随包发行的 2.0 开屏 PV(public/ 静态资源,Vite 原样打进 dist)。 */ +const SPLASH_SRC = '/openless-2.0-splash.mp4'; +/** 播放结束后整层渐隐的时长,与 global.css 的 ol-splash-out 保持一致。 */ +const FADE_MS = 1200; +/** 看门狗:`ended` / `error` 双双失灵(损坏编码、后台节流挂起)时, + * 开屏层最多占用 25s 也必须渐隐让位,绝不把用户永久挡在动画后面。 */ +const SPLASH_WATCHDOG_MS = 25_000; + +type SplashPhase = 'pending' | 'playing' | 'fading' | 'done'; + +/** + * 2.0 开屏 PV:仅在「配置文件里没有本大版本标记」的首启播放一次。 + * 标记由 Rust `take_splash_playback` 读写 preferences.json(浏览器开发模式用 + * localStorage 同语义模拟),播放判定发生在组件挂载时,因此每个 webview + * 进程只会消费一次。 + * + * 表现要求:全屏铺满(任意窗口比例下 object-fit: cover 裁切填满,无黑边); + * 播完从最后一帧画面开始整体渐隐到透明消失(ol-splash-out)。 + */ +export function SplashVideo() { + const [phase, setPhase] = useState('pending'); + const videoRef = useRef(null); + + useEffect(() => { + let cancelled = false; + takeSplashPlayback() + .then((shouldPlay) => { + if (!cancelled) setPhase(shouldPlay ? 'playing' : 'done'); + }) + .catch(() => { + // 判定 IPC 失败 = 不播。开屏动画是锦上添花,绝不因它挡住应用本身。 + if (!cancelled) setPhase('done'); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (phase !== 'playing') return; + const watchdog = window.setTimeout(() => setPhase('fading'), SPLASH_WATCHDOG_MS); + return () => window.clearTimeout(watchdog); + }, [phase]); + + useEffect(() => { + if (phase !== 'fading') return; + const timer = window.setTimeout(() => setPhase('done'), FADE_MS); + return () => window.clearTimeout(timer); + }, [phase]); + + useEffect(() => { + if (phase !== 'playing') return; + const video = videoRef.current; + if (!video) return; + // autoPlay 属性先尝试有声播放;WKWebView 拒绝带音频的自动播放时降级为 + // 静音续播——保证动画画面永远完整,声音能出则出。 + video.play().catch(() => { + video.muted = true; + video.play().catch(() => setPhase('fading')); + }); + }, [phase]); + + if (phase === 'pending' || phase === 'done') return null; + return ( +
+
+ ); +} diff --git a/openless-all/app/src/components/StylePackIconPicker.tsx b/openless-all/app/src/components/StylePackIconPicker.tsx new file mode 100644 index 000000000..8a1bfa859 --- /dev/null +++ b/openless-all/app/src/components/StylePackIconPicker.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from './Icon'; +import { readStylePackIcon, setStylePackIcon } from '../lib/ipc'; +import { isStyleIconDataUrl, rasterizeStyleSvg } from '../lib/stylePackIcon'; +import type { StylePack } from '../lib/types'; + +const DEFAULT_ICONS = { raw: 'mic', light: 'feather', structured: 'layout', formal: 'doc' }; + +export function StylePackIconPicker({ + pack, + onSaved, + onStatus, +}: { + pack: StylePack; + onSaved: (pack: StylePack) => void; + onStatus: (failed: boolean, message: string) => void; +}) { + const { t } = useTranslation(); + const [src, setSrc] = useState(null); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + useEffect(() => { + let cancelled = false; + void readStylePackIcon(pack.id) + .then((value) => { + if (!cancelled) setSrc(isStyleIconDataUrl(value) ? value : null); + }) + .catch(() => { + if (!cancelled) setSrc(null); + }); + return () => { + cancelled = true; + }; + }, [pack.id, pack.iconPath, pack.updatedAt]); + + const save = async (file: File | null) => { + setBusy(true); + try { + const png = file ? await rasterizeStyleSvg(file) : null; + const saved = await setStylePackIcon(pack.id, png); + const value = await readStylePackIcon(pack.id); + setSrc(isStyleIconDataUrl(value) ? value : null); + onSaved(saved); + onStatus(false, t('style.pack.iconSaved')); + } catch (error) { + onStatus( + true, + t( + error instanceof Error && error.message === 'invalidSvg' + ? 'style.pack.iconInvalid' + : 'style.pack.iconSaveFailed', + ), + ); + } finally { + setBusy(false); + } + }; + + return ( + + + { + const file = event.target.files?.[0]; + event.target.value = ''; + if (file) void save(file); + }} + /> + {src && ( + + )} + + ); +} diff --git a/openless-all/app/src/components/ThinkingDots.tsx b/openless-all/app/src/components/ThinkingDots.tsx index 30d632c8a..3627ac880 100644 --- a/openless-all/app/src/components/ThinkingDots.tsx +++ b/openless-all/app/src/components/ThinkingDots.tsx @@ -28,7 +28,8 @@ export function ThinkingDots({ size = 20, style }: ThinkingDotsProps) { width: size, height: size, flexShrink: 0, - animation: 'ol-thinking-spin 1.7s linear infinite, ol-thinking-breathe 2.8s ease-in-out infinite', + animation: + 'ol-thinking-spin 1.7s linear infinite, ol-thinking-breathe 2.8s ease-in-out infinite', willChange: 'transform', ...style, }} diff --git a/openless-all/app/src/components/Tooltip.tsx b/openless-all/app/src/components/Tooltip.tsx index c57297074..84f9b8b22 100644 --- a/openless-all/app/src/components/Tooltip.tsx +++ b/openless-all/app/src/components/Tooltip.tsx @@ -106,7 +106,7 @@ export function Tooltip({ const hide = () => { clearTimer(); - setPos(prev => { + setPos((prev) => { if (prev) warmUntil = Date.now() + WARM_LINGER_MS; return null; }); @@ -155,15 +155,15 @@ export function Tooltip({ let nextPlacement = pos.placement; if ( - pos.placement === 'bottom' - && bubbleRect.bottom > window.innerHeight - margin - && pos.anchor.top - 8 - bubbleRect.height >= margin + pos.placement === 'bottom' && + bubbleRect.bottom > window.innerHeight - margin && + pos.anchor.top - 8 - bubbleRect.height >= margin ) { nextPlacement = 'top'; } else if ( - pos.placement === 'top' - && bubbleRect.top < margin - && pos.anchor.bottom + 8 + bubbleRect.height <= window.innerHeight - margin + pos.placement === 'top' && + bubbleRect.top < margin && + pos.anchor.bottom + 8 + bubbleRect.height <= window.innerHeight - margin ) { nextPlacement = 'bottom'; } @@ -173,10 +173,12 @@ export function Tooltip({ return; } - const offsetX = Math.max(margin - bubbleRect.left, 0) - - Math.max(bubbleRect.right - (window.innerWidth - margin), 0); - const offsetY = Math.max(margin - bubbleRect.top, 0) - - Math.max(bubbleRect.bottom - (window.innerHeight - margin), 0); + const offsetX = + Math.max(margin - bubbleRect.left, 0) - + Math.max(bubbleRect.right - (window.innerWidth - margin), 0); + const offsetY = + Math.max(margin - bubbleRect.top, 0) - + Math.max(bubbleRect.bottom - (window.innerHeight - margin), 0); if (Math.abs(offsetX) > 0.5 || Math.abs(offsetY) > 0.5) { setPos({ diff --git a/openless-all/app/src/components/VocabSuggestionCard.tsx b/openless-all/app/src/components/VocabSuggestionCard.tsx index ebcdde9f9..03ecb9e09 100644 --- a/openless-all/app/src/components/VocabSuggestionCard.tsx +++ b/openless-all/app/src/components/VocabSuggestionCard.tsx @@ -10,6 +10,7 @@ // ——真机上自动收进词汇表的 5 条里只有 1 条是对的。逐条看一眼是这里唯一可靠的判据, // 所以不提供任何批量入口。 +import { Icon } from './Icon'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -45,16 +46,16 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { }; }, [suggestions]); - const visible = suggestions.filter(s => !resolved.has(s.id)); + const visible = suggestions.filter((s) => !resolved.has(s.id)); if (visible.length === 0) return null; // 勾和叉走同一条乐观更新:先本地隐藏,失败了再放回来让用户重点一次。 const resolve = async (id: string, commit: (id: string) => Promise) => { - setResolved(prev => new Set(prev).add(id)); + setResolved((prev) => new Set(prev).add(id)); try { await commit(id); } catch { - setResolved(prev => { + setResolved((prev) => { const next = new Set(prev); next.delete(id); return next; @@ -87,8 +88,7 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { // 描边用 1px 实边 + 扩散阴影,与胶囊本体同一套写法。早期版本用 // `0.5px solid`:非整数边框落在半个物理像素里,圆角边缘看着就是糊的。 border: '1px solid var(--ol-capsule-pill-border)', - boxShadow: - 'var(--ol-capsule-pill-shadow), var(--ol-capsule-pill-inset)', + boxShadow: 'var(--ol-capsule-pill-shadow), var(--ol-capsule-pill-inset)', color: 'var(--ol-capsule-btn-ink)', fontFamily: 'var(--ol-font-sans)', // 子元素一律不许溢出圆角。 @@ -107,7 +107,7 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) {
- {visible.map(s => ( + {visible.map((s) => (
{ + onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); }} @@ -183,9 +183,7 @@ function CardButton({ alignItems: 'center', justifyContent: 'center', background: - kind === 'accept' - ? 'var(--ol-capsule-btn-bg-confirm)' - : 'var(--ol-capsule-btn-bg)', + kind === 'accept' ? 'var(--ol-capsule-btn-bg-confirm)' : 'var(--ol-capsule-btn-bg)', color: 'var(--ol-capsule-btn-ink)', border: '0.8px solid var(--ol-capsule-btn-border)', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.06)', @@ -194,27 +192,7 @@ function CardButton({ 'background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }} > - {kind === 'accept' ? ( - - - - ) : ( - - - - )} + ); } diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 142c9c91d..60f3a040b 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -1,12 +1,20 @@ -import { type CSSProperties, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { Icon } from './Icon'; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; export type OS = 'mac' | 'win' | 'linux' | 'android'; export function detectOS(): OS { if (typeof navigator === 'undefined') return 'mac'; - const uaDataPlatform = ( - navigator as Navigator & { userAgentData?: { platform?: string } } - ).userAgentData?.platform ?? ''; + const uaDataPlatform = + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + ''; const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; if (/Mac|iPhone|iPad|iPod/.test(hints)) return 'mac'; if (/Android/i.test(hints)) return 'android'; @@ -27,16 +35,14 @@ interface WindowChromeProps { height?: number | string; } -export function WindowChrome({ - os = 'mac', - children, - height = 800, -}: WindowChromeProps) { +export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChromeProps) { // Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。 // Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。 const shellRadius = os === 'mac' ? 0 : os === 'win' || os === 'android' ? 0 : 14; - const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : os === 'android' ? 0 : 14; - const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0; + const consoleRadius = + os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : os === 'android' ? 0 : 14; + const titlebarHeight = + os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0; const useSolidSurface = os === 'linux' || os === 'android'; @@ -49,26 +55,35 @@ export function WindowChrome({ return (
{os === 'mac' && (
{`.ol-linux-close-btn:hover{background:rgba(220,38,38,0.12)!important;color:rgb(220,38,38)!important}`} )} -
- {children} -
+
{children}
); } @@ -105,24 +118,32 @@ function LinuxTitlebar() { useEffect(() => { let cancelled = false; let unlisten: (() => void) | undefined; - import('@tauri-apps/api/window').then(({ getCurrentWindow }) => { - if (cancelled) return; - const w = getCurrentWindow(); - winRef.current = w; - w.isMaximized().then((m) => { - if (!cancelled) setMaximized(m); - }).catch(() => {}); - // Keep icon in sync when user maximizes via double-click / keyboard shortcut - w.listen('tauri://resize', () => { + import('@tauri-apps/api/window') + .then(({ getCurrentWindow }) => { if (cancelled) return; - w.isMaximized().then((m) => { - if (!cancelled) setMaximized(m); - }).catch(() => {}); - }).then((fn) => { - if (cancelled) fn(); - else unlisten = fn; - }).catch(() => {}); - }).catch(() => {}); + const w = getCurrentWindow(); + winRef.current = w; + w.isMaximized() + .then((m) => { + if (!cancelled) setMaximized(m); + }) + .catch(() => {}); + // Keep icon in sync when user maximizes via double-click / keyboard shortcut + w.listen('tauri://resize', () => { + if (cancelled) return; + w.isMaximized() + .then((m) => { + if (!cancelled) setMaximized(m); + }) + .catch(() => {}); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => {}); + }) + .catch(() => {}); return () => { cancelled = true; unlisten?.(); @@ -139,7 +160,9 @@ function LinuxTitlebar() { w.toggleMaximize().catch(() => {}); // Re-query after window manager processes the toggle, in case WM rejects it setTimeout(() => { - w.isMaximized().then(setMaximized).catch(() => {}); + w.isMaximized() + .then(setMaximized) + .catch(() => {}); }, 300); }, []); @@ -178,15 +201,14 @@ function LinuxTitlebar() { - +
@@ -198,43 +220,33 @@ function LinuxTitlebar() { const svgWrap: CSSProperties = { width: 12, height: 12, display: 'block' }; const ctrlBtn: CSSProperties = { - width: 30, height: 24, - display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - borderRadius: 5, border: 0, padding: 0, - background: 'transparent', color: 'var(--ol-ink-3)', - fontFamily: 'inherit', cursor: 'default', + width: 30, + height: 24, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 5, + border: 0, + padding: 0, + background: 'transparent', + color: 'var(--ol-ink-3)', + fontFamily: 'inherit', + cursor: 'default', transition: 'background 0.12s, color 0.12s', }; function MinimizeSvg() { - return ( - - - - ); + return ; } function MaximizeSvg() { - return ( - - - - ); + return ; } function RestoreSvg() { - return ( - - - - - ); + return ; } function CloseSvg() { - return ( - - - - ); + return ; } diff --git a/openless-all/app/src/components/chat/avatars.tsx b/openless-all/app/src/components/chat/avatars.tsx index 339b964f1..26f61a318 100644 --- a/openless-all/app/src/components/chat/avatars.tsx +++ b/openless-all/app/src/components/chat/avatars.tsx @@ -64,7 +64,7 @@ export function OrbAvatar({ size = 32 }: { size?: number }) { setUnavailable(true); return undefined; } - const unsubscribe = subscribeOrbFrames(source => { + const unsubscribe = subscribeOrbFrames((source) => { // 中心裁切放大(源按胶囊构图,光环只占画面 ~1/3;头像里裁掉外圈留白 // 让转环撑满,动画本体不变)。 const crop = 0.62; @@ -105,7 +105,7 @@ export function useGithubLogin(refreshKey?: string | number): string { if (!isTauri) return; let cancelled = false; getSettings() - .then(prefs => { + .then((prefs) => { if (!cancelled) setLogin((prefs.marketplaceDevLogin ?? '').trim()); }) .catch(() => { diff --git a/openless-all/app/src/components/chat/index.ts b/openless-all/app/src/components/chat/index.ts deleted file mode 100644 index 647fd8d20..000000000 --- a/openless-all/app/src/components/chat/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -// 统一聊天面板组件库出口。 -// -// 官方 shadcn base-rhea 组件(ui/ 目录源码按官方 Manual 安装 1:1 内嵌,行为来自 -// @shadcn/react;两个面板 = 官方 message-scroller-demo 同款骨架): -// · 骨架 Card* / Empty* / InputGroup* —— 对话框壳、空状态、输入组。 -// · 滚动框 MessageScroller* —— 两个面板共用的「扫描框」。 -// · 消息 Message*/Bubble* —— 划词追问(QA)的对话形态(带头像)。 -// · 标记 Marker* —— Less Computer 的工具调用 / 上下文压缩标签形态(无头像)。 -// 本仓库组装层:ThinkingOrb/OrbAvatar(胶囊同款思考动画)、UserAvatar(GitHub)、 -// AssistantMarkdown(流式 markdown)、useChatPanelLifecycle(出现/消失动画信号)。 - -export { Button, buttonVariants } from './ui/button'; -export { Input } from './ui/input'; -export { Textarea } from './ui/textarea'; -export { Spinner } from './ui/spinner'; -export { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from './ui/card'; -export { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from './ui/empty'; -export { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, - InputGroupText, - InputGroupTextarea, -} from './ui/input-group'; -export { - MessageScroller, - MessageScrollerButton, - MessageScrollerContent, - MessageScrollerItem, - MessageScrollerProvider, - MessageScrollerViewport, - useMessageScroller, - useMessageScrollerScrollable, - useMessageScrollerVisibility, -} from './ui/message-scroller'; -export { - Message, - MessageAvatar, - MessageContent, - MessageFooter, - MessageGroup, - MessageHeader, -} from './ui/message'; -export { Bubble, BubbleContent, BubbleGroup } from './ui/bubble'; -export { Marker, MarkerContent, MarkerIcon, markerVariants } from './ui/marker'; -export { ThinkingOrb, OrbAvatar, UserAvatar, useGithubLogin } from './avatars'; -export { AssistantMarkdown } from './markdown'; -export { useChatPanelLifecycle } from './lifecycle'; diff --git a/openless-all/app/src/components/chat/lifecycle.ts b/openless-all/app/src/components/chat/lifecycle.ts index 3651b16b2..a8006d440 100644 --- a/openless-all/app/src/components/chat/lifecycle.ts +++ b/openless-all/app/src/components/chat/lifecycle.ts @@ -24,7 +24,7 @@ export function useChatPanelLifecycle(): { enterEpoch: number; closing: boolean const { listen } = await import('@tauri-apps/api/event'); const shownHandle = await listen('chat-panel:shown', () => { setClosing(false); - setEnterEpoch(epoch => epoch + 1); + setEnterEpoch((epoch) => epoch + 1); }); const closingHandle = await listen('chat-panel:closing', () => { setClosing(true); diff --git a/openless-all/app/src/components/chat/ui/bubble.tsx b/openless-all/app/src/components/chat/ui/bubble.tsx index f18206c11..56cfdad39 100644 --- a/openless-all/app/src/components/chat/ui/bubble.tsx +++ b/openless-all/app/src/components/chat/ui/bubble.tsx @@ -1,55 +1,55 @@ -import * as React from "react" -import { mergeProps } from "@base-ui/react/merge-props" -import { useRender } from "@base-ui/react/use-render" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react'; +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from "../lib/utils" +import { cn } from '../lib/utils'; -function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) { +function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } const bubbleVariants = cva( - "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", + 'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full', { variants: { variant: { default: - "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80", + '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80', secondary: - "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", + '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]', muted: - "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]", + '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]', tinted: - "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]", + '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]', outline: - "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30", + '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30', ghost: - "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50", + 'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50', destructive: - "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30", + '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30', }, }, defaultVariants: { - variant: "default", + variant: 'default', }, - } -) + }, +); function Bubble({ - variant = "default", - align = "start", + variant = 'default', + align = 'start', className, ...props -}: React.ComponentProps<"div"> & +}: React.ComponentProps<'div'> & VariantProps & { - align?: "start" | "end" + align?: 'start' | 'end'; }) { return (
- ) + ); } -function BubbleContent({ - className, - render, - ...props -}: useRender.ComponentProps<"div">) { +function BubbleContent({ className, render, ...props }: useRender.ComponentProps<'div'>) { return useRender({ - defaultTagName: "div", - props: mergeProps<"div">( + defaultTagName: 'div', + props: mergeProps<'div'>( { className: cn( - "w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30", - className + 'w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30', + className, ), }, - props + props, ), render, state: { - slot: "bubble-content", + slot: 'bubble-content', }, - }) + }); } const bubbleReactionsVariants = cva( - "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0", + 'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0', { variants: { side: { - top: "top-0 -translate-y-3/4", - bottom: "bottom-0 translate-y-3/4", + top: 'top-0 -translate-y-3/4', + bottom: 'bottom-0 translate-y-3/4', }, align: { - start: "left-3", - end: "right-3", + start: 'left-3', + end: 'right-3', }, }, defaultVariants: { - side: "bottom", - align: "end", + side: 'bottom', + align: 'end', }, - } -) + }, +); function BubbleReactions({ - side = "bottom", - align = "end", + side = 'bottom', + align = 'end', className, ...props -}: React.ComponentProps<"div"> & { - align?: "start" | "end" - side?: "top" | "bottom" +}: React.ComponentProps<'div'> & { + align?: 'start' | 'end'; + side?: 'top' | 'bottom'; }) { return (
- ) + ); } -export { BubbleGroup, Bubble, BubbleContent, BubbleReactions } +export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }; diff --git a/openless-all/app/src/components/chat/ui/button.tsx b/openless-all/app/src/components/chat/ui/button.tsx index ad82941f3..b82a3b7f9 100644 --- a/openless-all/app/src/components/chat/ui/button.tsx +++ b/openless-all/app/src/components/chat/ui/button.tsx @@ -1,54 +1,53 @@ -import { Button as ButtonPrimitive } from "@base-ui/react/button" -import { cva, type VariantProps } from "class-variance-authority" +import { Button as ButtonPrimitive } from '@base-ui/react/button'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from "../lib/utils" +import { cn } from '../lib/utils'; const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-2xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + default: 'bg-primary text-primary-foreground hover:bg-primary/80', outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30", + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30', secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', }, size: { default: - "h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + 'h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5', xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - lg: "h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", - icon: "size-8", - "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", - "icon-sm": "size-7", - "icon-lg": "size-9", + sm: 'h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + lg: 'h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', + icon: 'size-8', + 'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-7', + 'icon-lg': 'size-9', }, }, defaultVariants: { - variant: "default", - size: "default", + variant: 'default', + size: 'default', }, - } -) + }, +); function Button({ className, - variant = "default", - size = "default", + variant = 'default', + size = 'default', inert, ...props -}: ButtonPrimitive.Props & - VariantProps & { inert?: boolean }) { +}: ButtonPrimitive.Props & VariantProps & { inert?: boolean }) { // React 18 兼容:@shadcn/react 按 React 19 传布尔 inert,React 18 会把 false // 渲染成 inert="false"(HTML 中属性存在即生效)。归一成「有/无属性」。 - const inertAttr = inert ? ({ inert: "" } as Record) : undefined + const inertAttr = inert ? ({ inert: '' } as Record) : undefined; return ( - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/openless-all/app/src/components/chat/ui/card.tsx b/openless-all/app/src/components/chat/ui/card.tsx index 15e526a5b..1ed494dad 100644 --- a/openless-all/app/src/components/chat/ui/card.tsx +++ b/openless-all/app/src/components/chat/ui/card.tsx @@ -1,100 +1,85 @@ -import * as React from "react" +import * as React from 'react'; -import { cn } from "../lib/utils" +import { cn } from '../lib/utils'; function Card({ className, - size = "default", + size = 'default', ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { return (
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]", - className + 'group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm ring-1 ring-foreground/5 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]', + className, )} {...props} /> - ) + ); } -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function CardAction({ className, ...props }: React.ComponentProps<"div">) { +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function CardContent({ className, ...props }: React.ComponentProps<"div">) { +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { return ( -
- ) +
+ ); } -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, -} +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/openless-all/app/src/components/chat/ui/empty.tsx b/openless-all/app/src/components/chat/ui/empty.tsx index 243ace265..c22acc3ed 100644 --- a/openless-all/app/src/components/chat/ui/empty.tsx +++ b/openless-all/app/src/components/chat/ui/empty.tsx @@ -1,51 +1,51 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from "../lib/utils" +import { cn } from '../lib/utils'; -function Empty({ className, ...props }: React.ComponentProps<"div">) { +function Empty({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { +function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } const emptyMediaVariants = cva( - "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', { variants: { variant: { - default: "bg-transparent", + default: 'bg-transparent', icon: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5", }, }, defaultVariants: { - variant: "default", + variant: 'default', }, - } -) + }, +); function EmptyMedia({ className, - variant = "default", + variant = 'default', ...props -}: React.ComponentProps<"div"> & VariantProps) { +}: React.ComponentProps<'div'> & VariantProps) { return (
- ) + ); } -function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { return (
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", - className + 'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', + className, )} {...props} /> - ) + ); } -function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } -export { - Empty, - EmptyHeader, - EmptyTitle, - EmptyDescription, - EmptyContent, - EmptyMedia, -} +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/openless-all/app/src/components/chat/ui/input-group.tsx b/openless-all/app/src/components/chat/ui/input-group.tsx index aa737783f..4937af138 100644 --- a/openless-all/app/src/components/chat/ui/input-group.tsx +++ b/openless-all/app/src/components/chat/ui/input-group.tsx @@ -1,23 +1,23 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from "../lib/utils" -import { Button } from "./button" -import { Input } from "./input" -import { Textarea } from "./textarea" +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { Input } from './input'; +import { Textarea } from './textarea'; -function InputGroup({ className, ...props }: React.ComponentProps<"div">) { +function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { return (
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", - className + 'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-2xl border border-transparent bg-input/50 transition-[color,box-shadow] duration-200 outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', + className, )} {...props} /> - ) + ); } const inputGroupAddonVariants = cva( @@ -25,27 +25,25 @@ const inputGroupAddonVariants = cva( { variants: { align: { - "inline-start": - "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]", - "inline-end": - "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]", - "block-start": - "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2", - "block-end": - "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2", + 'inline-start': 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]', + 'inline-end': 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]', + 'block-start': + 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2', + 'block-end': + 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2', }, }, defaultVariants: { - align: "inline-start", + align: 'inline-start', }, - } -) + }, +); function InputGroupAddon({ className, - align = "inline-start", + align = 'inline-start', ...props -}: React.ComponentProps<"div"> & VariantProps) { +}: React.ComponentProps<'div'> & VariantProps) { return (
{ - if ((e.target as HTMLElement).closest("button")) { - return + if ((e.target as HTMLElement).closest('button')) { + return; } - e.currentTarget.parentElement?.querySelector("input")?.focus() + e.currentTarget.parentElement?.querySelector('input')?.focus(); }} {...props} /> - ) + ); } -const inputGroupButtonVariants = cva( - "flex items-center gap-2 rounded-2xl text-sm shadow-none", - { - variants: { - size: { - xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5", - sm: "", - "icon-xs": "size-6 rounded-xl p-0 has-[>svg]:p-0", - "icon-sm": "size-8 p-0 has-[>svg]:p-0", - }, - }, - defaultVariants: { - size: "xs", +const inputGroupButtonVariants = cva('flex items-center gap-2 rounded-2xl text-sm shadow-none', { + variants: { + size: { + xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5", + sm: '', + 'icon-xs': 'size-6 rounded-xl p-0 has-[>svg]:p-0', + 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', }, - } -) + }, + defaultVariants: { + size: 'xs', + }, +}); function InputGroupButton({ className, - type = "button", - variant = "ghost", - size = "xs", + type = 'button', + variant = 'ghost', + size = 'xs', ...props -}: Omit, "size" | "type"> & +}: Omit, 'size' | 'type'> & VariantProps & { - type?: "button" | "submit" | "reset" + type?: 'button' | 'submit' | 'reset'; }) { return (