feat(linux-egui): native EGUI parity host (#1060)

* refactor(linux): move shared runtime assets out of Tauri

* feat(linux-egui): adopt redesigned native shell

* feat(linux-egui): add typed popup process bridge

* feat(linux-egui): add native desktop services

* feat(linux-egui): add verified AppImage replacement

* feat(linux-egui): render native popup processes

* feat(linux-egui): bind vocabulary corrections and styles

* feat(linux-egui): add real history actions

* feat(linux-egui): wire native popup supervisors

* feat(linux-egui): bind marketplace and device login

* fix(linux-egui): do not overreport update capability

* feat(linux-egui): honor minimized startup

* test(linux-egui): lock Tauri-free parity contracts

* fix(core): keep provider URL validation clippy-clean

* refactor(linux-egui): keep view data clippy-clean

* feat(settings): transact launch at login effects

* feat(linux-egui): complete verified AppImage updater

* feat(linux-egui): restore native desktop lifecycle

* feat(linux-egui): restore history audio workflows

* feat(linux-egui): complete local model controls

* feat(linux-egui): add native style pack editor

* feat(linux-egui): complete marketplace workflows

* feat(linux-egui): merge dirty settings on revision conflict

* feat(linux-egui): complete fcitx5 desktop hotkeys

* feat(linux-egui): harden and restore native popups

* feat(linux-egui): bind shared vocabulary presets

* feat(linux-egui): edit style hotkeys without blocking frames

* feat(linux-egui): persist and export diagnostic logs

* feat(linux-egui): edit all fcitx5 hotkeys

* fix(linux-egui): follow terminal capture cancellation

* docs(linux-egui): record parity and device limits

* docs(linux-egui): track latest 1019 head

* fix(linux-egui): reload fcitx5 after addon update

* feat(linux-egui): restore recording and appearance settings

* feat(linux-egui): restore Core-backed overview

* feat(linux-egui): restore native recording feedback

* ci(linux-egui): lock release artifacts and checksums

* feat(linux-egui): localize native interface

* fix(linux-egui): tolerate legacy fcitx addon methods

* [pi] Work in progress

* [pi] Work in progress

* [pi] All tasks are complete. Here's a summary of what w...

* fix(linux-egui): preserve original frontend content geometry

* fix(linux-egui): align beta contracts and slim release

* ci: make Linux PR and release gates reliable

* fix(linux): remove dead frontend artifacts and restore autostart

* fix(settings): handle launch-at-login in Tauri restore

* fix(ci): follow relocated Linux remote input assets

* fix(ci): normalize Windows contract paths

* refactor(linux): rely on packaged fcitx addon

* feat(linux): add audio backend fallback and fcitx clipboard

* ci(linux): use native deb and rpm packaging

* fix(linux): bound addon reload and use app version

* fix(linux-egui): restore input and CI build

* fix(linux-egui): align window input regions

* fix(linux-egui): restore frontend input routing

* wip(linux-egui): 2.0 parity checkpoint before parallel page rewrites

* chore: parity agent orchestration script

* [pi] 我负责的三个功能性 bug 全部修完,全量测试通过。报告如下。

* [pi] 全绿(24 个 test binary 全 ok;`coding_agent` 那条是已知时序 fl...

* [pi] 快捷键与选区做完了,全量验证通过。

* [pi] 三个浮窗按 Tauri 重做完了,旧的臆造实现已整段删除。

* [pi] 四项遗留全部做完,并回答了 Vulkan 的问题。

* [pi] Siri 光效 GPU 化(GLSL 移植)+ 圆角矩形扫光着色器,接胶囊与思考态;Vulkan 结论附证据

* [pi] 实现完成,验证全绿并打好包。

* [pi] Work in progress

* [pi] Work in progress

* [pi] **任务 1(胶囊 wlr-layer-shell 主路径)完成并出包。**

* [pi] 任务 2 完成。**开工时先核对了前任的产物,结果发现任务 1 其实从未真正生效**——两个阻塞性 ...

* [pi] 三个问题里属于我这个工单的(③ 吞键、④ 选区助手)已经修完并出包。**其中「吞键」我现在就把你机器...

* [pi] 任务2:静音失败归一化 + 胶囊终态自动收起

① 不说话报 InvalidArgument:
- 根因 Core `mark_dictation_failed` 把错误码名当 message(format!("{:?}", code)),
  宿主原样送进胶囊/状态栏;空音频在 Core 里就是 InvalidArgument。
- 新增 linux-egui/src/dictation_feedback.rs(纯函数):capsule_outcome 分类相位+message,
  内部错误码永不作为用户文案;normalize_stop_result 把 InvalidArgument/InvalidState/
  Busy/Cancelled 这类预期内停止错误归一为「没有结果」,真失败仍上报。
- 三个停止/取消入口(胶囊 ✕/✓、Core 自动停止)改走 normalize_stop_result。

② 弹窗不自动收回:
- 按 Tauri coordinator::core_capsule_hide_delay 契约落地 capsule_hide_delay:
  成功/失败 2s、取消立即、进行中不收;schedule_capsule_dismissal 延时后经
  UiResult::CapsuleDismissDue 回主线程,capsule_hide_is_still_current 用会话+相位
  双守卫,避免误关用户中途新开的录音。
- 收起=结束弹窗进程(layer surface 只能随进程销毁),并在进行中的相位按需
  ensure_popup 重新拉起(phase_shows_capsule 排除终态,避免迟到事件把药丸喊回来)。
- 新增进程级测试:request_shutdown 必须真的让子进程退出且不算 crash。

验证:全量 cargo test 全绿(含 localization 契约)、i18n --check 干净(未新增 key)、
两个发布契约通过;包 2.0.0-Beta.1-28。

* [pi] 修复热键:修饰键触发不再吞键、Shift+符号键匹配(Ctrl+Shift+;),插件更新后自动重启 fcitx5

- 插件侧新增 hotkey_match.h 纯函数:字母大小写折叠 + US 布局 base/shifted 视为同一物理键
  (实测 fcitx 的 Key::normalize 对符号键只改 states 不改 sym,所以注册 ';' 永远匹配不到到达的 ':')。
  21 条断言的 hotkey_match_contract 覆盖 Ctrl+Shift+; / Ctrl+; 不误触发 / 字母 / 裸修饰键。
- 取消「裸修饰键一律拒绝注册」:改由插件保证不吞修饰键(shouldConsume),
  按住修饰键当热键期间若按下其它键则判定组合键并放弃触发。
- 录制器支持录裸修饰键(跨帧看 Modifiers:按住→无其它键→松开),记为 LeftControl 等。
- 新增 /proc 时间戳比较:安装包替换 libopenless.so 后,若插件比运行中的 fcitx5 新,
  启动时自动 fcitx5 -r,否则新匹配规则不会生效。
- 诊断日志:命中与「修饰位一致但键不同」的近似未命中(限速 1 次/秒)。

验证:cargo test 全绿;ctest 2/2(input_target_contract + hotkey_match_contract);
deb 内 libopenless.so 与新构建 sha256 一致(66d534f0…)。

* [pi] 任务2:报错路径胶囊收回(Core reset 时序 + 宿主兜底 + 宿主断开自退场)

* [pi] Work in progress

* [pi] 执行完了。取证结论很明确,而且**deb 打包没问题**。

* [pi] Work in progress

* [pi] 修复:启动不再被插件探测/重载阻塞(主窗口必现)+ /usr/local 手工残留不再冒充随包插件 + 指纹持久化(消除每次启动重载);热键注册摘要与取证日志

* fix(linux-egui): 胶囊去掉自造外圈扫光(Tauri 经典药丸只有 1px 中性描边)+ 风格包草稿行不再误改已有行 + 打包 postinst 改 D-Bus 重载

- 胶囊:录音/思考不再画红/黑外圈(用户报「语音输入弹窗有一个红边」),
  改成 Tauri 的「1px 中性描边 + 随音量放大 1.8%」;中心声波/流体圆点保留。
  两条新测试锁死:录音相位不得出现偏红涂色、GPU 回调数固定 1/1/0。
- 风格直达快捷键:草稿行选择器曾把 draft_pack 当行号下发 StyleHotkeyRepack,
  会把已有行的包改掉,且 StyleHotkeyDraftPack 从未构造(编译告警)。
  新增 style_pack_pick_action(row: Option<usize>, next) + 测试。
- popup_layer:去掉不必要的 unsafe 与未被使用的 FrameCallback 别名。
- 打包脚本:postinst/postrm 不再 `fcitx5 -r`(会替换 daemon 并前台常驻、装包卡 5s),
  改用 D-Bus controller Restart(实测 0.002s 返回)。

* fix(linux-egui): Ctrl+Shift+; 热键被 CapsLock 状态位吃掉——插件按 fcitx 的 normalize 语义只比 ctrl/alt/shift/super;近失日志降到 Info 并加可开关逐键 trace;宿主逐项记录注册的 (sym,states)

* fix(linux-egui): fcitx5 缺席/插件过旧不再拖死后端——启动链对 fcitx5 失败改为警告降级(heatkey listener 变 Option)

* ci(release): 共用一条跨平台发布流水线(-tauri/-egui 按后缀分流)+ 末端聚合 zip

Linux egui 的发布此前是一条从没被调用、也从没跑成功过的独立工作流:
fork 里 dispatch 时它带了一个不以 v 开头的 release_tag,在第 8 步
(依赖安装 + fcitx5 插件构建 + 全量测试之后)报
"release_tag must name an existing v* release",白烧 4.5 分钟 runner。

- release-tauri.yml 变成唯一一条发版流水线,按 tag 后缀决定构建哪些平台:
  v*-tauri → macOS/Windows 的 Tauri 包;v*-egui → Linux deb/rpm(egui 原生宿主,
  通过 workflow_call 复用 release-linux-egui.yml);手动 dispatch → 两侧都构建。
- 新增 bundle-all-artifacts:把本次 run 的全部 artifact 收成一个
  OpenLess-<tag>-all-artifacts.zip(zip 名自带 -tauri/-egui 后缀),
  根目录附覆盖全部文件的 SHA256SUMS,tag 推送时挂到同一个 release。
  与 build job 同一套渠道判定,beta release 不会被这个只挂 zip 的 job 升格。
- release-linux-egui.yml:tag 校验提到编译之前(几秒出结论);release target
  从「release 必须先存在」改为「tag 必须存在」,让 -egui 这个新命名空间
  首次推送时能自己建 release;版本推导先剥 -egui(-tauri 仅作历史兼容,
  保持 release 契约测试要求的写法);首次为 -egui tag 建 release 时正确标 prerelease。

* fix(linux-egui): 关闭键改为退回托盘(不退出进程)+ 托盘唤起按 最小化恢复→显示→聚焦 回任务栏;QA 热键主键被输入法引擎占用时释放(pinyin QuickPhraseKey,带备份)

* ci(release): Linux egui 发布 job 换到 ubuntu-24.04(与 ci.yml 同因)

实测 run 35120587777:22.04 上冷缓存重编 libspa 0.10.1 直接编译失败
(spa_video_info_raw has no field named flags / missing spa_meta_first),
整条发布流程卡在第 8 步。ci.yml 的 linux-core-contract 早就因为同一个原因
换到 ubuntu-24.04 并留了注释,这条工作流从没跑过所以一直没跟上。

注意:deb/rpm 的 glibc 基线随之抬高到 24.04(glibc 2.39);若需要覆盖
22.04 用户,得在依赖层解决 libspa/PipeWire 版本,而不是换 runner。

* ci(release): 单一 -egui tag 触发同一 run 内四平台并发(macOS arm64/x64 + Windows + Linux egui)

按用户拍板的方案 B 收口 tag 约定:v<version>-egui(例 v2.0.0-Beta.1-egui)
一次推送 → 同一个 run 里四平台并发构建 → 所有产物挂同一个 Release
→ 末尾 bundle-all-artifacts 打成 OpenLess-<tag>-all-artifacts.zip。

- 后缀不再是平台分发开关:-egui tag 下 build(Tauri 三腿)与
  release-linux-egui(workflow_call,tag 由编排层传入)都跑,zip job needs 全四者。
- build 里所有 release 相关步骤(签名可用性检查、release body prelude、
  Create/update release)从「仅 -tauri」放宽到任何 v* tag,否则 -egui 发布时
  dmg/msi/exe 只进 artifacts、不会挂到 Release。
- 历史 v*-tauri tag 兼容保留:仍只跑 Tauri 三平台(旧行为),文件头注释写清
  两者关系与迁移注意(Cask URL/README/bump-version.sh/android-apk.yml)。
- Linux 产物命名统一带 egui 标识:deb/rpm 已是
  OpenLess-Linux-egui-<ver>-<arch>(脚本原有),校验和文件改名 SHA256SUMS-egui,
  避免与其它平台/聚合层那份混淆;版本串仍由传入 tag 推导(剥 -egui)。
- update-homebrew-cask 保持仅 -tauri:cask 下载 URL 写死 v#{version}-tauri,
  迁移到 -egui 前不能动,已作为待办写进文件头注释。

* fix(linux-egui): 关闭键真正隐藏(Wayland 塌缩 1x1,不能 unmap/最小化)+ 事件泵用真线程保活 + Ctrl+Shift+; 匹配「Shift 折进符号」

三个实测问题(-40 真机反馈):

1. 点关闭键没反应:winit 在原生 Wayland 上 `set_visible` 是空实现
   (winit-0.30 wayland/window/mod.rs「Not possible on Wayland」),
   所以 -40 的 `Visible(false)` 等于什么都没做。最小化虽受支持,但最小化的
   surface 拿不到 frame callback → eframe 的 update() 停摆 → 热键、托盘、
   单实例唤起全部失效(实测心跳停、按热键无反应、重启也拉不回来)。
   改为 Wayland 上隐藏 = 临时放开最小内尺寸 + 塌缩到 1x1 并保持映射:
   窗口看不见了,事件循环照常跑(实测隐藏后 recording=true,热键仍生效;
   再次启动能把窗口按原尺寸恢复)。X11 仍走真 unmap。

2. 事件泵保活必须用真线程:tokio 运行时是 current-thread,spawn 的任务只在
   别处 block_on 时才推进,当后台泵用等于没保活。

3. Ctrl+Shift+; 到达插件时是 sym=0x3a(':') 且 states 里【没有 Shift 位】
   (trace 实测;字母如 Ctrl+Shift+S 则是 sym=0x53 states=0x05)。注册值
   0x3b/0x05 因此永远匹配不上。匹配规则改为:符号相同则严格比修饰位;符号
   成 base/shifted 对时,只有注册值本身带 Shift 才接受、并忽略事件里的
   Shift 位(Ctrl+; 与 Ctrl+Shift+; 双向仍可区分)。近失日志同步放宽到
   「除 Shift 外的修饰位一致」,否则这类失败永远没有日志。

* ci(release): 四平台真正并发——egui job 去掉 needs: build,Linux 资产的 release 写入交给聚合 job

needs: build 会让 Linux 腿等 macOS/Windows 编完才启动,那不是四平台并发。
去掉它之后两个 job 可能同时建同一个 release,所以:
- release-linux-egui.yml 新增 attach_release 输入(默认 true 保持单独 dispatch 行为)
- 编排层传 attach_release: false,Linux 资产改由 bundle-all-artifacts(needs 四者、
  最后一个 job)统一挂到 release,顺带和 zip 一起写,写入点唯一。
bundle job 里 Linux 资产那步用 hashFiles 守卫:egui 腿失败时不会让整步失败、
也不会把 zip 一起拖下水(zip 自己有 test -s 硬门禁)。

* ci(release): 聚合 zip 只装 Linux 平台产物(用户口径:all 指 Linux 各产物)

- bundle-all-artifacts 只下载 openless-linux-egui-x86_64 这一个 artifact,
  zip 根部就是打包脚本的原始文件名(.deb/.rpm/SHA256SUMS),不再塞 dmg/msi/exe,
  也不再另造一层汇总校验和——打包脚本自带的 SHA256SUMS 就是权威。
- Linux 校验和文件命名改回 SHA256SUMS(撤销上一轮的 SHA256SUMS-egui)。
- needs 仍为 [build, release-linux-egui]:它仍是 Linux 资产的唯一 release 写入点
  (egui job 保持 attach_release: false)。
- ubuntu-24.04 的分发基线写进注释:glibc ≥ 2.39,支持范围 Ubuntu 24.04+,
  22.04 / Debian 12 不在支持范围内(用户已确认,防止被改回去)。
- Homebrew、Cask、README、bump-version.sh、android-apk.yml、macOS/Windows 构建步骤均未动。

* fix(linux-egui): 最小化键改为退回托盘(Wayland 无法取消最小化,最小化的窗口会永久停掉 egui 重绘循环 → 热键消费与弹窗拉起一起失效)

- 有托盘:最小化 → 与关闭键同一条 hide 路径(Wayland 塌缩隐藏,事件循环继续跑)
- 无托盘:保持真正的窗口管理器最小化,否则窗口找不回来
- 新增 minimize_action() 决策函数 + 测试锁死该语义

* fix(linux-egui): 发布门禁单测不再依赖宿主是否装了 deb

a_per_user_copy_only_shadows_when_the_package_has_the_addon 之前直接断言
/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so 存在:装了 deb 的开发机绿,
干净的 CI runner 上 is_file()=false → 该断言必挂,于是 Linux 发布腿在第 8 步
「Verify framework-independent Linux contract」失败,后续 upload/checksums
与聚合 zip job 级联失败。

改为 tempfile 自建 per-user 文件,覆盖「无文件」与「有文件 + 包内有 addon」两个
分支,并保留「无包内 addon 时不 shadow」的反例断言。用 unshare + tmpfs 隐藏系统
插件目录后该测试仍通过,证明不再依赖宿主文件系统。

* fix(linux-egui): 删除自动改写输入法配置的逻辑,并用契约测试锁死

已实测证伪「拼音的 QuickPhraseKey=semicolon 会吃掉 Ctrl+Shift+;」:
fcitx 的 Key::check 要求修饰位精确相等(semicolon 是 states=0,而
Ctrl+Shift+; 到达时是 states=Ctrl),两者本就能共存。用户真机验证:
保持 QuickPhraseKey=semicolon 时按 Ctrl+Shift+; 面板照常弹出。

- settings.rs: 去掉 apply_hotkeys 里对 release_engine_key_conflict 的调用
- fcitx5.rs: 删除 release_engine_key_conflict{,_in}、release_engine_reserved_key、
  engine_reserved_keys、fcitx_key_aliases、fcitx_punctuation_name、
  reload_addon_config 及 4 个相关单元测试(-259 行)
- tests/fcitx5_config_contract.rs: 新增不变量契约,断言宿主源码里不出现
  输入法配置目录与引擎保留键;已用注入违规验证它非空转
- 真机 A/B:旧二进制会把临时 HOME 的 QuickPhraseKey 清空并留日志,
  新二进制字节不变、0 条日志,且启动路径确实跑过(有热键注册日志)

* [pi] Work in progress

* fix(linux-egui): 恢复被清理删掉的 provider/凭据编辑能力,发布门禁不再被拦

`1157fe51` 的界面重写把旧 monolith 的 provider 编辑器一起删了,新设置页只留住
「渠道列表 + 新建 + 启停 + 校验 + 删除」,于是 Linux 版既不能录入/修改/删除云服务
凭据,也不能改名、换 provider 类型、重排渠道、选当前渠道 —— 发布流水线第 8 步的
`check-linux-public-surface.ps1` 正是在拦这个(它的注释写明:只有状态、或只有本地
模型捷径的界面不算可用安装路径)。

- 宿主重新接上 Core 的 provider/凭据 API:rename_channel、set_channel_provider_type、
  reorder_channels、set_active_provider、set_credential、remove_credential,以及
  provider.validate / provider.list_models(模型目录属于 Core)。
- 编辑器草稿放在宿主侧(ProviderEditorForm)并每帧镜像给 view model:重新拉取渠道
  列表不会冲掉正在输入的内容;密钥字段只写,打开编辑器不会把已存密钥读进 egui。
- 界面补回:渠道行的编辑/上移下移/换类型/设为当前,以及按 AuthRequirement 渲染的
  编辑器(Endpoint/Model/Resource ID/密钥/鉴权模式 + 读取模型 + 保存 + 清空密钥)。
- 顺手修掉「新建渠道」名称框推的是编辑前的值:宿主会把旧值写回,每敲一个字被回灌。
- 新增本地契约测试 provider_surface_contract.rs,把这组 Core 调用钉住,避免下次合并
  或清理再把实现删掉(历史上正是这样丢的)。

* ci(release): 聚合 zip 改成「手动安装」散装包(不再装 deb/rpm)+ 附 install.sh

用户澄清了这个 zip 的用途:不是把安装包再打一层,而是给装不了 deb/rpm 的发行版
手动安装用。所以:

- payload 用 `dpkg-deb -x` 解开 deb 得到精确安装树(usr/bin/openless、
  usr/lib/*/fcitx5/libopenless.so、addon conf、desktop、metainfo、icon),
  并新增硬门禁:散装清单必须与 `dpkg-deb -c` 的文件清单逐项一致(漏文件最难发现)。
- 新增 `openless-all/app/scripts/linux-egui-manual-install.sh`,随包发布为
  install.sh:检查 root、先验 SHA256SUMS、按用途给权限铺到 /、用 D-Bus controller
  Restart 重载 fcitx5(与 deb postinst 一致,不用会卡住的 `fcitx5 -r`),最后打印
  重启与卸载提示。
- SHA256SUMS 改为对散装文件 + install.sh 重算;zip 内硬门禁断言不得出现 deb/rpm。
- zip 名自解释:OpenLess-Linux-egui-<tag 或 run>-x86_64-manual.zip;job 名/artifact
  名与注释一并从 all-artifacts 改为 manual install,避免语义继续误导。
- deb/rpm 与它们自己的 SHA256SUMS 仍作为独立 release 资产保留(那条 attach 步骤没动)。
- bundle job 补 checkout(脚本随仓库版本走,不内联进 workflow)。

* ci(release): Linux 腿内联成平级 job(不再折叠)+ deb/rpm 与 manual zip 合并到一个 job + 升 action 到 node24 版本消掉 node 警告

- 去掉 workflow_call 调用:release-linux-egui.yml 的步骤内联为 release-tauri.yml 里与
  build 平级的 linux-egui job,Actions 页面上不再被渲染成嵌套折叠条目
- 同一个 job 内完成 deb/rpm 打包与手动安装 zip 组装(删掉单独的 bundle job)
- release 竞态:内联后与 Tauri 三层并发写同一 release,先用 gh release view||create
  幂等地建出来(吞掉并发 422),softprops 只做 attach/update
- manual zip 语义:散装 usr/ 树 + install.sh + 重算 SHA256SUMS,不含 deb/rpm;
  deb/rpm 仍作为独立 release 资产
- action 升版(仅这两个发布工作流):checkout v4→v6、setup-node v4→v7、
  upload-artifact v4→v7,消除 GitHub action 自身 node20 运行时带来的
  DEP0040 punycode / DEP0169 url.parse 弃用警告(用户点名的 node 警告)
- 契约测试加锁:断言 Linux 腿必须是内联普通 job、manual zip 必须在同一 job 产出、
  两份副本的关键门禁不能漂移

* ci(release): 统一发布语义——任何 v* 标签都出全平台(Tauri 三平台 + Linux egui + 安卓)

- release-tauri.yml: push.tags 收敛为 'v*';build 与 linux-egui 的 if 不再按 -tauri/-egui
  后缀分流(后缀降级为命名约定);Homebrew cask 仍只跟稳定的 -tauri 正式版
  (注释写明该边界与影响,避免被顺手放开)
- android-apk.yml: 触发从 'v*-tauri' 扩展为 'v*';is_tag_release 判定不再要求 -tauri;
  OPENLESS_RELEASE_CHANNEL 判定与桌面统一(含 -beta-egui);未签名 debug fallback 语义不变;
  注释说明为何 Android 是独立 workflow(另一套 JDK/SDK/Gradle 工具链,塞进 matrix 会让
  每步都长满 if)以及它的资产如何落到同一个 release
- 顺带把这几个 action 升到 node24 版本(checkout v6 / setup-node v7 / upload-artifact v7),
  与发布工作流一致,消掉 action 自身运行时带出的弃用警告
- 契约测试新增:两份工作流都必须对 'v*' 触发、安卓必须挂到同一个 release、
  build/linux 两个 job 必须无条件运行、Homebrew 仍锁 -tauri

* feat(linux-egui): 关窗后切到无窗口宿主(窗口进程真退出)+ 事件泵不再依赖窗口绘制

- 抽出与渲染无关的 tick():原生事件、托盘命令、自动更新检查、心跳日志;
  eframe 的 update() 与无窗口宿主的 run_headless() 循环共用它,热键消费与
  弹窗拉起不再依赖「窗口是否在绘制」(最小化/隐藏/无窗口都照常工作)。
- 关闭键/最小化键:有托盘且没有进行中的会话时切到无窗口宿主。Wayland 下
  winit 无法隐藏窗口(set_visible 是空实现),只有窗口进程真的退出,任务栏
  与窗口列表里的条目才会消失;会话进行中退回托盘,避免新进程重开后端丢会话。
- 进程形态切换用同可执行文件的新进程 + --takeover:新进程等旧进程释放单实例
  锁后接管(不在交接期被转发后直接退出),托盘在接管后重试一次;接管超时降级
  为无锁启动,保证托盘/窗口仍可用。
- Core 的 ShowMain/FocusMain 提示在无窗口宿主里不拉起窗口进程(那只是「把已有
  窗口推前面」),只有托盘「显示主窗口」才是明确的用户动作。

* feat(linux-egui): 常驻无窗口宿主 + 独立 UI 窗口进程(真实时 IPC,关窗不再重开后端)

形态:宿主进程(无窗口,长期存活)持有 Core 后端 / 数据目录 / 单实例锁 /
热键监听 / 托盘 / 弹窗监督器;UI 窗口进程(--ui-client)只渲染。

- 新增 `src/ui/bridge.rs`:unix socket + JSONL 双向协议。快照带单调递增序号,
  UI 只采纳更大序号(重放/乱序/回退一律丢弃);快照可合并(UI 慢时跳帧,
  绝不画过期状态),控制帧永远先于待发快照落盘。
- 关窗 = **只有窗口进程退出**(Wayland 下任务栏条目真的消失);托盘「显示主窗口」
  或用户再次启动 = 宿主拉起新窗口进程,宿主 PID 与后端状态原地不动
  (实测:宿主 PID 不变、开窗约 50ms,不再重开后端)。
- 时序:先 bind socket 再拉 UI(UI 连不上就直接退出);UI 断连立即作废窗口句柄,
  会话/录音/弹窗不受影响;宿主退出先发 Shutdown 再释放单实例锁。
- 删除重启式交接:--headless / --takeover / HandoffMode / spawn_handoff_process /
  塌缩 1x1 隐藏 / 最小化改道 / 事件泵后台唤醒线程全部移除。
- `FrontendViewModel` + `FrontendAction` 加最小 serde 集:Lang 按 tag 序列化
  (i18n.rs 不允许手改),theme_mode 随快照下发,UI 进程据此配色。
- 事件消费与弹窗拉起改由宿主循环驱动(无窗口 ⇒ 必须线程化),另加
  OPENLESS_UI_DEBUG=1 诊断开关记录 UI 侧指针点击与动作。

* feat(linux-egui): 设置页排版对齐 Tauri(行=200px 标签列+1fr 控件列/左对齐、卡片与分区标题 14px)+ 浮窗尺寸语义对齐(qa 420×540、preview 640×440 可缩放且抢焦点)

* feat(linux-egui): 补齐设置页剩余排版对齐(行标签 500 字重 / 输入框 32·13.5 / 分段选中片阴影 / 无热键后端隐藏快捷键分区)

* feat(linux-egui): 在 Linux 实现 Less Computer 浮窗(420×540 / 常驻宿主唯一所有者 / 事件序列经 IPC 呈现 + 审批与取消链路)

- 新增 PopupKind::LessComputer(--less-computer)与协议版本 4:HostToPopup::LessComputer
  携带宿主事件序列(user/tool/compaction/assistant/error)、working、审批与错误;
  PopupToHost 增加 Submit/Approve/Cancel/Dismiss 四条动作。
- 宿主是事件序列唯一所有者:Core 的 LessComputerEventKind 逐条转成面板条目,
  流式增量合并为一条助手条目,终局正文替换它;窗口进程重连即收到完整序列。
- HostAction::ShowLessComputer 不再是空实现;面板尺寸取自共享常量(420×540),
  位置与 qa 一致(居中卡片);设置里的开关此前只翻转 coding_agent_enabled,现在
  有真实 UI 可看。
- 顺手修正 qa 弹窗视口尺寸的硬编码 520×520(应为共享常量 QA_WINDOW_SIZE=420×540)。

* [pi] # 选区读取为什么是「缓冲区里最后一次」——调查结论

* feat(linux-egui): 设置页顶栏/录音行/胶囊设置与试听按 Tauri 补齐 + 模态居中修正

用户实测反馈的七条:

1. 去掉设置页顶部那一层「设置」标题栏(桌面端左右两栏各自通到顶:左栏顶端是
   搜索框,右栏顶端才是「分类标题 + 修改后自动保存 + 关闭」,对齐 SettingsModal
   的桌面分支)。
2. 通用页第一行「录音快捷键」此前是只读文本,改成真正的 ShortcutRecorder
   (行内展开 → 录制 / 停用),并补上行下方的录音方式提示语。
3. 补上「录音提示音」的试听按钮,走与录音开始同一段合成提示音。
4. 补上「录音胶囊」开关与「胶囊样式」选择(siri / classic / typeless),并让样式
   真正改变胶囊渲染;关掉开关时胶囊立刻收起。协议 v4 → v5 增加 style 字段。
5. 默认录音方式一行补上可见提示语(Tauri modeDesc)。
6. 麦克风枚举过滤 cpal/PipeWire 混进来的输出监听与重复节点,并在枚举失败/无设备
   时显式提示;每次打开设置重新枚举。
7. 设置模态此前稳定偏下 19.5px、卡片被内容撑宽 22px(横向偏 11px),且在窗口缩放
   后要等下一帧才居中:改为由 body.center() 显式算位置并修正两栏宽度预算。

* fix(linux-egui): 补齐 P0 —— 窗口边缘拉伸光标 + 锁定胶囊按钮可点

P0(本轮核对结果):
* 胶囊退出/结束转录按钮可点:行为本来就是对的(layer-shell 只给指针输入,两个圆钮
  靠 `ui.interact` 命中),但没有任何测试锁住它。新增 `the_capsule_buttons_report_
  cancel_and_confirm`:把真实指针事件按到 ✕ / ✓ 的圆心,必须分别返回 Cancel/Confirm。
* resize 光标:**确实缺失** —— 八个拖拽区只发 BeginResize、不设光标,无边框窗口
  完全没有「这里能拉伸」的提示。按方向设置 ResizeHorizontal/Vertical/NwSe/NeSw,
  并新增 `the_window_edges_show_a_resize_cursor_on_hover` 四角四边逐一断言。

第 8 条(qa / Less Computer 避开任务栏)按决定**不改代码**:Wayland 没有
skip-taskbar 协议,winit 的该开关只在 Windows 生效;换成自研 layer-shell 就必须
自己补 xkb 键码 + zwp_text_input_v3 + wl_data_device,而 winit 在 Wayland 上已经
提供输入法支持(fcitx5 中文输入依赖它)—— 为一个任务栏条目牺牲追问面板的中文输入
不划算。

* test(linux-egui): 锁住两个被报为回归的交互(拖标题栏移窗口 / 分组开合)

用户报「拖标题栏移不动窗口」「设置里可展开分组点不开」。排查结果:**两者都不复现**。

- 无头:`the_titlebar_press_starts_a_window_drag`(标题栏按下必须发出
  ViewportCommand::StartDrag)、`the_collapsible_group_header_toggles_on_click`
  (点分组标题行后内容消失 = 真的点开了)—— 都通过。
- 真机(uinput 绝对坐标注入,源码版单实例):
  * 点侧栏「历史」→ 截图对比 AE=21984,页面确实切到历史记录(点击被送达)
  * 拖标题栏(+150,+100)→ KWin 读到的窗口几何 340,118 → **490,218**(窗口真被拖走)
- 结论:两条都不是代码回归。真机不clean 状态(陈旧单实例套接字 / 两个宿主并存)
  会让人以为交互失灵 —— 本轮排查时我确实复现过这种假象。

把这两条固化成回归测试,以后真回归会被抓住。

* test(linux-egui): 补上分组开合测试用的 test-only 入口

上一个提交 d69642ac 里的 `the_collapsible_group_header_toggles_on_click` 依赖
settings.rs 里的 `test_group_toggle`(cfg(test) 包装 card_group),漏提交会让
干净检出编译不过。这里补上。

* [pi] Work in progress

* feat(linux-egui): 润色结果并入「选区助手」面板(收尾 9a109965 的半成品)

9a109965 把独立 preview 窗口拆掉了但停在中间态:面板里还留着「可编辑」的
TextEdit、`preview_focus_requested` 只为它服务的字段、以及指向已删窗口的注释与
常量。这次把它做完并定型:

* 润色结果模式 = 选区助手面板里的第二套 UI(`polish_result_mode`),签名收成
  `&QaPolishState`:**只读展示**(原先的可编辑 TextEdit 换成可选可滚动的只读
  文本区,用户要求不做就地编辑),底部按钮集与 Tauri `SelectionPolishPreview.tsx`
  一致:✕ / 取消(`selectionPolishPreview.cancel`,line 72/125)+ ✓ 确认并替换
  (`selectionPolishPreview.confirmReplace`,line 139)—— 后者就是把结果写回
  原选区的「插入」(→ `PopupToHost::ConfirmPolish` → Core `selection.confirm`)。
* 副标题跟着语义改:Tauri 的 `selectionPolishPreview.subtitle` 原写「可直接编辑」,
  与只读冲突,5 个语言一起改成「只读结果;点「确认并替换」才会写回原选区。」
  (egui 目录由 `scripts/sync-egui-i18n.mjs` 写入)。
* `HostAction::HideSelectionPreview` 只撤掉「润色结果」模式:面板若正在提问对话
  中,不能被润色流程的收尾动作关掉。
* 清掉半成品留下的尾巴:`preview_focus_requested` 字段、`PreviewAction` →
  `PolishResultAction`、`selection_preview_visible` → `polish_result_visible`、
  已删窗口的常量注释(`PREVIEW_WINDOW_SIZE`/`PREVIEW_MIN_SIZE`/`PREVIEW_PADDING`)。

验证:`cargo test --locked` 1257 passed / 0 failed;`sync-egui-i18n.mjs --check` 干净。

* [pi] Work in progress

* fix(linux-egui): 标题栏拖动补两点 —— 未聚焦时先要回焦点、最大化时先还原再拖

egui-winit 对 ViewportCommand::StartDrag 带 window.has_focus() 前置条件
(egui-winit-0.33.3/src/lib.rs:1403-1409),未聚焦那一帧会被整帧丢掉,而 Wayland
的 xdg_toplevel.move 只认按下那一帧的 serial;另外最大化窗口的 move 请求会被
合成器忽略。现在:未聚焦时同帧补发 Focus,最大化时先 Maximized(false) 再拖。

* fix(linux-egui): 设置弹窗降到 Order::Foreground,修「点开的控件被弹窗盖住」

egui 的 ComboBox 下拉/弹出菜单是 Order::Foreground(popup.rs:150),而同层后创建者
在上、跨 Order 是 Tooltip > Foreground。设置弹窗占着 Tooltip,于是里面所有
ComboBox(渠道 provider / 认证方式 / 新增渠道 / 通用下拉等)展开后都被弹窗挡住,
表现为需要点开的控件都打不开。降到 Foreground:先创建的遮罩输入层仍在其下,
弹窗内再打开的下拉因创建更晚而在其上。

* fix(linux-egui): 设置遮罩改用 Area 排序(不再盖在弹窗上)+ 去掉「模拟粘贴快捷键」选项

- 遮罩原先用 ctx.layer_painter() 建原生图层,同 Order 内相对位置不受控;弹窗从
  Tooltip 降到 Foreground 后遮罩反而盖到弹窗上面(用户报「设置整体被阴影层压住」)。
  合并进原本就存在的 backdrop-input Area 并在弹窗之前创建,遮罩稳定在下面。
- 按用户要求移除「模拟粘贴快捷键」设置行(Ctrl+V / Ctrl+Shift+V)。

* feat(linux-egui): 接入上游 #1082 的「稳定模式(先录音后识别)」

合并 origin/beta 后唯一的冲突是 linux-egui/src/main.rs:上游往旧的 settings_ui 里加了
一个复选框,而那一整块我们早已重写成前端模块架构 —— 逐块保留 HEAD 侧,并把该设置
按新架构接上(否则上游这个新开关在 Linux 侧等于不存在):
view model 增 stable_transcription、SettingsField::StableTranscription、
SettingsDirty 白名单与 merge、宿主回灌与写回偏好、录音卡片里的开关行;
i18n 两个 key 由 sync 脚本从 Tauri 目录写入(904 keys,--check 干净)。

* fix(linux-egui): 遮罩与卡片合并成同一个 Area(点遮罩不再盖住卡片)+ 条带提到 Tooltip

- 阴影上移的真因:egui 在 Area 被按下时把它抬到同层最上面
  (egui-0.33.3/src/containers/area.rs:549 move_to_top)。遮罩原本是独立 Area,
  于是点一下遮罩就盖住卡片、设置没法用。现在遮罩、点击拦截与卡片同属一个 Area
  且遮罩先画 —— 结构上不可能压住卡片。
- 设置打开时改不了窗口大小:遮罩覆盖 body,而左/右/下三侧拉伸条带落在 body 范围内,
  被遮罩吃掉。条带提到 Order::Tooltip(层级高一层,仍只占外圈 6px/18px)。
- 卡片矩形改写进 egui memory,供居中/宽度测试查询(Area 面积已不等于卡片)。

* refactor(tauri): 删除 Tauri Linux 专有文件(linux_fcitx.rs / tauri.linux.conf.json)

Linux 桌面不再由 Tauri 提供(已改用 egui 前端 openless-all/app/linux-egui),先删掉
两个只服务 Linux 的文件:

- src-tauri/src/linux_fcitx.rs(976 行):fcitx5 插件桥 —— 热键同步、CommitText 插入、
  PRIMARY 选区读取、aux-down 跟随、AppImage 期插件自动安装。egui 侧有自己的
  linux-egui/src/fcitx5.rs,不依赖它。
- src-tauri/tauri.linux.conf.json:Linux 平台覆盖配置。

调用点与编译期门禁在后续提交里一并清理(见 7b778d9b、68fe1442)。

* refactor(tauri): 删除 Tauri 的 Linux 桌面实现(Linux 已改用 egui 前端)

Linux 桌面不再由 Tauri 提供,相关实现整体删除:
- src-tauri/tauri.linux.conf.json(Linux 平台覆盖配置)
- src-tauri/src/linux_fcitx.rs(976 行的 fcitx5 插件桥:热键同步、CommitText 插入、
  PRIMARY 选区读取、aux-down 跟随、插件自动安装)
- hotkey.rs 的 Linux 平台 adapter 模块(fcitx5 作为热键源)
- unicode_keystroke.rs 的 Linux impl / enigo 错误变体 / Linux 再导出 / 相关测试助手
- insertion.rs 的 Linux insert(fcitx CommitText + 剪贴板回退)与 3 条 Linux 测试
- selection.rs 的 Linux PRIMARY 选区读取模块与两处 Linux 分支
- lib.rs 的 mod linux_fcitx 与 AppImage 期插件自动安装调用

为免误在 Linux 上构建 Tauri 版时面对一堆“找不到模块”的零散错误,lib.rs 增加一条
显式的 compile_error!(仅 target_os = "linux"),指向 egui 前端与出包脚本。
macOS / Windows / Android(target_os = "android")不受影响。

egui 侧未受影响:cargo check --workspace 通过、openless-linux-egui 测试全绿(181+87…)、
package-linux-egui.sh 出包成功。

* feat(linux-egui): 图标装多档 hicolor 尺寸 + 提示音与 Tauri 逐参数对齐

图标(用户要求:复用 Tauri 的图标集):
- egui 侧不能引用 Tauri 源码树(两条契约都禁止脚本出现该路径),所以把
  src-tauri/icons 的 5 档 PNG 以**字节相同的副本**放进 linux-egui/packaging/icons/,
  由 release 契约做逐字节一致性断言 —— 素材仍是一份,且不会静默漂移。
- package-linux-egui.sh 由「只装一档、且把 512×512 的图塞进 256x256 目录」改为装
  32/64/128/256/512 五档 hicolor(256 取自 128x128@2x.png,512 取自 icon.png)。
- 已核对 public/AppIcon.png 与 src-tauri/icons/icon.png md5 相同(7017a34f…,均 512×512)。

提示音(用户要求:与 Tauri 完全一致):
- 参数本就与 audioCue.ts 逐字段一致;本轮补上两处**语义**差异:
  * 包络改为 Web Audio 的两段指数斜坡语义
    (0.0001 → 5ms 指数 attack 到 peakGain → 指数 decay 回 0.0001),
    原来是线性 4ms attack + 任意 tau 的 exp(-5t) 衰减;
  * 去掉 egui 自加的「结束提示音」(下行小三度)—— Tauri 的 stopAudioCue() 只停不发声;
    现在 play_cue_stop() 等价于作废当前代次(正在播的提示音静音),与 Tauri 同语义;
  * 新增代次机制:新提示音开始时在播的旧音立刻静音,对应 Tauri 排期前的 stopVoices()。
- 设置页「试听」继续复用同一实现(main.rs 的 PreviewAudioCue → play_cue_start())。

* refactor(tauri): 清掉已删除的 linux_fcitx 模块的残留调用点

linux_fcitx.rs 与 Linux 平台实现删除后,仍有多处 Linux 专有路径引用已删的模块/函数。
这些调用只出现在 cfg(target_os = "linux") 分支里,但等于给 Linux 构建留一堆悬空符号,
所以按「Linux 桌面不再支持」一并清理:

- coordinator/capsule_focus.rs:整段 fcitx5「候选词下方显示听写状态」的 aux-down 逻辑
  (LAST_AUX / RETRY_GEN / set_aux_down / clear_aux_down)。
- coordinator/hotkey_loops.rs:启动前的插件可用性检查、fcitx 通道准备、
  start_dictation_signal_listener 启动块,以及 custom_dictation_key_string /
  sync_custom_dictation_to_plugin 两个 Linux 辅助函数。
- coordinator.rs:ensure_modifier_hotkey_monitor 里的插件绑定同步、同样的 fcitx 监听
  启动块,以及 combo 热键更新路径里的插件同步调用。
- selection.rs:两处「其它平台」兜底不再排除 Linux(非 Linux 代码不动)。
- core_adapters.rs / unicode_keystroke.rs:流式插入的 Linux 分支不再调用已删的
  type_unicode_chunk,改返回显式的 TypeError::UnsupportedPlatform。

macOS / Windows / Android 不受影响(这些分支本就不为它们编译;mac 有自己的
switch_to_ascii / restore_input_source 实现)。egui 侧未受影响:workspace check 通过、
openless-linux-egui 测试全绿。

* refactor: 把 Remote Input TLS 身份实现移到平台中立目录 shared/remote_tls

原先 linux-egui 通过 #[path] 直接引 src-tauri 的源码,egui 无法在 Tauri 之外独立构建。
现在两侧(src-tauri/remote_server、linux-egui/remote_input、backend-tests)都引
shared/remote_tls/tls_identity.rs,egui 对 src-tauri 的引用清零,并收紧
linux-egui-tauri-free-contract(去掉 remote_input.rs 的豁免)。

* fix(linux-egui): 修风格市场对官网 API 的请求放大

对齐 Tauri 市场页(src/pages/Marketplace.tsx):
- 搜索加 300ms 防抖(原先每敲一个字符、以及每次输入法预编辑变化都发请求)
- my_likes 从 load_marketplace 拆出,改为每次会话只取一次(原先每次搜索都带一个认证请求)
- 列表请求加单调序号,丢弃过期响应(对齐 Tauri 的 reqSeqRef,latest-wins)
- limit 100 -> 50

原先一次 4~6 字符的搜索会打出 8~12 个请求(半数为认证请求),输入法预编辑下更多。

* [pi] 盘点完成(只读,未改任何仓库文件)。报告已落盘 `/tmp/ime-inventory-report...

* refactor(fcitx5): 删除插件里已无人调用的候选词下方状态弹窗

SetAuxDown/ClearAuxDown 的实现、焦点切换补写、PostInputMethod 恢复、
lastAuxText_ 缓存全部删除:Tauri 的 Linux 调用点已随 linux_fcitx.rs 一起删除,
egui 从未调用过,全仓库已无调用者(纯死代码)。

* mod(selection): 划词润色预览独立弹窗下线,改为复用选区助手(qa)面板

- 删除独立窗口 selection-polish-preview:窗口创建、能力表条目、前端路由
  (main.tsx / App.tsx)与页面 src/pages/SelectionPolishPreview.tsx 一并移除
- 后端 show_selection_polish_preview 改为 show_qa_window(app, "polish-preview")
  并向 qa 发 selection-polish-preview:shown;hide 只发 hide 事件,是否收起面板
  交给前端判断(面板可能正在提问对话中,不能被润色流程收尾一起关掉;与 egui
  侧 HostAction::HideSelectionPreview 的语义一致)
- 新增 SELECTION_POLISH_PREVIEW_PENDING 标志:get_selection_polish_preview 只在
  真正处于润色模式时返回负载 —— 面板懒创建后挂载补拉不会误入润色模式
- QaPanel 增加「润色结果」模式:标题/副标题、只读结果框、原文摘要、
  取消 / 「确认并替换」(= 写回原选区的插入,走 Core selection.confirm);
  Esc 与 ✕ 都是取消并关闭。沿用 selectionPolishPreview.* 文案(5 语言已具备)
- 新增 scripts/selection-polish-panel-contract.test.mjs 锁住合并(npm test 自动发现)
- docs/architecture.md 的窗口清单同步

* docs(linux-egui): 注释改为指向 Tauri 侧合并后的选区助手面板

Tauri 的独立润色预览窗口(SelectionPolishPreview.tsx)已下线,注释不再指向已删文件。

* refactor(tauri): 删除 Tauri 侧剩余的 Linux 专用实现

Linux 桌面已由 egui 前端承担,Tauri 不再构建 Linux。本次清掉残留:

- 纯 Linux cfg 块:audio_mute 的 wpctl/pactl 后端、tauri_coordinator_host 的
  gtk 版胶囊窗口与 Linux 早退分支、permissions 的 Linux 麦克风探测、lib.rs 的
  gtk 光标穿透与 WebKitGTK nudge、main.rs 的 WebKit 环境变量、core_adapters 的
  Linux ASR/插入分支、asr/local 的 Linux 后端与空实现、side_aware_combo 的
  「Linux 暂不支持」分支
- 12+ 个文件上的 cfg_attr(target_os = "linux", allow(dead_code, ...)) 抑制
- 保留 lib.rs 的 compile_error! 防线(有意拦住误在 Linux 上构建 Tauri)
- 保留 any(target_os = "macos", target_os = "linux") 共享块(macOS 本地 Qwen ASR)
- 补齐非 mac/win 桌面的中立兜底(hotkey 后端桩、insertion 剪贴板兜底、ASR 不可用),
  不引入任何 Linux 命名;删除无调用者的 host 包装 switch_to_ascii/restore_input_source

* chore(tauri): 移除 Linux 专用依赖与 wayland-scanner vendor 补丁

- 删 [target.'cfg(target_os = "linux")'.dependencies](dbus 已零引用、gtk 只被删除的
  Linux 代码使用)与 keyring 的 unix-except-macos/android 段(含 linux-native-sync-persistent)
- 删 [patch.crates-io] wayland-scanner 与 src-tauri/vendor/wayland-scanner/(只为 Linux
  构建修 issue #801 的 quick-xml 漏洞)
- arboard 去掉 wayland-data-control feature
- Cargo.lock 随 manifest 重解析(-379 行)

* refactor(react): 移除前端里的 Linux 分支与专属文案键

- WindowChrome:OS 类型/UA 嗅探/LinuxTitlebar/ol-linux-* 类名/专用常量全部删除,
  外壳圆角恒为 0(交回原生装饰),并清掉随之无用的 import 与 SVG 组件
- RecordingInputSection:去掉 os !== 'linux' 条件与 Linux 专属「流式输入」开关行
- AudioCue / localAsr / platform / ChannelList / CodingAgentSection / ShortcutsSection:
  本地引擎与能力判断只保留 macOS / Windows / Android
- global.css 删掉 html[data-ol-platform='linux'] 规则(该属性在所有分支都已不再取 linux)
- i18n 9 个语言删除 streamingInsertTitleLinux / streamingInsertHintLinux(React 与 egui 均无引用),
  同步更新 experimentalTitles.test.ts;「仅 Windows / Linux」这类平台说明文案保留(Linux 仍由 egui 提供产品)

* test(contracts): 新增 tauri-linux-free 契约并修受影响的既有契约

- 新增 scripts/tauri-linux-free-contract.test.mjs:断言 src-tauri 无 Linux 专用 cfg、
  manifest 无 Linux 目标依赖/wayland-scanner patch、vendor 补丁目录不存在、React 无 Linux 分支、
  lib.rs 的 compile_error! 防线仍在(反证:注入探针文件后契约确实失败)
- macos-capsule-spaces-contract:函数体边界改为「下一项平台属性」,不再假定紧跟 Linux 实现
- windows-ui-config:外壳断言改查 shellRadius 恒 0;删除已退役的 Linux 标题栏断言

* fix(linux-egui): 胶囊边距按实测面板高度计算,不再依赖 exclusive_zone(-1)

KDE 下胶囊会压在底部面板上:原先靠 exclusive_zone(-1) 让合成器把表面挪出其他
独占区,但并非所有合成器都兑现该请求。现在改为自己量:

- bind wl_output,读 CURRENT mode 得到整屏高度
- 两个一次性 layer surface 探针(anchor=bottom+left+right,size=0)分别请求
  exclusive_zone(-1) 与 (0),拿到合成器给出的可用高度,探针随后销毁
- 纯函数 capsule_bottom_margin/PanelInsetProbe 决策:合成器不避让面板时
  margin = panel + gap;已避让时 margin = gap;测不到时退化为 gap(原行为)
- 胶囊改用 exclusive_zone(0),边距自己扛;测量值打到 stderr 供实机核对
- 新增 5 个单测覆盖决策树

* Revert "fix(linux-egui): 胶囊边距按实测面板高度计算,不再依赖 exclusive_zone(-1)"

This reverts commit 73c59cfd3750e70dcc07933b5224b79b6574d838.

* fix(fcitx5): 选区探针自行读 PRIMARY,修「旧选区被当成当前选区」

现象:在新选区里选中了没有 text mime 的东西(图片/文件/密码)时,划词助手
拿到的还是**上一次**的选区文本。

根因(已用源码核实):fcitx5 clipboard addon 把 PRIMARY 缓存在全局 primary_,
其 waylandclipboard.cpp 的 DataOffer::receiveRealData() 在 offer 既没有
text/plain;charset=utf-8 也没有 text/plain 时直接 return,回调不触发、setPrimary()
不被调用 —— 缓存里留着上一次的文本。而 IClipboard 公共接口
(clipboard_public.h)只有 primary()/setPrimary(),没有任何"选区变了"或"还有效吗"
的通知,插件无从判断缓存是否过期。

改动:
- 新增 primary_selection.{h,cpp}:用 ext-data-control(KWin 6 提供,
  协议规定绑定 device 时补发一次 primary_selection,故一次 roundtrip 即拿到当前
  状态)读当前 PRIMARY 的 mime 列表与文本;区分 NoSelection / NoText /
  ReadFailed / Unsupported,并沿用 fcitx5 的密码管理器 hint 与 1s→150ms 超时策略。
- ③ 来源优先级改为「合成器此刻的 PRIMARY」优先,应用 surrounding 只在探针给不出
  结论时兜底(它是客户端本地缓存,且协议限定 ≤4000 字节)。
- ② NoText 判失效,既不回退 clipboard 缓存也不回退 surrounding。
- ④(部分):更新插件文件头 GetSelectionText 的说明;GetSelectionText 与
  CaptureSelectionTarget 共用同一探针,探针不可用才退回缓存。
- 构建:内置 wayland-protocols 1.49 的 ext-data-control-v1.xml(见 protocols/README.md),
  由 wayland-scanner 生成胶水;project() 需声明 C 语言,否则生成的 .c 会被
  CMake 当作头文件跳过(链接期 undefined reference)。
- 契约:input_target_contract 通过 setPrimaryReader 注入探针结果,新增 ①②③ 用例。

* fix(fcitx5): 探针不可用时才回退 PRIMARY 缓存,并加契约双向覆盖

任务书 ② 要求“新选区没有 text mime 就判失效、不回退旧值”,但严格照做会让
**探针不可用**的场景(X11 会话、合成器没有 ext-data-control)失去唯一来源:
X11/XIM 类应用既不导出 PRIMARY、也没有 surrounding text,于是划词彻底拿不到
选区 —— 相对改动前是功能回退。

改为分级边界:
- 探针**给出结论**(NoText / NoSelection)→ 仍严格判失效,绝不回退旧值(② 的本意);
- 探针**不可用**(Unsupported)且应用 surrounding 也为空 → 才退回 clipboard
  addon 缓存,这是最后一级兜底,也是改动前的行为。

为此给缓存读取加了注入缝 selectionCacheReader_(默认实现仍是
cachedPrimarySelection()),契约用例据此同时覆盖两条边界:
- Unsupported + surrounding 空 → 命中 "clipboard-cache-fallback"(返回缓存文本);
- NoSelection / NoText → 捕获为空、getSelectionText() 也为空,缓存里有值也不读。

* refactor(linux-egui): 市场/风格弹窗遮罩统一为「单 Area」,历史确认框加固

「点阴影后阴影上移、设置没法用」的根因是遮罩与卡片处在不同容器:egui 会在指针按下时
把被按到的 Area 抬到同层最上(egui-0.33.3 area.rs:549 的 move_to_top),独立遮罩一旦
被抬起就盖住卡片。设置弹窗此前已改成「遮罩 + 点击拦截 + 卡片同属一个 Area」,这次把
同样的结构铺到其余两处遮罩:

- marketplace.rs:删掉 painter 遮罩 + 独立 backdrop-input Area + Tooltip 卡片的三段式,
  合并为单个 openless-marketplace-detail-modal(Foreground)。卡片高度由内容决定(描述
  行数可变),所以把上一帧实测高度记在 memory 里居中(首帧用估算值 + request_repaint
  收敛),并把本帧**实际画出**的卡片矩形写进 openless-marketplace-detail-card-rect
  供测试查询。
- style.rs:同款三段式合并为单个 openless-style-editor-modal(Foreground);卡片尺寸本
  来就固定,矩形直接算出并写进 openless-style-editor-card-rect。
- history.rs:confirm_overlay 本来就是单 Area,但 Area 只 sense hover —— 点遮罩会漏到
  下方页面(确认框开着时列表行仍会被选中)。改成 Sense::click() 吃掉点击(Tauri 这里用
  原生 window.confirm(),点外面不关闭,所以只拦截不关闭),并写
  openless-history-confirm-card-rect。
- 前两处顺带从 Order::Tooltip 挪回 Foreground:跨 Order 是 Tooltip > Foreground,弹窗
  占 Tooltip 会盖住同层弹出的下拉/菜单。
- layout.rs 的 resize 条带仍是 Order::Tooltip(必须在遮罩之上),未改动。

测试(ui/frontend/mod.rs):新增 assert_overlay_keeps_the_card_on_top 结构性断言与三处
弹窗各一条用例——卡片必须落在弹窗自己的 LayerId 上、在自己的遮罩区里居中、遮罩点上的
输入层也是弹窗自己;点一下遮罩后卡片既不移位、也不会被抬起的遮罩盖住。反向验证:把遮罩
临时挪到另一个容器(不同 Order/不同 Area)后,用例立即失败于
「the card must live in the modal's own layer」,还原后通过。

* fix(linux-egui): 自己窗口里的热键本地兜底,弹窗崩溃重启加预算,QA 热键按 Tauri 切面板

插件只在 fcitx5 的 InputContextKeyEvent 上匹配热键,而我们的窗口从不注册
text-input(winit 默认 ime_allowed=false),所以窗口有焦点时插件一个信号都
不发:主窗口里热键全死,选区助手弹出并开始录音后按语音热键也停不下来。
现在每个窗口进程(主窗口 + 各弹窗)自己做本地匹配,复用设置页录制器的
captured_binding 与注册给插件的 (keysym, states),并与插件信号共用一张去重表
(同一物理按键只处理一次)。

弹窗崩溃(Exited{crashed:true})以前会无条件立刻重开,必崩的面板就变成
无限弹出;改为 60s 窗口内最多重开 N 次,超预算停手并记日志。

QA 热键以前走 Core 的 toggle_recording(切换录音),与 Tauri 的「切换面板显隐」
不一致;现在按 Tauri 语义切面板。

* fix(linux-egui): openless --help/--version 只打印并退出,不再启动应用

* fix(linux-egui): 润色结果沿用选区助手面板的外壳

润色内容并进选区助手面板后,egui 侧仍自己一套排版:没有头尾分隔线、
没有选区助手的 14px 内容边距,看着就像已经下线的独立预览窗口。现在
CardHeader / CardContent / CardFooter 的留白、字号、分隔线全部与选区
助手面板一致(头部还可拖动窗口)。

新增回归测试 the_polish_result_reuses_the_selection_ask_chrome:断言
润色模式画出的头部分隔线与选区助手面板的位置、宽度一致。

* docs: Linux egui 依赖升级计划(依赖批次 / Rust 1.98.1 / Vulkan-wgpu 工作量评估)

把升级调研结论固化进仓库:当前图形栈与全部依赖的可升/已最新清单、
四批执行计划(Rust 工具链、semver patch、10 个 breaking、egui 0.36+glow 0.17)、
Vulkan(wgpu) 的文件级工作量(siri_gl.rs 1090 行 / popup_layer.rs 919 行 / 4 个
GLSL→WGSL)与'双后端不划算、先 profiling'的结论、以及 egui 升级必查的回归清单。

状态:暂缓执行,后续照单执行。

* fix(linux-egui): 修「alt+X 弹出空面板」与「录音热键误弹选区助手」

两个宿主侧状态/时序 bug:

1) show_selection_popup() 先 ensure_popup 再 early-return:没有选区或没
   有会话时会把弹窗进程拉起来却一个内容帧都不发 → 凭空出现一个**空的
   选区助手面板**(用户报的 alt+X 弹出错误弹窗),并留下
   polish_result_visible 脏标志。现在先校验负载再拉弹窗,缺负载时记 warn
   并清标志。

2) qa_visible 残留:面板 ✕、弹窗进程正常退出、崩溃重启预算用尽这三条
   路径都没清 qa_visible,于是下一次「录音热键」被 apply_local_hotkey_edges
   当成「向选区助手提问」,把面板又弹出来(用户报的第二个症状)。现在
   统一走 forget_qa_panel_visibility(),并要求 qa_state 存在才算面板在
   提问态。

* alt+X 不再弹空润色面板:快照要带选区且结果非空,否则只在语音窗报错

- SelectionStateChanged 以前只看 phase:起手 Capturing 就 ensure_popup +
  送一帧空 PolishPreview,于是按 alt+X 会先冒出一个空的润色结果面板
  (用户报的「弹窗不对」),没有选区时更是空转一圈再消失。
- 现在要求 phase=Preview 且 preview_text 非空、并且 source_text(捕获到的
  选区)非空,才把润色结果帧送进选区助手面板并置 polish_result_visible。
- 没有选区时改为弹语音输入窗(胶囊)报错后退出:文案用 Tauri 的
  capsule.selectionPolish.noSelection('未选中内容'),终态 2 秒后自动收起,
  不再静默无反馈。
- 诊断日志:有快照但结果不可用时打 debug,无选区时打 warn,便于下次真机定位。

* fix(core): 缓冲转写后台挂接改走注入的 TaskSpawner

CI 的 linux job 卡在 Enforce Linux dependency boundaries:
crates/openless-core/src/dictation_engine.rs:1154 在生产代码里直接
tokio::spawn,绕过 TaskSpawner(check-core-runtime-seam.ps1 明令禁止)。
该行由上游 a8ebcf6ff(#1082 录音优先/稳定模式)引入。

- BufferedTranscriptionInner 增加 task_spawner 字段;
  BufferedTranscriptionSession 增 with_task_spawner 构造器,
  new/new_with_limit 保持原签名并默认 TokioTaskSpawner(与
  BackendDependencies::default 同源)。
- buffered_transcription_session 接收 task_spawner。
- DictationEngine 增加可覆写的 buffered_task_spawner(),默认返回
  TokioTaskSpawner;start_transcription_with_progress 用它喂给缓冲区。
- attach_in_background 不再直接 tokio::spawn,行为不变。

* chore(linux-egui): 清掉全部编译警告(死代码与残留状态)

bin 与 test 构建现在都是 0 warning:

- UiResult 里 ProviderMutation / MarketplaceFlow / MarketplaceAuthPoll /
  UpdateProgress / UpdateInstalled 五个变体从未被构造(新前端没有市场登录
  与「安装更新」入口),删掉变体及其处理分支。
- OverviewSummary 的 last_7 / last_30 恒无读者(前端用 activity_daily 的
  尾段自己切窗口):连 ActivityAggregate、aggregate_window 一起删,
  测试改为只断言 daily/heatmap 的窗口化。
- 旧 settings 表单草稿字段(qa_input / new_channel_name / vocabulary_* /
  correction_* / vocab_preset_* / history_search / marketplace_flow /
  style_hotkey_*)在 OpenLessEguiApp 上已无读者,删字段与两处构造初始化
  (弹窗进程自己那份 qa_input 保留)。
- ProvidersState::Failed / ProviderEditorState::Failed 的 String 负载、
  ProviderEditorState::Loading 的 kind/channel_id 无人读,改为无负载变体。
- 删掉被新 UI 取代的 shortcut_editor / optional_shortcut_editor;
  set_style_pack_hotkey 只有测试用,移进 mod tests。
- animated / painted 的初值恒被覆盖,改为延迟初始化。
- 顺手修掉 --help 那个测试上重复的 #[test](重复注册了同一个用例),
  以及 popup.rs 测试模块里没用的 use super::*。

* [pi] Work in progress

* [pi] Work in progress

* test(linux-egui): 跟上 egui 0.36 的测试侧 API,并消费 FullOutput 的纹理增量

d055873a 把 egui/eframe 升到 0.36.2 时只改了生产代码,测试目标编译不过
(13 个 E0308/E0599/E0560),另有一批测试在 drop FullOutput 时 panic。

- 0.36 删掉了 CentralPanel::show(&Context, ..) 与 Context::run:测试改用
  ctx.run_ui(RawInput, |ui| ..),即取 root Ui 再交给被测函数。
- RawInput 不再有 modifiers 字段:改在 begin_pass 后用
  ctx.input_mut(|i| i.modifiers = ..) 注入(仅测试需要)。
- epaint 0.36 起 FullOutput 的 TexturesDelta 未处理就 drop 会 panic
  ("Dropped TexturesDelta with N unapplied deltas")。无头测试没有渲染器,
  新增 run_pass / end_pass 两个测试助手统一 clear,替换 50 处出口。

剩余 6 个 GPU 回调断言仍失败:d055873a 把 siri_gl 的 glow 着色器路径从渲染
路径上摘掉(见该文件第 7 行的 allow(dead_code) 注释),paint() 现在只画 CPU
形状且恒返回 true,warm_up() 是空函数,但测试还在断言 Shape::Callback。

* test(linux-egui): 光效测试跟上「着色器路径已摘除」的现状

d055873a 把 siri_gl 的 glow 着色器从渲染路径上摘掉(paint 改为直接用 CPU
形状画光效并恒返回 true,warm_up 成空函数),但 6 个测试还在断言旧的
「GPU 回调 + CPU 回退所有权」语义。按现状改写断言,等 wgpu 光效落地后再恢复:

- paint_draws_every_mode_on_the_cpu_and_claims_ownership(原
  paint_queues_a_gpu_callback):三种模式都画 CPU 形状且返回 true,帧里没有
  Shape::Callback。
- warm_up_queues_nothing_while_the_shader_path_is_off:warm_up 不排队、不改
  状态,也不影响 paint 的所有权结论。
- a_rejected_driver_still_gets_the_cpu_glow:GPU_FAILED 时 CPU 光效照画并
  拿走中心,回调数 0。
- capsule_keeps_the_centre_glow_off_the_gpu_path(原
  capsule_queues_the_gpu_glow_per_state):录音/思考的中心光效仍在(CPU
  波形折线 / 流体圆点,新增 count_centre_glow 计数),回调数恒为 0,终态
  无光效;继续锁死「没有外圈红扫光」。
- recording_capsule_keeps_its_centre_visual:中心视觉的判据补上 CPU 波形。
- capsule_paints_state_specific_content:文字口径按样式分——classic 中心画
  「思考中」文字,siri 中心是光效不再叠文字(与 GPU 就绪后的终态一致)。

测试:--lib 192 passed、bin 93 passed、5 契约 PASS、i18n --check PASS。

* [pi] 搞完了,包给你:

* fix(linux-egui): refine titlebar and hotkey capture

* fix(linux-egui): synchronize provider channel editing

* fix(linux-egui): align shortcut handling and settings UI

* fix(linux-egui): refine Siri capsule and popup layout

* fix(linux-egui): keep capsule within desktop work area

* fix(linux-egui): keep capsule native on Wayland

* fix(linux-egui): keep Wayland capsule from stealing focus

* fix(linux-egui): align settings controls in fixed columns

* fix(linux-egui): refine Siri capsule and settings identity

* fix(linux-egui): 设置行固定标签列,模型块照 Tauri 重写

两处用户报的问题 + 三个测试构建遗留错误。

1) 录音与输入等设置页的开关/下拉又贴回标签了。17c8a620 把 row_desc 从
   ui.horizontal 换成 allocate_ui_with_layout,但没撑住标签列宽度——egui 只按
   内容实际宽度推进光标,于是标签列塌缩成文字宽度,控件紧贴标签(实测同一页
   12 个开关的 x 是 235/167/124/138/181/100…)。现在显式 set_min_width,恢复
   Tauri 的 minmax(0,200px) + 16px 间距硬列宽。新增几何回归测试
   setting_rows_put_every_control_on_one_column 把「所有控件同一列」钉住。

2) provider 编辑器的「模型」块照 Tauri ProvidersSection 重写:
   - 模型字段(ChannelModelField):descriptor 带 staticModels 时是预设下拉,
     末尾附「自定义模型…」逃生口切手输;无预设时是手输框;值为空且有
     defaultModel 时给「填入默认」。
   - 可用模型(ProviderTools):当前 endpoint 命中带文档页的预设时按钮是
     「查看支持的模型」(打开文档),否则「拉取模型」;拉取为空时明说
     「鉴权成功,但没有返回可用模型。」;拉到后是带搜索框的下拉,**选中即写入
     模型字段并落地凭据**(Tauri applyModel),不再要求用户再按一次保存。
   - orcarouter 这类没有固定清单的服务商,打开编辑器时自动拉一次目录。
   仍然替换掉自造的 providers.model_list / btn.list_models 文案,改用 Tauri
   catalog 的 settings.channels.modelTitle/modelHint/availableModels 与
   settings.providers.*(i18n 同步新增 16 个 key)。

3) 修 ChatGPT 那两个提交留下的测试构建错误(以前根本没编译过,所以从没跑):
   popup.rs 里 env 迭代拿到的是 &OsStr,不能和 &str 直接比;fcitx5_contract /
   host_contract 还在用 TranslationPressed 的旧 unit 形态;capsule 的 Wayland
   断言与 popup_command 实际行为不符(只设 OPENLESS_CAPSULE_PATH,不显式设
   WAYLAND_DISPLAY),改成断言「不删 WAYLAND_DISPLAY + 带上 layer 提示」。

测试:lib 192 passed、bin 95 passed、5 契约 PASS、i18n --check PASS、fmt 干净。

* fix(linux-egui): 对齐上游 Beta.2 的窗口层次、侧栏与历史详情

用户拿官方 Tauri Beta.2 实况(视频 2026-09-25)逐项对照,一次修完:

窗口/侧栏底色(左右搞反了)
- 侧栏改 n--ol-sidebar-bg (#f0f0f1),内容区底板从前是 CANVAS (#fafafa) 改回
  纯白(Tauri 的 ol-console-main 是 --ol-surface);两个层次之前正好反着。
- 自绘标题栏不再是纯白,改浅灰 #f4f4f5 + 一条底部分隔线(对照视频里那条
  标题栏带的灰底);标题栏高度 38 → 36(对齐 Tauri LINUX_TITLEBAR_HEIGHT)。
- 新增几何回归测试 shell_paints_a_gray_sidebar_over_white_content_and_a_
  tinted_titlebar,直接钉住「侧栏灰 / 内容白 / 标题栏更灰」。

BETA 徽章与版本号
- 徽章照 Tauri FloatingShell:透明底 + 0.5px 蓝描边(--ol-pill-blue-border)+
  圆角 5 + 10px 半粗蓝字(之前是蓝淡底、无描边、圆角 7)。
- 版本号另起一行:侧栏只有 188px,同行会把版本号拦腰折断
  (「版本 v2.0.0-」/「Beta.2+…」),视频里上游也是换行显示的。

设置/内容区留白与滚动
- 页面顶部留白 0 → 56px、底部 8 → 24px,对齐 Tauri 的页面容器
  (padding: 56px 28px 24px)——「今日概览」「历史记录」这些标题下移。
- 滚动条改预留 8px 槽位(Tauri 是 scrollbar-gutter: stable),不再悬浮压住
  右边那列文字。

概览页
- 「概览」导航图标改成上游的 lucide ChartNoAxesColumn(只有三根柱,没有
  坐标轴);之前多画了一条 L 形轴。
- 大标题 / 分区标题改用 Medium 字重(Tauri 是 600),字号不变。
- 窄屏判定改成按**窗口宽度**比 720(Tauri useMobileLayout 的阀值),不再是内容区
  比 760:主窗口缩到最小(960)时内容区只有 748,之前会错把「星期几张图」和
  「最近识别」上下堆叠。窗口太矮时整页滚动,不再把年度活动热力图裁掉。

历史页(按用户要求 + 上游 History.tsx)
- 详情头右侧的三个平铺按钮(删除/重新转录/导出)收进「…」菜单:右对齐、向下
  展开,含 播放 / 导出录音 / 重新转录 / 删除(上游还有「重新润色」,egui 后端
  还没这个能力)。菜单默认收起,点外面或 Esc 关闭。
- 流水线明细只留「识别」一行(用来语音识别的模型);润色/插入两步不再展示。
- 结果区默认只显示润色结果(胶囊是「润色 · 风格名」),原文靠「查看原文 /
  隐藏原文」开关展开,不再默认摆两栏重复内容;润色失败时回退显示原文。
- 新增 i18n key:history.show_raw / history.hide_raw / shell.beta_tag /
  history.play_recording(前两个同时补进 Tauri 的 8 个语言文件,与上游一致)。

省略号「···」变「…」
- 根因是字体:本机 fc-match sans-serif 命中的是 Noto Sans CJK SC,CJK 面的
  U+2026 是**居中**的全角省略号;把拉丁面(sans-serif:lang=en)排到链首,
  与 macOS 的 system-ui(拉丁优先、CJK 回退)一致。

测试:lib 192 passed、bin 96 passed(新增 1 个配色几何测试、改 2 个过时断言)、
5 契约全 PASS、i18n --check PASS、fmt 干净。

* fix(linux-egui): 纯图标按钮的图标居中

历史详情右上角的「…」菜单按钮传的是空 label,原来会按「图标 + 间距」的一半
偏移 3px,看着不居中。

* feat(linux-egui): 对齐历史、词典、风格与市场的第一批交互

* fix(ci): 用 basename 判定 lib.rs,兼容 Windows 路径分隔符

`tauri-linux-free-contract.test.mjs` 之前用 `file.endsWith('/lib.rs')`
判断「Linux 守卫文件」,在 Windows runner 上路径分隔符是 `\`,于是
`openless-linux-egui/src/lib.rs` 的 `cfg(target_os = "linux")` 守卫块被判成
违规,job 直接失败。改用 `path.basename`,并把 i18n 目录的跳过条件换成能同时
匹配 `/` 与 `\` 的正则。

本地 `node scripts/tauri-linux-free-contract.test.mjs` 通过(122 个 Rust 文件、
214 个 React 文件);远端 Windows CI 需在推送后确认。

* feat(core): 速记采用独立历史语义(永久录音、同一 id 更新、免于普通清理)

对齐上游 Beta.2 的 QuickNote:速记不是「history 页改个名」,它有自己的
持久化语义。

- `HistorySource::QuickNote` + `DictationOutputTarget::QuickNote`:录音开始时
  先落一条草稿(`error_code = "recording"`),完成时用**同一个历史 id** 覆盖它,
  取消时把草稿标记为 `cancelled`,而不是留下半条记录或整条消失。
- 速记不插入文本(`insert_text = false`)且音频永久归档。
- 历史清理策略区分两类记录:普通记录仍受 `retention_days` 与 `max_entries`
  限制,速记只受 200 条硬上限约束;`clear()` 也只清普通记录,不再连速记一起删。
- `dictation_output_target()` 让宿主能查询当前会话的输出目标(按钮/快捷键
  切换录音时不必猜)。
- 偏好里新增可选的速记快捷键,并接入既有的冲突消解顺序(听写 > 翻译 > 选区
  助手 > 速记 > …)。

`cargo test --locked -p openless-core` 通过。

* feat(linux-egui): 速记页、快捷键、风格包图标与翻译页缩放修复

对齐上游 Beta.2 的界面与交互,并修掉用户报告的几处偏差。

速记(QuickNote)
- 新增侧栏页面:复用历史列表/详情,只筛 `HistorySource::QuickNote`;按钮与
  快捷键共用同一条开关逻辑(未在速记中则开始永久录音,开始中则结束)。
- 速记页顶部是上游同款可收起的快捷键卡片(录制 / 停用 / × 收起),收起状态
  持久化到 `linux-ui-state.json` 的 `quick_note_shortcut_hidden`(该写入改为
  「合并单键」而不是覆盖整个文件,避免把语言偏好清掉)。
- 设置页「快捷键与选区」新增速记行;宿主的本地热键、fcitx5 插件(新增
  `QuickNoteEvent` / `SetQuickNoteHotkeyRaw`)与去重都接上同一条通路;插件没
  这个新方法时不报错(旧插件兼容)。

风格包图标
- 新增 `style_icon.rs`:本地把用户选的 SVG 栅格化成 128px PNG 再交给 Core。
  先做 XML 结构校验(根元素、禁用 `script`/`foreignObject`/`image`/`animate*`、
  禁止外部 `href`/`url(...)`/`@import`/`on*`),再限 256 KB 输入 / 64 KiB 输出。
- 卡片上的图标按钮(含铅笔角标、自定义时的 × 复位)走真实的
  `set_style_pack_icon`;纹理解码按 `(pack id, icon path)` 缓存,不再每帧重解码。

翻译页
- 卡片头部的标题/说明与状态药丸在同一 `horizontal` 里:窄列下文字不换行会把
  药丸顶出卡片,egui 的 `columns` 随即把整页宽度记为「内容宽度」,下一帧按更
  大的宽度再排一次——这正是用户看到的缩放卡顿与「使用方法」被顶出可视区的
  来源。给文字块加上按药丸宽度算出的 `set_max_width`,让说明换行。
- `ComboBox::width` 是最小文本宽度,最终按钮还要加图标与内边距,原来的
  `available - 4` 会溢出卡片;改为预留 28px。
- 新增回归测试:大→中→小三种窗口下页面都不得超出视口,且最小窗口下内容必须
  高于视口(说明「使用方法」靠滚动可达)。

纠正规则页
- 按上游合成单张卡片:「只看自动收集」改为真正的复选框(此前只是个不可点的
  徽章,筛选根本用不了),与「一键清空」同行;规则为空时在同一张卡片内给出
  占位说明。

选区助手
- 「保存历史」卡片的图标改为上游同款 40×40 圆角底 + 居中图标,标题/说明按
  12/13px 与 5px 间距居中排布,开关尺寸对齐 40×24。

词典
- 筛选标签的选中胶囊只用一个动画值插值(原来在同一帧对同一个 id 写三次不同
  目标,胶囊会抖)。

风格/市场
- 卡片角标配色与「衍生自」判定对齐上游(作者与来源作者相同时也算衍生);
  市场卡片去掉未打开详情就出现的安装按钮,详情弹窗按 560px 固定宽度居中。

i18n:`src/i18n/*.ts` 与 `linux-egui/src/i18n.rs` 同步(速记导航、快捷键卡片、
图标提示等)。`node scripts/sync-egui-i18n.mjs --check` 无漂移。

* chore(release): 同步上游 Beta.2 的两条发版约定

按上游 Beta.2 的修法同步(上游 commit a4732685 / d78b3598):

1. 日期构建元数据写成 `+build.YYYYMMDD`。Tauri 2.10.1 会把纯数字日期当成
   Windows 产品版本的第 4 段,那是 16 位字段,NSIS 打包会直接失败;加
   `build.` 前缀后 NSIS 走它支持的数字回退。应用内与 updater manifest 里仍
   是完整版本号。同时明确 tag 仍用 `v<X.Y.Z>-Beta.<N>-tauri` 形式:已发布
   的客户端只认数字 `Beta.N` 后缀。
2. Beta tag 的桌面包先落在**共享 draft** 里,三平台(macOS/Windows、
   Android、Linux egui)全部构建完、管理员核对过包与 updater manifest 之后
   才发布;正式版仍然直接发布。RELEASING.md 补上「发布后不要在未回到 draft
   的情况下重跑资产 workflow」。

Linux egui 工作流用 `draft: <是否 beta>`(不是省略字段),因为它的 tag
命名空间独立:首次为 `v*-Beta.N-egui` 建 release 时必须自己判,否则会把
预发布包推给正式版用户。

三个 workflow 都过了 js-yaml 解析;没有 workflow 语法层面的验证。

* feat(linux-egui): 风格编辑器改为上游 Beta.2 的右侧抽屉

用户报告「风格编辑器与市场详情弹窗和原版不符」。查过 Tauri 源码后确认:
Beta.2 的风格编辑器不是居中 Modal,而是贴右的抽屉 —— `top/right/bottom: 16`、
`width: min(760px, 100vw - 32px)`、滑动进场,头部固定(标题 15/600 + 随工作流
切换的说明 + 28px 圆形关闭),下面一个滚动体(padding 18、gap 16)。

布局
- `editor_overlay` 改成抽屉几何;遮罩/点击拦截/卡片仍同属一个 `Area`
  (这是之前修过的「点阴影后阴影上移」的根因,不能回退)。
- 测试夹具加参数:市场详情与历史确认仍是居中 Modal,编辑器断言
  「右边缘内缩 16、宽 760、上下各内缩 16」,遮罩探针按位置选点。

内容(按 Beta.2 的信息顺序)
- 头部:`style.pack.editorTitle` + `dictationPromptEditorDesc` /
  `selectionPromptEditorDesc`(按当前工作流)。两处写成字面量调用:
  i18n 同步只收录 `tr_l10n(…, "key")` 形式的键。
- 药丸行:内置/导入、当前模式、生效中、未保存;右侧是「导出 ZIP」与
  「启用」(复用列表行的 `StyleExport` / `StyleActivate`)。
- 字段:名称、作者、版本、标签、说明、推荐模型(带提示)、兼容版本。Core 的
  `StylePack` 本来就有这些字段,此前编辑器不显示它们,保存时反而被清掉。
- 提示词按工作流分开:听写只改 `prompt`,选区改 `selection_prompt` 与
  `voice_edit_prompt`(上游两个工作流读同一个包的不同槽位)。
- 底部:保存 / 撤销(草稿与已存包一致时禁用;撤销回读 Core 的持久化值);
  内置包显示「恢复内置」,导入包显示「删除导入」。

真实通路(不做无效按钮)
- 「恢复内置」接 Core `reset_builtin_style_pack`,「删除导入」接
  `remove_style_pack`;两者都会 publish `StylePacksChanged`,宿主据此重载列表。
  此前「恢复默认」只清空本地输入框,什么都没保存 —— 现在是真的恢复。
- 保存成功后抽屉保持打开(上游如此),并以刚存的包作为新的 dirty 基准。
- 新增 `style_editor_is_dirty`(自由函数,供 `sync_view_model` 在 `vm` 可变借用
  期间调用)与 `hydrate_style_editor`(打开 / 撤销 / 新建共用同一套回填)。
- 新增测试:两套工作流各画哪些提示词字段、内置/导入分别给哪个破坏性按钮。

`cargo test --locked -p openless-core -p openless-linux-egui` 通过(egui 侧 100 项),
五项发布契约、fmt 与 i18n `--check` 通过。

* feat(linux-egui): 编辑器补齐运行时指令卡片,市场详情补安装中状态

风格编辑器(听写工作流)
- 新增「运行时指令」卡片:上下文前提 / 热词 / 历史指令三行,每行显示
  Core 当前是否真的把它们拼进了提示词。数据来自
  `OpenLessBackend::preview_style_pack_runtime`,宿主用**草稿**去问 Core,
  所以卡片和真正发出去的提示词不会各说各话(不自己复刻规则)。
- `omits_front_app` 时补一行说明(上游 `runtimePreviewOmittedFrontApp`)。

市场详情
- 模式标签、衍生标签改用药丸(outline / ok),版本号用等宽字体,
  与上游同一行的 baseline 布局一致;提示词区域改成 12px 等宽。
- 新增安装中状态:`marketplace_installing` 记住正在安装的包 id,按钮变为
  「安装中…」且禁用;安装任务无论成败都通过
  `UiResult::MarketplaceInstallFinished` 通知宿主清状态(此前是不追踪,
  按钮永远可重复点)。
- 为此给 `view_model::MarketplacePack` 补上 `id` —— 安装动作是按列表下标
  走的,但界面要按 id 判断哪一个包在装。

两处 i18n 取值改成两个字面量调用:同步脚本只收录写在 `tr_l10n(…, "key")`
里的键,条件表达式里的键不会被采集(否则界面会直接显示原始 key)。

`cargo test --locked -p openless-core -p openless-linux-egui` 通过,
fmt 与 i18n `--check` 通过。

* chore(release): 应用版本对齐上游 Beta.2

五处 Tauri 版本号一起从 2.0.0-Beta.1 提到 2.0.0-Beta.2(package.json、
package-lock.json 根与嵌套、tauri.conf.json、src-tauri/Cargo.toml、
src-tauri/Cargo.lock 的 openless 包),与本次对齐的基准一致。CI 的
version-sync 校验要求这五处必须相同,所以只能一起改。

`openless-linux-egui` crate 自身的版本仍是 0.1.0:界面显示的版本来自
build.rs 里的 `OPENLESS_LINUX_VERSION`(缺省回落到 package.json),
所以打包标签由环境变量决定。

* chore: 移出会话编排脚本 .agents/run-parity-agents.mjs

这个文件是当时为并行改写页面用的一次性 pi-subagents 编排脚本(开头就写着
"One-off orchestration script",内容是给子代理的任务提示词),跟产品无关,
却在 #1060 的 diff 里以新增文件的形式排在最前面。本分支相对上游 beta 多出来
的顶层路径也只有它一个。

- 删除文件;本地副本留在 ~/.pi/agent/artifacts/run-parity-agents.mjs。
- .gitignore 加 `.agents/`,避免以后再被 `git add -A` 带进去。
- 注意:文件仍存在于历史提交 5be7d667 里,PR 的提交列表还能翻到;要彻底消失
  需要改写历史(rebase 丢弃该提交)后强推,风险大,先不做。

* refactor(linux-egui): 删掉无调用方的死代码,补上界面读不到的快捷键

真正的死代码(生产路径零引用,删前逐个 grep 过)

- `ButtonKind::Dark`:只有定义没有构造点。
- `LinuxBackendBuilder::with_host_actions`:宿主动作走 `LinuxBackendConfig`
  字段那条路,这个 builder 方法没人调。
- `OpenLessBackend::feed_less_computer_pcm`:速记/对话语音的 PCM 是 Core 经
  `AudioConsumer` 喂进 session 的,这条手动喂数据的口子没接线。
- `OpenLessBackend::download_marketplace_archive` + `src/marketplace.rs`:
  界面下载压缩包走的是 main.rs 里「文件对话框 + `desktop::atomic_save`」那条,
  而 `atomic_save` 的校验更强(规范化父目录、拒绝符号链接、原子替换),
  这个 create_new 版本的写入器和它的两个测试都没人用。
- `AppImageUpdater::manual_download_url`:返回一个常量,无调用方。
- `LocalePref::to_tag` 本来是死代码——因为 `save_locale_pref` 把同样的逻辑
  又抄了一遍。改成调用它,去掉重复而不是删掉函数。

界面上的空键帽(占位)

设置页「快捷键」卡片里 `switch_style_hotkey` / `open_app_hotkey` /
`coding_agent_hotkey` 三行一直在渲染,但 `sync_view_model` 只填了听写/翻译/
QA/速记四个,这三行无论 Core 偏好里有没有绑定都画成空串,看着像「未设置」,
而录制那条路其实实实在在写进了偏好。改为一律从偏好取 `display_label()`。

新增回归测试 `shortcut_rows_show_the_bindings_core_holds`:把三条绑定塞进
偏好后同步,断言三行都不是空串。已验证删掉其中一处填充该测试会失败。

`cargo test --locked -p openless-core -p openless-linux-egui` 全绿,
fmt 与 i18n `--check` 通过,五项发布契约 PASS。

* refactor(linux-egui): 删掉遗留 GL 路径与无人显示的字段,接通 AppImage 自更新

一、删除遗留 GL 着色器路径(siri_gl.rs 1121 → 354 行)

渲染早就走 wgpu,`draw` 零调用点,模块头挂着 `#![allow(dead_code)]`、注释写着
「保留作为 source parity 参考」。一并删除:4 段 GLSL(顶点 + wave/orb/ring)、
`SiriMode::fragment_source/required_uniforms/index`、`GlowProgram` /
`compile` / `ensure_program` / `prepare_all` / `draw`、`PROGRAMS` 缓存、
`GPU_READY`/`GPU_FAILED`、warm-up 队列(`warm_up` / `should_queue_warm_up` /
两个 static)、以及只为它们存在的 test hook 与 7 个测试,另加 popups.rs 里
2 处 `warm_up(ui)` 空调用与 5 处 `gpu_state_guard()`。
`SiriGlow` 上只被 GL 上传读过的 `resolved` / `speed` 字段与构造参数随之删掉
(CPU 画笔本来就不读它们,行为不变)。CPU 绘制、`visual_voice`、`tick` 及其
测试原样保留,模块文档改成现状描述。

二、HistoryEntry 里确实没人画的字段

`insert_status` / `llm_model` / `polish_ms` 在 VM、赋值与测试夹具里全部移除
(`HistoryInsertStatus` 枚举只剩镜像用途,一并删)。`app_name` 与
`dictionary_count` 按约定保留,等补列表元信息行时用。

三、接通 AppImage 自更新(上游 Tauri 走的是插件的 download + install)

上游 egui 宿主的 `download_and_install` 有生产调用方(main.rs),Tauri 侧
`AutoUpdate.tsx` 也在用,只有我们这边整条链没接 —— 所以是补,不是删:

- 宿主:`install_update()`(`download_and_install` + 进度回调 + `InstalledUpdate`),
  新增 `UiResult::UpdateProgress/UpdateInstalled`、`update_installed` /
  `update_install_error`,动作 `UpdateInstall` / `UpdateDismiss`;
  `update_dialog_stage()` 把宿主状态映射成界面阶段(字节数到齐 = 进入校验/替换)。
- 界面:新增 `ui/frontend/update.rs`,按 Tauri `Modal` 做居中卡片(≤560px),
  遮罩 + 点击拦截 + 卡片同属一个 `Area`;五个阶段各有自己的标题/说明,
  下载中显示进度条与 `progress`/`progressUnknown` 文案,安装中只显示标签。
  「已安装」不给「立即重启」按钮:宿主没有重启实现,`HostAction::RequestRestart`
  目前只写一行状态,放个按钮就是新的占位。
- i18n 复用上游 `settings.about.updateDialog.*` 全部译文(14 个 key),
  并删掉三条确实没人引用的旧 key(`update.available/downloaded/manual_notice`)。

本地门禁:`cargo test --locked -p openless-core -p openless-linux-egui` 全绿,
五项发布契约 PASS,fmt 与 i18n `--check` 通过。

* refactor(linux-egui): 不出 AppImage,整条自更新链删除

Linux 只发 deb/rpm(release 契约里明确断言打包脚本与工作流不得出现
appimagetool/AppImage),所以 `src/updater.rs` 这条「下载-校验-替换 AppImage」
的路在任何真实安装形态下都进不去:`LinuxUpdateSupport` 只有运行在 AppImage 里
才会给 `AppImage`,而 `supports_auto_update()` 又只对 AppImage 为真。上一个提
交按上游 Tauri 的形状把它接到了界面,现在按实际情况删除。

宿主(linux-egui):
- 删 `src/updater.rs`(1081 行:manifest 解析、checksum/minisign 校验、原子替换、
  周期检查调度)与其在 lib.rs 的全部导出;`minisign-verify` 一并从 Cargo.toml
  移除(Cargo.lock −7 行)。
- 删 `LinuxUpdateSupport` / `UpdateSchedule` / `UpdateManifest` / `DownloadProgress`
  / `InstalledUpdate` 及宿主字段 `update_support` / `update_schedule` /
  `update_started` / `update_manifest` / `update_busy` / `update_progress` /
  `update_installed` / `update_install_error`,删 `request_update_check` /
  `install_update` / `UpdateCheck|UpdateProgress|UpdateInstalled` / tick() 里的
  自动检查。
- `LinuxCapabilitySnapshot::detect(tray_available)` 不再接包类型与更新器;
  `supports_auto_update` 恒为 false(deb/rpm 无法替换自己),界面据此隐藏全部
  更新控件。

界面:删上一个提交加的 `update.rs` 自更新对话框、VM 里的 `UpdateStage`/进度字段、
`auto_update_capable`、About 卡「检查更新」按钮、Beta 渠道卡片
(`SettingsField::BetaChannel`、`SettingsActionField::{CheckUpdate,CheckBetaUpdate}`、
`SettingsDirty::update_channel`)与 28 条随之失去引用的 i18n key。
「自动检查更新」开关保留 —— 上游桌面版同样显示它,且它写的是 Core 偏好。

契约:`linux-egui-release-contract` 增加两条守卫——宿主不得再出现
`src/updater.rs`,Cargo.toml 不得依赖 minisign-verify。

本地门禁:core + linux-egui 测试全绿,五项发布契约 PASS,fmt 与 i18n `--check` 通过。

* docs: 更新说明不再把 AppImage 当作 Linux 发布格式

AppImage 早已从 Linux 渠道下线(`package-linux-egui.sh` 只出 deb/rpm,发布
workflow 与契约都断言产物里没有 AppImage),但 RELEASING 与 handoff 文档还在写
「deb/rpm/AppImage 安装、升级、回滚」「独立 updater manifest」「AppImage 在
listener 前安装插件」。逐处改成 deb/rpm + SHA256SUMS,并说明宿主不保留任何更新
路径、`supports_auto_update` 恒为 false、界面据此隐藏上游更新控件。

* chore(linux-egui): 清掉 270 条早期 egui 页面留下的旧 i18n key

catalog 里 367 条 key 已经没人引用,来源查清了:一半是 d14a3048
「feat(linux-egui): localize native interface」时那套旧 egui 页面自己的词表
(`nav.styles`/`nav.providers`/`nav.models`/`nav.assistant` 对应当时的十页导航、
`btn.*` 80 条、`metric.*`/`heading.*` 对应旧概览、`theme.light|dark|system`、
`lang.zh-CN`…、`recmode.*`、`auth.*`、`providers.*`、`status.*`),后来页面按
上游 Tauri 重写(现在用 `nav.group_style`/`settings.theme.*`/`settings.language.*`
等),旧 key 就再没有调用点。

处理:删掉其中 Tauri 根本没有对应 key 的 270 条(`nav.styles`、`btn.refresh`、
`lbl.*`、`metric.chars_today` …),catalog 987 → 717。
保留 94 条 Tauri 仍然在用同名 key 的(`history.vocab_hits`、`overview.recent_empty`
等)——它们没有调用点只是因为对应 UI 元素还没做,留着下次补界面时直接可用。
`metric.near7` 例外:它被 i18n.rs 自己的单测引用,留下。

删除走的是 `sync-egui-i18n.mjs` 同一套 `Msg { key: "…", text: row(…) }` 扫描器,
只动 CATALOG 切片,TEST_CATALOG 的 `k.full`/`k.missing_en` 不受影响。
本地门禁:core + linux-egui 测试 1282 通过,五项发布契约 PASS,
`sync-egui-i18n.mjs --check` 与 `cargo fmt --check` 干净。

* fix(linux-egui): 恢复 5 条被误删的词条——Tauri 有同名 key,是待补的界面文案

上一提交按「Tauri 没有同名 key」筛掉 270 条,其中 5 条判断错了(我的 key 提取比
同步脚本粗,漏了这 5 个):

- settings.recording.paste_shortcut_desc
- settings.selection_workspace.polish_hotkey_desc
- settings.coding_agent.voice_hotkey_desc
- settings.advanced.multimodal_pipeline_title_hint
- translation.howto_indicator_desc

它们在上游都在渲染:RecordingInputSection 粘贴快捷键的 desc、SelectionWorkspace
润色快捷键的 desc、ShortcutsSection 语音快捷键的 desc、MultimodalPipelineSection
卡片标题的 hint、Translation 页「如何确认翻译模式已开启」的说明。本分支对应位置
现在传的是空 desc(如 `ShortcutRow { hint: String::new() }`),属于还没接的界面
文案,不该当死代码删。已按 HEAD~1 的原文恢复(同步脚本对 TS 有的 key 会重写,
所以文本与 TS 一致)。catalog 现 722 条:987 − 265(真正没有调用点、Tauri 也没有
同名 key 的旧词条)。

* ci: Linux 构建装 liblzma-dev —— 冷缓存下 openless-core 链接失败

PR #1060 的 "Linux core and egui host tests" 在 openless-core 链接阶段就挂了:

  rust-lld: error: unable to find library -llzma
  could not compile `openless-core` (test "volcengine_credentials") due to 1 previous error

`openless-core` → `zip 2`(默认特性含 lzma)→ `xz2` → `lzma-sys` 动态链接
`-llzma`,而 GitHub 的 ubuntu-24.04 runner 只有运行时的 liblzma5,没有
`liblzma.so` 开发符号链接,三个 Linux 构建 job 的 apt 列表里都没装 liblzma-dev。
fork 自己的 CI 之所以一直是绿的,是 rust-cache 命中后不再重链,只有冷缓存的
上游 PR 任务暴露出来(与本分支的清理无关:`lzma-sys` 在 beta 的 Cargo.lock
里同样存在)。

在会编译 Rust 的三个 Linux job 里补上 liblzma-dev:
`ci.yml`(linux-core-contract)、`release-linux-egui.yml`、`release-tauri.yml`
(其 linux-egui job 也 `cargo build --release -p openless-linux-egui`)。

* fix(linux-egui): retain local text edits across host snapshots

* fix(linux-egui): declare host and IME runtime libraries

* fix(linux-egui): keep beta-owned platforms and Core unchanged

* refactor(tauri): reuse QA polish preview and drop Linux desktop host

* ci(linux-egui): verify deb and rpm in PRs and attach on shared tags

* docs: publish contributor guide and retire stale audit scripts

* ci(linux-egui): combine Core and package checks; revert unrelated docs

* fix(linux-egui): keep history fixture fresh and match Tauri tools and icons

* fix(linux-egui): restore incrementing deb and rpm package revision

* fix(linux-egui): align settings icons and anchor vocab footer

* Align Linux vocabulary modal and filters with Tauri

* Fix Linux overview footer clipping and marketplace avatar

* Align translation style badge and responsive guide with Tauri

* Keep history columns usable and isolate page scrolling at minimum size

* Rebuild content viewport on resize and blur settings backdrop

* Blur the settings backdrop live on the GPU instead of screenshotting a frame

The mask behind the settings sheet was a dark tint plus a card shadow, and the
previous attempt at a frosted look captured a single window screenshot. A
screenshot can never be live: the captured frame contains the mask itself, so
each frame would blur an already-blurred image until the backdrop dissolved.

The page is now drawn a second time, offscreen, from the same tessellated
shapes and blurred on the GPU (page -> MSAA resolve -> blur H -> blur V) inside
update, before eframe paints. The overlay samples that texture, so the backdrop
is always the current page and follows scrolling, resizing and page switches.

* Keep the page scrolling after the compositor takes over a window drag

Pressing the title bar hands the pointer to the compositor on the press frame
(Wayland's xdg_toplevel.move only accepts that serial). The price is that the
release of that gesture belongs to the compositor too, so egui keeps believing
the button is still down and keeps the drag attached to the title-bar drag zone.
ScrollArea refuses the wheel while dragged_id is set, so after moving a window
the whole page stopped scrolling until the user clicked something; the window
also moved under the pointer without any motion event, leaving egui with the
press-time coordinates (usually on the title bar), which drops the wheel as well.

raw_input_hook now repairs both before each pass: it releases a press the window
manager kept for longer than a normal click, and, when a wheel arrives while the
known pointer position is not inside the body, points the pointer at the body
centre - the wheel proves it is over the window, and a real pointer event
overrides the guess immediately.

Also match macOS on the settings sheet: the mask is the blurred page plus the
same --ol-overlay-bg tint, and the card uses the --ol-shadow-xl sized shadow.

* Match the mask to the window corners and stop a corner resize swallowing the wheel

Three reports from the same build:

* The blurred settings mask was painted as a square image over the rounded
  bottom corners, so it stuck out past the window shape. It is now a textured
  rounded rect using the content corners (and no rounding when the window fills
  the screen), and the tint above it shares the same shape.
* A maximized or tiled window kept the 6px transparent margin and the 14px
  corner radius that exist to make the floating window look like one, leaving a
  blank frame around the screen. Both are now dropped when the window fills the
  screen (maximized, fullscreen, or surface size matching the monitor).
* Resizing from a corner still killed page scrolling: the corner grips are the
  app's own BeginResize zones, so the press is handed to the compositor exactly
  like a title-bar drag, and the release never comes back. The grips now record
  the handoff too, so the orphaned press is released and the wheel works again.
  A regression test reproduces the reported flow (Overview, drag the corner to
  the minimum size, then scroll); without the fix it reports offset=0.

* linux-egui: make recording playback seekable and align shortcut controls

* fix(linux-egui): align style editor drawer and live backdrop

* fix(linux-egui): bound style editor textareas and match form spacing

* fix(linux-egui): stamp package revision in binary and complete style editor parity

* fix(linux-egui): finish style editor marketplace actions

* fix(linux-egui): blur every modal backdrop

* fix(linux-egui): wire style labels and locale settings

* chore: format rust sources

* chore(linux-egui): bump package revision to 97

* fix(ui): stabilize oauth dialog and button layout

* chore(linux-egui): bump package revision to 98

* fix(linux-egui): widen the GitHub dialog and centre its status row

* chore(linux-egui): bump package revision to 99

* fix(linux-egui): hand-paint the close controls and widen the device-code tile

* fix(linux-egui): align style cards with the Tauri pack card

* fix(core): run encrypted-sync store work on the host task spawner

Beta.3's encrypted-sync store called tokio::spawn directly, which needs an
ambient runtime and defeats the TaskSpawner seam the runtime gate enforces.
The store now takes the injected spawner and awaits an owned operation through
a channel, so dropping the caller still cannot cancel journalled work.

Also teach the gate which module files are test-only: files declared by
`#[cfg(test)] mod x;` (including a `#[path]` include inside a test module)
were reported as production and broke the Linux job. The gate now classifies
them transitively and reports every violation instead of stopping at the first.

Refreshes the Linux package revision to 102.

* ci: keep pull request caches inside the repository cache quota

The repository holds 14 GB of caches against GitHub's 10 GB limit, so the
least recently used entries were evicted and jobs such as the Linux egui check
started from a cold dependency tree (~10 minutes). Pull requests no longer
store their own copy of the platform dependency trees; they restore the base
branch cache, which only branch pushes and tags write. The Linux job keeps its
own cache, saves it even when the test step fails, and builds without debug
info so the cache stays small and links stay fast.

* ci: scope platform gates to the changed areas and bound job runtime

Every push of a linux-egui-only change re-ran the macOS, Windows and Android
Tauri jobs plus the Rust 1.88 MSRV compile, although those builds cannot reach
that code. A 'changes' job now feeds scripts/ci-changed-areas.sh, which decides
per area whether the change set can affect it and always answers 'true' when the
diff is unreadable - a skipped platform check must be provably out of scope, not
the result of a failed lookup. Push, tag and manual runs stay fully verified.
The contract test drives the script against real git diffs in both directions.

Also bounds every job with timeout-minutes (a stuck run previously held a
runner for 24 hours) and stops publishing Linux deb/rpm artifacts on plain push
runs, where only the pull request and tag runs have consumers.

README now states the MSRV policy that CI actually enforces: 1.88 for
openless-core and src-tauri on macOS and Windows, stable for the Linux host
(which requires 1.95 because of egui).

* ci: keep the actions cache budget visible and prune dead scopes

The repository was holding 14 GB of caches against GitHub's 10 GB limit, so the
least recently used scopes were evicted silently and jobs started from a cold
dependency tree: the same cargo test step costs 148 s restored and 579 s cold.
Pull request runs no longer store their own copy of the platform trees, and this
adds the rest of the policy: ci-cache-usage.sh reports usage per scope, --prune
deletes only scopes of closed or merged pull requests (open, branch and tag
scopes stay untouched), and cache-maintenance.yml runs it weekly.

docs/ci-trigger-and-cache-policy.md records the measured sizes, the per-platform
budget, the key layout and the release pre-warm step so the duplication is not
reintroduced.

* ci: let prose-only change sets skip the Linux package chain too

The Linux job is the most expensive single job in a Linux pull request, and a
documentation-only change set cannot reach the host build, its tests or the
deb/rpm chain. The reusable workflow now takes a 'scope' input (default 'full'),
so push, tag and release callers keep building everything while the pull request
run passes 'none' for prose-only change sets. Gated runs leave checkout and the
version assertion in place, so the job stays a well-formed no-op.

* ci: make the changed-area wiring fail open

The first run of the gating skipped the Linux job although the change set
contained code: the detect step never wrote the 'linux' output, so the reusable
workflow received an empty scope. Two fixes:

- The detect step computes every area in one loop, and each gate now skips only
  on an explicit 'false' (an empty or unreadable output runs the job); the
  Linux job's scope input defaults to a full build.
- The contract test reads ci.yml and the reusable workflow and asserts that
  every area is published as a job output, that each gate is fail-open, and that
  the reusable workflow defaults to a full build. Removing the 'linux' output
  again makes that test fail, which is the regression it should catch.

* docs: record the measured CI timings after the cache and gate changes

* fix(linux): close PR 1060 runtime and UI review findings

* fix(ci): preserve locked Tauri dependencies on non-macOS targets

* fix(linux): satisfy Rust 1.98 icon conversion lint

---------

Co-authored-by: egui-agent <egui@local>
Co-authored-by: worker <worker@local>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 11:00:34 +08:00
1 parent 60b43e25cf
commit 913279c049
265 files changed
+48918 -26645

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: Cache maintenance
# GitHub keeps 10 GB of Actions caches per repository. The platform dependency
# trees are large (the Windows Tauri tree alone is ~1.6 GB), so unused scopes
# evict the caches the CI jobs restore - which silently turns every run into a
# cold build. This job keeps the budget visible and drops the scopes that can
# never be restored again: pull requests that are already closed or merged.
#
# It runs weekly and on demand. It never touches branch or tag scopes, so the
# caches the release path depends on are safe.
on:
schedule:
# Monday 03:17 UTC: after the weekend, before the working week.
- cron: '17 3 * * 1'
workflow_dispatch:
permissions:
actions: write # required to delete caches
contents: read
concurrency:
group: cache-maintenance
cancel-in-progress: false
jobs:
report:
name: Report and prune caches
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
submodules: false
- name: Report cache usage and prune dead pull request scopes
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
LIMIT_GB: '8'
working-directory: openless-all/app
run: bash scripts/ci-cache-usage.sh --prune --repo "$REPO" --limit-gb "$LIMIT_GB"
+164 -12
View File
@@ -1,16 +1,168 @@
name: Linux egui build and backend checks name: Linux egui build and backend checks
# Only the CI and tag-release workflows call this deb/rpm build and verification path.
# No standalone push trigger: a Linux PR must show one combined Linux check.
on: on:
pull_request: workflow_call:
branches: [beta] inputs:
paths: # 'none' skips a change set that is only prose (see
- 'openless-all/app/**' # scripts/ci-changed-areas.sh): the callers that must always build the
- 'openless-all/scripts/linux-*/**' # packages, including the tag release, leave the default in place.
- '.github/workflows/*linux-egui.yml' scope:
push: required: false
branches: [Linux-egui] type: string
workflow_dispatch: default: full
permissions: permissions:
contents: write contents: read
concurrency:
group: linux-egui-build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
native: build-linux-egui:
uses: ./.github/workflows/release-linux-egui.yml # PipeWire/libspa in this host needs 24.04. Packages target glibc >= 2.39.
runs-on: ubuntu-24.04
# A cold dependency tree costs ~18 minutes; anything past this is a hang.
timeout-minutes: 90
env:
RELEASE_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') && github.ref_name || '' }}
# Debug info dominates both compile time and cache size for this host, and
# the gate below never reads it. The Linux job keeps its own cache so a
# red run still starts warm.
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_PROFILE_TEST_DEBUG: 0
steps:
- uses: actions/checkout@v6
with:
submodules: false
- name: Install native build and packaging dependencies
if: inputs.scope != 'none'
run: |
sudo add-apt-repository -y universe || true
sudo apt-get update
sudo apt-get install -y \
appstream build-essential cmake cpio desktop-file-utils \
fcitx5-modules-dev file libasound2-dev libdbus-1-dev \
libfcitx5config-dev libfcitx5core-dev libfcitx5utils-dev \
liblzma-dev libpipewire-0.3-dev libpulse-dev libssl-dev \
libwayland-dev libx11-dev libxkbcommon-dev pkg-config ripgrep rpm
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
if: inputs.scope != 'none'
- uses: actions/setup-node@v4
with:
node-version: '22'
if: inputs.scope != 'none'
- name: Resolve package version and validate release tag
id: version
working-directory: openless-all/app
env:
VERSION_TAG: ${{ env.RELEASE_TAG }}
run: |
set -euo pipefail
VERSION=$(node -p "require('./package.json').version")
if [ -n "$VERSION_TAG" ]; then
TAG_VERSION=${VERSION_TAG#v}
TAG_VERSION=${TAG_VERSION%-tauri}
if [ "${VERSION%%+*}" != "$TAG_VERSION" ]; then
echo "::error::tag $VERSION_TAG does not match package.json version $VERSION"
exit 1
fi
fi
# Linux packages use the same explicitly incremented -N revision as
# earlier Beta.2-78/-79 builds. Keep it separate from the Tauri tag.
REVISION=$(< linux-egui/package-revision)
[[ "$REVISION" =~ ^[1-9][0-9]*$ ]]
echo "version=${VERSION%%+*}-$REVISION" >> "$GITHUB_OUTPUT"
- uses: swatinem/rust-cache@v2
if: inputs.scope != 'none'
with:
workspaces: 'openless-all/app -> target'
# Saving after a failed test step keeps the next push incremental
# instead of repeating a cold build of the whole dependency tree.
cache-on-failure: true
- name: Build and test fcitx5 plugin
if: inputs.scope != 'none'
working-directory: openless-all/scripts/linux-fcitx5-plugin
run: |
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
cmake --build build --parallel
ctest --test-dir build --output-on-failure
test -s build/libopenless.so
test -s build/openless.conf
- name: Test Core, Linux host, and platform boundaries
if: inputs.scope != 'none'
working-directory: openless-all/app
run: |
cargo test --locked -p openless-core
cargo clippy --locked -p openless-core --all-targets -- -D warnings
cargo test --locked -p openless-linux-egui --all-targets
cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings
cargo check --locked -p openless-linux-egui --all-targets
node scripts/linux-egui-tauri-free-contract.test.mjs
node scripts/linux-egui-release-contract.test.mjs
node scripts/ci-changed-areas.test.mjs
node scripts/tauri-linux-free-contract.test.mjs
pwsh -NoProfile -File ./scripts/check-core-deps.ps1
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
- name: Test remote input TLS identity and Unix key permissions
if: inputs.scope != 'none'
working-directory: openless-all/app
run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls
- name: Validate Linux desktop metadata
if: inputs.scope != 'none'
run: |
desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop
appstreamcli validate --no-net openless-all/app/linux-egui/packaging/top.openless.OpenLess.metainfo.xml
- name: Build Linux egui host
if: inputs.scope != 'none'
working-directory: openless-all/app
run: cargo build --locked --release -p openless-linux-egui
- name: Package deb and rpm without Tauri
if: inputs.scope != 'none'
working-directory: openless-all/app
env:
OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }}
OPENLESS_LINUX_ARCH: x86_64
run: |
# CI runners are disposable. Ignore old outputs restored by a cache.
rm -f target/linux-egui-packages/*.deb target/linux-egui-packages/*.rpm \
target/linux-egui-packages/SHA256SUMS
bash scripts/package-linux-egui.sh
- name: Generate checksums and verify distributable packages
if: inputs.scope != 'none'
working-directory: openless-all/app
run: |
set -euo pipefail
OUTPUT=target/linux-egui-packages
( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS )
bash scripts/verify-linux-egui-packages.sh
- name: Upload verified Linux packages as workflow artifacts
# Push builds keep the release path warm but do not publish artifacts;
# the tag release and pull request runs are the consumers.
if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '')
uses: actions/upload-artifact@v7
with:
name: openless-linux-egui-x86_64
path: openless-all/app/target/linux-egui-packages/*
if-no-files-found: error
+63 -25
View File
@@ -3,8 +3,9 @@ name: CI
# 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责 # 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责
# 在合并前快速验证两件事: # 在合并前快速验证两件事:
# 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) # 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift)
# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 只测试共享 core。 # 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux Core、
# 跑 build-mac.sh / windows-package-msvc.ps1 / Tauri bundle 太重;只跑轻量 cargo check + vite build。 # remote_tls、egui 和 deb/rpm 统一使用发版构建链检查(PR 不上传 Release)。
# macOS/Windows/Android 不跑完整 Tauri bundle;Linux 则运行完整 deb/rpm 发版构建校验。
on: on:
push: push:
@@ -25,10 +26,42 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
# 判定这次改动能不能影响到各平台门禁。只有“改动物理上够不到”的变更才会
# 跳过对应 job:判定脚本对任何未知情况(没有基线、git 失败、空 diff)都返回
# true。push / tag / 手动运行不带基线,因此始终全量验证。
changes:
name: Changed areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
tauri: ${{ steps.detect.outputs.tauri }}
msrv: ${{ steps.detect.outputs.msrv }}
linux: ${{ steps.detect.outputs.linux }}
steps:
- uses: actions/checkout@v4
with:
# 需要基线提交才能算出差异,客户端不再重复 fetch。
fetch-depth: 0
- name: Detect changed areas
id: detect
working-directory: openless-all/app
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
for area in tauri msrv linux; do
verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA")
# 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。
echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT"
done
android-check: android-check:
if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' needs: [changes]
if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false'
name: Android cargo check name: Android cargo check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
defaults: defaults:
run: run:
working-directory: openless-all/app working-directory: openless-all/app
@@ -92,6 +125,10 @@ jobs:
- uses: swatinem/rust-cache@v2 - uses: swatinem/rust-cache@v2
with: with:
workspaces: 'openless-all/app/src-tauri -> target' workspaces: 'openless-all/app/src-tauri -> target'
# 仓库缓存上限 10 GB,超出后按 LRU 淘汰。过去每个 PR 都会各存一份
# ~1.6 GB 的依赖树,把其他 job 的缓存挤掉,导致每次运行都从零编译。
# 现在 PR 只从基线分支恢复,只有分支推送和 tag 才写入缓存。
save-if: ${{ github.event_name != 'pull_request' }}
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
@@ -172,31 +209,24 @@ jobs:
-x :app:rustBuildX86_64Debug -x :app:rustBuildX86_64Debug
--no-daemon --no-daemon
linux-core-contract: # The Linux core/egui/deb+rpm checks live in a reusable workflow, which runs
# the same `cargo test -p openless-core` as upstream's `linux-core-contract`
# plus the Linux host, fcitx5 and package contracts.
linux-egui-package:
needs: [changes]
if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos'
name: Linux core tests name: Linux Core, egui and deb/rpm checks
runs-on: ubuntu-22.04 permissions:
defaults: contents: read
run: uses: ./.github/workflows/check-linux-egui.yml
working-directory: openless-all/app with:
steps: # 散文改动跳过整条 Linux 构建与打包链;输出缺失/未知时退回 full。
- uses: actions/checkout@v4 scope: ${{ needs.changes.outputs.linux == 'false' && 'none' || 'full' }}
with:
submodules: false
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: 'openless-all/app -> target'
- name: Test Core
run: cargo test --locked -p openless-core
- name: Test remote input TLS identity and Unix key permissions
run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls
cross-platform: cross-platform:
needs: [changes]
# 失败即运行:只有判定脚本明确说“够不到”才跳过。
if: needs.changes.outputs.tauri != 'false'
name: ${{ matrix.label }} checks name: ${{ matrix.label }} checks
strategy: strategy:
# 一个平台挂掉不阻塞其他平台拿到验证结果。 # 一个平台挂掉不阻塞其他平台拿到验证结果。
@@ -204,6 +234,7 @@ jobs:
matrix: matrix:
include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"os":"macos-latest","label":"macOS","preflight":false}]' || '[{"os":"macos-latest","label":"macOS","preflight":false},{"os":"windows-latest","label":"Windows","preflight":true}]') }} include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"os":"macos-latest","label":"macOS","preflight":false}]' || '[{"os":"macos-latest","label":"macOS","preflight":false},{"os":"windows-latest","label":"Windows","preflight":true}]') }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
env: env:
# 新增 shared Core 后,macOS 首次编译同时构建 MLX C++ 依赖和完整 # 新增 shared Core 后,macOS 首次编译同时构建 MLX C++ 依赖和完整
# Tauri test binary;限制并发避免 arm64 runner 在峰值内存处被系统终止。 # Tauri test binary;限制并发避免 arm64 runner 在峰值内存处被系统终止。
@@ -242,12 +273,14 @@ jobs:
if: runner.os == 'Windows' if: runner.os == 'Windows'
with: with:
workspaces: 'openless-all/app/src-tauri -> target' workspaces: 'openless-all/app/src-tauri -> target'
save-if: ${{ github.event_name != 'pull_request' }}
- name: Cache macOS stable dependencies - name: Cache macOS stable dependencies
if: runner.os == 'macOS' if: runner.os == 'macOS'
uses: swatinem/rust-cache@v2 uses: swatinem/rust-cache@v2
with: with:
key: macos-stable-v1 key: macos-stable-v1
save-if: ${{ github.event_name != 'pull_request' }}
workspaces: | workspaces: |
openless-all/app -> target openless-all/app -> target
openless-all/app/src-tauri -> target openless-all/app/src-tauri -> target
@@ -368,8 +401,12 @@ jobs:
echo "[ok] 全部 5 处版本号一致:$PKG" echo "[ok] 全部 5 处版本号一致:$PKG"
macos-msrv: macos-msrv:
needs: [changes]
# 失败即运行:只有判定脚本明确说“够不到”才跳过。
if: needs.changes.outputs.msrv != 'false'
name: macOS Rust 1.88 MSRV name: macOS Rust 1.88 MSRV
runs-on: macos-latest runs-on: macos-latest
timeout-minutes: 60
env: env:
CARGO_BUILD_JOBS: 2 CARGO_BUILD_JOBS: 2
CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_DEV_DEBUG: 0
@@ -391,6 +428,7 @@ jobs:
- uses: swatinem/rust-cache@v2 - uses: swatinem/rust-cache@v2
with: with:
key: macos-msrv-v1 key: macos-msrv-v1
save-if: ${{ github.event_name != 'pull_request' }}
workspaces: | workspaces: |
openless-all/app/src-tauri -> target openless-all/app/src-tauri -> target
openless-all/app/src-tauri/backend-tests -> target openless-all/app/src-tauri/backend-tests -> target
+38 -252
View File
@@ -1,268 +1,54 @@
name: Release Linux egui name: Release Linux egui
# This workflow deliberately has no automatic tag trigger until real Ubuntu # An admin pushes v*-tauri only after CI and Linux device acceptance. This
# audio, focus/input, install, upgrade, and rollback evidence is recorded. It is # workflow calls the exact same deb/rpm build used by CI, then attaches its
# already reusable by a release orchestrator: pass release_tag to upload verified packages. # verified assets to the shared Tauri/Android Release (Beta remains a draft).
on: on:
push:
tags:
- 'v*-tauri'
workflow_dispatch: workflow_dispatch:
inputs:
release_tag:
description: Existing GitHub release tag to receive Linux assets; blank only uploads Actions artifacts
required: false
type: string
workflow_call:
inputs:
release_tag:
required: false
type: string
secrets:
LINUX_EGUI_MINISIGN_SECRET_KEY:
required: false
permissions: permissions:
contents: write contents: read
concurrency:
group: release-linux-egui-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }}
cancel-in-progress: false
jobs: jobs:
build-linux-egui: build-linux-egui:
runs-on: ubuntu-22.04 permissions:
env: contents: read
RELEASE_TAG: ${{ inputs.release_tag }} uses: ./.github/workflows/check-linux-egui.yml
publish-linux-egui:
# Manual builds only upload Actions artifacts; they never change a Release.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
needs: build-linux-egui
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/download-artifact@v8
with:
submodules: false
- name: Install native build and packaging dependencies
run: |
sudo add-apt-repository -y universe || true
sudo apt-get update
sudo apt-get install -y \
appstream \
build-essential \
cmake \
clang \
desktop-file-utils \
extra-cmake-modules \
fcitx5-modules-dev \
file \
fuse \
libasound2-dev \
libdbus-1-dev \
libfcitx5config-dev \
libfcitx5core-dev \
libfcitx5utils-dev \
libopenblas-dev \
libkf5globalaccel-dev \
qtbase5-dev \
libatspi2.0-dev \
pulseaudio-utils \
fonts-noto-cjk \
libssl-dev \
libwayland-dev \
libx11-dev \
libxkbcommon-dev \
patchelf \
pkg-config \
ripgrep \
rpm \
ruby-dev \
wget
sudo gem install --no-document fpm -v 1.16.0
- name: Install appimagetool
env:
APPIMAGETOOL_SHA256: b90f4a8b18967545fda78a445b27680a1642f1ef9488ced28b65398f2be7add2
run: |
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage \
-O /tmp/appimagetool
echo "$APPIMAGETOOL_SHA256 /tmp/appimagetool" | sha256sum --check --strict
chmod +x /tmp/appimagetool
sudo install -m 0755 /tmp/appimagetool /usr/local/bin/appimagetool
file /usr/local/bin/appimagetool | grep -q ELF
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: '1.94.0'
components: clippy
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: openless-all/app/package-lock.json
- name: Build React baseline and generate native locale catalog
working-directory: openless-all/app
run: |
npm ci
npm run build
node ../scripts/sync-egui-locales.mjs
- name: Build GNOME and KDE desktop integration
run: |
node openless-all/scripts/linux-desktop/gnome/build.mjs
cmake -S openless-all/scripts/linux-desktop/kde -B openless-all/scripts/linux-desktop/kde/build -DCMAKE_BUILD_TYPE=Release
cmake --build openless-all/scripts/linux-desktop/kde/build --parallel
- name: Cache Cargo
uses: swatinem/rust-cache@v2
with:
workspaces: 'openless-all/app -> target'
- name: Build fcitx5 plugin
working-directory: openless-all/scripts/linux-fcitx5-plugin
run: |
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
cmake --build build --parallel
ctest --test-dir build --output-on-failure
test -s build/libopenless.so
test -s build/openless.conf
- name: Checkout pinned Qwen ASR runtime source
run: git submodule update --init --depth 1 -- openless-all/app/src-tauri/vendor/qwen-asr
- name: Build portable Qwen ASR runtime
working-directory: openless-all/app/src-tauri/vendor/qwen-asr
run: |
make blas CFLAGS_BASE="-Wall -Wextra -O3 -ffast-math -mtune=generic"
test -x qwen_asr
./qwen_asr --help >/dev/null 2>&1
- name: Verify framework-independent Linux contract
working-directory: openless-all/app
shell: bash
run: |
set -euo pipefail
cargo test --locked -p openless-core
cargo clippy --locked -p openless-core --all-targets -- -D warnings
cargo test --locked -p openless-linux-egui --lib --test host_contract
cargo check --locked -p openless-linux-egui --all-targets
pwsh -NoProfile -File ./scripts/check-core-deps.ps1
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
- name: Validate release target and UI gate
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -z "${RELEASE_TAG:-}" ]; then
exit 0
fi
case "$RELEASE_TAG" in
v*-tauri) ;;
*)
echo "::error::release_tag must name an existing v*-tauri release"
exit 1
;;
esac
if grep -q 'openless-linux-egui host stub' openless-all/app/linux-egui/src/main.rs; then
echo "::error::The egui UI stub cannot be uploaded as a release asset"
exit 1
fi
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Validate Linux desktop metadata
run: |
desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop
appstreamcli validate --no-net openless-all/app/linux-egui/packaging/top.openless.OpenLess.metainfo.xml
- name: Build Linux egui host
working-directory: openless-all/app
run: cargo build --locked --release -p openless-linux-egui
- name: Resolve package version
id: version
shell: bash
working-directory: openless-all/app
run: |
if [ -n "${RELEASE_TAG:-}" ]; then
VERSION=${RELEASE_TAG#v}
VERSION=${VERSION%-tauri}
else
VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml | head -1)
fi
test -n "$VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Package deb, rpm, and AppImage without Tauri
working-directory: openless-all/app
env:
OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }}
OPENLESS_LINUX_ARCH: x86_64
APPIMAGE_EXTRACT_AND_RUN: '1'
run: bash scripts/package-linux-egui.sh
- name: Verify package contents and ELF dependencies
working-directory: openless-all/app
run: bash scripts/verify-linux-egui-packages.sh
- name: Sign AppImage and write independent updater manifest
working-directory: openless-all/app
env:
MINISIGN_SECRET: ${{ secrets.LINUX_EGUI_MINISIGN_SECRET_KEY }}
VERSION: ${{ steps.version.outputs.version }}
run: |
OUTPUT=target/linux-egui-packages
APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit)
if [ -n "${RELEASE_TAG:-}" ] && [ -z "${MINISIGN_SECRET:-}" ]; then
echo "::error::LINUX_EGUI_MINISIGN_SECRET_KEY is required for release upload"
exit 1
fi
if [ -n "${MINISIGN_SECRET:-}" ]; then
if ! command -v minisign >/dev/null 2>&1; then
cargo install --locked --version 0.9.1 minisign
fi
printf '%s' "$MINISIGN_SECRET" > "$RUNNER_TEMP/linux-egui.minisign.key"
minisign -S -s "$RUNNER_TEMP/linux-egui.minisign.key" -m "$APPIMAGE" -x "$APPIMAGE.minisig"
fi
SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1)
ASSET=$(basename "$APPIMAGE")
RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}"
SIGNATURE=null
if [ -f "$APPIMAGE.minisig" ]; then
SIGNATURE=$(base64 -w0 "$APPIMAGE.minisig" | jq -R .)
fi
jq -n \
--arg version "$VERSION" \
--arg url "https://github.com/$RELEASE_REPOSITORY/releases/download/${RELEASE_TAG:-manual}/$ASSET" \
--arg sha256 "$SHA256" \
--argjson signature "$SIGNATURE" \
'{schemaVersion:1, host:"linux-egui", arch:"x86_64", version:$version, url:$url, sha256:$sha256, minisign:$signature}' \
> "$OUTPUT/latest-linux-egui-x86_64.json"
- name: Verify Linux updater manifest matches the artifact
working-directory: openless-all/app
run: |
set -euo pipefail
OUTPUT=target/linux-egui-packages
APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit)
MANIFEST="$OUTPUT/latest-linux-egui-x86_64.json"
test -s "$APPIMAGE"
test -s "$MANIFEST"
SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1)
RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}"
jq -e \
--arg sha256 "$SHA256" \
--arg repository "$RELEASE_REPOSITORY" \
'.schemaVersion == 1 and .host == "linux-egui" and .arch == "x86_64" and .sha256 == $sha256 and (.url | startswith("https://github.com/" + $repository + "/releases/download/"))' \
"$MANIFEST" >/dev/null
- name: Upload Linux egui workflow artifacts
uses: actions/upload-artifact@v4
with: with:
name: openless-linux-egui-x86_64 name: openless-linux-egui-x86_64
path: openless-all/app/target/linux-egui-packages/* path: linux-egui-packages
if-no-files-found: error
- name: Upload Linux egui assets to existing release - name: Verify downloaded release assets
if: inputs.release_tag != '' run: |
set -euo pipefail
test "$(find linux-egui-packages -maxdepth 1 -name '*.deb' | wc -l)" -eq 1
test "$(find linux-egui-packages -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1
test "$(find linux-egui-packages -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0
( cd linux-egui-packages && sha256sum --check --strict SHA256SUMS )
- name: Attach Linux assets to the shared release draft
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ inputs.release_tag }} tag_name: ${{ github.ref_name }}
files: openless-all/app/target/linux-egui-packages/* draft: ${{ endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.') }}
prerelease: ${{ endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.') }}
files: linux-egui-packages/*
fail_on_unmatched_files: true fail_on_unmatched_files: true
+4
View File
@@ -42,6 +42,9 @@ jobs:
matrix: matrix:
include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }} include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }}
runs-on: ${{ matrix.platform }} runs-on: ${{ matrix.platform }}
# A release build takes ~33 minutes from a cold cache; anything past this is
# a hang that would otherwise hold the runner and the release for hours.
timeout-minutes: 120
env: env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -562,6 +565,7 @@ jobs:
name: Update Homebrew cask (stable only) name: Update Homebrew cask (stable only)
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
permissions: permissions:
contents: write contents: write
if: >- if: >-
+5 -5
View File
@@ -213,9 +213,9 @@ Every item below is one more layer sedimented into a default — a capability yo
- **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor).
- **Local model management** — manage on-disk local-ASR model storage from Settings. - **Local model management** — manage on-disk local-ASR model storage from Settings.
- **Multilingual UI** — Settings → Language switches between 简体中文 / 繁體中文 / English / 日本語 / 한국어 (auto-detected on first launch). - **Multilingual UI** — Settings → Language switches between 简体中文 / 繁體中文 / English / 日本語 / 한국어 (auto-detected on first launch).
- **In-app auto-update on the Tauri hosts** — Settings → About → Check; signed updater artifacts via the Tauri updater plugin on macOS, Windows, and Android. Linux has an independent manifest and updater contract. - **In-app auto-update on the Tauri hosts** — Settings → About → Check; signed updater artifacts via the Tauri updater plugin on macOS, Windows, and Android. Linux deb/rpm packages have no in-app updater or AppImage manifest.
- **Beta channel (opt-in)** — Settings → About → Join Beta channel exposes the latest pre-release build for manual download. Beta releases never reach Stable users automatically (see [Contributing workflow](#contributing-workflow)). - **Beta channel (opt-in)** — Settings → About → Join Beta channel exposes the latest pre-release build for manual download. Beta releases never reach Stable users automatically (see [Contributing workflow](#contributing-workflow)).
- **Distribution channels** — direct DMG/EXE from [Releases](../../releases), Homebrew Cask (add the project tap first; see installation below), and a Windows installer. Linux packages are not published until the separate egui UI is complete and its release gate is enabled. - **Distribution channels** — direct DMG/EXE from [Releases](../../releases), Homebrew Cask (add the project tap first; see installation below), and a Windows installer. Linux deb/rpm packages attach to the shared Release only after device acceptance and an admin's release tag.
- **Single-instance lock** — prevents two OpenLess processes from racing the same hotkey edge. - **Single-instance lock** — prevents two OpenLess processes from racing the same hotkey edge.
- Dictionary entries are injected as Volcengine ASR `context.hotwords` and as semantic hints during polish; hits accumulate per session. - Dictionary entries are injected as Volcengine ASR `context.hotwords` and as semantic hints during polish; hits accumulate per session.
- Platform-native global hotkey: CGEventTap on macOS, low-level keyboard hook (`WH_KEYBOARD_LL`) on Windows. - Platform-native global hotkey: CGEventTap on macOS, low-level keyboard hook (`WH_KEYBOARD_LL`) on Windows.
@@ -237,7 +237,7 @@ Go to [Releases](../../releases) and download:
- In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings. - In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings.
- Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts. - Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts.
- If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK. - If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK.
- **Linux**: the Tauri/WebView build has been retired. `linux-egui` now contains a native `eframe` UI backed by the shared Core 2.0 services. Production release still requires Linux CI artifacts plus real Ubuntu audio, focus/input, install, upgrade, and rollback evidence. - **Linux**: the Tauri/WebView build has been retired. The native `eframe` host uses the shared Core 2.0 services. After CI and real Ubuntu audio, input, install, upgrade and rollback acceptance, the shared release includes deb/rpm packages and `SHA256SUMS`. Linux requires glibc ≥ 2.39 (Ubuntu 24.04 or a comparable distribution); there is no AppImage or in-app updater.
- **macOS (Homebrew)**: - **macOS (Homebrew)**:
```bash ```bash
brew tap Open-Less/openless https://github.com/Open-Less/openless brew tap Open-Less/openless https://github.com/Open-Less/openless
@@ -267,7 +267,7 @@ For the full end-user walkthrough, see [USAGE.md](USAGE.md).
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). 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. Rust 1.88 is the minimum supported toolchain for the desktop application sources (`openless-core`, `src-tauri`); the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS and Windows; the Linux egui host requires Rust 1.95 and is verified on stable.
On Apple Silicon, compiling the optional Qwen3-ASR MLX backend requires Xcode's MetalToolchain component. Install it with `xcodebuild -downloadComponent MetalToolchain` and verify it with `xcrun --find metal`. This is a source-build dependency; packaged OpenLess applications do not require it at runtime. On Apple Silicon, compiling the optional Qwen3-ASR MLX backend requires Xcode's MetalToolchain component. Install it with `xcodebuild -downloadComponent MetalToolchain` and verify it with `xcrun --find metal`. This is a source-build dependency; packaged OpenLess applications do not require it at runtime.
@@ -398,7 +398,7 @@ 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). `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<version>-tauri` / `v<version>-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 `v<version>-tauri` / `v<version>-Beta.N-tauri` workflows build the macOS, Windows and Android hosts alongside the independent Linux egui deb/rpm workflow. After Linux device acceptance and an admin's tag, verified Linux packages attach to the shared Release; Beta assets remain in a draft until an admin publishes them. Linux has no AppImage or in-app updater manifest.
The dictation pipeline: `hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`. The dictation pipeline: `hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`.
+5 -5
View File
@@ -218,9 +218,9 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
- **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。
- **本地模型管理**——在设置中管理本地 ASR 模型在磁盘上的存储。 - **本地模型管理**——在设置中管理本地 ASR 模型在磁盘上的存储。
- **多语言界面**——设置 → 语言 可在 简体中文 / 繁體中文 / English / 日本語 / 한국어 之间切换(首次启动自动检测)。 - **多语言界面**——设置 → 语言 可在 简体中文 / 繁體中文 / English / 日本語 / 한국어 之间切换(首次启动自动检测)。
- **Tauri 宿主内自动更新**——macOS、Windows 与 Android 通过 设置 → 关于 → 检查 获取签名产物;Linux 使用独立更新清单与 updater 契约。 - **Tauri 宿主内自动更新**——macOS、Windows 与 Android 通过 设置 → 关于 → 检查 获取签名产物;Linux deb/rpm 不提供应用内更新或 AppImage 更新清单。
- **Beta 频道(可选加入)**——设置 → 关于 → 加入 Beta 频道,可下载最新预发布版本进行手动安装。Beta 版本绝不会自动推送给 Stable 用户(见[贡献流程](#贡献流程))。 - **Beta 频道(可选加入)**——设置 → 关于 → 加入 Beta 频道,可下载最新预发布版本进行手动安装。Beta 版本绝不会自动推送给 Stable 用户(见[贡献流程](#贡献流程))。
- **分发渠道**——从 [Releases](../../releases) 直接下载 DMG/EXE、Homebrew Cask(须先添加项目 tap,见下方安装步骤)、Windows 安装包。Linux 包会等独立 egui UI 完成并启用发布门禁后再正式发布。 - **分发渠道**——从 [Releases](../../releases) 直接下载 DMG/EXE、Homebrew Cask(须先添加项目 tap,见下方安装步骤)、Windows 安装包。Linux deb/rpm 在真机验收、管理员创建发版 tag 后附到共用 Release。
- **单实例锁**——防止两个 OpenLess 进程争抢同一个快捷键边沿。 - **单实例锁**——防止两个 OpenLess 进程争抢同一个快捷键边沿。
- 词典条目注入到支持热词的 ASR 提供方(Volcengine 的 `context.hotwords`、StepFun 的 `hotwords`、Whisper 兼容的 `prompt`(ZenMux 除外——其 JSON 协议不携带 `prompt`/`hotwords`)、百炼的 vocabulary_id),并在润色时作为语义提示;命中次数按会话累计。讯飞实时语音转写标准版没有请求级热词参数,需在讯飞控制台配置个性化热词。 - 词典条目注入到支持热词的 ASR 提供方(Volcengine 的 `context.hotwords`、StepFun 的 `hotwords`、Whisper 兼容的 `prompt`(ZenMux 除外——其 JSON 协议不携带 `prompt`/`hotwords`)、百炼的 vocabulary_id),并在润色时作为语义提示;命中次数按会话累计。讯飞实时语音转写标准版没有请求级热词参数,需在讯飞控制台配置个性化热词。
- 平台原生全局快捷键:macOS 上为 CGEventTap,Windows 上为低级键盘钩子(`WH_KEYBOARD_LL`)。 - 平台原生全局快捷键:macOS 上为 CGEventTap,Windows 上为低级键盘钩子(`WH_KEYBOARD_LL`)。
@@ -242,7 +242,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
- 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。 - 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。
- 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。 - 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。
- 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。 - 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。
- **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。`linux-egui` 现已包含基于共享 Core 2.0 服务的原生 `eframe` UI;正式发布仍需 Linux CI 产物以及 Ubuntu 真实音频、焦点输入、安装、升级和回滚证据。 - **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。基于共享 Core 2.0 服务的原生 `eframe` 宿主经 CI 和 Ubuntu 真实音频、输入、安装、升级、回滚验收后,随共用 Release 提供 deb/rpm 和 `SHA256SUMS`。要求 glibc ≥ 2.39(Ubuntu 24.04 或同等级发行版);没有 AppImage 或应用内更新。
- **macOS(Homebrew)**: - **macOS(Homebrew)**:
```bash ```bash
brew tap Open-Less/openless https://github.com/Open-Less/openless brew tap Open-Less/openless https://github.com/Open-Less/openless
@@ -272,7 +272,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
活跃 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)。 活跃 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。 Rust 1.88 是桌面应用源码(`openless-core`、`src-tauri`)所支持的最低工具链版本;建议使用最新 stable Rust。CI 在 macOS 与 Windows 上同时验证 Rust 1.88 与 stable;Linux egui 宿主需要 Rust 1.95,按 stable 验证。
Apple Silicon 编译可选的 Qwen3-ASR MLX 后端时需要 Xcode 的 MetalToolchain 组件。执行 `xcodebuild -downloadComponent MetalToolchain` 安装,并用 `xcrun --find metal` 验证。它只属于源码构建依赖,已打包的 OpenLess 应用运行时不需要该组件。 Apple Silicon 编译可选的 Qwen3-ASR MLX 后端时需要 Xcode 的 MetalToolchain 组件。执行 `xcodebuild -downloadComponent MetalToolchain` 安装,并用 `xcrun --find metal` 验证。它只属于源码构建依赖,已打包的 OpenLess 应用运行时不需要该组件。
@@ -403,7 +403,7 @@ 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)。 `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<version>-tauri` / `v<version>-Beta.N-tauri` 工作流发布 macOS、Windows 与 Android 宿主。Linux deb/rpm/AppImage 由 `release-linux-egui.yml` 使用独立 manifest 构建;自动发布仍以产物成功和 Ubuntu 真实安装、运行、升级、回滚证据为门禁。 `v<version>-tauri` / `v<version>-Beta.N-tauri` 工作流构建 macOS、Windows、Android 宿主以及独立的 Linux egui deb/rpm。Linux 真机验收完成、管理员创建 tag 后,验证过的 Linux 包自动附到共用 Release;Beta 资产先留在草稿中供管理员核对发布。Linux 不提供 AppImage 或应用内更新清单。
听写流水线:`hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`。 听写流水线:`hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`。
+19 -15
View File
@@ -7,7 +7,7 @@ day-to-day development.
**Only repository administrators may create version tags and publish releases.** **Only repository administrators may create version tags and publish releases.**
- Only an admin may create a release tag (`v*-tauri`), invoke the Linux asset workflow for an existing release, or publish a GitHub Release. - Only an admin may create a release tag (`v*-tauri`) or publish a GitHub Release. Linux assets are attached automatically by the tag workflow after product acceptance.
- Contributors (including AI agents) **must not** create release tags, publish - Contributors (including AI agents) **must not** create release tags, publish
releases, or trigger release automation. If a release is needed, **request an admin releases, or trigger release automation. If a release is needed, **request an admin
to cut it** — open an issue or ping a maintainer with the target version and the to cut it** — open an issue or ping a maintainer with the target version and the
@@ -43,9 +43,13 @@ component, which is a 16-bit field. The full version still appears in the app an
updater manifest; NSIS uses its supported numeric fallback for file metadata. updater manifest; NSIS uses its supported numeric fallback for file metadata.
See [the pinned NSIS bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.10.1/crates/tauri-bundler/src/bundle/windows/nsis/mod.rs#L149). See [the pinned NSIS bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.10.1/crates/tauri-bundler/src/bundle/windows/nsis/mod.rs#L149).
These tags publish the macOS, Windows, and Android Tauri hosts. Linux is not part of the Tauri matrix. Its independent host is built by `.github/workflows/release-linux-egui.yml`, which accepts an existing `release_tag` and writes `latest-linux-egui-x86_64.json`. This workflow has no automatic tag trigger; Linux publication requires its own product acceptance below. These tags build the macOS, Windows, and Android Tauri hosts and the independent Linux egui host in parallel. Linux is not part of the Tauri matrix: `.github/workflows/release-linux-egui.yml` builds only deb/rpm, attaches them to the same Release draft and publishes no in-app updater manifest or AppImage. Linux product acceptance must be completed **before** an admin pushes the shared tag. CI runs the same Linux packaging and verification path on PRs without attaching release assets.
For each new Linux package candidate, increment `openless-all/app/linux-egui/package-revision`
from the last `-N` suffix (for example `2.0.0-Beta.2-79` → `2.0.0-Beta.2-80`).
Both local packaging and the shared CI/tag build use this explicit revision; never drop
it or derive it from the Tauri tag, whose SemVer remains `2.0.0-Beta.2`.
Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. This first delivery includes a usable Core contract and a [split handoff directory](docs/linux-egui-handoff/README.md) for Linux; the egui team owns remaining Linux Host/UI work and Linux product acceptance. Incomplete Linux application features do not independently block this Windows/macOS delivery. Shared Core defects and the desktop platforms' own acceptance requirements still do. Existing Android builds do not expand this scope into a new full-support commitment. Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. The egui team owns Linux Host/UI work and Linux product acceptance. Although Linux application gaps did not previously block Windows/macOS delivery, this shared-tag workflow now attaches Linux packages automatically: do not cut a shared release tag until Linux is accepted as well. Existing Android builds do not expand this scope into a new full-support commitment.
## Version-sync gate ## Version-sync gate
@@ -74,26 +78,26 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as
1. Branch is the intended channel (`beta` for Beta, `main` for Stable). 1. Branch is the intended channel (`beta` for Beta, `main` for Stable).
2. All five version files match (version-sync gate green). 2. All five version files match (version-sync gate green).
3. CI is green on the commit being tagged. 3. CI is green on the commit being tagged.
4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. 4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. Linux acceptance below is required before including Linux assets.
5. Then, and only then, push the release tag. 5. Then, and only then, push the release tag.
6. Beta tag workflows upload desktop and Android assets to a shared draft. 6. Beta tag workflows upload Tauri, Android and Linux egui assets to a shared draft.
Wait for both workflows to succeed, verify the packages and Beta updater Wait for **all three** workflows to succeed, verify the packages, Linux deb/rpm
manifests, then publish that draft as a prerelease. Do not rerun an asset checksums and Beta updater manifests, then publish that draft as a prerelease.
workflow after publication without first returning the release to draft. Do not rerun an asset workflow after publication without first returning the release to draft.
Before attaching Linux assets, additionally require all of the following: Before pushing a tag that will automatically attach Linux assets, additionally require all of the following:
1. The egui team has completed the [Linux Host/UI gaps and acceptance](docs/linux-egui-handoff/07-acceptance.md). The existing `eframe::App` is a starting implementation; its presence and successful packaging alone do not establish product completeness. 1. The egui team has completed the [Linux Host/UI gaps and acceptance](docs/linux-egui-handoff/07-acceptance.md). The existing `eframe::App` is a starting implementation; its presence and successful packaging alone do not establish product completeness.
2. Linux core/host tests, dependency gates, and secret-surface gates are green on Ubuntu. 2. Linux core/host tests, dependency gates, and secret-surface gates are green on Ubuntu.
3. The manual Linux workflow verifies the ELF dependency list, deb/rpm/AppImage contents, desktop metadata, fcitx5 plugin paths, minisign output, and independent updater manifest. 3. The Linux workflow verifies ELF dependencies, deb/rpm contents, desktop metadata, fcitx5 plugin paths and package SHA-256 checksums. There is no AppImage, minisign key or Linux updater manifest.
4. An admin passes the already published Tauri release tag as `release_tag`; a non-empty tag also requires `LINUX_EGUI_MINISIGN_SECRET_KEY`. 4. The shared tag points to the same commit whose CI and Linux product acceptance were reviewed. A `workflow_dispatch` build only uploads Actions artifacts, never Release assets.
## Process summary ## Process summary
1. Land work on `beta` via PRs (open PRs against `beta`, never `main`). 1. Land work on `beta` via PRs (open PRs against `beta`, never `main`).
2. For a Stable release, a maintainer merges `beta → main`. 2. For a Stable release, a maintainer merges `beta → main`.
3. An **admin** bumps the Tauri version (five-location sync), verifies CI is green, 3. An **admin** bumps the Tauri version (five-location sync), verifies CI is green,
and pushes the release tag, which triggers the macOS/Windows/Android publish and and pushes the release tag, which triggers the macOS/Windows/Android and Linux
auto-update pipeline. asset workflows (only supported platforms generate auto-update manifests).
4. After Linux product acceptance and packaging have passed on its native 4. After Linux product acceptance and the shared tag's Tauri, Android and Linux
runner, an admin invokes the independent Linux workflow against that existing tag. workflows pass, an admin reviews all assets and publishes the shared draft.
+1 -1
View File
@@ -65,7 +65,7 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装
## 5. 窗口体系 ## 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` 守护并转发启动意图。 `src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`(含复用它的「润色结果」模式)、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。
主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。
+108
View File
@@ -0,0 +1,108 @@
# CI 触发范围与缓存配额
状态:canonical;更新:2026-09-26。本文说明 CI 为什么在什么情况下跑哪些 job、缓存在 10 GB 预算内怎么分配,以及发版前怎么把耗时压下来。
## 1. 触发与门控
| 事件 | 行为 |
|---|---|
| `pull_request` → `main` / `beta` | 按改动范围门控:改动够不到的平台直接跳过(见下) |
| `push` → `main` / `beta` | 全量验证(合并后的基线必须有完整证据),不跳过任何 job |
| tag `v*-tauri` | 发版:调用同一个可复用 Linux 打包工作流,并发布 deb/rpm |
| `workflow_dispatch` | 全量;`platform=macos` 只跑 macOS(仅 macOS 验证入口) |
同一 PR 连续推送会取消旧运行(`concurrency.cancel-in-progress`)。
### 改动范围判定
`openless-all/app/scripts/ci-changed-areas.sh` 对每个重平台 job 判定“改动物理上够不够得到它”,CI 的 `changes` job 调用它填 `changes.outputs.tauri` / `changes.outputs.msrv`:
| 区域 | 跳过条件(全部改动都命中才算跳过) | 覆盖的 job |
|---|---|---|
| `tauri` | 仅 `linux-egui/**`(但**不含**它的 `Cargo.toml`,那会动全工作区 lock)、`scripts/linux-fcitx5-plugin/**`、`docs/**`、`*.md`、`LICENSE` | macOS / Windows 桌面检查、Android 检查 |
| `msrv` | 全部改动里没有任何 `.rs`、`Cargo.toml`、`Cargo.lock`、`rust-toolchain*`、`.github/**` | macOS Rust 1.88 MSRV |
| `linux` | 全部改动都是散文(`docs/**`、`*.md`、`LICENSE`、`NOTICE`) | Linux Core/egui 测试与 deb/rpm 打包链 |
规则是**失败即运行**,而且这一原则贯穿三层:
1. 脚本层:没有基线提交(push / tag / 手动)、git 读取失败、diff 为空、区域名未知,一律返回 `true`。
2. 工作流层:`changes` job 只把明确的 `false` 当作跳过依据,其余(含输出缺失)都算运行;`linux` 的 `scope` 输入默认 `full`。
3. 契约层:`scripts/ci-changed-areas.test.mjs` 既驱动脚本对真实 git 差异做正反判定,也检查 ci.yml 是否把每个区域都发布成 job 输出、每个门控是否写成 fail-open、可复用工作流是否默认全量构建。区域名或输出接线写错会直接测试失败——这正是它抓到的一次真实故障(`linux` 输出漏写导致 Linux job 被误跳过)。
跳过只允许是“改动可达性可证明”的结果,不允许是“读不到差异”或“接线漏了”的兜底。
`linux-egui-package` 通过可复用工作流的 `scope` 输入接收结果(`full` / `none`,**默认 `full`**):纯散文改动跳过整个 Linux 构建与打包链,而 push / tag / 发版调用不传该输入,因此永远全量构建。
## 2. MSRV 为什么是独立 job
- `openless-core`、`src-tauri`、`src-tauri/backend-tests` 都声明 `rust-version = "1.88"`,README 对外承诺该版本,因此必须有 CI 用真的 1.88 编译:MSRV 只会被“依赖升级抬高要求”或“自己用了更新的语言特性/API”打破,两者都只有在 1.88 下编译才会暴露。
- 工具链不同 ⇒ cargo fingerprint 全失效、产物与缓存不能共用。历史上 stable 与 MSRV 挤在同一个 job 里共用缓存,串行 29 分 59 秒(见 [`macos-build-performance.md`](macos-build-performance.md));拆成并行独立 job、缓存分开后是 stable 8m36s / MSRV 3m39s。
- 平台专属依赖图不同,所以 macOS 与 Windows 各自验证:macOS 是 `keyring/apple-native`、MLX/qwen-asr;Windows 是 `keyring/windows-native`、TSF IME。
- Linux 宿主 `linux-egui` 声明 `rust-version = "1.95"`(egui 0.36 的要求),因此 Linux 侧不存在 1.88 检查,README 已按此修正。
## 3. 缓存配额分配(10 GB / 仓库)
GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job 只会打印 `No cache found.`,表现为“无理由全量冷编译”。实测过的对照:`cargo test -p openless-core` 命中 148 s,冷启动 579 s。
实测体积与收益(一次命中节省 / 该 job 缓存体积):
| job | 缓存 | 命中收益 | 性价比 |
|---|---|---|---|
| macOS Rust 1.88 MSRV | 0.72 GB | −13.1 min | 1102 s/GB |
| macOS stable | 0.88 GB | −11.4 min | 775 s/GB |
| Linux egui | 1.34 GB | −10.8 min | 483 s/GB |
| Android | 0.75 GB | −2.4 min | 188 s/GB |
| Windows | 1.62 GB | −4.6 min | 169 s/GB |
分配原则:**按平台配额 + 只有基线分支/tag/迭代中的 job 允许写。**
| 归属 | 目标 | 谁写 | 谁读 |
|---|---|---|---|
| Windows 依赖树 | 1.2–1.6 GB | `push`(beta/main)、tag | 所有 PR、发版 |
| macOS(stable 0.88 + MSRV 0.72 + MLX 0.30) | ~1.9 GB | 同上 | 同上 |
| Linux(egui + core) | ~1.7 GB | `push`、tag,**以及 PR 里的 `build-linux-egui`** | 同上 |
| Android | 0.75 GB | `push`、tag | PR |
| 发版(tag 作用域) | ~1.0 GB | tag / 发版前预热 | — |
| 余量 | ≥2 GB | — | — |
对应的机制:
- 重量级 job 用 `save-if: ${{ github.event_name != 'pull_request' }}`:PR 只从基线分支恢复,不再各自复制一份 1.6 GB 依赖树(此前两个 PR 就占了 10.87 GB,把基线缓存挤掉)。
- `build-linux-egui` 保留 PR 内自写并开 `cache-on-failure: true`:失败的运行也会把依赖缓存存下来,下一次迭代仍然热;同时关掉 dev/test debug info,缓存更小、链接更快。
- key 结构为 `v0-rust-<job>-<os>-<环境哈希>-<lockfile哈希>`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。
- `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。
## 4. 维护
- 报告与清理:`bash openless-all/app/scripts/ci-cache-usage.sh [--prune] [--limit-gb 8] [--repo OWNER/NAME]`。`--prune` 只删已关闭/已合并 PR 的 `refs/pull/*` 作用域(先查 PR 状态,状态读不到就保留),永不触碰分支与 tag 作用域;用量超过预算时退出码为 1。
- 自动维护:`.github/workflows/cache-maintenance.yml` 每周一 03:17 UTC 运行并支持手动触发,预算 8 GB。
- 合并/关闭 PR 后可手动立即回收:`gh api -X DELETE repos/Open-Less/openless/actions/caches?ref=refs/pull/<N>/merge`。
## 5. 实测对照
同一 PR(#1060)、同一批 job,改动前后:
| job | 改动前(冷 / 超配额) | 缓存配额生效后 | 门控生效后 |
|---|---|---|---|
| Changed areas | — | — | 11 s |
| macOS checks | 20m01s | 8m36s | 6m16s |
| Windows checks | 20m04s | 15m30s | 13m17s |
| Android | 8m11s | 5m50s | 5m50s |
| macOS Rust 1.88 MSRV | 16m47s | 3m39s | 3m39s |
| Linux egui(测试 + 打包) | 17m21s | 6m35s(旧缓存前缀) | 8m08s(新前缀首次命中) |
同一步骤的冷 / 热对照:`cargo test -p openless-core` 命中 201 s、冷启动 579 s;`cargo build --release -p openless-linux-egui` 命中 124 s、冷启动 463 s。整轮墙钟时间由最慢的 job 决定(本轮 Windows 13m17s),改动前是 20m12s。
## 6. 发版耗时
`Release Tauri (cross-platform)` 的基线是 ~32.7 分钟,关键路径是 macOS Intel 构建。原因是 tag 运行只能读默认分支(`beta`)的缓存,而基线分支跑的是 debug/test profile,不产生 `macos-release-v1` 前缀的 release 缓存,因此每个新 tag 都要从零编译 release 依赖。
**发版前先预热**:把一个 `Release Tauri (cross-platform)` 运行 dispatch 到普通分支(上游历史上就在 `perf/macos-ci-build` 这样做过,单次 11–19 分钟),预热完成后再打 tag,可把发版压到 ~10 分钟量级。
Linux 发版不受影响:`release-linux-egui.yml` 复用 CI 的同一个 `build-linux-egui` job(同名同 key),tag 运行能恢复基线分支的缓存。
## 7. 已知取舍
- 改动范围门控只跳过“够不到”的平台;`push` 到 `main`/`beta` 始终全量,所以合并后仍有完整证据。
- `build-linux-egui` 在 `push` 时仍会构建 release 二进制(保持基线缓存里有 release 产物、并持续验证打包链),但不再上传 Action artifact;artifact 只在 PR 与 tag 运行产生。
- 所有 job 都设了 `timeout-minutes`,避免卡死的运行长时间占用并发与时间。
+1
View File
@@ -26,6 +26,7 @@
## 平台与运营 ## 平台与运营
- [macOS CI 与打包耗时](macos-build-performance.md):基线日志、Rust 编译优化、缓存边界与仅 macOS 验证入口。 - [macOS CI 与打包耗时](macos-build-performance.md):基线日志、Rust 编译优化、缓存边界与仅 macOS 验证入口。
- [CI 触发范围与缓存配额](ci-trigger-and-cache-policy.md):改动范围门控、10 GB 缓存分配与维护、发版预热点。
- [Android APK 编译耗时调研与实现](android-build-time-research-1103.md):ABI 并行、缓存回写和发布验证。 - [Android APK 编译耗时调研与实现](android-build-time-research-1103.md):ABI 并行、缓存回写和发布验证。
- [Android APK / 悬浮窗计划](android-mobile-apk-overlay-plan.md)(实施中) - [Android APK / 悬浮窗计划](android-mobile-apk-overlay-plan.md)(实施中)
- [火山引擎 ASR 配置](volcengine-setup.md) - [火山引擎 ASR 配置](volcengine-setup.md)
+10 -4
View File
@@ -1,6 +1,6 @@
# Linux egui 后端接口契约(2.0.0) # Linux egui 后端接口契约(2.0.0)
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。
## 1. 合同文件 ## 1. 合同文件
@@ -17,8 +17,8 @@
## 2. Linux 侧公开签名(源码为准) ## 2. Linux 侧公开签名(源码为准)
- `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc<OpenLessBackend>)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`drain_events(...)`、`feed_less_computer_pcm(&[u8])`。 - `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc<OpenLessBackend>)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`update_preference_fields(...)`(`preference_patch.rs`) 、`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`。 - `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_settings_runtime / with_polish_failure_policy` → `build() -> LinuxBackendRuntime`。
- 事件消费:`drain_events`(`lib.rs`)批量取走 Core 语义事件;订阅经 `EventSubscription`。 - 事件消费:`drain_events`(`lib.rs`)批量取走 Core 语义事件;订阅经 `EventSubscription`。
## 3. 注入接口(`crates/openless-core/src/ports.rs`) ## 3. 注入接口(`crates/openless-core/src/ports.rs`)
@@ -28,11 +28,17 @@
## 4. 与桌面共享 ## 4. 与桌面共享
- 云 ASR/LLM/Omni/Auxiliary 实现两端共用(Core `asr/`、`provider_*`、`omni`、`llm_gemini`);平台 Host 只注入原生录音、凭据、窗口、进程、焦点与插入 Adapter。 - 云 ASR/LLM/Omni/Auxiliary 实现两端共用(Core `asr/`、`provider_*`、`omni`、`llm_gemini`);平台 Host 只注入原生录音、凭据、窗口、进程、焦点与插入 Adapter。
- 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 与 Core 同进程,经类型化 Rust Interface 调用。 - 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 常驻宿主与 Core 同进程,经类型化 Rust Interface 调用;独立 UI 子进程经 Unix socket JSONL v2 通信,先 Hello/Ready 握手再收业务快照和热键。关闭 UI 不停止宿主或 Backend。
- provider 公开目录:Core `provider_rules::provider_descriptors` → 生成 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors`)。 - provider 公开目录:Core `provider_rules::provider_descriptors` → 生成 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors`)。
## 5. 行为约定(合同级) ## 5. 行为约定(合同级)
- fcitx5 为生产启动硬依赖:持有单实例锁后检查插件和会话 D-Bus,按需刷新并在 15 秒就绪预算内等待,再启动监听器、注册必需热键、启动 Core 和 Remote Input。失败清理已启动资源并显示错误窗口;关闭后以非零状态退出,外层锁覆盖完整生命周期。
- 设置保存状态与动作处理位于 `linux_app/settings_save.rs`:按序列化字段生成补丁、串行提交、只重试明确 revision 冲突(最多三次),失败保留草稿并显式重试;Core 合同与持久化格式不变。
- 窗口生命周期与 client 位于 `linux_app/window.rs`,协议门禁位于 `ui/bridge.rs`。显式显示窗口请求创建窗口或发送 FocusMain;Wayland 是否激活由合成器决定。
- 历史缓存位于 `linux_app/history.rs`:初始加载、history revision 变化、事件重放截断或显式刷新触发单任务读取,历史文件和 WAV 探测只在 `spawn_blocking` 中进行;概览复用缓存。
- Linux 当前不包含任何本地 ASR 推理运行时;Qwen、MLX、Foundry 均明确不支持,不记作待设备验证。
- 原生预加载必须使用请求的 target/provider type;平台无法准备流式时以 `supports_streaming=false` 保留一次性落字。 - 原生预加载必须使用请求的 target/provider type;平台无法准备流式时以 `supports_streaming=false` 保留一次性落字。
- Remote stop 保持可取消的 session;socket 下行只转发本连接所属 session 的事件。 - Remote stop 保持可取消的 session;socket 下行只转发本连接所属 session 的事件。
- 自动 contract 不替代真实平台证据:Ubuntu/Windows/macOS/Android 的设备、安装、升级与签名结果按[验收](linux-egui-handoff/07-acceptance.md)与[桌面验收](2.0-desktop-acceptance.md)分别记录。 - 自动 contract 不替代真实平台证据:Ubuntu/Windows/macOS/Android 的设备、安装、升级与签名结果按[验收](linux-egui-handoff/07-acceptance.md)与[桌面验收](2.0-desktop-acceptance.md)分别记录。
+32
View File
@@ -0,0 +1,32 @@
# Linux egui 依赖升级与 Vulkan 渲染
更新:2026-09-24。以 `linux-egui/Cargo.toml`、Cargo.lock 和当前源码为准;旧 Pi 记录和此前暂缓计划只作为线索。
## 已完成
- eframe / egui / egui-winit / egui-wgpu 从 0.33.3 升至 0.36.2;wgpu 从 27 升至 30.0.1;Glow 绑定随之升至 0.17。
- Linux 主窗口和普通 eframe 弹窗显式选择 `Renderer::Wgpu`,并把 wgpu 实例可用后端限定为 Vulkan。
- Siri 波形、圆点和选区助手环改用 egui 原生图形绘制,避免 Glow shader callback 在 WGPU 窗口中被忽略后退化成普通音量条。
- 0.36 破坏性 API 已适配:`App::ui`、`Context::run_ui`、`CentralPanel::show(&mut Ui)`、文本编辑框 frame、UI 样式按主题设置,以及 Glow layer-shell 纹理增量可变传递。
- Cargo.lock 已解析升级 62 个兼容依赖;包括 eframe/egui 图形栈、accesskit、wgpu/Naga 等。其余直接依赖未为追新而跨主版本改动。
## 仍存在的 GL 路径
自建的 Wayland wlr-layer-shell 胶囊面仍通过 `glutin`/EGL 和 `egui_glow::Painter` 提交 epaint 图元。这是独立于 eframe 主窗/普通弹窗的 surface 实现;Siri 效果本身已不再依赖 GL shader。X11 overlay 也保留现有窗口管理器路径。
若要让 layer-shell surface 也由 WGPU/Vulkan 提交,需要将 `popup_layer.rs` 的手工 Wayland surface、presentation 和帧同步整体改成 wgpu surface;这不是 egui 升级的 API 适配,需另行实现与设备验证。
## 当前验证与限制
- 已运行 `cargo fmt --manifest-path openless-all/app/Cargo.toml --package openless-linux-egui`。
- 已运行 `cargo check --manifest-path openless-all/app/Cargo.toml -p openless-linux-egui`,在 Rust 1.95.0 下通过。Cargo 缓存和 build 输出位于 `/tmp`。
- 本环境 `vulkaninfo --summary` 只枚举出 llvmpipe CPU Vulkan 设备;没有真实 GPU,因此不能据此证明硬件 Vulkan 性能、Wayland layer-shell 和发行版驱动表现。
- 未运行测试或完整安装包构建。egui 0.36 API 变更可能影响现有 GUI 测试源码,运行验证前需单独完成测试 API 迁移。
- eframe 仍依赖 winit 0.30;此版本升级不会解决 Wayland 触摸屏拖动窗口所需 serial 的缺口。
## 后续设备验收
1. 在带 Vulkan 硬件驱动的 Wayland 与 X11 主机启动主窗及三个弹窗,确认日志枚举到硬件 Vulkan adapter。
2. 检查 Siri 录音波形、处理动画和 QA 录音环在透明 surface 上的颜色、裁剪、帧率与显存/CPU 占用。
3. 检查 LayerShell 胶囊的 EGL surface、窗口位置、焦点与音频动画;当前这条 surface 仍走 GL。
4. 在无 Vulkan loader 或 adapter 时确认故障提示清晰。目前选择的是 Vulkan-only,启动时没有自动回退 GL。
+34 -17
View File
@@ -1,20 +1,37 @@
# 02:Linux-egui L01–L12 状态 # 02:Linux 缺口登记与实施顺序
更新:2026-09-11;锁定基线:867a0d8。实现、自动检查、真实桌面验收是三个独立维度。当前没有开展前端界面验证或浏览器自动化。最终检查证据与产物见 [08 交付记录](08-linux-egui-2.0.md)。 状态:以当前 `openless-linux-egui` 源码盘点;更新:2026-09-24。此表区分代码实现、自动验证和设备验证,不把待实测项目记作未实现。
| ID | 已实现及源码 | 自动验证 | 待人工验收 | ## 1. 状态定义
| --- | --- | --- | --- |
| L01 | desktop_bridge/x11_desktop、GNOME/KWin/KGlobalAccel、fcitx5;完整快捷键、严格冲突事务、恢复和重启重绑 | 设置事务、raw 键值转换、fcitx 输入合同;Linux/Qt 编译 | 八种桌面/协议组合的全局按下释放,尤其左右修饰键;桌面服务重启与实际冲突 |
| L02 | context/atspi 注入两个 Adapter;版本化目标/隐私/会话代次和文字观察 | 过期目标/迟到捕获/隐私/编辑差异测试 | GTK/Qt/浏览器应用身份及文本接口,实际手改产生建议 |
| L03 | audio、audio_mute、audio_cue、recordings;归档、保留、提示音、播放导出、静音恢复 | WAV、输出设备恢复、异常释放测试 | 真麦克风、PulseAudio/PipeWire、设备切换、退出恢复 |
| L04 | selection_voice/host_windows/popup;捕获、意图、预览、应用、取消和撤回 | Core 16 项 Selection Voice 合同、原生选区目标和撤回测试 | 焦点恢复、不同输入客户端、跨屏定位;修改后的原目标被拒绝 |
| L05 | 八页中的词典/纠错/风格;Core 持久化、预设增改删、启停、导入导出、独立提示词 | Core 词典/风格/配置持久化;原生编译 | 页面交互及视觉对照 |
| L06 | host_marketplace;真实查询/排序/详情/安装/下载/点赞/发布管理、OAuth | Core 市场协议与凭据测试;原生编译 | 授权登录、服务端发布/删除及网络错误,不在测试中发布真实风格包 |
| L07 | host_history;完整历史、统计、原文/结果、播放/导出/重转写/试用润色/取消/清理 | Core 历史/录音保留,原生 WAV/路径/取消合同 | 真实音频播放与模型重转写;界面布局 |
| L08 | host_models/host_omni;下载/取消/清理/路径/镜像/激活/测试/释放/删除、Omni 配置 | Core 下载取消、运行时缺失、Qwen 进程组结束测试;原生编译 | 实际模型下载和推理;外部服务端配置 |
| L09 | ui/settings 与 host_settings;七类设置、渠道、手机输入、云同步、隐私、Less Computer | Core revision/持久化、凭据锁定、事件和审批合同 | 七类设置全部子页及各成功/失败/取消路径,审批跨页保留 |
| L10 | tray/desktop/updater;托盘、后台、自启、通知、AppImage 校验/原子更新/回滚 | 自启、托盘命令、签名/校验、取消、回滚测试 | 桌面托盘/通知能力;签名发布包升级与包管理器安装 |
| L11 | CPAL/fcitx5/Secret Service/Qwen/CLI/Remote TLS 保留并重新接线 | Core 及 Linux 后端测试、fcitx C++ 合同、Qt 编译 | 真实 Linux 会话、钥匙环、设备、远程手机输入 |
| L12 | Linux CI、release 构建、deb/rpm/AppImage、桌面组件和交付说明 | 构建/包内容结果见 08,未签名候选产物 | 八种桌面组合验收及正式私钥签名发布;不能以 WSL 编译代替 |
完成判据:实现与自动证据齐备后可提交人工验收;只有对应矩阵和签名发布证据完成,才关闭真实桌面/分发维度。所有待人工项均保持未验收,不能标成已通过。 - **待实现**:Linux 生产路径缺少实际代码或用户入口。
- **已实现,待设备验证**:生产代码和自动化覆盖已存在,仍需真实桌面、设备或安装证据。
- **明确不支持**:当前 Linux 能力边界,不得在 capability/UI 中宣称可用。
## 2. 待办登记
| ID | 当前状态与源码锚点 | 关闭标准 |
| --- | --- | --- |
| L01 | **部分完成**:QA 编辑预览的“确认并替换”现已按 Tauri 处理应用失败/成功/取消结果,失败时保留预览;Selection Voice 本身仍无 Linux 生产触发和录音/意图/应用完整编排 | 从按键边沿、捕获原选区、录音终结、意图分流到 QA/编辑预览及应用/取消/撤回均按 session 所有权执行;失败/迟到结果不能作用于新会话 |
| L02 | **明确不支持**:Linux 不保证前台应用身份或插入后手改观察;`backend.rs` 使用 Core Noop HostContext/EditObservation adapter | 保持 `source_app = None` 和 capability 真实;只有找到跨 X11/Wayland 可靠且尊重隐私的机制才重开 |
| L03 | **已实现,待设备验证**:`audio.rs` CPAL 录音、WAV 归档、保留策略、静音/恢复、提示音及历史录音操作已接入 | X11/Wayland 验证设备拔插、取消/错误/退出后的恢复、提示音与实际音量设备行为 |
| L04 | **已实现,待设备验证**:fcitx5 为启动硬依赖;插件、会话 D-Bus 或必需热键失败进入错误窗口,关闭后退出;不提供 IBus/系统全局热键替代 | X11/Wayland 验证触发、冲突拒绝、重绑/重启恢复及不支持场景提示 |
| L05 | **已实现,待设备验证**:远程手机输入 HTTPS/WSS、TLS 身份、PIN、录音页、Core 外部音频会话与设置 UI 位于 `remote_input.rs` / `runtime.rs` / `settings.rs` | 用真实手机和 Linux 主机完成同网连接、证书信任、PIN、录音插入/仅转写、断线恢复、禁用/关停撤销;记录平台/浏览器矩阵 |
| L06 | **已实现,待设备验证**:Secret Service 凭据、fcitx5、托盘/通知/自启均有 Host 实现;Qwen 等本地 ASR **明确不支持**,没有推理运行时;更新器不适用(只发 deb/rpm,宿主不自更新) | 逐项在目标发行版/桌面运行验证;不得将未实现的本地 ASR 记作待设备验证 |
| L07 | **明确不支持**:Linux 不承诺 IBus 或通用全局热键;Selection Voice 在 L01 完成前保持隐藏 | 能力发现、设置和产品文案持续反映实际能力 |
| L08 | **已实现,待设备验证**:deb/rpm 包和发布工作流已存在(AppImage 已下线) | 完成安装、升级、回滚、校验和与分发验收记录 |
## 3. 实施顺序
1. 完成 L01,按 Tauri 的 Selection Voice 状态机接线,并保留 Linux 对不可用目标能力的显式拒绝。
2. 完成 L05 的真实手机端到端验证;自动化测试通过不能替代手机/桌面证据。
3. 依序补齐 L03/L04/L06/L08 的设备证据;L02/L07 是明确边界,不伪造支持。
## 4. 源码审计补充
- `openless-core` 内有 **5 处 `target_os = "linux"` 条件编译,分布在 4 个文件**:ASR frame、Volcengine ASR、`shared_types.rs` 两处、polish。另有 2 处 `cfg(unix)`,分别是事务替换和录音归档,属于 Unix 通用逻辑。其余 Linux 文本主要是文档/测试或平台中立策略说明。
- 目前不建议拆 Core:这些条件编译只覆盖少量输入格式/供应商差异,服务、策略和契约仍平台中立;Linux 的 DBus、录音、fcitx5、桌面窗口和远程输入适配器已经位于 `linux-egui`。
- 无边框窗口拖动的触摸屏限制来自 Wayland 拖动协议所需的指针按键 serial,winit 丢弃触摸 serial;升级 egui 不能改变该协议条件。桌面侧可用 KWin 的 **Alt+F7** 移动窗口(键盘移动后用触摸板/方向键定位);若当前会话是 X11,可由窗口管理器移动。此项目不在应用内伪造拖动实现。
- 依赖升级与 Vulkan 后端切换已落入源码;见 [`linux-egui-dependency-upgrade.md`](../linux-egui-dependency-upgrade.md)。设备图形路径仍需在真实 GPU/Wayland/X11 主机验收。
记录格式:`ID / commit / 实现效果 / 自动证据 / 设备证据 / 剩余限制`。
@@ -1,13 +1,11 @@
# 03:全局热键与窗口 # 03:全局热键与窗口
> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应缺口:L01、L10。系统注册和窗口效果由 Linux Host 负责,业务动作继续调用 Core。 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应缺口:L01、L10。系统注册和窗口效果由 Linux Host 负责,业务动作继续调用 Core。
## 1. 已有实现 ## 1. 已有实现
- **fcitx5 热键监听**:`hotkeys.rs` `Fcitx5HotkeyListener::start()/drain()/take_error()`——经 fcitx5 插件路由热键事件(已实现待实测,见 L11)。 - **fcitx5 热键监听**:`hotkeys.rs` `Fcitx5HotkeyListener::start()/drain()/take_error()`——经 fcitx5 插件路由热键事件(已实现待实测,见 L11)。
- **fcitx5 输入/选区**:`fcitx5.rs`——插件安装(AppImage 在 listener 前安装)、PRIMARY 选区、落字。 - **fcitx5 输入/选区**:`fcitx5.rs`——插件安装(deb/rpm 下把随包插件装进用户 fcitx5 搜索路径)、PRIMARY 选区、落字。
- **单实例**:`single_instance.rs` `SingleInstanceGuard::acquire(path)` + `SingleInstanceBroker::acquire_or_forward()/drain()`——二次启动意图转发给首实例。 - **单实例**:`single_instance.rs` `SingleInstanceGuard::acquire(path)` + `SingleInstanceBroker::acquire_or_forward()/drain()`——二次启动意图转发给首实例。
- **设置侧拒绝逻辑**:`settings.rs:56-61` 对 `switch_style` / `open_app` / `style_packs` 三类热键修改给出明确拒绝信息(L01 的直接证据)。 - **设置侧拒绝逻辑**:`settings.rs:56-61` 对 `switch_style` / `open_app` / `style_packs` 三类热键修改给出明确拒绝信息(L01 的直接证据)。
- **能力探测**:`capabilities.rs` `LinuxCapabilitySnapshot`(session 类型、`PlatformCapabilities`、权限快照),`supports_tray` 只是探测字段。 - **能力探测**:`capabilities.rs` `LinuxCapabilitySnapshot`(session 类型、`PlatformCapabilities`、权限快照),`supports_tray` 只是探测字段。
+1 -3
View File
@@ -1,7 +1,5 @@
# 04:页面与领域操作 # 04:页面与领域操作
> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。页面定义:`linux-egui/src/ui_state.rs` `Page` 枚举(10 页);主循环与交互:`main.rs`。 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。页面定义:`linux-egui/src/ui_state.rs` `Page` 枚举(10 页);主循环与交互:`main.rs`。
## 1. 十页现状 ## 1. 十页现状
@@ -14,7 +12,7 @@
| Selection | 划词润色预览/撤回(`selection.rs`) | Selection Voice 生产触发/捕获/意图路由(L04) | | Selection | 划词润色预览/撤回(`selection.rs`) | Selection Voice 生产触发/捕获/意图路由(L04) |
| Agent | Less Computer 工具调用、审批固定在页面外、后台事件保留 | Agent 检测/模型/路径/权限配置(L09) | | Agent | Less Computer 工具调用、审批固定在页面外、后台事件保留 | Agent 检测/模型/路径/权限配置(L09) |
| Services | AI 服务配置、渠道管理(`settings.rs` + Core provider 面) | Omni 有效模式配置入口完整性(L09) | | Services | AI 服务配置、渠道管理(`settings.rs` + Core provider 面) | Omni 有效模式配置入口完整性(L09) |
| Models | 本地 Qwen 下载/激活/取消 | 完整模型管理:路径/镜像/详情/删除/预载/释放(L08) | | 本地 ASR | 明确不支持;Linux 不包含 Qwen 推理运行时 | 保持能力声明与 UI 一致,不作为待设备验证功能 |
| Remote | 手机输入独立页、连接状态、陈旧地址处理 | — | | Remote | 手机输入独立页、连接状态、陈旧地址处理 | — |
| History | 只读最近条目 | 完整历史/统计/录音操作(L07,依赖 L03) | | History | 只读最近条目 | 完整历史/统计/录音操作(L07,依赖 L03) |
| Settings | 现有设置独立页 | 词典/纠错/风格包/市场无页面(L05/L06);多数设置缺实际消费者(L09) | | Settings | 现有设置独立页 | 词典/纠错/风格包/市场无页面(L05/L06);多数设置缺实际消费者(L09) |
@@ -1,7 +1,5 @@
# 05:原生宿主、数据与系统集成 # 05:原生宿主、数据与系统集成
> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应 L02、L03、L10、L11。平台效果归 Linux Host;现有代码可复用。 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。对应 L02、L03、L10、L11。平台效果归 Linux Host;现有代码可复用。
## 1. 音频(`audio.rs`) ## 1. 音频(`audio.rs`)
@@ -22,7 +20,7 @@ Core `ports.rs` 提供 `EditObservationSink` / `EditObservationAdapter`(`ports
## 4. 桌面集成(L10) ## 4. 桌面集成(L10)
未实现:托盘(`capabilities.rs` 仅探测 `supports_tray`)、自启、通知(仅状态栏)、检查/下载/安装更新与重启。AppImage 能力判定(`LinuxPackageKind`)不等于更新器已接好。关闭标准:各能力有真实 Host 效果与失败反馈,不以配置状态伪造就绪。 未实现:托盘(`capabilities.rs` 仅探测 `supports_tray`)、自启、通知(仅状态栏)。更新不在范围内:Linux 只发 deb/rpm,宿主无法替换自己,`PlatformCapabilities::supports_auto_update` 恒为 false,界面据此隐藏上游的全部更新控件(检查按钮、Beta 渠道卡)。关闭标准:各能力有真实 Host 效果与失败反馈,不以配置状态伪造就绪。
## 5. 进程与运行时 ## 5. 进程与运行时
@@ -1,7 +1,5 @@
# 06:事件、会话与取消 # 06:事件、会话与取消
> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。
入口:Core `events.rs` / `types.rs`、`linux-egui/src/lib.rs`(`LinuxHost`)、`linux-egui/src/main.rs`(主循环)。 入口:Core `events.rs` / `types.rs`、`linux-egui/src/lib.rs`(`LinuxHost`)、`linux-egui/src/main.rs`(主循环)。
+2 -4
View File
@@ -1,7 +1,5 @@
# 07:验收与证据 # 07:验收与证据
> 2026-09-11 更新:下文保留 2026-09-07 原始合同及缺口背景。Linux-egui 分支当前实现和验证状态见 [02 状态表](02-gap-register.md) 与 [08 交付记录](08-linux-egui-2.0.md);本文旧缺口不作为现状结论。
状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。 状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。
## 1. 两个不同的完成门 ## 1. 两个不同的完成门
@@ -17,7 +15,7 @@
| Core | `cargo test -p openless-core --locked` | | Core | `cargo test -p openless-core --locked` |
| Linux Host/合同 | `cargo test -p openless-linux-egui --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` 的目标工具链) | | 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) | | 打包 | `release-linux-egui.yml`(deb/rpm + `SHA256SUMS`;AppImage 已从该渠道下线) |
## 3. 真机矩阵(L11/L12) ## 3. 真机矩阵(L11/L12)
@@ -25,7 +23,7 @@
- 输入:fcitx5 插件热键路由、PRIMARY 选区、落字目标应用矩阵。 - 输入:fcitx5 插件热键路由、PRIMARY 选区、落字目标应用矩阵。
- 音频:CPAL 设备、系统静音/恢复终态。 - 音频:CPAL 设备、系统静音/恢复终态。
- 凭据:Secret Service(含锁定/未解锁态)。 - 凭据:Secret Service(含锁定/未解锁态)。
- 打包:AppImage / deb / rpm 安装、升级、回滚、卸载残留;签名与分发渠道证据。 - 打包:deb / rpm 安装、升级、回滚、卸载残留;校验和与分发渠道证据。
## 4. 记录格式 ## 4. 记录格式
@@ -1,136 +0,0 @@
# Linux-egui 2.0 实施与交付记录
基准:`Open-Less/openless:beta` 的 `867a0d8ce746c64cadc1be68fead98d9838b9f15`。分支:`Linux-egui`。执行时锁定基准,后续上游变动不混入本次复刻。原 PI 工作仍在 `feat/bundled-pi-computer` 的 `75a3a2710c251e4deca453fab17a07a3fb79047b`。
## 源码对应关系
下表路径均相对于 `openless-all/app/`。以基准 React 源码为设计来源;没有运行前端界面测试、截图比对或浏览器自动化。像素一致性、真实桌面焦点及设备行为由使用者按后面的矩阵验收。
| React 2.0 来源 | egui 实现与服务入口 |
| --- | --- |
| `src/pages/Overview.tsx` | `linux-egui/src/ui/frontend/pages.rs` 概览;Core 历史、活动、凭据配置状态;异步结果按请求代次接收 |
| `src/pages/History.tsx` | 同文件历史;`host_history.rs` 播放/导出/重转写/试用润色/取消/删除;原文和最终文本分别展示 |
| `src/pages/Vocab.tsx` | 词汇启停、纠错规则、真实内置及自定义预设,统一调用 Core 持久化 |
| `src/pages/Style.tsx` | `host_styles.rs` 听写/选区风格、独立提示词、创建/编辑/复位/删除、ZIP 导入导出 |
| `src/pages/Marketplace.tsx` | `ui/frontend/marketplace.rs`、`host_marketplace.rs` 查询、排序、点赞、详情、安装、ZIP、我的发布、设备 OAuth |
| `src/pages/Translation.tsx`、`SelectionAsk.tsx`、`Corrections.tsx` | `ui/frontend/pages.rs` 翻译目标/工作语言、QA 历史开关、纠错建议接受/拒绝 |
| `src/components/SettingsModal.tsx`、`src/pages/settings/` | `ui/settings.rs` 七类设置;`host_settings.rs`、`host_models.rs`、`host_omni.rs` 提交 Core 设置事务 |
| `src/components/Onboarding.tsx` | `host_onboarding.rs` 首次启动、麦克风与桌面组件、进入服务配置 |
| `src/components/Capsule.tsx`、`TypelessCapsule.tsx` | 独立录音胶囊进程,音量、阶段及录音操作由主进程会话驱动 |
| `src/pages/QaPanel.tsx`、`SelectionPolishPreview.tsx` | `popup.rs` 协议和 `main.rs` 浮窗渲染;复用 Core QA/Selection 会话 |
| `src/pages/SelectionVoiceIntentPicker.tsx` | `host_windows.rs` 意图、预览、应用与撤回;`selection_voice.rs` 持有录音及原输入目标 |
| `src/pages/LessComputerPanel.tsx`、`LessComputerGlow.tsx` | 独立原生 viewport,输出、工具事件、审批与取消共享主 Core;状态边框动画 |
侧栏为 226 逻辑像素;设置弹窗上限 960×680,导航栏 214。窄窗口收缩导航及内容区域,页面按可用宽度调整卡片列数。`design_tokens.rs` 与 `ui/theme.rs` 统一深浅主题。`scripts/sync-egui-locales.mjs` 从 React 语言资源生成八种语言目录;Linux UI 偏好单独保存语言、字体缩放和启动引导状态,云同步沿用 Core UI 偏好字段。
## 所有权和原生边界
- 渲染只产生 `FrontendAction`;宿主通过异步队列调用 Core。设置字段合并到最新 revision,原生重绑失败恢复旧配置;Omni 凭据不写入 UI 状态文件。
- 主窗口隐藏或切页不销毁 Core。QA、选区预览、胶囊通过版本化 JSONL 与主进程共享会话,迟到的旧会话操作被拒绝。Less Computer 审批不依附当前页面。
- 桌面桥使用 `org.openless.Desktop1`,协议版本 1,提供热键边沿、前台身份、工作区、定位和恢复。X11 使用 x11rb/XInput2/RandR;GNOME 使用 Shell 扩展;KDE 使用 KWin 脚本和 KGlobalAccel 服务。桌面服务重启后重新注册当前绑定。
- fcitx5 插件在原 IC 中保留选区、周边文本及字符偏移;写入与撤回前检查焦点、目标和完整快照。`ContextSnapshot` 按版本和目标读取应用信息,可禁止读取文本,密码字段不返回文本。
- `context.rs` 注入 `HostContextAdapter` / `EditObservationAdapter`,fcitx5 与 AT-SPI 读取均有大小和时间界限;观察绑定会话代次及目标,迟到捕获不能覆盖新目标。无可用文本接口时保留 Core 的失败/剪贴板回退语义。
- 录音归档沿用 Core WAV/历史格式和保留策略。录音静音保存实际输出设备和原状态,正常停止、取消、错误及释放时恢复。播放只管理自身 `paplay` 子进程。
- AppImage 下载在校验 SHA-256 和仓库固定 minisign 公钥后,以同目录原子替换提交;失败恢复旧 inode,取消在提交开始前生效。deb/rpm 由系统包管理器更新。
接口来源:[GNOME 扩展文档](https://gjs.guide/extensions/)、[KWin 脚本接口](https://develop.kde.org/docs/plasma/kwin/api/)、[AT-SPI Text](https://gnome.pages.gitlab.gnome.org/at-spi2-core/libatspi/iface.Text.html)。GNOME 42–44 与 45+ 分别提供旧模块和 ES module 入口。
## 安装和构建
构建环境:WSL Ubuntu 22.04、Rust 1.94.0、Node.js 22、x86_64。编译需要 ALSA、DBus、OpenSSL、X11/XInput2/RandR、Wayland/xkbcommon、fcitx5 开发包、OpenBLAS、Qt5/KF5GlobalAccel、CMake/Clang。打包另需 fpm 1.16.0、appimagetool、patchelf、rpm、Noto CJK 字体。
```bash
cd openless-all/app
npm ci
npm run build
node ../scripts/sync-egui-locales.mjs
node ../scripts/linux-desktop/gnome/build.mjs
cmake -S ../scripts/linux-fcitx5-plugin -B ../scripts/linux-fcitx5-plugin/build -DCMAKE_BUILD_TYPE=Release
cmake --build ../scripts/linux-fcitx5-plugin/build --parallel
ctest --test-dir ../scripts/linux-fcitx5-plugin/build --output-on-failure
cmake -S ../scripts/linux-desktop/kde -B ../scripts/linux-desktop/kde/build -DCMAKE_BUILD_TYPE=Release
cmake --build ../scripts/linux-desktop/kde/build --parallel
git submodule update --init --depth 1 -- src-tauri/vendor/qwen-asr
make -C src-tauri/vendor/qwen-asr blas CFLAGS_BASE="-Wall -Wextra -O3 -ffast-math -mtune=generic"
cargo check --locked -p openless-linux-egui
cargo test --locked -p openless-core
cargo test --locked -p openless-linux-egui --lib --test host_contract
cargo build --locked --release -p openless-linux-egui
OPENLESS_LINUX_VERSION=2.0.0-Beta.1 APPIMAGE_EXTRACT_AND_RUN=1 bash scripts/package-linux-egui.sh
bash scripts/verify-linux-egui-packages.sh
```
产物位于 `$CARGO_TARGET_DIR/linux-egui-packages`,未设置该变量时为 `app/target/linux-egui-packages`。包含 Linux ELF、deb、rpm、AppImage、桌面集成 tar.gz 及 SHA256SUMS。Qwen 固定子模块为 `b00b789b17051aea61e9717458171100662318a4`,不下载模型作为安装包内容。
```bash
# Ubuntu / Debian:apt 同时安装声明的运行依赖
sudo apt install ./OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.deb
# Fedora / RPM 系统
sudo dnf install ./OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.rpm
# AppImage 仍需要宿主安装并启用 fcitx5,以及 PulseAudio 或 PipeWire-Pulse。
chmod +x OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.AppImage
./OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.AppImage
```
在设置 → 关于与更新中安装/启用/卸载桌面组件;系统包也提供 `openless-desktop-integration install|enable|uninstall`。独立组件包解压后运行 `bash linux-desktop/install.sh install`,在实际桌面用户会话中执行。GNOME 第一次安装通常需注销再登录后启用;更新正在使用的 fcitx5 `.so` 后需重启 fcitx5 或重新登录。KDE helper 及私有 Qt 库复制到用户目录,AppImage 退出后仍可启动。
普通 CI 产物是**未签名的候选包**。SHA256SUMS 校验传输完整性;正式 AppImage 自动更新必须由仓库发布流水线提供 `LINUX_EGUI_MINISIGN_SECRET_KEY` 签名,客户端拒绝未签名包。没有生成或替换上游签名私钥。
## 自动检查记录
执行日期:2026-09-11。以下检查在 WSL Ubuntu 22.04 / Rust 1.94.0 执行,React 构建使用 Windows Node.js 22。
| 检查 | 最终结果 |
| --- | --- |
| `npm run build` | TypeScript + Vite 通过,19.83 秒;没有运行界面测试或浏览器 |
| `cargo test --locked -p openless-core` | 957 项通过,1 项既有忽略 |
| `cargo clippy --locked -p openless-core --all-targets -- -D warnings` | 通过;兼容当前 Clippy 的错误类型、布尔式和测试初始化告警已修复 |
| `cargo test --locked -p openless-linux-egui --lib --test host_contract` | 125 项库测试 + 4 项宿主契约通过 |
| `cargo check --locked -p openless-linux-egui --all-targets` | 全部目标编译通过;不运行 egui 界面测试 |
| fcitx5 CMake / CTest | 编译通过,1 项输入目标契约通过,包括目标失效、焦点恢复与密码字段拒绝 |
| KDE helper / Qwen CMake、Make | Qt5/KF5 helper 和固定 Qwen 子模块编译通过 |
| Core/Linux 依赖、密钥表面、测试隔离、运行时边界、Linux 公共接口 | 全部通过 |
| `cargo build --locked --release -p openless-linux-egui` | 最终 Linux x86_64 Release 通过;egui 宿主仍有 35 条未使用代码告警 |
| `package-linux-egui.sh` / `verify-linux-egui-packages.sh` | deb、rpm、AppImage 和独立组件包生成通过;SHA-256、ELF 依赖、桌面元数据、fcitx5/Qwen/字体/Qt 组件及解包检查通过 |
原生测试覆盖快捷键冲突与恢复、目标和观察结果过期、取消竞态、原输出设备静音恢复(含部分生效后失败)、凭据锁定、设置事务、下载取消、签名/校验、原子更新与失败回滚。测试不调用真实云端发布/删除操作,不替代桌面验收。
本地产物目录为 `openless-all/app/target/linux-egui/linux-egui-packages/`,未提交二进制到 Git。包中保留私有 Qt/KF5 等共享库的许可文件;deb/rpm 的包版本使用 `2.0.0~Beta.1`,确保 Beta 排在正式 `2.0.0` 之前。文件名和应用版本仍为 `2.0.0-Beta.1`。
| 文件 | 字节数 |
| --- | ---: |
| `openless-linux-egui` | 50,904,256 |
| `OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.deb` | 45,802,718 |
| `OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.rpm` | 45,788,946 |
| `OpenLess-Linux-egui-2.0.0-Beta.1-x86_64.AppImage` | 75,007,168 |
| `OpenLess-desktop-integration-2.0.0-Beta.1-x86_64.tar.gz` | 28,358,066 |
本地 AppImage SHA-256:`637ca3d5a45640634ed4316c689a0e0420a937d07227233dbc0582f5fd21328c`。全部文件摘要随包保存在 `SHA256SUMS`。云端独立重建的字节数和摘要以对应 Actions artifact 内的 `SHA256SUMS` 为准。CI 入口为 `.github/workflows/check-linux-egui.yml`,复用打包工作流;云端运行链接及产物链接记录在 PR。
## 人工验收矩阵
全部单元格初始为待验收,不能由 WSL 编译结果替代。
| 环境 | 界面/缩放 | 按下/释放/重绑 | 焦点/选区/撤回 | 音频/静音 | 托盘/安装/更新 |
| --- | --- | --- | --- | --- | --- |
| GNOME 42 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| GNOME 42 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| GNOME 46 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| GNOME 46 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| Plasma 5.27 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| Plasma 5.27 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| Plasma 6 / X11 | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
| Plasma 6 / Wayland | 待验收 | 待验收 | 待验收 | 待验收 | 待验收 |
1. 对照锁定 React 源码逐页检查八个主页面、七类设置、渠道/模型/Omni/手机输入/云同步子页;深浅主题、八种语言、字体和窗口缩放;颜色、图标、圆角、阴影、间距、滚动和弹窗状态。
2. 检查按住/单击/自动模式、左右修饰键、组合键、风格直达与全部 Less Computer 热键;与桌面现有快捷键冲突应失败并保留旧绑定;重启应用、fcitx5、桌面组件后再测。Wayland 未启用组件时只能依赖 fcitx5 客户端事件;不算通过全局热键验收。
3. 在 GTK、Qt、浏览器文本框中选择重复文本、切换焦点、关闭目标窗口、修改原选区、取消处理;任何过期目标不得写入新应用。检查 QA、语音意图、预览编辑、应用、撤回及无文本接口时反馈。
4. 用真实麦克风测试开始/停止/取消/异常/退出;录音时切换默认音频输出,确认恢复的是原设备原状态。检查归档保留、播放、导出、重转写及删除。
5. 锁定系统钥匙环,测试保存/连接失败;检查密钥不出现在状态文件和诊断日志;关闭上下文权限后确认不读取文档文本。
6. 切页、隐藏主窗口并触发浮窗;在 Less Computer 等待审批时隐藏再显示窗口,确认审批和取消不丢失。双实例启动应转发到原会话。
7. 测试双屏、负坐标、不同缩放、面板保留区域;浮窗应出现在原目标所在屏幕。重点验收 GNOME 42 的释放事件及 KDE 修饰键表示差异。
8. 分别安装/卸载 deb、rpm、AppImage 桌面组件;检查 autostart、托盘环境能力及通知。签名发布环境验证中断下载、签名错误、空间不足、原子安装和失败恢复;未签名候选包不能计为签名发布通过。
## 贡献归属
选择性复用了 [PR #1055](https://github.com/Open-Less/openless/pull/1055) 的设计 tokens(sim,`b87ca21764f6022b5ca493b0f53f5939f75c7aca`),以及 [PR #1060](https://github.com/Open-Less/openless/pull/1060) 的 egui 页面、浮窗协议、音频/托盘/更新等基础(aeoform,`0303488e863c8641f69084600f830c3f40f43b0f`)。本分支按锁定 beta 重新接入 Core、保留基线 Qwen 能力,并补充桌面桥、上下文观察、真实页面操作、打包与检查。提交保留对应 Co-authored-by。
+4 -7
View File
@@ -1,8 +1,6 @@
# Linux 接入交接总览 # Linux 接入交接总览
状态:canonical;更新:2026-09-11。当前基线 `867a0d8ce746c64cadc1be68fead98d9838b9f15`,实现分支 `Linux-egui`。 状态:canonical(2026-09-07 以源码为准重写,基线 `openless-linux-egui` + Core `639c2fbf` 后的 beta);更新:2026-09-07。
当前实现、构建和安装说明以 [08 Linux-egui 2.0 交付记录](08-linux-egui-2.0.md) 为准,L01–L12 的实现/自动检查/人工验收分别见 [02 状态表](02-gap-register.md)。03–07 保留原始交接合同,里面的 2026-09-07 缺口描述是历史基线,不能代表本分支现状。
## 1. 责任与范围 ## 1. 责任与范围
@@ -13,17 +11,16 @@ Linux Host Adapter 的剩余实现、全局热键、窗口/托盘/权限/更新
- 组装:`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)。 - 组装:`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()`。 - 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`。 - 已实现模块:`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/frontend/view_model.rs` 八个主页面,`ui/settings.rs` 七类设置;`host_*.rs` 负责页面异步操作和窗口生命周期,`main.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)。 - 打包:`release-linux-egui.yml`(deb/rpm + `SHA256SUMS`;AppImage 已下线)。
## 3. 阅读顺序 ## 3. 阅读顺序
1. [01 Core 合同](01-core-contract.md):可调用的 Core 面与合同文件。 1. [01 Core 合同](01-core-contract.md):可调用的 Core 面与合同文件。
2. [02 缺口登记](02-gap-register.md):L01–L12 现状、责任与关闭标准(推进主表)。 2. [02 缺口登记](02-gap-register.md):L01–L12 现状、责任与关闭标准(推进主表)。
3. [03 热键与窗口](03-hotkeys-and-windows.md)、[05 原生宿主与数据](05-native-host-and-data.md):接线缺口细节。 3. [03 热键与窗口](03-hotkeys-and-windows.md)、[05 原生宿主与数据](05-native-host-and-data.md):接线缺口细节。
4. [04 页面与领域](04-ui-domains.md):原领域合同;当前页面映射见 08。 4. [04 页面与领域](04-ui-domains.md):10 页现状与界面缺口。
5. [06 事件与会话](06-events-and-sessions.md):订阅、重放、取消语义。 5. [06 事件与会话](06-events-and-sessions.md):订阅、重放、取消语义。
6. [07 验收](07-acceptance.md):两个完成门与证据要求。 6. [07 验收](07-acceptance.md):两个完成门与证据要求。
7. [08 实施与交付](08-linux-egui-2.0.md):锁定基准、实际源码映射、构建证据、产物、安装与 GNOME/KDE 人工矩阵。
接口细节见[后端契约](../linux-egui-backend-contract.md);长接口与历史实现参考保留在契约文档。 接口细节见[后端契约](../linux-egui-backend-contract.md);长接口与历史实现参考保留在契约文档。
+92
View File
@@ -0,0 +1,92 @@
# Linux egui / Tauri 2 parity tracker
This stack is maintained in PR #1060 (review baseline `227374ea`). Linux ships one
`openless-linux-egui` executable and does not link Tauri, Wry, or WebKitGTK.
Windows, macOS, and Android remain on Tauri.
## Implemented on the native Core 2.0 path
- eframe/egui 0.36.2 (wgpu 30.0.1, CPAL 0.18.2) shell, system CJK fallback, single instance, minimized
startup, close-to-tray, safe shutdown, XDG autostart, notifications, external
URLs, file dialogs, file logging, and diagnostic-log export
- fcitx5 dictation, QA, selection polish, translation, style switching, main
window, style-pack direct, and applicable Coding Agent hotkeys; settings use
strict collision checks; startup waits for plugin reload before registration
- CPAL recording with canonical WAV archives, Core retention policy, history
playback/export and failed-session retranscription
- Native `mute_during_recording` (PipeWire `wpctl`/PulseAudio `pactl`) with
guaranteed sink restore by RAII across stop/cancel/error/drop/shutdown
- Native recording start/stop audio cues synthesized to the default sink on a
worker thread (never blocking the egui frame), gated by `audio_cue_on_record`
and muted-aware (`audio_mute`/`audio_cue` modules)
- Core-backed history search/delete/clear/copy/re-polish/retranscribe, vocabulary,
pending corrections, shared vocabulary presets, correction rules, and complete
style-pack CRUD/reset/prompt diagnostics/ZIP import-export/direct hotkeys
- provider channel CRUD/order/enable/activation, credential metadata, endpoint,
model listing, validation, and revision-aware settings conflict merging
- Remote Input TLS service, URLs, PIN, locale, connection count, and error events
- Marketplace list/detail/install/download/upload/update/delete, likes, authored
packs, GitHub device flow, polling, cancel, and logout
- dictation, QA text/voice, selection polish preview/confirm/cancel/revert, and
Less Computer text/voice/stream/tool approval/cancel through high-level Core APIs
- #997 native QA/selection/capsule popup design: same-executable re-entry,
versioned serde JSONL, Markdown, drag/Esc, QA microphone and Enter submit,
session/sequence/kind guards, nonblocking pipes, crash restart, and snapshot replay
- ~~stable/beta AppImage checks, scheduling, byte progress, SHA-256/minisign
verification, atomic replace~~ — not ported: AppImage is retired from the Linux
channel and deb/rpm cannot replace themselves, so the host keeps no update path
- Remote Input assets, shared icons, version parsing, packaging,
and release workflow are independent of `src-tauri`
The backend host remains alive when its independent UI process closes. Internal
UI JSONL protocol v2 requires Hello/Ready, rejects mismatches, and resends bindings
and a full snapshot on reconnect. Explicit tray/launcher/open-window requests
create a missing UI or send FocusMain; Wayland activation remains compositor policy.
Core retains contract 2.0.0. Settings field patches are serialized and rebased onto
Core revisions; history and WAV probes run in one event-driven blocking task,
with cached results shared by History and Overview.
## Automated verification commands
- `cargo test -p openless-core --locked`
- `cargo test -p openless-linux-egui --locked --all-targets`
- `cargo clippy --locked -p openless-core --all-targets -- -D warnings`
- `cargo check --locked -p openless-linux-egui --all-targets`
- `cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings`
- PR #1019 Core/public-surface/dependency contract scripts
- fcitx5 C++ build plus `input_target_contract`
- `cargo tree` and release ELF `ldd` checks for Tauri/Wry/WebKitGTK
- Linux Tauri-free source, packaging, workflow, production-mock, capability, popup,
settings-conflict, IPC handshake/reconnect, lifecycle, and staged-file gates
## Deliberate limits and device evidence still required
- fcitx5 is a startup requirement: missing addon, unavailable session D-Bus or
failed required shortcut registration prevents Backend startup. An error window
explains recovery; closing it exits nonzero while releasing the process lock.
There is no IBus or global-hotkey fallback.
- Selection Voice remains hidden because the Linux production target/intent adapter
is not implemented. It must not be advertised through capabilities. Selection
polish and QA remain available.
- Local ASR is unsupported on Linux, including Generic Qwen, Foundry and MLX.
No inference runtime is shipped; this is not an awaiting-device-verification item.
- Foreground-application identity and native post-insertion edit observation
are explicit `Unsupported` on Linux: fcitx5 exposes surrounding text but no
reliable app/control identity across X11/Wayland, PRIMARY cannot prove an
original selection, and there is deliberately no IBus/global-hotkey fallback.
The factory keeps Core's Noop HostContext/EditObservation adapters (reporting
`source_app = None`) instead of faking edits. These are L02/L03 follow-ups,
not simulated UI data or false capabilities.
- Mute/restore and start/stop cues are implemented (`audio_mute`/`audio_cue`),
but the cpal cue-playback and real sink mute/restore paths still require
X11/Wayland device runs before being recorded as verified; a Linux output
stream is a PipeWire/KDE sink-input, so device evidence must confirm the cue
does not disturb the session or trigger unexpected volume OSD.
- X11 and Wayland device runs are still required for focus, Unicode insertion,
popup positioning, tray, fcitx5 reload/rebind, microphone unplug/recovery,
Secret Service, real phone Remote Input, and startup failure/recovery. Ignored hardware tests or a green build are not
recorded as device proof.
The automated scope is complete only when every command above passes at the
current PR #1060 head. The device-only rows remain “implemented, awaiting device
evidence” or “explicit unsupported” and must not be described as verified.
+972 -295
View File
File diff suppressed because it is too large. Load diff
@@ -2502,6 +2502,7 @@ impl OpenLessBackend {
Arc::clone(&marketplace), Arc::clone(&marketplace),
github_client_id, github_client_id,
BackendEventPublisher::new(Arc::clone(&events)), BackendEventPublisher::new(Arc::clone(&events)),
Arc::clone(&deps.task_spawner),
)?; )?;
encrypted_sync = Some(service); encrypted_sync = Some(service);
encrypted_sync_store = Some(store); encrypted_sync_store = Some(store);
@@ -16,6 +16,9 @@ use crate::cloud_sync_e2ee_store::{
use super::{document_error, local::LocalStorage, service::SyncServiceData, SyncResult}; use super::{document_error, local::LocalStorage, service::SyncServiceData, SyncResult};
// Composition boundary: keep the existing injected services explicit rather than
// adding a second dependency bundle solely for this constructor.
#[allow(clippy::too_many_arguments)]
pub(crate) fn build( pub(crate) fn build(
config: super::EncryptedSyncConfig, config: super::EncryptedSyncConfig,
data_dir: &Path, data_dir: &Path,
@@ -24,6 +27,7 @@ pub(crate) fn build(
marketplace: Arc<crate::marketplace::MarketplaceService>, marketplace: Arc<crate::marketplace::MarketplaceService>,
github_client_id: String, github_client_id: String,
events: crate::events::BackendEventPublisher, events: crate::events::BackendEventPublisher,
tasks: Arc<dyn crate::TaskSpawner>,
) -> SyncResult<(super::EncryptedSyncService, Arc<CoreSyncStore>)> { ) -> SyncResult<(super::EncryptedSyncService, Arc<CoreSyncStore>)> {
let origin = url::Url::parse(&config.service_origin) let origin = url::Url::parse(&config.service_origin)
.map_err(|_| super::error("unsupported_protocol"))?; .map_err(|_| super::error("unsupported_protocol"))?;
@@ -63,6 +67,7 @@ pub(crate) fn build(
device, device,
gate, gate,
Arc::new(local.clone()), Arc::new(local.clone()),
tasks,
) )
.map_err(document_error)?, .map_err(document_error)?,
); );
@@ -36,6 +36,7 @@ struct Inner {
device: SourceDevice, device: SourceDevice,
gate: Arc<SyncWriteGate>, gate: Arc<SyncWriteGate>,
extensions: Arc<dyn ProtectedExtensionStore>, extensions: Arc<dyn ProtectedExtensionStore>,
tasks: Arc<dyn crate::TaskSpawner>,
restoring: AtomicBool, restoring: AtomicBool,
metadata: tokio::sync::Mutex<()>, metadata: tokio::sync::Mutex<()>,
runtime_idle: std::sync::Mutex<Option<Arc<dyn Fn() -> bool + Send + Sync>>>, runtime_idle: std::sync::Mutex<Option<Arc<dyn Fn() -> bool + Send + Sync>>>,
@@ -50,6 +51,7 @@ impl CoreSyncStore {
device: SourceDevice, device: SourceDevice,
gate: Arc<SyncWriteGate>, gate: Arc<SyncWriteGate>,
extensions: Arc<dyn ProtectedExtensionStore>, extensions: Arc<dyn ProtectedExtensionStore>,
tasks: Arc<dyn crate::TaskSpawner>,
) -> DocumentResult<Self> { ) -> DocumentResult<Self> {
let registered = gate::open_for_data_dir(&data_dir)?; let registered = gate::open_for_data_dir(&data_dir)?;
if !Arc::ptr_eq(&registered, &gate) { if !Arc::ptr_eq(&registered, &gate) {
@@ -70,6 +72,7 @@ impl CoreSyncStore {
device, device,
gate, gate,
extensions, extensions,
tasks,
restoring: AtomicBool::new(false), restoring: AtomicBool::new(false),
metadata: tokio::sync::Mutex::new(()), metadata: tokio::sync::Mutex::new(()),
runtime_idle: std::sync::Mutex::new(None), runtime_idle: std::sync::Mutex::new(None),
@@ -207,7 +210,7 @@ impl CoreSyncStore {
let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)?; let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)?;
let store = self.clone(); let store = self.clone();
// Detach only the owned, journalled operation: dropping an IPC waiter cannot cancel it. // Detach only the owned, journalled operation: dropping an IPC waiter cannot cancel it.
tokio::spawn(async move { self.spawn_owned(async move {
let _metadata = store.inner.metadata.lock().await; let _metadata = store.inner.metadata.lock().await;
let _runtime = store.begin_runtime_restore()?; let _runtime = store.begin_runtime_restore()?;
store.runtime_effects()?; store.runtime_effects()?;
@@ -224,7 +227,6 @@ impl CoreSyncStore {
store.capture_locked(&scope, &permit).await store.capture_locked(&scope, &permit).await
}) })
.await .await
.map_err(|_| DocumentError::RecoveryRequired)?
} }
pub async fn record_baseline( pub async fn record_baseline(
@@ -235,12 +237,11 @@ impl CoreSyncStore {
) -> DocumentResult<()> { ) -> DocumentResult<()> {
self.validate_local_scope(&scope)?; self.validate_local_scope(&scope)?;
let store = self.clone(); let store = self.clone();
tokio::spawn(async move { self.spawn_owned(async move {
let _metadata = store.inner.metadata.lock().await; let _metadata = store.inner.metadata.lock().await;
store.baseline_locked(&scope, documents, revision).await store.baseline_locked(&scope, documents, revision).await
}) })
.await .await
.map_err(|_| DocumentError::RecoveryRequired)?
} }
pub async fn recover_registered(&self) -> DocumentResult<()> { pub async fn recover_registered(&self) -> DocumentResult<()> {
@@ -249,7 +250,7 @@ impl CoreSyncStore {
return Ok(()); return Ok(());
} }
let store = self.clone(); let store = self.clone();
tokio::spawn(async move { self.spawn_owned(async move {
use crate::cloud_sync_e2ee_documents::JournalStore; use crate::cloud_sync_e2ee_documents::JournalStore;
let _metadata = store.inner.metadata.lock().await; let _metadata = store.inner.metadata.lock().await;
let _runtime = store.begin_runtime_restore()?; let _runtime = store.begin_runtime_restore()?;
@@ -283,7 +284,24 @@ impl CoreSyncStore {
Ok(()) Ok(())
}) })
.await .await
.map_err(|_| DocumentError::RecoveryRequired)? }
/// Runs an owned operation on the host task spawner and awaits its outcome.
/// Dropping the awaiting caller cannot cancel the operation: the spawned
/// task owns the sender side of the channel.
async fn spawn_owned<T, F>(&self, operation: F) -> DocumentResult<T>
where
T: Send + 'static,
F: std::future::Future<Output = DocumentResult<T>> + Send + 'static,
{
let (sender, receiver) = tokio::sync::oneshot::channel();
self.inner.tasks.spawn(Box::pin(async move {
let _ = sender.send(operation.await);
}));
match receiver.await {
Ok(outcome) => outcome,
Err(_) => Err(DocumentError::RecoveryRequired),
}
} }
fn validate_local_scope(&self, scope: &SyncScope) -> DocumentResult<()> { fn validate_local_scope(&self, scope: &SyncScope) -> DocumentResult<()> {
@@ -420,7 +438,7 @@ impl CoreSyncStore {
return Err(DocumentError::InvalidDocument); return Err(DocumentError::InvalidDocument);
} }
let store = self.clone(); let store = self.clone();
tokio::spawn(async move { self.spawn_owned(async move {
let _metadata = store.inner.metadata.lock().await; let _metadata = store.inner.metadata.lock().await;
let permit = store.inner.gate.begin_mutation()?; let permit = store.inner.gate.begin_mutation()?;
if let Some(expected) = expected_revision { if let Some(expected) = expected_revision {
@@ -459,7 +477,6 @@ impl CoreSyncStore {
permit.commit(ChangeOrigin::User).map(|_| ()) permit.commit(ChangeOrigin::User).map(|_| ())
}) })
.await .await
.map_err(|_| DocumentError::RecoveryRequired)?
} }
} }
@@ -317,6 +317,7 @@ impl Fixture {
device, device,
gate, gate,
extensions.clone(), extensions.clone(),
Arc::new(crate::TokioTaskSpawner),
) )
.unwrap(); .unwrap();
store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap();
@@ -359,6 +360,7 @@ impl Fixture {
device, device,
gate, gate,
extensions.clone(), extensions.clone(),
Arc::new(crate::TokioTaskSpawner),
) )
.unwrap(); .unwrap();
store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap();
@@ -797,7 +797,14 @@ pub fn is_tencent_cloud_provider(id: &str) -> bool {
pub fn is_whisper_compatible_provider(id: &str) -> bool { pub fn is_whisper_compatible_provider(id: &str) -> bool {
matches!( matches!(
id, id,
"whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter" | "stepfun" | "zenmux" | "minimax" "whisper"
| "siliconflow"
| "zhipu"
| "groq"
| "openrouter"
| "stepfun"
| "zenmux"
| "minimax"
) || id == OPENAI_COMPATIBLE_ASR_PROVIDER_ID ) || id == OPENAI_COMPATIBLE_ASR_PROVIDER_ID
} }
@@ -1874,20 +1874,52 @@ mod tests {
{"id":"legacy/chat"} {"id":"legacy/chat"}
]}"#; ]}"#;
for (kind, channel_kind, key_account, expected) in [ for (kind, channel_kind, key_account, expected) in [
(ProviderKind::Llm, ChannelKind::Llm, LLM_API_KEY_ACCOUNT, (
vec!["google/gemini-2.5-flash", "google/gemini-tts", "google/gemini-unknown", "orcarouter/fusion-flash"]), ProviderKind::Llm,
(ProviderKind::Asr, ChannelKind::Asr, ASR_API_KEY_ACCOUNT, ChannelKind::Llm,
vec!["google/gemini-2.5-flash"]), LLM_API_KEY_ACCOUNT,
vec![
"google/gemini-2.5-flash",
"google/gemini-tts",
"google/gemini-unknown",
"orcarouter/fusion-flash",
],
),
(
ProviderKind::Asr,
ChannelKind::Asr,
ASR_API_KEY_ACCOUNT,
vec!["google/gemini-2.5-flash"],
),
] { ] {
let credentials = Arc::new(InMemoryCredentialStore::default()); let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; let channel =
create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await;
let transport = Arc::new(FakeProviderTransport::default()); let transport = Arc::new(FakeProviderTransport::default());
transport.push_response(200, catalog.as_bytes().to_vec()); transport.push_response(200, catalog.as_bytes().to_vec());
let service = ProviderService::new_with_transport(credentials.clone(), Arc::new(crate::TokioTaskSpawner), transport.clone()); let service = ProviderService::new_with_transport(
let request = ProviderRequest { kind, channel_id: Some(channel.clone()), thinking_enabled: false }; credentials.clone(),
Arc::new(crate::TokioTaskSpawner),
transport.clone(),
);
let request = ProviderRequest {
kind,
channel_id: Some(channel.clone()),
thinking_enabled: false,
};
assert!(service.list_models(request.clone()).await.is_err()); assert!(service.list_models(request.clone()).await.is_err());
let namespace = if kind == ProviderKind::Llm { CredentialNamespace::Llm } else { CredentialNamespace::Asr }; let namespace = if kind == ProviderKind::Llm {
credentials.write(CredentialKey::new(namespace, Some(channel), key_account).unwrap(), SecretValue::new("fixture-key")).await.unwrap(); CredentialNamespace::Llm
} else {
CredentialNamespace::Asr
};
credentials
.write(
CredentialKey::new(namespace, Some(channel), key_account).unwrap(),
SecretValue::new("fixture-key"),
)
.await
.unwrap();
let result = service.list_models(request).await.unwrap(); let result = service.list_models(request).await.unwrap();
assert_eq!(result.models, expected); assert_eq!(result.models, expected);
let requests = transport.requests(); let requests = transport.requests();
@@ -1991,22 +2023,44 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn orcarouter_validation_uses_shared_audio_chat_transcription() { async fn orcarouter_validation_uses_shared_audio_chat_transcription() {
let (endpoint, request) = spawn_http_response("200 OK", "application/json", let (endpoint, request) = spawn_http_response(
r#"{"choices":[{"message":{"content":"transcript"}}]}"#); "200 OK",
"application/json",
r#"{"choices":[{"message":{"content":"transcript"}}]}"#,
);
let credentials = Arc::new(InMemoryCredentialStore::default()); let credentials = Arc::new(InMemoryCredentialStore::default());
let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "orcarouter", &[ let channel = create_channel_with_values(
(ASR_ENDPOINT_ACCOUNT, &endpoint), (ASR_API_KEY_ACCOUNT, "fixture-key"), &credentials,
]).await; ChannelKind::Asr,
"orcarouter",
&[
(ASR_ENDPOINT_ACCOUNT, &endpoint),
(ASR_API_KEY_ACCOUNT, "fixture-key"),
],
)
.await;
let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner));
service.validate(ProviderRequest { kind: ProviderKind::Asr, channel_id: Some(channel), thinking_enabled: false }).await.unwrap(); service
let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); .validate(ProviderRequest {
kind: ProviderKind::Asr,
channel_id: Some(channel),
thinking_enabled: false,
})
.await
.unwrap();
let request =
String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap();
assert!(request.starts_with("POST /v1/chat/completions ")); assert!(request.starts_with("POST /v1/chat/completions "));
let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); let body: serde_json::Value =
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL); assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL);
let content = &body["messages"][0]["content"]; let content = &body["messages"][0]["content"];
assert_eq!(content[0]["type"], "text"); assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["input_audio"]["format"], "wav"); assert_eq!(content[1]["input_audio"]["format"], "wav");
assert!(!content[1]["input_audio"]["data"].as_str().unwrap().starts_with("data:")); assert!(!content[1]["input_audio"]["data"]
.as_str()
.unwrap()
.starts_with("data:"));
} }
async fn service_with_fake_transport() -> (ProviderService, Arc<FakeProviderTransport>, String) async fn service_with_fake_transport() -> (ProviderService, Arc<FakeProviderTransport>, String)
@@ -168,14 +168,10 @@ pub fn strip_echoed_scaffolding(text: &str) -> String {
const MARKER: &str = "__OPENLESS_SCAFFOLD_BODY__"; const MARKER: &str = "__OPENLESS_SCAFFOLD_BODY__";
let raw = crate::prompts::user_prompt(MARKER); let raw = crate::prompts::user_prompt(MARKER);
let selection = crate::prompts::selection_user_prompt(MARKER); let selection = crate::prompts::selection_user_prompt(MARKER);
let raw_traditional = apply_chinese_script_preference( let raw_traditional =
&raw, apply_chinese_script_preference(&raw, ChineseScriptPreference::Traditional);
ChineseScriptPreference::Traditional, let selection_traditional =
); apply_chinese_script_preference(&selection, ChineseScriptPreference::Traditional);
let selection_traditional = apply_chinese_script_preference(
&selection,
ChineseScriptPreference::Traditional,
);
let stripped = [raw.as_str(), raw_traditional.as_str()] let stripped = [raw.as_str(), raw_traditional.as_str()]
.into_iter() .into_iter()
@@ -186,12 +182,7 @@ pub fn strip_echoed_scaffolding(text: &str) -> String {
[selection.as_str(), selection_traditional.as_str()] [selection.as_str(), selection_traditional.as_str()]
.into_iter() .into_iter()
.find_map(|template| { .find_map(|template| {
strip_complete_template( strip_complete_template(text, template, "<selected_text>", "</selected_text>")
text,
template,
"<selected_text>",
"</selected_text>",
)
}) })
}) })
.unwrap_or_else(|| text.to_string()); .unwrap_or_else(|| text.to_string());
@@ -220,7 +211,10 @@ pub fn streaming_insert_eligible(
mod tests { mod tests {
#[test] #[test]
fn strip_echoed_scaffolding_none_passes_through() { fn strip_echoed_scaffolding_none_passes_through() {
assert_eq!(strip_echoed_scaffolding("純正文,沒有標籤。"), "純正文,沒有標籤。"); assert_eq!(
strip_echoed_scaffolding("純正文,沒有標籤。"),
"純正文,沒有標籤。"
);
} }
#[test] #[test]
@@ -719,12 +719,11 @@ async fn transient_platform_failure_keeps_the_preview_retryable() {
async fn stale_preview_apply_settles_the_session_and_hides_the_preview() { async fn stale_preview_apply_settles_the_session_and_hides_the_preview() {
// apply 报「目标已失效」类错误(Cancelled 语义)时 session 必须结算, // apply 报「目标已失效」类错误(Cancelled 语义)时 session 必须结算,
// 预览隐藏、不允许无限重试一个已经不存在的目标。 // 预览隐藏、不允许无限重试一个已经不存在的目标。
let runtime = RecordingSelectionRuntime::new("source text").with_apply_error( let runtime =
BackendError::new( RecordingSelectionRuntime::new("source text").with_apply_error(BackendError::new(
BackendErrorCode::Cancelled, BackendErrorCode::Cancelled,
"selection target is no longer active", "selection target is no longer active",
), ));
);
let host = openless_core::testing::RecordingHostActions::default(); let host = openless_core::testing::RecordingHostActions::default();
let (backend, data_dir) = backend_with_selection_parts_and_host( let (backend, data_dir) = backend_with_selection_parts_and_host(
runtime.clone(), runtime.clone(),
+24 -9
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "openless-linux-egui" name = "openless-linux-egui"
version = "2.0.0-Beta.2+build.20260924" version = "0.1.0"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
description = "Linux host seam for the OpenLess egui frontend" description = "Linux host seam for the OpenLess egui frontend"
edition = "2021" edition = "2021"
rust-version = "1.88" rust-version = "1.95"
publish = false publish = false
[dependencies] [dependencies]
@@ -15,7 +15,6 @@ futures-util = "0.3"
log = "0.4" log = "0.4"
simplelog = "0.12" simplelog = "0.12"
base64 = "0.22" base64 = "0.22"
minisign-verify = "0.2.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
semver = "1" semver = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
@@ -23,18 +22,34 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
[features]
default = ["x11-overlay"]
# Native X11 overlay placement for X11 sessions. Wayland sessions use native
# layer-shell when available and never route the capsule through XWayland.
x11-overlay = ["dep:x11rb"]
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
dbus = "0.9" dbus = "0.9"
x11rb = { version = "0.13.2", features = ["randr", "xinput"] }
keyring = { version = "3.6.3", default-features = false, features = ["linux-native-sync-persistent", "crypto-rust"] } keyring = { version = "3.6.3", default-features = false, features = ["linux-native-sync-persistent", "crypto-rust"] }
# PipeWire's PulseAudio protocol also supports the GNOME 42 / Ubuntu 22.04 cpal = { version = "0.18.2", default-features = false, features = ["pipewire", "pulseaudio"] }
# baseline (native cpal PipeWire requires libpipewire >= 0.3.53).
cpal = { version = "0.18.2", default-features = false, features = ["pulseaudio"] }
rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] }
egui = "=0.33.3" egui = "=0.36.2"
eframe = { version = "=0.33.3", default-features = false, features = ["accesskit", "default_fonts", "glow", "wayland", "x11"] } # wlr-layer-shell 胶囊窗口:KWin 6.7+/sway/Hyprland 等合成器直接给
# 「贴底居中 + 不抢键盘焦点」的 overlay surface;不支持时回退到 X11 叠加层。
wayland-client = "0.31"
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
glutin = { version = "0.32", default-features = false, features = ["egl", "wayland"] }
egui_glow = "0.36.2"
glow = "0.17"
raw-window-handle = "0.6"
eframe = { version = "=0.36.2", default-features = false, features = ["accesskit", "default_fonts", "wgpu_no_default_features", "wayland", "x11"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "vulkan", "wgsl"] }
image = { version = "0.25.10", default-features = false, features = ["png"] } image = { version = "0.25.10", default-features = false, features = ["png"] }
# Rasterize user-supplied SVG icons locally before sending PNG to Core.
resvg = { version = "=0.45.1", default-features = false, features = ["text", "system-fonts"] }
roxmltree = "0.20"
libc = "0.2" libc = "0.2"
x11rb = { version = "0.13.2", optional = true }
axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] }
hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] }
local-ip-address = "0.6" local-ip-address = "0.6"
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-zap" aria-hidden="true"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"></path></svg>

After

Width:  |  Height:  |  Size: 404 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-square" aria-hidden="true"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path></svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>

After

Width:  |  Height:  |  Size: 266 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right" aria-hidden="true"><path d="m9 18 6-6-6-6"></path></svg>

After

Width:  |  Height:  |  Size: 272 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock3 lucide-clock-3" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 6v6h4"></path></svg>

After

Width:  |  Height:  |  Size: 316 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>

After

Width:  |  Height:  |  Size: 285 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cloud" aria-hidden="true"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path></svg>

After

Width:  |  Height:  |  Size: 302 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>

After

Width:  |  Height:  |  Size: 367 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text" aria-hidden="true"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>

After

Width:  |  Height:  |  Size: 483 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download" aria-hidden="true"><path d="M12 15V3"></path><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><path d="m7 10 5 5 5-5"></path></svg>

After

Width:  |  Height:  |  Size: 352 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-external-link" aria-hidden="true"><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg>

After

Width:  |  Height:  |  Size: 371 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-feather" aria-hidden="true"><path d="M12.67 19a2 2 0 0 0 1.416-.588l6.154-6.172a6 6 0 0 0-8.49-8.49L5.586 9.914A2 2 0 0 0 5 11.328V18a1 1 0 0 0 1 1z"></path><path d="M16 8 2 22"></path><path d="M17.5 15H9"></path></svg>

After

Width:  |  Height:  |  Size: 420 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hash" aria-hidden="true"><line x1="4" x2="20" y1="9" y2="9"></line><line x1="4" x2="20" y1="15" y2="15"></line><line x1="10" x2="8" y1="3" y2="21"></line><line x1="16" x2="14" y1="3" y2="21"></line></svg>

After

Width:  |  Height:  |  Size: 405 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-question-mark" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><path d="M12 17h.01"></path></svg>

After

Width:  |  Height:  |  Size: 370 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-history" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>

After

Width:  |  Height:  |  Size: 357 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-left" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M9 3v18"></path></svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mic" aria-hidden="true"><path d="M12 19v3"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><rect x="9" y="2" width="6" height="13" rx="3"></rect></svg>

After

Width:  |  Height:  |  Size: 355 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor" aria-hidden="true"><rect width="20" height="14" x="2" y="3" rx="2"></rect><line x1="8" x2="16" y1="21" y2="21"></line><line x1="12" x2="12" y1="17" y2="21"></line></svg>

After

Width:  |  Height:  |  Size: 379 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis" aria-hidden="true"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>

After

Width:  |  Height:  |  Size: 352 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chart-no-axes-column" aria-hidden="true"><path d="M5 21v-6"></path><path d="M12 21V3"></path><path d="M19 21V9"></path></svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"></path><path d="m15 5 4 4"></path></svg>

After

Width:  |  Height:  |  Size: 407 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pin" aria-hidden="true"><path d="M12 17v5"></path><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path></svg>

After

Width:  |  Height:  |  Size: 477 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-play" aria-hidden="true"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"></path></svg>

After

Width:  |  Height:  |  Size: 332 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw" aria-hidden="true"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"></path><path d="M8 16H3v5"></path></svg>

After

Width:  |  Height:  |  Size: 430 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search" aria-hidden="true"><path d="m21 21-4.34-4.34"></path><circle cx="11" cy="11" r="8"></circle></svg>

After

Width:  |  Height:  |  Size: 307 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-square-text" aria-hidden="true"><path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"></path><path d="M7 11h10"></path><path d="M7 15h6"></path><path d="M7 7h8"></path></svg>

After

Width:  |  Height:  |  Size: 455 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up" aria-hidden="true"><path d="m5 12 7-7 7 7"></path><path d="M12 19V5"></path></svg>

After

Width:  |  Height:  |  Size: 293 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings" aria-hidden="true"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"></path><circle cx="12" cy="12" r="3"></circle></svg>

After

Width:  |  Height:  |  Size: 620 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-check" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="m9 12 2 2 4-4"></path></svg>

After

Width:  |  Height:  |  Size: 451 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles" aria-hidden="true"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"></path><path d="M20 2v4"></path><path d="M22 4h-4"></path><circle cx="4" cy="20" r="2"></circle></svg>

After

Width:  |  Height:  |  Size: 605 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"></rect></svg>

After

Width:  |  Height:  |  Size: 289 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sliders-horizontal" aria-hidden="true"><path d="M10 5H3"></path><path d="M12 19H3"></path><path d="M14 3v4"></path><path d="M16 17v4"></path><path d="M21 12h-9"></path><path d="M21 19h-5"></path><path d="M21 5h-7"></path><path d="M8 10v4"></path><path d="M8 12H3"></path></svg>

After

Width:  |  Height:  |  Size: 478 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2 lucide-trash-2" aria-hidden="true"><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M3 6h18"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>

After

Width:  |  Height:  |  Size: 440 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-upload" aria-hidden="true"><path d="M12 3v12"></path><path d="m17 8-5-5-5 5"></path><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path></svg>

After

Width:  |  Height:  |  Size: 350 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-open-text" aria-hidden="true"><path d="M12 7v14"></path><path d="M16 12h2"></path><path d="M16 8h2"></path><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"></path><path d="M6 12h2"></path><path d="M6 8h2"></path></svg>

After

Width:  |  Height:  |  Size: 516 B

@@ -1 +0,0 @@
{"zh-CN":{"cloudSync.title":"云同步","cloudSync.description":"使用 GitHub 账号,在设备之间同步词典、风格与个人偏好。","cloudSync.signIn":"使用 GitHub 登录","cloudSync.account":"同步账号","cloudSync.refresh":"刷新状态","cloudSync.loading":"正在读取云端状态…","cloudSync.noBackup":"云端尚无备份","cloudSync.available":"云端备份已就绪","cloudSync.summary":"{{dictionary}} 个词条 · {{corrections}} 条纠正规则 · {{stylePacks}} 个风格","cloudSync.updated":"更新于 {{time}}","cloudSync.upload":"备份到云端","cloudSync.restore":"从云端恢复","cloudSync.delete":"删除云端备份","cloudSync.working":"正在同步…","cloudSync.uploadSuccess":"已备份到云端","cloudSync.restoreSuccess":"已恢复云端配置","cloudSync.deleteSuccess":"云端备份已删除","cloudSync.failed":"同步失败:{{error}}","cloudSync.conflict":"云端已有更新。请刷新状态后,再决定备份或恢复。","cloudSync.unavailable":"官方同步服务暂不可用,请稍后重试。","cloudSync.signInRequired":"请先登录 GitHub。","cloudSync.restoreTitle":"恢复云端备份?","cloudSync.restoreDescription":"云端的词典、纠正规则、风格和同步偏好将覆盖本机对应内容。API 密钥、设备目录与权限保持本机设置。","cloudSync.deleteTitle":"删除云端备份?","cloudSync.deleteDescription":"仅删除这个 GitHub 账号的云端备份,本机数据会保留。","cloudSync.confirmRestore":"恢复并替换","cloudSync.confirmDelete":"删除备份","cloudSync.scope":"同步词典、纠正规则、风格图标与常用偏好。API 密钥、登录凭据及设备专属设置保留在本机。","macDictationKey.Changed":"保存期间快捷键已改变,请重试。","macDictationKey.label":"Mac 听写键","macDictationKey.description":"用麦克风图标键替换当前听写快捷键。退出 OpenLess 后,此键交回 macOS。","macDictationKey.Permission":"请在 macOS「隐私与安全性 → 辅助功能」中允许 OpenLess 后重试。","macDictationKey.Busy":"请先结束当前听写,再更改快捷键。","macDictationKey.Unavailable":"无法启用此快捷键,已保存的绑定未改变。请重试或选择其他键。","app.name":"OpenLess","app.tagline":"自然说话,完美书写","common.loading":"加载中…","common.retry":"重试","common.settingsLoadFailed":"设置加载失败","common.refresh":"刷新","common.clear":"清空","common.copy":"复制","common.delete":"删除","common.later":"稍后","common.cancel":"取消","common.close":"关闭","common.show":"显示","common.hide":"隐藏","common.saved":"已保存","common.saving":"保存中","common.experimental":"实验性","common.copied":"已复制","common.operationFailed":"操作失败","common.add":"添加","common.durationSeconds":"{{value}} 秒","common.durationMillis":"{{value}} 毫秒","common.durationMinutes":"{{value}} 分钟","capsule.thinking":"thinking","capsule.using":"using","capsule.cancelled":"已取消","capsule.error":"出错了","capsule.inserted":"已插入 {{count}}","capsule.translating":"正在翻译","capsule.selectionPolish.polishing":"正在润色...","capsule.selectionPolish.replaced":"已替换","capsule.selectionPolish.noSelection":"未选中内容","capsule.selectionPolish.failed":"润色失败,请重试","selectionPolishPreview.title":"选区润色预览","selectionPolishPreview.subtitle":"可直接编辑;点击确认后才会替换原选区。","selectionPolishPreview.cancel":"取消","selectionPolishPreview.resultLabel":"润色结果","selectionPolishPreview.sourcePrefix":"原文:","selectionPolishPreview.applyError":"未能应用:","selectionPolishPreview.confirmReplace":"确认并替换","selectionVoiceIntent.title":"你想做什么?","selectionVoiceIntent.subtitle":"已识别你的语音指令,请选择处理方式。","selectionVoiceIntent.loading":"加载中…","selectionVoiceIntent.sourcePrefix":"选区:","selectionVoiceIntent.errorPrefix":"未能继续:","selectionVoiceIntent.question":"提问","selectionVoiceIntent.edit":"编辑选区","selectionVoiceIntent.cancel":"取消","qa.title":"划词追问","qa.headerHint":"随时提问","qa.thinking":"思考中…","qa.error":"出错了,请稍后再试。","qa.errorRetry":"重试","qa.errorRetryHint":"请再试一次。","qa.pinTooltip":"固定(不自动关闭)","qa.unpinTooltip":"取消固定","qa.closeTooltip":"关闭","qa.micLabel":"语音提问","qa.micStop":"结束录音","qa.selectionPreview":"基于选中文本:","qa.emptyTitle":"有什么可以帮你?","qa.emptyDesc":"选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。","qa.recordingHint":"录音中…再按一次 {{recordHotkey}} 结束并提问","qa.mobileRecordLabel":"录音按钮","qa.mobileRecordStart":"开始录音","qa.mobileRecordStop":"结束并提交","qa.composerPlaceholder":"输入问Line truncated
+43
View File
@@ -0,0 +1,43 @@
//! Stamp the product version into the Linux egui binary.
//!
//! The egui host and the Tauri app ship as one product, so the version shown in
//! the UI must track `package.json` — the release workflow derives the package
//! version from the same file. `CARGO_PKG_VERSION` only carries the internal
//! crate version (`0.1.0`), which is not what users should see.
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=../package.json");
println!("cargo:rerun-if-changed=package-revision");
println!("cargo:rerun-if-env-changed=OPENLESS_LINUX_VERSION");
let base = read_package_version().expect("package.json must declare the product version");
let revision = std::fs::read_to_string(
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("package-revision"),
)
.expect("Linux package revision is required");
let revision = revision.trim();
assert!(
!revision.is_empty() && revision.bytes().all(|c| c.is_ascii_digit()),
"invalid Linux package revision"
);
let expected = format!("{}-{revision}", base.split('+').next().unwrap_or(&base));
if let Ok(version) = std::env::var("OPENLESS_LINUX_VERSION") {
assert_eq!(version, expected, "binary and package versions must match");
}
println!("cargo:rustc-env=OPENLESS_APP_VERSION={expected}");
}
/// Extract the top-level `version` field. The build script stays
/// dependency-free on purpose, so this is a small targeted scan rather than a
/// JSON parse: `package.json` declares exactly one `"version"` key.
fn read_package_version() -> Option<String> {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?);
let raw = std::fs::read_to_string(manifest.join("../package.json")).ok()?;
let key = "\"version\"";
let rest = &raw[raw.find(key)? + key.len()..];
let rest = &rest[rest.find(':')? + 1..];
let rest = &rest[rest.find('"')? + 1..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
@@ -0,0 +1 @@
102
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=${APPDIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"}
export APPDIR="$ROOT"
export OPENLESS_IME_FONT="$ROOT/usr/lib/openless/resources/fonts/NotoSansCJK-Regular.ttc"
exec "$ROOT/usr/bin/openless" "$@"
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=/usr
if [ -n "${APPDIR:-}" ]; then ROOT="$APPDIR/usr"; fi
exec bash "$ROOT/lib/openless/resources/linux-desktop/install.sh" "$@"
-118
View File
@@ -1,118 +0,0 @@
//! AT-SPI fallback uses the accessibility bus and a unique bus owner/object
//! pair. Only the focused text object is read; passwords are never queried.
use crate::context::{platform, TargetSnapshot, CONTEXT_PROTOCOL_VERSION};
use dbus::blocking::{stdintf::org_freedesktop_dbus::Properties, Connection};
use openless_core::BackendError;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
const ACCESSIBLE: &str = "org.a11y.atspi.Accessible";
const TEXT: &str = "org.a11y.atspi.Text";
const ROOT: &str = "/org/a11y/atspi/accessible/root";
type Object = (String, dbus::Path<'static>);
fn connect() -> Result<Connection, BackendError> {
let session = Connection::new_session().map_err(platform)?;
let (address,): (String,) = session
.with_proxy("org.a11y.Bus", "/org/a11y/bus", Duration::from_millis(500))
.method_call("org.a11y.Bus", "GetAddress", ())
.map_err(platform)?;
let mut channel = dbus::channel::Channel::open_private(&address).map_err(platform)?;
channel.register().map_err(platform)?;
Ok(Connection::from(channel))
}
fn focused(connection: &Connection, object: &Object) -> bool {
let states: Result<(Vec<u32>,), _> = connection
.with_proxy(&object.0, object.1.clone(), Duration::from_millis(100))
.method_call(ACCESSIBLE, "GetState", ());
states.is_ok_and(|(s,)| {
s.first()
.is_some_and(|bits| bits & (1 << 12) != 0 && bits & (1 << 6) == 0)
})
}
pub fn snapshot(
expected: Option<&str>,
include_text: bool,
) -> Result<TargetSnapshot, BackendError> {
let connection = connect()?;
let object = if let Some(expected) = expected {
let (owner, path) = expected
.strip_prefix("atspi:")
.and_then(|s| s.split_once('|'))
.ok_or_else(|| platform("invalid AT-SPI identity"))?;
(
owner.to_string(),
dbus::Path::new(path.to_string()).map_err(platform)?,
)
} else {
let root =
connection.with_proxy("org.a11y.atspi.Registry", ROOT, Duration::from_millis(200));
let (apps,): (Vec<Object>,) = root
.method_call(ACCESSIBLE, "GetChildren", ())
.map_err(platform)?;
let mut queue: VecDeque<Object> = apps.into();
let started = Instant::now();
let mut visited = 0;
let mut found = None;
while let Some(candidate) = queue.pop_front() {
if visited >= 512 || started.elapsed() > Duration::from_secs(2) {
break;
}
visited += 1;
if focused(&connection, &candidate) {
found = Some(candidate);
break;
}
let children: Result<(Vec<Object>,), _> = connection
.with_proxy(
&candidate.0,
candidate.1.clone(),
Duration::from_millis(100),
)
.method_call(ACCESSIBLE, "GetChildren", ());
if let Ok((children,)) = children {
queue.extend(children.into_iter().take(128));
}
}
found.ok_or_else(|| platform("no focused AT-SPI object"))?
};
if !focused(&connection, &object) {
return Err(platform("AT-SPI target lost focus"));
}
let proxy = connection.with_proxy(&object.0, object.1.clone(), Duration::from_millis(300));
let (role,): (String,) = proxy
.method_call(ACCESSIBLE, "GetRoleName", ())
.map_err(platform)?;
let sensitive = role.to_ascii_lowercase().contains("password");
let application = connection
.with_proxy(&object.0, ROOT, Duration::from_millis(100))
.get::<String>(ACCESSIBLE, "Name")
.unwrap_or_default();
let mut snapshot = TargetSnapshot {
version: CONTEXT_PROTOCOL_VERSION,
target: format!("atspi:{}|{}", object.0, object.1),
application,
sensitive,
text: None,
cursor: 0,
};
if include_text && !sensitive {
let count: i32 = proxy.get(TEXT, "CharacterCount").map_err(platform)?;
if !(0..=16384).contains(&count) {
return Err(platform("AT-SPI document exceeds observation limit"));
}
let cursor: i32 = proxy.get(TEXT, "CaretOffset").map_err(platform)?;
let (text,): (String,) = proxy
.method_call(TEXT, "GetText", (0_i32, count))
.map_err(platform)?;
snapshot.text = Some(text);
snapshot.cursor = cursor.max(0) as usize;
}
if !focused(&connection, &object) {
return Err(platform("AT-SPI target changed during capture"));
}
crate::context::validate_snapshot(&snapshot, expected, include_text)?;
Ok(snapshot)
}
+16 -5
View File
@@ -48,6 +48,8 @@ impl AudioRecorder for LinuxCpalRecorder {
// audio adapter. Never owned by Core; restore is the guard's Drop. // audio adapter. Never owned by Core; restore is the guard's Drop.
let mute_during_recording = context.recording.mute_during_recording; let mute_during_recording = context.recording.mute_during_recording;
let recordings_dir = self.recordings_dir.clone(); let recordings_dir = self.recordings_dir.clone();
let require_archive =
context.output_target == openless_core::DictationOutputTarget::QuickNote;
Box::pin(async move { Box::pin(async move {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@@ -56,6 +58,7 @@ impl AudioRecorder for LinuxCpalRecorder {
session_id, session_id,
preferred_device_name, preferred_device_name,
recordings_dir, recordings_dir,
require_archive,
mute_during_recording, mute_during_recording,
consumer, consumer,
progress, progress,
@@ -92,7 +95,6 @@ struct LinuxActiveRecording {
mute: Option<crate::audio_mute::AudioMuteGuard>, mute: Option<crate::audio_mute::AudioMuteGuard>,
} }
#[cfg(target_os = "linux")]
impl Drop for LinuxActiveRecording { impl Drop for LinuxActiveRecording {
fn drop(&mut self) { fn drop(&mut self) {
// Taking the guard here forces the field to be consumed (and therefore // Taking the guard here forces the field to be consumed (and therefore
@@ -100,10 +102,6 @@ impl Drop for LinuxActiveRecording {
// dropped directly on an early error or during Core shutdown before it // dropped directly on an early error or during Core shutdown before it
// had a chance to call stop. Double restore is harmless because Drop // had a chance to call stop. Double restore is harmless because Drop
// of an already-taken guard is a no-op. // of an already-taken guard is a no-op.
self.stop.store(true, std::sync::atomic::Ordering::Release);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
self.mute.take(); self.mute.take();
} }
} }
@@ -198,6 +196,7 @@ fn start_linux_recording(
session_id: SessionId, session_id: SessionId,
preferred_device_name: Option<String>, preferred_device_name: Option<String>,
recordings_dir: Option<std::path::PathBuf>, recordings_dir: Option<std::path::PathBuf>,
require_archive: bool,
mute_during_recording: bool, mute_during_recording: bool,
consumer: Arc<dyn AudioConsumer>, consumer: Arc<dyn AudioConsumer>,
progress: Arc<dyn RecordingProgressSink>, progress: Arc<dyn RecordingProgressSink>,
@@ -230,11 +229,23 @@ fn start_linux_recording(
(Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive)) (Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive))
} }
Err(error) => { Err(error) => {
if require_archive {
return Err(BackendError::new(
BackendErrorCode::Persistence,
format!("quick note recording archive is required: {error}"),
));
}
log::warn!("failed to create Linux recording archive: {error}"); log::warn!("failed to create Linux recording archive: {error}");
(None, None) (None, None)
} }
} }
} }
None if require_archive => {
return Err(BackendError::new(
BackendErrorCode::Persistence,
"quick note recording archive directory is missing",
))
}
None => (None, None), None => (None, None),
}; };
let thread = std::thread::Builder::new() let thread = std::thread::Builder::new()
+146 -63
View File
@@ -1,10 +1,23 @@
//! Native recording start/stop audio cues for Linux. //! Native recording cue for Linux.
//! //!
//! The Windows/macOS Tauri shell synthesizes a "recording started" chime with //! The Windows/macOS Tauri shell synthesizes the "recording started" chime with
//! the Web Audio API in a webview (`app/src/lib/audioCue.ts`) and silences it //! the Web Audio API in a webview (`app/src/lib/audioCue.ts`,
//! when the recording ends. This crate is the native egui host, so there is no //! `recordStartCueTones()` + `scheduleCueVoices()`). This crate is the native
//! webview; the equivalent cue is rendered as PCM and played to the default //! egui host, so there is no webview; the same cue is rendered as PCM here and
//! output sink with cpal (already a dependency for the microphone). //! played to the default output sink with cpal (already a dependency for the
//! microphone). Parameters **and** envelope semantics are kept identical to
//! `audioCue.ts` so Linux and Windows/macOS share one sound:
//!
//! | `audioCue.ts` | here |
//! |------------------------------------------------------|-------------------------------------|
//! | `recordStartCueTones()` 880 Hz / 0 ms / 130 ms / .16 | `start_cue_tones()[0]` |
//! | 1108.73 Hz / 95 ms / 170 ms / .18 | `start_cue_tones()[1]` |
//! | `osc.type = 'sine'` | `sin(2 * pi * freq_hz * t)` |
//! | `setValueAtTime(0.0001, t0)` | `CUE_FLOOR` |
//! | `exponentialRamp(peakGain, t0 + 0.005)` | 5 ms 指数 attack |
//! | `exponentialRamp(0.0001, tEnd)` | 指数 decay 到 `CUE_FLOOR` |
//! | `stopAudioCue()` = `stopVoices()`,**不发声** | `play_cue_stop()` = 作废当前代次 |
//! | `scheduleCueVoices()` 先 `stopVoices()` 防叠音 | 新一代次使在播的旧音静音 |
//! //!
//! Honesty rules: //! Honesty rules:
//! - The frame is never blocked: `play_cue_start`/`play_cue_stop` enqueue a //! - The frame is never blocked: `play_cue_start`/`play_cue_stop` enqueue a
@@ -15,14 +28,26 @@
//! - Cues are gated by the caller on `audio_cue_on_record`, and the start cue //! - Cues are gated by the caller on `audio_cue_on_record`, and the start cue
//! is additionally suppressed when `mute_during_recording` is active (an //! is additionally suppressed when `mute_during_recording` is active (an
//! audible start cue through a deliberately muted sink is both pointless and //! audible start cue through a deliberately muted sink is both pointless and
//! a needless PipeWire/KDE sink-input blip). The stop cue may still play //! a needless PipeWire/KDE sink-input blip).
//! after output is restored.
//! - Synthesis is pure (`render_cue_mono`) so it is unit-testable without any //! - Synthesis is pure (`render_cue_mono`) so it is unit-testable without any
//! audio device; the cpal playback path still needs real-device evidence on //! audio device; the cpal playback path still needs real-device evidence on
//! X11/Wayland before it may be reported as verified. //! X11/Wayland before it may be reported as verified.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
/// Web Audio 的指数斜坡下限(`audioCue.ts` 里用 0.0001 代替 0:0 无法走指数)。
const CUE_FLOOR: f32 = 0.0001;
/// `audioCue.ts` 的 attack 时间:`exponentialRampToValueAtTime(peakGain, t0 + 0.005)`。
const CUE_ATTACK_SECS: f32 = 0.005;
/// 当前提示音代次。与 Tauri 的 `playSeq` / `stopVoices()` 对应:
/// - 每次 `play_cue_start` 递增:新音开始时在播的旧音立即静音(Tauri 排期前会
/// `stopVoices()`,避免连按热键叠音越来越响)。
/// - `play_cue_stop` 也递增:等价于 Tauri 的 `stopAudioCue()` —— 只停不发声,
/// Tauri 在录音结束时并没有“结束提示音”。
static CUE_GENERATION: AtomicU64 = AtomicU64::new(0);
/// A single synthesized sine note relative to the cue start. /// A single synthesized sine note relative to the cue start.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct CueTone { pub struct CueTone {
@@ -36,8 +61,8 @@ pub struct CueTone {
pub peak_gain: f32, pub peak_gain: f32,
} }
/// "Recording started" chime: rising minor third (A5 -> C#6), mirroring the /// "Recording started" chime: rising minor third (A5 -> C#6) — 与 Tauri
/// reference Web Audio cue so Linux and Windows/macOS share one sound. /// `recordStartCueTones()` 逐字段一致(880/0ms/130ms/0.16 + 1108.73/95ms/170ms/0.18)。
pub fn start_cue_tones() -> Vec<CueTone> { pub fn start_cue_tones() -> Vec<CueTone> {
vec![ vec![
CueTone { CueTone {
@@ -55,25 +80,6 @@ pub fn start_cue_tones() -> Vec<CueTone> {
] ]
} }
/// "Recording ended" cue: descending minor third (E5 -> C5). Soft and short so
/// it reads as a clear "done" without masking the terminal feedback.
pub fn stop_cue_tones() -> Vec<CueTone> {
vec![
CueTone {
freq_hz: 659.25,
start_ms: 0.0,
duration_ms: 120.0,
peak_gain: 0.13,
},
CueTone {
freq_hz: 523.25,
start_ms: 90.0,
duration_ms: 150.0,
peak_gain: 0.15,
},
]
}
/// Total cue duration in milliseconds (end of the last tone). /// Total cue duration in milliseconds (end of the last tone).
pub fn cue_total_duration_ms(tones: &[CueTone]) -> u32 { pub fn cue_total_duration_ms(tones: &[CueTone]) -> u32 {
tones.iter().fold(0u32, |acc, tone| { tones.iter().fold(0u32, |acc, tone| {
@@ -94,27 +100,29 @@ pub fn render_cue_mono(tones: &[CueTone], sample_rate: u32) -> Vec<f32> {
for tone in tones { for tone in tones {
let start = (tone.start_ms / 1000.0 * sr).round() as usize; let start = (tone.start_ms / 1000.0 * sr).round() as usize;
let dur = ((tone.duration_ms / 1000.0) * sr).round() as usize; let dur = ((tone.duration_ms / 1000.0) * sr).round() as usize;
let attack = ((0.004 * sr).round() as usize).clamp(1, dur.max(1)); let dur_secs = dur as f32 / sr;
let release_span = (dur.saturating_sub(attack)).max(1) as f32; // `math.exp` 的指数斜坡在**增益空间是直线**:
// gain.setValueAtTime(0.0001, t0)
// gain.exponentialRampToValueAtTime(peakGain, t0 + 0.005)
// gain.exponentialRampToValueAtTime(0.0001, tEnd)
// 即 g(t) = a * (b/a)^(t/T)。这里逐步复现同一个曲线(不是线性 attack +
// 任意 tau 的衰减)。
let attack_secs = CUE_ATTACK_SECS.min(dur_secs.max(f32::EPSILON));
let decay_secs = (dur_secs - attack_secs).max(f32::EPSILON);
for i in 0..dur { for i in 0..dur {
let idx = start + i; let idx = start + i;
if idx >= out.len() { if idx >= out.len() {
break; break;
} }
let attack_env = if i < attack { let t = i as f32 / sr;
i as f32 / attack as f32 let env = if t < attack_secs {
CUE_FLOOR * (tone.peak_gain / CUE_FLOOR).powf(t / attack_secs)
} else { } else {
1.0 let frac = ((t - attack_secs) / decay_secs).min(1.0);
tone.peak_gain * (CUE_FLOOR / tone.peak_gain).powf(frac)
}; };
let release_env = if i >= attack {
let frac = (i - attack) as f32 / release_span;
(-5.0 * frac).exp()
} else {
1.0
};
let env = attack_env * release_env;
let phase = std::f32::consts::TAU * tone.freq_hz * (idx as f32 / sr); let phase = std::f32::consts::TAU * tone.freq_hz * (idx as f32 / sr);
out[idx] += phase.sin() * tone.peak_gain * env; out[idx] += phase.sin() * env;
} }
} }
for sample in &mut out { for sample in &mut out {
@@ -124,24 +132,33 @@ pub fn render_cue_mono(tones: &[CueTone], sample_rate: u32) -> Vec<f32> {
} }
/// Play a start cue asynchronously (never blocks the caller/frame). /// Play a start cue asynchronously (never blocks the caller/frame).
/// 与 Tauri `playRecordStartCue()` 同语义:新的一生效后,上一声立即静音。
pub fn play_cue_start() { pub fn play_cue_start() {
play_cue(start_cue_tones()); let generation = CUE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
play_cue(start_cue_tones(), generation);
} }
/// Play a stop cue asynchronously (never blocks the caller/frame). /// 停止当前提示音。对应 Tauri `stopAudioCue()` —— **只停不发声**:
/// Tauri 在录音结束时调用的就是它,并没有“结束提示音”。
/// 正在播放的提示音会被静音(代次作废),未播放的不再开始。
pub fn play_cue_stop() { pub fn play_cue_stop() {
play_cue(stop_cue_tones()); CUE_GENERATION.fetch_add(1, Ordering::SeqCst);
}
/// 该代次是否仍是当前代次;`false` 表示应立刻静音(等价 `stopVoices()`)。
fn cue_generation_is_current(generation: u64) -> bool {
CUE_GENERATION.load(Ordering::SeqCst) == generation
} }
/// Best-effort asynchronous playback on a detached worker thread. /// Best-effort asynchronous playback on a detached worker thread.
fn play_cue(tones: Vec<CueTone>) { fn play_cue(tones: Vec<CueTone>, generation: u64) {
if tones.is_empty() { if tones.is_empty() {
return; return;
} }
std::thread::Builder::new() std::thread::Builder::new()
.name("openless-audio-cue".to_string()) .name("openless-audio-cue".to_string())
.spawn(move || { .spawn(move || {
if let Err(error) = play_cue_blocking(&tones) { if let Err(error) = play_cue_blocking(&tones, generation) {
log::debug!("[audio-cue] cue playback unavailable: {error}"); log::debug!("[audio-cue] cue playback unavailable: {error}");
} }
}) })
@@ -150,7 +167,7 @@ fn play_cue(tones: Vec<CueTone>) {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> { fn play_cue_blocking(tones: &[CueTone], generation: u64) -> Result<(), String> {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
let host = cpal::default_host(); let host = cpal::default_host();
@@ -182,6 +199,16 @@ fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> {
device.build_output_stream::<$ty, _, _>( device.build_output_stream::<$ty, _, _>(
*config, *config,
move |data: &mut [$ty], _: &cpal::OutputCallbackInfo| { move |data: &mut [$ty], _: &cpal::OutputCallbackInfo| {
// 代次不再是最新(被新的提示音接管,或录音结束调了
// `play_cue_stop`)→ 立刻静音并结束,等价 Tauri 的
// `stopVoices()`。
if !cue_generation_is_current(generation) {
for sample in data.iter_mut() {
*sample = $convert(0.0);
}
done.store(true, std::sync::atomic::Ordering::Release);
return;
}
let frames = data.len() / channels; let frames = data.len() / channels;
let mut pos = idx.load(std::sync::atomic::Ordering::Acquire); let mut pos = idx.load(std::sync::atomic::Ordering::Acquire);
for frame in 0..frames { for frame in 0..frames {
@@ -240,7 +267,7 @@ fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> {
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
fn play_cue_blocking(_tones: &[CueTone]) -> Result<(), String> { fn play_cue_blocking(_tones: &[CueTone], _generation: u64) -> Result<(), String> {
Err("audio cue playback is only available on Linux".to_string()) Err("audio cue playback is only available on Linux".to_string())
} }
@@ -248,21 +275,62 @@ fn play_cue_blocking(_tones: &[CueTone]) -> Result<(), String> {
mod tests { mod tests {
use super::*; use super::*;
/// 与 Tauri `recordStartCueTones()`(`src/lib/audioCue.ts:29-34`)逐字段一致。
#[test] #[test]
fn start_cue_is_a_rising_two_tone_and_stop_is_descending() { fn start_cue_matches_the_tauri_tone_table() {
let start = start_cue_tones(); let tones = start_cue_tones();
assert_eq!(start.len(), 2); assert_eq!(tones.len(), 2, "the Tauri cue has two tones");
assert!(start[0].freq_hz < start[1].freq_hz, "start cue rises"); assert_eq!(tones[0].freq_hz, 880.0);
let stop = stop_cue_tones(); assert_eq!(tones[0].start_ms, 0.0);
assert_eq!(stop.len(), 2); assert_eq!(tones[0].duration_ms, 130.0);
assert!(stop[0].freq_hz > stop[1].freq_hz, "stop cue descends"); assert_eq!(tones[0].peak_gain, 0.16);
assert_eq!(tones[1].freq_hz, 1108.73, "rising minor third A5 -> C#6");
assert_eq!(tones[1].start_ms, 95.0);
assert_eq!(tones[1].duration_ms, 170.0);
assert_eq!(tones[1].peak_gain, 0.18);
assert_eq!(
cue_total_duration_ms(&tones),
265,
"total duration is 265ms"
);
} }
/// 包络复现 Web Audio 的两段指数斜坡:0.0001 → 5ms 到峰值 → 到末尾回到 0.0001。
#[test] #[test]
fn total_duration_is_last_tone_end() { fn envelope_is_two_exponential_ramps_like_web_audio() {
let start = start_cue_tones(); let sr = 48_000u32;
assert_eq!(cue_total_duration_ms(&start), 265); let tone = CueTone {
assert!(cue_total_duration_ms(&stop_cue_tones()) > 0); freq_hz: 880.0,
start_ms: 0.0,
duration_ms: 130.0,
peak_gain: 0.16,
};
// 用 1 Hz 的音来取包络:sin(2pi * 1 * t) 在 t<0.25s 内近似线性递增,
// 便于直接看曲线;这里改为直接验证端点与峰值位置。
let mono = render_cue_mono(&[tone], sr);
let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs()));
assert!(
peak > 0.05 && peak <= 0.17,
"peak should sit near peak_gain: {peak}"
);
// 起点的指数 attack 从 0.0001 开始 → 首样本几乎无声。
assert!(
mono[0].abs() < 0.01,
"first sample starts at CUE_FLOOR: {}",
mono[0]
);
// 末尾回到 floor → 最后一个样本远小于峰值。
let last = mono[mono.len() - 1].abs();
assert!(
last < peak * 0.5,
"tail decays towards the floor: {last} vs peak {peak}"
);
// 5ms 之后就应在衰减(第 5ms 样本的包络低于峰值)。
let at_5ms = (0.005 * sr as f32) as usize;
assert!(
mono[at_5ms].abs() < peak,
"exponential decay starts after 5ms"
);
} }
#[test] #[test]
@@ -285,16 +353,31 @@ mod tests {
); );
} }
/// `play_cue_stop` 对应 Tauri `stopAudioCue()`:只停不发声(作废代次),
/// 因此不能合成任何“结束音”。
#[test]
fn stopping_a_cue_only_silences_it() {
let before = CUE_GENERATION.load(Ordering::SeqCst);
play_cue_stop();
let after = CUE_GENERATION.load(Ordering::SeqCst);
assert_eq!(after, before + 1, "stop only bumps the generation");
assert!(
!cue_generation_is_current(before),
"the previous generation must be invalidated (an in-flight cue is silenced)"
);
}
#[test] #[test]
fn empty_tones_render_to_empty_and_play_is_a_noop() { fn empty_tones_render_to_empty_and_play_is_a_noop() {
assert!(render_cue_mono(&[], 44_100).is_empty()); assert!(render_cue_mono(&[], 44_100).is_empty());
play_cue(Vec::new()); let generation = CUE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
play_cue(Vec::new(), generation);
} }
#[test] #[test]
fn mono_cue_respects_sample_rate_scaling() { fn mono_cue_respects_sample_rate_scaling() {
let at_44k = render_cue_mono(&stop_cue_tones(), 44_100); let at_44k = render_cue_mono(&start_cue_tones(), 44_100);
let at_48k = render_cue_mono(&stop_cue_tones(), 48_000); let at_48k = render_cue_mono(&start_cue_tones(), 48_000);
// Higher sample rate yields proportionally more samples for the same cue. // Higher sample rate yields proportionally more samples for the same cue.
assert!(at_48k.len() > at_44k.len()); assert!(at_48k.len() > at_44k.len());
} }
+168 -148
View File
@@ -1,192 +1,212 @@
//! Restore the output that was muted at activation, even if the default changes. //! Temporary system-output mute while recording.
use std::sync::Arc; //!
//! Linux parity with the legacy desktop [`AudioMuteGuard`] behavior.
//! `mute_during_recording` is applied by
//! the host recorder (never by Core) so the capture itself never hears the
//! user's speakers. Restore is guaranteed by RAII: the guard restores the
//! previous mute state when it is dropped, which happens on every terminal
//! path of the recorder lifecycle — normal stop, cancel, runtime fault/error
//! and process shutdown — because Core consumes/drops the active recording
//! handle on each of those.
//!
//! Muting is intentionally best-effort like the reference: if neither `wpctl`
//! (PipeWire) nor `pactl` (PulseAudio) can drive the default sink, activation
//! fails but capture still proceeds. The parsed state helpers are pure so
//! they can be unit-tested without a live audio server.
trait OutputControl: Send + Sync { /// The output-mute backend discovered at activation time.
fn current(&self) -> Result<(String, bool), String>; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String>; enum MuteBackend {
Wpctl,
Pactl,
} }
/// Guard that restores the previous default-sink mute state on drop.
#[derive(Debug)]
pub struct AudioMuteGuard { pub struct AudioMuteGuard {
inner: Option<(Arc<dyn OutputControl>, String, bool)>, inner: Option<platform::PlatformMuteGuard>,
}
impl std::fmt::Debug for AudioMuteGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudioMuteGuard")
.field("active", &self.inner.is_some())
.finish()
}
} }
impl AudioMuteGuard { impl AudioMuteGuard {
/// Mute the default output sink unless it is already muted, capturing the
/// previous state for later restore. Best-effort: on failure returns an
/// error and leaves the sink untouched.
pub fn activate() -> Result<Self, String> { pub fn activate() -> Result<Self, String> {
Self::with_control(Arc::new(NativeOutput)) Ok(Self {
} inner: Some(platform::activate()?),
fn with_control(control: Arc<dyn OutputControl>) -> Result<Self, String> { })
let (sink, was_muted) = control.current()?;
let guard = Self {
inner: Some((control.clone(), sink.clone(), was_muted)),
};
if !was_muted {
control.set_muted(&sink, true)?;
}
Ok(guard)
} }
/// A no-op guard used when `mute_during_recording` is disabled.
pub fn none() -> Self { pub fn none() -> Self {
Self { inner: None } Self { inner: None }
} }
} }
impl Drop for AudioMuteGuard { impl Drop for AudioMuteGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Some((control, sink, was_muted)) = self.inner.take() { if let Some(inner) = self.inner.take() {
if let Err(error) = control.set_muted(&sink, was_muted) { inner.restore();
log::warn!("restore recording output mute: {error}");
}
} }
} }
} }
/// `true` when a `wpctl get-volume` capture reports the sink muted.
pub fn parse_wpctl_muted(output: &str) -> bool { pub fn parse_wpctl_muted(output: &str) -> bool {
output.contains("[MUTED]") output.contains("[MUTED]")
} }
/// `true` when a `pactl get-sink-mute` capture reports the sink muted.
pub fn parse_pactl_muted(output: &str) -> bool { pub fn parse_pactl_muted(output: &str) -> bool {
output.to_ascii_lowercase().contains("yes") || output.contains('是') let lower = output.to_ascii_lowercase();
lower.contains("yes") || output.contains("是")
} }
struct NativeOutput;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn command(program: &str, args: &[&str]) -> Result<String, String> { mod platform {
let output = std::process::Command::new("timeout") use super::{parse_pactl_muted, parse_wpctl_muted, MuteBackend};
.args(["--signal=KILL", "2s", program]) use std::process::Command;
.args(args)
.env("LC_ALL", "C") #[derive(Debug)]
.output() pub struct PlatformMuteGuard {
.map_err(|e| e.to_string())?; backend: MuteBackend,
if !output.status.success() { was_muted: bool,
return Err(format!(
"{program}: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
} }
String::from_utf8(output.stdout).map_err(|e| e.to_string())
} pub fn activate() -> Result<PlatformMuteGuard, String> {
#[cfg(target_os = "linux")] if let Ok(was_muted) = wpctl_muted() {
impl OutputControl for NativeOutput { if !was_muted {
fn current(&self) -> Result<(String, bool), String> { set_wpctl_muted(true)?;
// PipeWire's PulseAudio compatibility service uses the same stable sink }
// names, which also work on the GNOME 42 PulseAudio baseline. return Ok(PlatformMuteGuard {
if let Ok(sink) = command("pactl", &["get-default-sink"]) { backend: MuteBackend::Wpctl,
let sink = sink.trim(); was_muted,
if !sink.is_empty() { });
let state = command("pactl", &["get-sink-mute", sink])?; }
return Ok((format!("pulse:{sink}"), parse_pactl_muted(&state))); let was_muted = pactl_muted()?;
if !was_muted {
set_pactl_muted(true)?;
}
Ok(PlatformMuteGuard {
backend: MuteBackend::Pactl,
was_muted,
})
}
impl PlatformMuteGuard {
pub fn restore(self) {
let result = match self.backend {
MuteBackend::Wpctl => set_wpctl_muted(self.was_muted),
MuteBackend::Pactl => set_pactl_muted(self.was_muted),
};
if let Err(error) = result {
log::warn!("[audio-mute] restore output mute failed: {error}");
} }
} }
let object = command("wpctl", &["inspect", "@DEFAULT_AUDIO_SINK@"])?;
let id = object
.trim()
.strip_prefix("id ")
.and_then(|s| s.split(',').next())
.filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
.ok_or("cannot identify the original output sink")?;
let state = command("wpctl", &["get-volume", id])?;
Ok((format!("pipewire:{id}"), parse_wpctl_muted(&state)))
} }
fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String> {
let value = if muted { "1" } else { "0" }; fn wpctl_muted() -> Result<bool, String> {
if let Some(name) = sink.strip_prefix("pulse:") { let output = Command::new("wpctl")
command("pactl", &["set-sink-mute", name, value])?; .args(["get-volume", "@DEFAULT_AUDIO_SINK@"])
} else if let Some(id) = sink.strip_prefix("pipewire:") { .output()
command("wpctl", &["set-mute", id, value])?; .map_err(|error| format!("wpctl get-volume failed: {error}"))?;
} else { if !output.status.success() {
return Err("invalid output identity".into()); return Err(std::str::from_utf8(&output.stderr)
.unwrap_or_default()
.trim()
.to_string());
}
Ok(parse_wpctl_muted(
std::str::from_utf8(&output.stdout).unwrap_or_default(),
))
}
fn set_wpctl_muted(muted: bool) -> Result<(), String> {
let value = if muted { "1" } else { "0" };
let output = Command::new("wpctl")
.args(["set-mute", "@DEFAULT_AUDIO_SINK@", value])
.output()
.map_err(|error| format!("wpctl set-mute failed: {error}"))?;
if output.status.success() {
Ok(())
} else {
Err(std::str::from_utf8(&output.stderr)
.unwrap_or_default()
.trim()
.to_string())
}
}
fn pactl_muted() -> Result<bool, String> {
let output = Command::new("pactl")
.args(["get-sink-mute", "@DEFAULT_SINK@"])
.output()
.map_err(|error| format!("pactl get-sink-mute failed: {error}"))?;
if !output.status.success() {
return Err(std::str::from_utf8(&output.stderr)
.unwrap_or_default()
.trim()
.to_string());
}
Ok(parse_pactl_muted(
std::str::from_utf8(&output.stdout).unwrap_or_default(),
))
}
fn set_pactl_muted(muted: bool) -> Result<(), String> {
let value = if muted { "1" } else { "0" };
let output = Command::new("pactl")
.args(["set-sink-mute", "@DEFAULT_SINK@", value])
.output()
.map_err(|error| format!("pactl set-sink-mute failed: {error}"))?;
if output.status.success() {
Ok(())
} else {
Err(std::str::from_utf8(&output.stderr)
.unwrap_or_default()
.trim()
.to_string())
} }
Ok(())
} }
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
impl OutputControl for NativeOutput { mod platform {
fn current(&self) -> Result<(String, bool), String> { #[derive(Debug)]
Err("Linux audio unavailable".into()) pub struct PlatformMuteGuard;
pub fn activate() -> Result<PlatformMuteGuard, String> {
Err("output mute is not supported on this platform".to_string())
} }
fn set_muted(&self, _: &str, _: bool) -> Result<(), String> {
Err("Linux audio unavailable".into()) impl PlatformMuteGuard {
pub fn restore(self) {}
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Mutex;
struct Output {
default: Mutex<String>,
calls: Mutex<Vec<(String, bool)>>,
muted: bool,
}
impl OutputControl for Output {
fn current(&self) -> Result<(String, bool), String> {
Ok((self.default.lock().unwrap().clone(), self.muted))
}
fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String> {
self.calls.lock().unwrap().push((sink.into(), muted));
Ok(())
}
}
#[test] #[test]
fn restore_original_sink_on_stop_cancel_and_unwind() { fn wpctl_output_parser_detects_muted_flag() {
for failure in [false, true] {
let output = Arc::new(Output {
default: Mutex::new("speakers".into()),
calls: Mutex::default(),
muted: false,
});
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _recording = AudioMuteGuard::with_control(output.clone()).unwrap();
*output.default.lock().unwrap() = "headphones".into();
if failure {
panic!("recording failed");
}
}));
assert_eq!(result.is_err(), failure);
assert_eq!(
*output.calls.lock().unwrap(),
vec![("speakers".into(), true), ("speakers".into(), false)]
);
}
}
#[test]
fn already_muted_output_stays_muted() {
let output = Arc::new(Output {
default: Mutex::new("speakers".into()),
calls: Mutex::default(),
muted: true,
});
drop(AudioMuteGuard::with_control(output.clone()).unwrap());
assert_eq!(
*output.calls.lock().unwrap(),
vec![("speakers".into(), true)]
);
}
#[test]
fn partially_applied_mute_is_restored_when_activation_reports_failure() {
struct FailingOutput(Mutex<Vec<bool>>);
impl OutputControl for FailingOutput {
fn current(&self) -> Result<(String, bool), String> {
Ok(("speakers".into(), false))
}
fn set_muted(&self, _: &str, muted: bool) -> Result<(), String> {
self.0.lock().unwrap().push(muted);
if muted {
Err("server disconnected after applying mute".into())
} else {
Ok(())
}
}
}
let output = Arc::new(FailingOutput(Mutex::default()));
assert!(AudioMuteGuard::with_control(output.clone()).is_err());
assert_eq!(*output.0.lock().unwrap(), vec![true, false]);
}
#[test]
fn native_state_parsers() {
assert!(parse_wpctl_muted("Volume: 0.00 [MUTED]")); assert!(parse_wpctl_muted("Volume: 0.00 [MUTED]"));
assert!(!parse_wpctl_muted("Volume: 0.82")); assert!(!parse_wpctl_muted("Volume: 0.82"));
}
#[test]
fn pactl_output_parser_detects_muted_flag_in_any_locale() {
assert!(parse_pactl_muted("Mute: yes")); assert!(parse_pactl_muted("Mute: yes"));
assert!(parse_pactl_muted("静音:是"));
assert!(!parse_pactl_muted("Mute: no")); assert!(!parse_pactl_muted("Mute: no"));
assert!(!parse_pactl_muted("静音:否"));
}
#[test]
fn disabled_guard_is_a_noop_that_drops_without_panicking() {
// `none()` is a pure no-op guard; dropping it must not panic and has no
// command side effect to assert, so we only verify construction/drop.
let guard = AudioMuteGuard::none();
drop(guard);
} }
} }
@@ -0,0 +1,234 @@
//! In-app playback for history recordings.
//!
//! Recordings are 16 kHz mono signed-16 WAV. This plays them through the default
//! CPAL output device with linear resampling to whatever rate the device runs
//! at, so the history page can show a real player bar instead of shelling out to
//! an external player.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
/// A playing clip. Dropping it stops playback.
pub struct ClipPlayer {
_stream: cpal::Stream,
played_samples: Arc<AtomicUsize>,
total_samples: usize,
channels: usize,
sample_rate: u32,
paused: Arc<AtomicBool>,
}
impl ClipPlayer {
/// Play `pcm` (16 kHz mono little-endian i16 bytes).
pub fn play(pcm: &[u8]) -> Result<Self, String> {
let mono: Vec<f32> = pcm
.as_chunks::<2>()
.0
.iter()
.map(|pair| i16::from_le_bytes(*pair) as f32 / 32_768.0)
.collect();
if mono.is_empty() {
return Err("empty recording".to_string());
}
let host = cpal::default_host();
let device = host
.default_output_device()
.ok_or_else(|| "no default output device".to_string())?;
let supported = device
.default_output_config()
.map_err(|error| error.to_string())?;
let sample_rate = supported.sample_rate();
let channels = supported.channels() as usize;
let sample_format = supported.sample_format();
let config: cpal::StreamConfig = supported.into();
// Linear resample 16 kHz -> device rate and fan out to every channel.
let ratio = 16_000.0_f64 / sample_rate as f64;
let out_frames = (mono.len() as f64 / ratio).ceil() as usize;
let mut interleaved: Vec<f32> = Vec::with_capacity(out_frames * channels);
for frame in 0..out_frames {
let source = frame as f64 * ratio;
let index = source.floor() as usize;
let frac = (source - index as f64) as f32;
let first = mono.get(index).copied().unwrap_or(0.0);
let second = mono.get(index + 1).copied().unwrap_or(first);
let value = first + (second - first) * frac;
for _ in 0..channels {
interleaved.push(value);
}
}
let data = Arc::new(interleaved);
let played_samples = Arc::new(AtomicUsize::new(0));
let paused = Arc::new(AtomicBool::new(false));
let total_samples = data.len();
let stream = build_stream(
&device,
config,
sample_format,
data.clone(),
played_samples.clone(),
paused.clone(),
)?;
stream.play().map_err(|error| error.to_string())?;
Ok(Self {
_stream: stream,
played_samples,
total_samples,
channels,
sample_rate,
paused,
})
}
/// Playback head in milliseconds.
pub fn position_ms(&self) -> u64 {
let frames = self
.played_samples
.load(Ordering::Relaxed)
.min(self.total_samples)
/ self.channels.max(1);
(frames as u64 * 1000) / self.sample_rate.max(1) as u64
}
/// Clip length in milliseconds.
pub fn total_ms(&self) -> u64 {
let frames = self.total_samples / self.channels.max(1);
(frames as u64 * 1000) / self.sample_rate.max(1) as u64
}
pub fn is_finished(&self) -> bool {
self.played_samples.load(Ordering::Relaxed) >= self.total_samples
}
pub fn is_paused(&self) -> bool {
self.paused.load(Ordering::Relaxed)
}
pub fn toggle_pause(&self) {
self.paused.fetch_xor(true, Ordering::Relaxed);
}
/// Seek in whole frames so all output channels stay aligned. The callback uses a
/// compare-exchange to avoid overwriting this position if a seek races an audio block.
pub fn seek_ms(&self, ms: u64) {
let frames = seek_frame(
ms,
self.sample_rate,
self.total_samples / self.channels.max(1),
);
self.played_samples
.store(frames * self.channels, Ordering::Relaxed);
}
}
fn seek_frame(ms: u64, rate: u32, frames: usize) -> usize {
((ms as u128 * rate as u128) / 1000).min(frames.saturating_sub(1) as u128) as usize
}
fn build_stream(
device: &cpal::Device,
config: cpal::StreamConfig,
format: cpal::SampleFormat,
data: Arc<Vec<f32>>,
played: Arc<AtomicUsize>,
paused: Arc<AtomicBool>,
) -> Result<cpal::Stream, String> {
let error_callback = |error| log::warn!("audio playback error: {error}");
// Fill the output buffer from `data`, starting at the played head.
macro_rules! writer {
($ty:ty, $convert:expr) => {{
let data = data.clone();
let played = played.clone();
let paused = paused.clone();
device
.build_output_stream(
config.clone(),
move |output: &mut [$ty], _| {
let convert: fn(f32) -> $ty = $convert;
write_samples(output, &data, &played, &paused, convert);
},
error_callback,
None,
)
.map_err(|error| error.to_string())
}};
}
match format {
cpal::SampleFormat::F32 => writer!(f32, |value| value),
cpal::SampleFormat::I16 => {
writer!(i16, |value| (value * 32_767.0).clamp(-32_768.0, 32_767.0)
as i16)
}
cpal::SampleFormat::U16 => {
writer!(
u16,
|value| ((value * 0.5 + 0.5) * 65_535.0).clamp(0.0, 65_535.0) as u16
)
}
other => Err(format!("unsupported output sample format: {other:?}")),
}
}
fn write_samples<T: Copy>(
output: &mut [T],
data: &[f32],
played: &AtomicUsize,
paused: &AtomicBool,
convert: fn(f32) -> T,
) {
let silence = convert(0.0);
if paused.load(Ordering::Relaxed) {
output.fill(silence);
return;
}
let start = played.load(Ordering::Relaxed);
for (offset, slot) in output.iter_mut().enumerate() {
*slot = data.get(start + offset).copied().map_or(silence, convert);
}
// A seek can arrive while we fill the device buffer: never let this callback
// overwrite the new cursor. Completion derives from the cursor, so seeking
// back from the end cannot race with a separate `finished` flag.
let _ = played.compare_exchange(
start,
start.saturating_add(output.len()).min(data.len()),
Ordering::Relaxed,
Ordering::Relaxed,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seek_clamps_at_both_ends_and_aligns_output_channels() {
assert_eq!(seek_frame(250, 48_000, 48_000), 12_000);
assert_eq!(seek_frame(u64::MAX, 48_000, 48_000), 47_999);
assert_eq!(seek_frame(0, 48_000, 0), 0);
// Two interleaved channels: a 250ms seek starts at sample 24_000.
let samples = seek_frame(250, 48_000, 48_000) * 2;
assert_eq!(samples, 24_000);
assert_eq!(samples % 2, 0);
}
#[test]
fn paused_output_is_silent_and_does_not_advance() {
let data = [0.5, -0.5, 0.25];
let played = AtomicUsize::new(1);
let paused = AtomicBool::new(true);
let mut output = [1.0; 2];
write_samples(&mut output, &data, &played, &paused, |v| v);
assert_eq!(output, [0.0, 0.0]);
assert_eq!(played.load(Ordering::Relaxed), 1);
paused.store(false, Ordering::Relaxed);
write_samples(&mut output, &data, &played, &paused, |v| v);
assert_eq!(output, [-0.5, 0.25]);
assert_eq!(played.load(Ordering::Relaxed), data.len());
}
}
File diff suppressed because it is too large. Load diff
+103 -40
View File
@@ -6,7 +6,7 @@ use openless_core::{
PlatformCapabilities, PlatformCapabilities,
}; };
use crate::{fcitx5_available, LinuxPackageKind}; use crate::fcitx5_available;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinuxDesktopSession { pub enum LinuxDesktopSession {
@@ -29,7 +29,6 @@ impl LinuxCapabilitySnapshot {
x11_display: Option<&str>, x11_display: Option<&str>,
fcitx5_ready: bool, fcitx5_ready: bool,
tray_available: bool, tray_available: bool,
package_kind: LinuxPackageKind,
) -> Self { ) -> Self {
let session = if wayland_display.is_some_and(|value| !value.trim().is_empty()) { let session = if wayland_display.is_some_and(|value| !value.trim().is_empty()) {
LinuxDesktopSession::Wayland LinuxDesktopSession::Wayland
@@ -48,10 +47,16 @@ impl LinuxCapabilitySnapshot {
supports_tray: desktop && tray_available, supports_tray: desktop && tray_available,
supports_overlay: session == LinuxDesktopSession::X11, supports_overlay: session == LinuxDesktopSession::X11,
supports_ime_input: desktop && fcitx5_ready, supports_ime_input: desktop && fcitx5_ready,
supports_local_asr: desktop, // Linux ships no local inference engine (Generic/Qwen, MLX or
// Foundry). Report false on every desktop session so the UI and
// downstream gate on the honest answer.
supports_local_asr: false,
supports_local_qwen3_mlx: false, supports_local_qwen3_mlx: false,
supports_in_app_dictation: false, supports_in_app_dictation: false,
supports_auto_update: package_kind == LinuxPackageKind::AppImage, // Linux ships deb/rpm only (no AppImage, no updater manifest),
// so the host can never replace its own package. Always false —
// the UI gates every update control on it.
supports_auto_update: false,
}, },
permissions: PermissionSnapshot { permissions: PermissionSnapshot {
microphone: if desktop { microphone: if desktop {
@@ -64,7 +69,7 @@ impl LinuxCapabilitySnapshot {
} }
} }
pub fn detect(tray_available: bool, package_kind: LinuxPackageKind) -> Self { pub fn detect(tray_available: bool) -> Self {
let wayland = std::env::var("WAYLAND_DISPLAY").ok(); let wayland = std::env::var("WAYLAND_DISPLAY").ok();
let x11 = std::env::var("DISPLAY").ok(); let x11 = std::env::var("DISPLAY").ok();
Self::from_environment( Self::from_environment(
@@ -72,7 +77,6 @@ impl LinuxCapabilitySnapshot {
x11.as_deref(), x11.as_deref(),
fcitx5_available(), fcitx5_available(),
tray_available, tray_available,
package_kind,
) )
} }
} }
@@ -192,7 +196,7 @@ impl PlatformApi for LinuxPlatformApi {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn enumerate_microphones() -> Result<Vec<MicrophoneDevice>, BackendError> { fn enumerate_microphones() -> Result<Vec<MicrophoneDevice>, BackendError> {
use cpal::traits::HostTrait; use cpal::traits::{DeviceTrait, HostTrait};
let host = cpal::default_host(); let host = cpal::default_host();
let default_name = host.default_input_device().map(|device| device.to_string()); let default_name = host.default_input_device().map(|device| device.to_string());
@@ -202,17 +206,91 @@ fn enumerate_microphones() -> Result<Vec<MicrophoneDevice>, BackendError> {
format!("failed to enumerate Linux microphones: {error}"), format!("failed to enumerate Linux microphones: {error}"),
) )
})?; })?;
devices let mut out: Vec<MicrophoneDevice> = Vec::new();
.enumerate() for (index, device) in devices.enumerate() {
.map(|(index, device)| { let name = device.to_string();
let name = device.to_string(); let channels = device
Ok(MicrophoneDevice { .default_input_config()
id: format!("cpal:{index}:{name}"), .map(|config| config.channels())
is_default: default_name.as_deref() == Some(name.as_str()), .unwrap_or(0);
name, if !selectable_microphone(&name, channels) {
}) continue;
}) }
.collect() // 同一个设备会以多条节点出现(例如「内置音频 Pro」出现两次)。
if out.iter().any(|existing| existing.name == name) {
continue;
}
out.push(MicrophoneDevice {
id: format!("cpal:{index}:{name}"),
is_default: default_name.as_deref() == Some(name.as_str()),
name,
});
}
Ok(out)
}
/// cpal 的 PipeWire 后端把**所有节点**都当成输入设备:输出监听(`sink-*`、
/// `*.monitor`)、合成默认节点、以及 HDMI/环绕监听都会出现在 `input_devices()` 里,
/// 同名设备还会重复出现。原样透传会让设置页的「首选麦克风」变成一个装满噪声的
/// 下拉框(用户反馈「没有首选麦克风功能」),所以这里按可识别特征过滤:
///
/// * 合成节点(`default_input` / `default_sink` / `unknown`)——界面里已经有
/// 「系统默认」这一项;
/// * PulseAudio/PipeWire 的 sink 与 monitor 命名;
/// * 声道数 > 2 的节点(HDMI 与环绕监听),真实麦克风是 1 或 2 声道。取不到配置时
/// 保留(0 声道),宁可多列也不误删用户真正的麦克风。
#[cfg(target_os = "linux")]
fn selectable_microphone(name: &str, channels: u16) -> bool {
let lowered = name.trim().to_ascii_lowercase();
if lowered.is_empty() || lowered == "unknown" {
return false;
}
if lowered == "default_input" || lowered == "default_sink" {
return false;
}
if lowered.starts_with("sink-") || lowered.starts_with("sink_") || lowered.ends_with(".monitor")
{
return false;
}
channels <= 2
}
#[cfg(all(test, target_os = "linux"))]
mod microphone_filter_tests {
use super::selectable_microphone;
#[test]
fn non_capture_nodes_are_not_offered_as_microphones() {
// 实测(本机 PipeWire):这 5 个都出现在 cpal 的 input_devices() 里。
for junk in [
"default_sink",
"default_input",
"sink-sunshine-stereo",
"sink-sunshine-surround71",
"unknown",
] {
assert!(
!selectable_microphone(junk, 2),
"{junk} is not a selectable microphone"
);
}
// HDMI/环绕监听是 6/8 声道。
assert!(!selectable_microphone(
"TU116 High Definition Audio Controller Pro 9",
8
));
assert!(!selectable_microphone(
"TU116 High Definition Audio Controller Pro",
6
));
// 真实设备保留(含非 ASCII 描述名与取不到配置的兜底)。
assert!(selectable_microphone(
"\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro",
2
));
assert!(selectable_microphone("USB microphone", 1));
assert!(selectable_microphone("Scarlett Solo", 0));
}
} }
#[cfg(test)] #[cfg(test)]
@@ -221,24 +299,15 @@ mod tests {
#[test] #[test]
fn x11_and_wayland_have_explicitly_different_overlay_capabilities() { fn x11_and_wayland_have_explicitly_different_overlay_capabilities() {
let x11 = LinuxCapabilitySnapshot::from_environment( let x11 = LinuxCapabilitySnapshot::from_environment(None, Some(":0"), true, true);
None,
Some(":0"),
true,
true,
LinuxPackageKind::AppImage,
);
assert_eq!(x11.session, LinuxDesktopSession::X11); assert_eq!(x11.session, LinuxDesktopSession::X11);
assert!(x11.capabilities.supports_overlay); assert!(x11.capabilities.supports_overlay);
assert!(x11.capabilities.supports_auto_update); assert!(!x11.capabilities.supports_local_asr);
// deb/rpm 是本平台唯一的发布格式:宿主永远不能替换自己。
assert!(!x11.capabilities.supports_auto_update);
let wayland = LinuxCapabilitySnapshot::from_environment( let wayland =
Some("wayland-0"), LinuxCapabilitySnapshot::from_environment(Some("wayland-0"), Some(":0"), false, false);
Some(":0"),
false,
false,
LinuxPackageKind::SystemPackage,
);
assert_eq!(wayland.session, LinuxDesktopSession::Wayland); assert_eq!(wayland.session, LinuxDesktopSession::Wayland);
assert!(!wayland.capabilities.supports_overlay); assert!(!wayland.capabilities.supports_overlay);
assert!(!wayland.capabilities.supports_desktop_hotkey); assert!(!wayland.capabilities.supports_desktop_hotkey);
@@ -247,13 +316,7 @@ mod tests {
#[test] #[test]
fn headless_session_does_not_claim_desktop_or_microphone_support() { fn headless_session_does_not_claim_desktop_or_microphone_support() {
let snapshot = LinuxCapabilitySnapshot::from_environment( let snapshot = LinuxCapabilitySnapshot::from_environment(None, None, false, false);
None,
None,
false,
false,
LinuxPackageKind::Development,
);
assert_eq!(snapshot.session, LinuxDesktopSession::Headless); assert_eq!(snapshot.session, LinuxDesktopSession::Headless);
assert!(!snapshot.capabilities.supports_local_asr); assert!(!snapshot.capabilities.supports_local_asr);
assert_eq!( assert_eq!(
+29 -30
View File
@@ -124,13 +124,6 @@ fn kill_process_group_with_id(
child.start_kill().map_err(platform_error) child.start_kill().map_err(platform_error)
} }
pub(crate) fn kill_process_group(
child: &mut tokio::process::Child,
) -> Result<(), openless_core::BackendError> {
let id = child.id();
kill_process_group_with_id(child, id)
}
impl CodingAgentProcessAdapter for LinuxCodingAgentProcessAdapter { impl CodingAgentProcessAdapter for LinuxCodingAgentProcessAdapter {
fn execute( fn execute(
&self, &self,
@@ -335,29 +328,19 @@ mod tests {
cancel_task.await.unwrap(); cancel_task.await.unwrap();
let pids = std::fs::read_to_string(&ready).expect("child must actually start"); let pids = std::fs::read_to_string(&ready).expect("child must actually start");
let _ = std::fs::remove_file(&ready); let _ = std::fs::remove_file(&ready);
let mut running = Vec::new(); // SIGKILL is delivered immediately but the kernel retires the process
for pid in pids.split_whitespace() { // (and its orphaned children) asynchronously, so give the group a
let alive = || { // bounded moment to disappear before calling the cancellation broken.
std::fs::read_to_string(format!("/proc/{pid}/stat")) let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
.ok() let mut running = alive_pids(&pids);
.and_then(|stat| { while !running.is_empty() && std::time::Instant::now() < deadline {
stat.rsplit_once(") ") tokio::time::sleep(std::time::Duration::from_millis(20)).await;
.map(|(_, rest)| !rest.starts_with('Z')) running = alive_pids(&pids);
}) }
.unwrap_or(false) // Only fixture PIDs read from our private ready file are killed.
}; for pid in &running {
// SIGKILL delivery to grandchildren can finish after wait() reaps unsafe {
// the group leader. Wait for that kernel transition, boundedly. libc::kill(pid.parse().unwrap(), libc::SIGKILL);
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
while alive() && tokio::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
if alive() {
running.push(pid.to_owned());
// Only fixture PIDs read from our private ready file are killed.
unsafe {
libc::kill(pid.parse().unwrap(), libc::SIGKILL);
}
} }
} }
let result = result.expect("cancelled child must exit promptly").unwrap(); let result = result.expect("cancelled child must exit promptly").unwrap();
@@ -367,4 +350,20 @@ mod tests {
"cancelled process group is still running: {running:?}" "cancelled process group is still running: {running:?}"
); );
} }
/// Fixture PIDs that are still alive (a zombie is already dead).
fn alive_pids(pids: &str) -> Vec<String> {
pids.split_whitespace()
.filter(|pid| {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.ok()
.and_then(|stat| {
stat.rsplit_once(") ")
.map(|(_, rest)| !rest.starts_with('Z'))
})
.unwrap_or(false)
})
.map(str::to_owned)
.collect()
}
} }
-298
View File
@@ -1,298 +0,0 @@
//! Privacy-gated context and edit observations, bound to the original input
//! identity. Neither a late worker nor a newly focused field can replace it.
use futures_util::future::BoxFuture;
use openless_core::{
BackendError, BackendErrorCode, EditObservationAdapter, EditObservationSink,
HostContextAdapter, HostContextCapture,
};
use serde::{Deserialize, Serialize};
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex,
};
pub const CONTEXT_PROTOCOL_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetSnapshot {
pub version: u32,
pub target: String,
#[serde(default)]
pub application: String,
#[serde(default)]
pub sensitive: bool,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub cursor: usize,
}
pub trait ContextReader: Send + Sync {
fn read(
&self,
expected: Option<&str>,
include_text: bool,
) -> Result<TargetSnapshot, BackendError>;
}
pub struct NativeContextReader;
impl ContextReader for NativeContextReader {
fn read(
&self,
expected: Option<&str>,
include_text: bool,
) -> Result<TargetSnapshot, BackendError> {
#[cfg(target_os = "linux")]
{
if expected.is_some_and(|s| s.starts_with("atspi:")) {
return crate::atspi::snapshot(expected, include_text);
}
let result = (|| {
let connection = dbus::blocking::Connection::new_session().map_err(platform)?;
let proxy = connection.with_proxy(
crate::fcitx5::DESTINATION,
crate::fcitx5::OBJECT_PATH,
std::time::Duration::from_millis(500),
);
let (json,): (String,) = proxy
.method_call(
crate::fcitx5::INTERFACE,
"ContextSnapshot",
(expected.unwrap_or_default(), include_text),
)
.map_err(platform)?;
let snapshot: TargetSnapshot = serde_json::from_str(&json).map_err(platform)?;
validate_snapshot(&snapshot, expected, include_text)?;
Ok(snapshot)
})();
// Once a native target was bound, never switch bridge or application.
if expected.is_none()
&& result.as_ref().map_or(true, |s: &TargetSnapshot| {
include_text && s.text.is_none() && !s.sensitive
})
{
if let Ok(snapshot) = crate::atspi::snapshot(None, include_text) {
return Ok(snapshot);
}
}
result
}
#[cfg(not(target_os = "linux"))]
{
let _ = (expected, include_text);
Err(platform("Linux context capture unavailable"))
}
}
}
pub fn validate_snapshot(
snapshot: &TargetSnapshot,
expected: Option<&str>,
include_text: bool,
) -> Result<(), BackendError> {
if snapshot.version != CONTEXT_PROTOCOL_VERSION
|| snapshot.target.is_empty()
|| expected.is_some_and(|e| e != snapshot.target)
{
return Err(platform(
"context target expired or bridge version mismatch",
));
}
if (snapshot.sensitive || !include_text) && snapshot.text.is_some() {
return Err(platform("context bridge returned text without permission"));
}
if snapshot.text.as_ref().is_some_and(|s| s.len() > 65536) {
return Err(platform("context exceeds capture limit"));
}
Ok(())
}
pub(crate) fn platform(error: impl std::fmt::Display) -> BackendError {
BackendError::new(BackendErrorCode::Platform, error.to_string())
}
#[derive(Clone)]
pub struct LinuxContextAdapter {
reader: Arc<dyn ContextReader>,
target: Arc<Mutex<Option<String>>>,
generation: Arc<AtomicU64>,
}
impl Default for LinuxContextAdapter {
fn default() -> Self {
Self::new(Arc::new(NativeContextReader))
}
}
impl LinuxContextAdapter {
pub fn new(reader: Arc<dyn ContextReader>) -> Self {
Self {
reader,
target: Arc::default(),
generation: Arc::default(),
}
}
}
impl HostContextAdapter for LinuxContextAdapter {
fn capture(
&self,
include_cursor: bool,
) -> BoxFuture<'static, Result<HostContextCapture, BackendError>> {
let this = self.clone();
let generation = {
let mut target = this.target.lock().unwrap_or_else(|p| p.into_inner());
let generation = this.generation.fetch_add(1, Ordering::SeqCst) + 1;
*target = None;
generation
};
Box::pin(async move {
tokio::task::spawn_blocking(move || {
let snapshot = match this.reader.read(None, include_cursor) {
Ok(snapshot) => snapshot,
// Applications may have neither IME surrounding text nor
// accessibility support. Dictation itself remains usable.
Err(_) => return Ok(HostContextCapture::default()),
};
validate_snapshot(&snapshot, None, include_cursor)?;
let mut target = this.target.lock().unwrap_or_else(|p| p.into_inner());
if this.generation.load(Ordering::SeqCst) != generation {
return Ok(HostContextCapture::default());
}
*target = Some(snapshot.target);
Ok(HostContextCapture {
front_app: (!snapshot.application.is_empty()).then_some(snapshot.application),
cursor_context: snapshot.text,
})
})
.await
.map_err(platform)?
})
}
}
impl EditObservationAdapter for LinuxContextAdapter {
fn arm(
&self,
typed_text: String,
sink: Arc<dyn EditObservationSink>,
) -> Result<(), BackendError> {
let (generation, target) = {
let target = self.target.lock().unwrap_or_else(|p| p.into_inner());
(
self.generation.fetch_add(1, Ordering::SeqCst) + 1,
target.clone(),
)
};
let Some(target) = target else {
return Ok(());
};
let this = self.clone();
std::thread::Builder::new()
.name("openless-edit-observer".into())
.spawn(move || {
let started = std::time::Instant::now();
let mut baseline: Option<String> = None;
while started.elapsed() < std::time::Duration::from_secs(90)
&& this.generation.load(Ordering::SeqCst) == generation
{
std::thread::sleep(std::time::Duration::from_millis(300));
let Ok(snapshot) = this.reader.read(Some(&target), true) else {
break;
};
if validate_snapshot(&snapshot, Some(&target), true).is_err()
|| snapshot.sensitive
{
break;
}
let Some(text) = snapshot.text else {
break;
};
if this.generation.load(Ordering::SeqCst) != generation {
break;
}
match baseline.as_ref() {
None if text.contains(&typed_text) && !typed_text.is_empty() => {
baseline = Some(text)
}
Some(before) if before != &text => {
if let Some(edit) =
openless_core::host_document::minimal_edit(before, &text)
{
if sink.publish(edit) {
baseline = Some(text);
}
}
}
_ => (),
}
}
})
.map_err(platform)?;
Ok(())
}
fn disarm(&self) {
let _guard = self.target.lock().unwrap_or_else(|p| p.into_inner());
self.generation.fetch_add(1, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn late_capture_cannot_replace_a_newer_target() {
struct Reader {
count: AtomicU64,
started: std::sync::mpsc::Sender<()>,
release: Mutex<std::sync::mpsc::Receiver<()>>,
}
impl ContextReader for Reader {
fn read(&self, _: Option<&str>, _: bool) -> Result<TargetSnapshot, BackendError> {
let old = self.count.fetch_add(1, Ordering::SeqCst) == 0;
if old {
self.started.send(()).unwrap();
self.release.lock().unwrap().recv().unwrap();
}
let name = if old { "old" } else { "new" };
Ok(TargetSnapshot {
version: 1,
target: name.into(),
application: name.into(),
..Default::default()
})
}
}
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let adapter = LinuxContextAdapter::new(Arc::new(Reader {
count: AtomicU64::new(0),
started: started_tx,
release: Mutex::new(release_rx),
}));
let first = tokio::spawn(adapter.capture(false));
tokio::task::spawn_blocking(move || started_rx.recv().unwrap())
.await
.unwrap();
assert_eq!(
adapter.capture(false).await.unwrap().front_app.as_deref(),
Some("new")
);
release_tx.send(()).unwrap();
assert!(first.await.unwrap().unwrap().front_app.is_none());
assert_eq!(adapter.target.lock().unwrap().as_deref(), Some("new"));
}
#[test]
fn mismatched_target_version_and_privacy_are_rejected() {
let mut s = TargetSnapshot {
version: 1,
target: "original".into(),
..Default::default()
};
assert!(validate_snapshot(&s, Some("new-target"), true).is_err());
s.version = 2;
assert!(validate_snapshot(&s, None, true).is_err());
s.version = 1;
s.text = Some("private".into());
assert!(validate_snapshot(&s, None, false).is_err());
s.sensitive = true;
assert!(validate_snapshot(&s, None, true).is_err());
}
}
@@ -1,230 +0,0 @@
//! 2.0 设计令牌(tokens.css 的 Rust 对照表)。
//!
//! 本模块是纯数据:不依赖 egui,任何目标都能编译与单测。egui 侧的
//! 换算(Color32 / Style / Visuals)在 `ui` 模块,仅 Linux 编译。
//!
//! 数值必须与 `src/styles/tokens.css` 一一对应;修改任何一侧都要同步
//! 另一侧并通过本模块的对照测试。CSS 十六进制按 `0xRRGGBB` 转录,
//! 省略 alpha 的透明色按实际叠加效果取实色近似并在字段注释里标明。
/// 圆角阶梯(tokens.css `--ol-r-*`)。egui 0.31 的 `CornerRadius` 以 u8 计,
/// 这里直接存 u8;需要 f32 的场合由使用方转换。
pub mod radius {
/// `--ol-r-sm: 6px`(控件内小组件)
pub const SM: u8 = 6;
/// `--ol-control-radius: 8px`(按钮、导航项)
pub const CONTROL: u8 = 8;
/// `--ol-r-md: 10px`
pub const MD: u8 = 10;
/// `--ol-r-lg` / `--ol-card-radius: 14px`(卡片、设置弹窗)
pub const CARD: u8 = 14;
/// `--ol-panel-radius` / `--ol-r-xl: 18px`(浮层面板)
pub const PANEL: u8 = 18;
/// `--ol-shell-radius: 32px`(窗口外壳;Linux 由 WM 裁剪,这里供自定义绘制参考)
pub const SHELL: u8 = 32;
}
/// 侧栏宽度:FloatingShell `SIDEBAR_WIDTH = 226`。
pub const SIDEBAR_WIDTH: f32 = 226.0;
/// 设置弹窗最大宽度:SettingsModal `maxWidth: 960`。
pub const SETTINGS_MAX_WIDTH: f32 = 960.0;
/// 设置弹窗最大高度:SettingsModal `maxHeight: 680`。
pub const SETTINGS_MAX_HEIGHT: f32 = 680.0;
/// 一个主题的全部颜色令牌。字段名与 tokens.css 的 `--ol-*` 变量对应。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThemeTokens {
/// `--ol-canvas`
pub canvas: u32,
/// `--ol-surface`(内容底;2.0 为纯白)
pub surface: u32,
/// `--ol-surface-2`(次级底 / 导航激活底)
pub surface_2: u32,
/// `--ol-line`
pub line: u32,
/// `--ol-line-strong`
pub line_strong: u32,
/// `--ol-line-soft`
pub line_soft: u32,
/// `--ol-ink`(主文字)
pub ink: u32,
/// `--ol-ink-2`
pub ink_2: u32,
/// `--ol-ink-3`(次级文字 / 导航未选中)
pub ink_3: u32,
/// `--ol-ink-4`(占位 / 弱化文字)
pub ink_4: u32,
/// `--ol-ink-5`
pub ink_5: u32,
/// `--ol-blue`(强调色)
pub blue: u32,
/// `--ol-blue-hover`
pub blue_hover: u32,
/// `--ol-blue-soft`(蓝色软底;暗色主题是 alpha 色,这里取实色近似)
pub blue_soft: u32,
/// `--ol-on-accent`
pub on_accent: u32,
/// `--ol-ok`
pub ok: u32,
/// `--ol-ok-soft`(暗色主题为 alpha 色,取实色近似)
pub ok_soft: u32,
/// `--ol-warn`
pub warn: u32,
/// `--ol-warn-soft`(暗色主题为 alpha 色,取实色近似)
pub warn_soft: u32,
/// `--ol-err`
pub err: u32,
/// `--ol-sidebar-bg`
pub sidebar_bg: u32,
/// `--ol-settings-rail-bg`
pub settings_rail_bg: u32,
/// `--ol-settings-content-bg`
pub settings_content_bg: u32,
/// `--ol-pill-selected-bg`(导航 / 分段选中深色胶囊;暗色主题为蓝色渐变,取上端色)
pub pill_selected_bg: u32,
/// `--ol-pill-selected-ink`
pub pill_selected_ink: u32,
/// `--ol-segmented-bg`(alpha 色,取实色近似)
pub segmented_bg: u32,
/// `--ol-segmented-active-bg`
pub segmented_active_bg: u32,
/// `--ol-overlay-bg`(设置弹窗遮罩;alpha 在 ui 层叠加,这里存 RGB)
pub overlay_rgb: u32,
}
/// 浅色主题(tokens.css `:root`,2.0 默认)。
pub const LIGHT: ThemeTokens = ThemeTokens {
canvas: 0xFFFFFF,
surface: 0xFFFFFF,
surface_2: 0xF4F4F5,
line: 0xE4E4E7,
line_strong: 0xD4D4D8,
line_soft: 0xF4F4F5,
ink: 0x09090B,
ink_2: 0x3F3F46,
ink_3: 0x71717A,
ink_4: 0xA1A1AA,
ink_5: 0xD4D4D8,
blue: 0x2563EB,
blue_hover: 0x1D4ED8,
blue_soft: 0xEFF4FF,
on_accent: 0xFFFFFF,
ok: 0x16A34A,
ok_soft: 0xECFDF5,
warn: 0xD97706,
warn_soft: 0xFFF7ED,
err: 0xDC2626,
sidebar_bg: 0xF0F0F1,
settings_rail_bg: 0xF0F0F1,
settings_content_bg: 0xF7F7F8,
pill_selected_bg: 0x18181B,
pill_selected_ink: 0xFFFFFF,
segmented_bg: 0xF4F4F5,
segmented_active_bg: 0xFFFFFF,
overlay_rgb: 0x0F1116,
};
/// 深色主题(tokens.css `[data-ol-theme='dark']`)。
pub const DARK: ThemeTokens = ThemeTokens {
canvas: 0x0C0C0E,
surface: 0x1C1C1F,
surface_2: 0x2A2A2E,
line: 0x27272A,
line_strong: 0x3F3F46,
line_soft: 0x1F1F23,
ink: 0xFAFAFA,
ink_2: 0xD4D4D8,
ink_3: 0xA1A1AA,
ink_4: 0x71717A,
ink_5: 0x3F3F46,
blue: 0x74B7FF,
blue_hover: 0x93C5FD,
// rgba(116,183,255,0.16) 叠加 surface 的实色近似。
blue_soft: 0x2A3A4E,
on_accent: 0xF8FBFF,
ok: 0x4ADE80,
// rgba(74,222,128,0.14) 叠加 surface 的实色近似。
ok_soft: 0x223529,
warn: 0xF59E0B,
// rgba(245,158,11,0.14) 叠加 surface 的实色近似。
warn_soft: 0x3A2E1C,
err: 0xF87171,
sidebar_bg: 0x141417,
settings_rail_bg: 0x141417,
settings_content_bg: 0x18181B,
// 暗色选中是蓝色渐变 3b82f6→2563eb,取上端。
pill_selected_bg: 0x3B82F6,
pill_selected_ink: 0xF4F7FB,
// rgba(226,232,240,0.07) 叠加 surface 的实色近似。
segmented_bg: 0x26262A,
segmented_active_bg: 0x27272A,
overlay_rgb: 0x05080D,
};
/// 按主题取令牌。
pub const fn tokens(dark: bool) -> &'static ThemeTokens {
if dark {
&DARK
} else {
&LIGHT
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 关键令牌必须与 tokens.css 字面值一致:这组断言是两个 UI 之间的对照合同。
#[test]
fn light_tokens_match_tokens_css() {
assert_eq!(LIGHT.surface, 0xFFFFFF);
assert_eq!(LIGHT.surface_2, 0xF4F4F5);
assert_eq!(LIGHT.line, 0xE4E4E7);
assert_eq!(LIGHT.ink, 0x09090B);
assert_eq!(LIGHT.ink_3, 0x71717A);
assert_eq!(LIGHT.blue, 0x2563EB);
assert_eq!(LIGHT.blue_hover, 0x1D4ED8);
assert_eq!(LIGHT.blue_soft, 0xEFF4FF);
assert_eq!(LIGHT.ok, 0x16A34A);
assert_eq!(LIGHT.warn, 0xD97706);
assert_eq!(LIGHT.err, 0xDC2626);
assert_eq!(LIGHT.sidebar_bg, 0xF0F0F1);
assert_eq!(LIGHT.settings_content_bg, 0xF7F7F8);
assert_eq!(LIGHT.pill_selected_bg, 0x18181B);
}
#[test]
fn dark_tokens_match_tokens_css() {
assert_eq!(DARK.canvas, 0x0C0C0E);
assert_eq!(DARK.surface, 0x1C1C1F);
assert_eq!(DARK.line, 0x27272A);
assert_eq!(DARK.ink, 0xFAFAFA);
assert_eq!(DARK.blue, 0x74B7FF);
assert_eq!(DARK.ok, 0x4ADE80);
assert_eq!(DARK.err, 0xF87171);
assert_eq!(DARK.sidebar_bg, 0x141417);
// 暗色选中胶囊是蓝色渐变(3b82f6→2563eb),ui 层取上端色。
assert_eq!(DARK.pill_selected_bg, 0x3B82F6);
}
/// 明暗两套令牌都自洽:文字色和底色有足够对比(不与底色撞色)。
#[test]
fn themes_keep_ink_distinct_from_surfaces() {
for theme in [&LIGHT, &DARK] {
assert_ne!(theme.ink, theme.surface);
assert_ne!(theme.ink, theme.surface_2);
assert_ne!(theme.line, theme.surface);
assert_ne!(theme.blue, theme.surface);
}
}
#[test]
fn radii_match_tokens_css_scale() {
assert_eq!(radius::SM, 6);
assert_eq!(radius::CONTROL, 8);
assert_eq!(radius::CARD, 14);
assert_eq!(radius::PANEL, 18);
assert_eq!(radius::SHELL, 32);
assert_eq!(SIDEBAR_WIDTH, 226.0);
}
}
@@ -1,491 +0,0 @@
//! Desktop bridge v1: screen coordinates are logical pixels, targets are
//! opaque, and hotkeys are installed as a transaction before preferences commit.
use crate::context::platform;
use openless_core::{BackendError, HotkeyRuntimeTarget};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex, OnceLock};
pub const DESKTOP_PROTOCOL_VERSION: u32 = 1;
pub const BUS_NAME: &str = "org.openless.Desktop1";
pub const BUS_PATH: &str = "/org/openless/Desktop1";
pub fn manage_component(mode: &str) -> Result<String, BackendError> {
if !["install", "enable", "uninstall"].contains(&mode) {
return Err(platform("invalid desktop component action"));
}
let layout = crate::LinuxResourceLayout::detect(
std::env::var_os("OPENLESS_LINUX_RESOURCES").map(std::path::PathBuf::from),
)?;
let packaged = layout.resource_root.join("linux-desktop/install.sh");
let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../scripts/linux-desktop/install.sh");
let script = if packaged.is_file() {
packaged
} else {
development
};
if !script.is_file() {
return Err(platform("桌面组件缺失,请重新安装 Linux 安装包"));
}
let output = std::process::Command::new("timeout")
.args(["30s", "bash"])
.arg(script)
.arg(mode)
.output()
.map_err(platform)?;
let message = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if output.status.success() {
Ok(message.trim().into())
} else {
Err(platform(message.trim()))
}
}
static TARGETS: OnceLock<Mutex<std::collections::HashMap<String, DesktopSnapshot>>> =
OnceLock::new();
static LAST_SCREEN: OnceLock<Mutex<Option<DesktopSnapshot>>> = OnceLock::new();
fn targets() -> &'static Mutex<std::collections::HashMap<String, DesktopSnapshot>> {
TARGETS.get_or_init(Mutex::default)
}
pub(crate) fn remember_focus(ticket: &str) {
if let Some(snapshot) = adapter().and_then(|a| a.snapshot().ok()) {
*LAST_SCREEN.get_or_init(Mutex::default).lock().unwrap() = Some(snapshot.clone());
let mut targets = targets().lock().unwrap();
if targets.len() >= 128 {
targets.clear();
}
targets.insert(ticket.into(), snapshot);
}
}
pub(crate) fn rekey_focus(from: &str, to: &str) {
let mut targets = targets().lock().unwrap();
if let Some(snapshot) = targets.remove(from) {
targets.insert(to.into(), snapshot);
}
}
pub(crate) fn forget_focus(ticket: &str) {
targets().lock().unwrap().remove(ticket);
}
pub(crate) fn restore_bound_focus(ticket: &str) -> Result<(), BackendError> {
let target = targets().lock().unwrap().get(ticket).cloned();
if let Some(target) = target {
let adapter = adapter().ok_or_else(|| platform("桌面组件暂时不可用"))?;
adapter.restore_focus(&target.target)?;
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_millis(700) {
if adapter.snapshot().is_ok_and(|s| s.target == target.target) {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
return Err(platform("无法恢复原目标窗口"));
}
Ok(()) // fcitx5 still verifies the original focused input context.
}
pub fn place_popup(title: &'static str, width: i32, height: i32, capsule: bool) {
let screen = LAST_SCREEN
.get_or_init(Mutex::default)
.lock()
.unwrap()
.clone();
let Some(screen) = screen else {
return;
};
let _ = std::thread::Builder::new()
.name("openless-popup-placement".into())
.spawn(move || {
let x = screen.x + ((screen.width as i32 - width) / 2).max(0);
let y = screen.y
+ if capsule {
(screen.height as i32 - height - 28).max(0)
} else {
((screen.height as i32 - height) / 2).max(0)
};
for _ in 0..12 {
std::thread::sleep(std::time::Duration::from_millis(80));
if adapter().is_some_and(|a| a.place(title, x, y).is_ok()) {
break;
}
}
});
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopSnapshot {
pub version: u32,
pub target: String,
pub application: String,
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
#[serde(default = "one")]
pub scale: f64,
}
fn one() -> f64 {
1.0
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopBinding {
pub action: String,
pub symbol: u32,
pub states: u32,
pub accelerator: String,
}
pub trait DesktopAdapter: Send + Sync {
fn snapshot(&self) -> Result<DesktopSnapshot, BackendError>;
fn bind(&self, bindings: &[DesktopBinding]) -> Result<(), BackendError>;
fn restore_focus(&self, target: &str) -> Result<(), BackendError>;
fn place(&self, title: &str, x: i32, y: i32) -> Result<(), BackendError>;
fn drain(&self) -> Vec<crate::LinuxHotkeyEvent>;
}
#[derive(Default)]
struct BridgeState {
adapter: Option<Arc<dyn DesktopAdapter>>,
bindings: Option<Vec<DesktopBinding>>,
owner: Option<String>,
}
static ADAPTER: OnceLock<Arc<Mutex<BridgeState>>> = OnceLock::new();
static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static DISCONNECTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn take_disconnected() -> bool {
DISCONNECTED.swap(false, std::sync::atomic::Ordering::AcqRel)
}
pub fn active() -> bool {
ACTIVE.load(std::sync::atomic::Ordering::Acquire)
}
pub fn bind_target(target: &HotkeyRuntimeTarget) -> Result<(), BackendError> {
let bindings = bindings(target)?;
let mut state = registry().lock().unwrap_or_else(|p| p.into_inner());
if let Some(adapter) = &state.adapter {
adapter.bind(&bindings)?;
ACTIVE.store(true, std::sync::atomic::Ordering::Release);
}
state.bindings = Some(bindings);
Ok(())
}
fn registry() -> &'static Arc<Mutex<BridgeState>> {
ADAPTER.get_or_init(|| {
let state = Arc::new(Mutex::new(BridgeState::default()));
refresh(&state);
let shared = state.clone();
std::thread::Builder::new()
.name("openless-desktop-recovery".into())
.spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(2));
refresh(&shared);
})
.expect("create desktop recovery worker");
state
})
}
pub fn adapter() -> Option<Arc<dyn DesktopAdapter>> {
registry()
.lock()
.unwrap_or_else(|p| p.into_inner())
.adapter
.clone()
}
fn refresh(shared: &Mutex<BridgeState>) {
#[cfg(target_os = "linux")]
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
#[cfg(target_os = "linux")]
let owner = if wayland {
desktop_owner()
} else {
Some("x11".into())
};
let mut state = shared.lock().unwrap_or_else(|p| p.into_inner());
#[cfg(target_os = "linux")]
{
if state.owner != owner {
if ACTIVE.swap(false, std::sync::atomic::Ordering::AcqRel) {
DISCONNECTED.store(true, std::sync::atomic::Ordering::Release);
}
state.adapter = None;
state.owner = owner.clone();
}
if state.adapter.is_some() || owner.is_none() {
return;
}
let detected = {
if wayland {
WaylandDesktop::start()
.map(|a| Arc::new(a) as Arc<dyn DesktopAdapter>)
.ok()
} else {
crate::x11_desktop::X11Desktop::start()
.map(|a| Arc::new(a) as Arc<dyn DesktopAdapter>)
.ok()
}
};
if let Some(adapter) = detected {
if let Some(bindings) = &state.bindings {
if adapter.bind(bindings).is_err() {
return;
}
ACTIVE.store(true, std::sync::atomic::Ordering::Release);
}
state.adapter = Some(adapter);
}
}
#[cfg(not(target_os = "linux"))]
let _ = &mut state;
}
#[cfg(target_os = "linux")]
fn desktop_owner() -> Option<String> {
let connection = dbus::blocking::Connection::new_session().ok()?;
let result: Result<(String,), _> = connection
.with_proxy(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
std::time::Duration::from_millis(500),
)
.method_call("org.freedesktop.DBus", "GetNameOwner", (BUS_NAME,));
result.ok().map(|value| value.0)
}
fn accelerator(symbol: u32, states: u32) -> String {
let mut result = String::new();
for (bit, name) in [
(4, "<Control>"),
(1, "<Shift>"),
(8, "<Alt>"),
(64, "<Super>"),
] {
if states & bit != 0 {
result.push_str(name);
}
}
// GDK accepts X11 keysym hexadecimal notation as well as named keys.
let name = match symbol {
0xffe1 => "Shift_L",
0xffe2 => "Shift_R",
0xffe3 => "Control_L",
0xffe4 => "Control_R",
0xffe9 => "Alt_L",
0xffea => "Alt_R",
0xffeb => "Super_L",
0xffec => "Super_R",
0xff0d => "Return",
0xff09 => "Tab",
0xff1b => "Escape",
0x20 => "space",
0xff08 => "BackSpace",
0xffff => "Delete",
0xff50 => "Home",
0xff57 => "End",
0xff55 => "Page_Up",
0xff56 => "Page_Down",
0xff52 => "Up",
0xff54 => "Down",
0xff51 => "Left",
0xff53 => "Right",
_ => "",
};
if !name.is_empty() {
result.push_str(name);
} else if (0xffbe..=0xffd5).contains(&symbol) {
result.push_str(&format!("F{}", symbol - 0xffbe + 1));
} else if (0x21..=0x7e).contains(&symbol) {
result.push(char::from_u32(symbol).unwrap());
} else {
result.push_str(&format!("0x{symbol:x}"));
}
result
}
pub fn bindings(target: &HotkeyRuntimeTarget) -> Result<Vec<DesktopBinding>, BackendError> {
let mut result = Vec::new();
let items = [
("DictationKeyEvent", Some(&target.dictation)),
("QaShortcutEvent", target.qa.as_ref()),
("SelectionPolishEvent", target.selection_polish.as_ref()),
("TranslationModifierEvent", Some(&target.translation)),
("OpenAppEvent", target.open_app.as_ref()),
(
"LessComputerPanelEvent",
target
.coding_agent_panel
.as_ref()
.filter(|_| target.coding_agent_enabled),
),
(
"LessComputerQuickEvent",
target
.coding_agent_quick
.as_ref()
.filter(|_| target.coding_agent_enabled),
),
("SwitchStyleEvent", target.switch_style.as_ref()),
(
"LessComputerKeyEvent",
target
.coding_agent_voice
.as_ref()
.filter(|_| target.coding_agent_enabled),
),
];
for (action, binding) in items {
if let Some(binding) = binding {
let (symbol, states) = crate::settings::shortcut_to_raw(binding)?;
result.push(DesktopBinding {
action: action.into(),
symbol,
states,
accelerator: accelerator(symbol, states),
});
}
}
for hotkey in &target.style_packs {
let (symbol, states) = crate::settings::shortcut_to_raw(&hotkey.binding)?;
result.push(DesktopBinding {
action: "StylePackHotkeyEvent".into(),
symbol,
states,
accelerator: accelerator(symbol, states),
});
}
for (index, binding) in result.iter().enumerate() {
if result[..index]
.iter()
.any(|b| b.symbol == binding.symbol && b.states == binding.states)
{
return Err(platform(format!("快捷键冲突:{}", binding.accelerator)));
}
}
Ok(result)
}
#[cfg(target_os = "linux")]
struct WaylandDesktop {
events: Arc<Mutex<Vec<crate::LinuxHotkeyEvent>>>,
stop: Arc<std::sync::atomic::AtomicBool>,
}
#[cfg(target_os = "linux")]
impl WaylandDesktop {
fn call<T: dbus::arg::ReadAll>(
&self,
method: &str,
args: impl dbus::arg::AppendAll,
) -> Result<T, BackendError> {
let connection = dbus::blocking::Connection::new_session().map_err(platform)?;
connection
.with_proxy(BUS_NAME, BUS_PATH, std::time::Duration::from_secs(2))
.method_call(BUS_NAME, method, args)
.map_err(platform)
}
fn start() -> Result<Self, BackendError> {
let events = Arc::new(Mutex::new(Vec::new()));
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let this = Self {
events: events.clone(),
stop: stop.clone(),
};
let (version,): (u32,) = this.call("Version", ())?;
if version != DESKTOP_PROTOCOL_VERSION {
return Err(platform("desktop bridge version mismatch"));
}
std::thread::Builder::new()
.name("openless-desktop-events".into())
.spawn(move || {
let Ok(connection) = dbus::blocking::Connection::new_session() else {
return;
};
let mut rule = dbus::message::MatchRule::new_signal(BUS_NAME, "Hotkey");
rule.sender = Some(BUS_NAME.into());
rule.path = Some(BUS_PATH.into());
let press_ids = crate::hotkeys::HotkeyPressIds::default();
let _subscription = connection.add_match(
rule,
move |(action, symbol, states, pressed): (String, u32, u32, bool), _, _| {
if let Some(event) = crate::hotkeys::event_from_signal(
&action,
symbol,
states,
pressed,
std::time::Instant::now(),
&press_ids,
) {
events.lock().unwrap().push(event);
}
true
},
);
while !stop.load(std::sync::atomic::Ordering::Acquire)
&& connection
.process(std::time::Duration::from_secs(1))
.is_ok()
{}
})
.map_err(platform)?;
Ok(this)
}
}
#[cfg(target_os = "linux")]
impl Drop for WaylandDesktop {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Release);
}
}
#[cfg(target_os = "linux")]
impl DesktopAdapter for WaylandDesktop {
fn snapshot(&self) -> Result<DesktopSnapshot, BackendError> {
let (json,): (String,) = self.call("Snapshot", ())?;
let snapshot: DesktopSnapshot = serde_json::from_str(&json).map_err(platform)?;
if snapshot.version != 1 {
return Err(platform("desktop version mismatch"));
}
Ok(snapshot)
}
fn bind(&self, bindings: &[DesktopBinding]) -> Result<(), BackendError> {
let (error,): (String,) = self.call(
"Bind",
(serde_json::to_string(bindings).map_err(platform)?,),
)?;
if error.is_empty() {
Ok(())
} else {
Err(platform(error))
}
}
fn restore_focus(&self, target: &str) -> Result<(), BackendError> {
let (ok,): (bool,) = self.call("Restore", (target,))?;
if ok {
Ok(())
} else {
Err(platform("original window no longer exists"))
}
}
fn place(&self, title: &str, x: i32, y: i32) -> Result<(), BackendError> {
let (ok,): (bool,) = self.call("Place", (title, x, y))?;
if ok {
Ok(())
} else {
Err(platform("popup not found"))
}
}
fn drain(&self) -> Vec<crate::LinuxHotkeyEvent> {
std::mem::take(&mut *self.events.lock().unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_shortcuts_fail_before_installing_anything() {
let mut preferences = openless_core::UserPreferences::default();
preferences.translation_hotkey = preferences.dictation_hotkey.clone();
assert!(bindings(&HotkeyRuntimeTarget::from(&preferences)).is_err());
}
}
@@ -0,0 +1,416 @@
//! 听写终态:给用户看的分类,以及胶囊何时自动收起。
//!
//! Core 的 `mark_dictation_failed` 只把错误码写进 `message`
//! (`format!("{:?}", error.code)`,例如 `InvalidArgument`),那是日志用语:
//! 直接送进胶囊/状态栏既看不懂,也像是宿主崩了。说话时正常、不说话就报这个,
//! 因为空音频在 Core 里就是 `InvalidArgument`(`providers.rs` 的
//! "recording contains no audio")。
//!
//! 这里把「相位 + message」归一成可本地化的分类,并把 Tauri Host 的收起时序
//! (成功/失败 2 秒、取消立即、进行中不收)固化成纯函数,宿主与单测共用同一份
//! 规则,避免两边各写一套。
use std::time::Duration;
use openless_core::{BackendError, BackendErrorCode, DictationPhase};
/// Tauri `coordinator.rs::CAPSULE_AUTO_HIDE_DELAY_MS`:终态在屏上的停留时长。
pub const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000;
/// Core 在失败终态里塞进 `message` 的错误码名,一律不能当用户文案。
pub fn is_backend_error_code(message: &str) -> bool {
matches!(
message.trim(),
"InvalidArgument"
| "InvalidState"
| "Busy"
| "Cancelled"
| "PermissionDenied"
| "Unsupported"
| "Provider"
| "Persistence"
| "Platform"
| "OutcomeUnknown"
| "Internal"
)
}
/// 胶囊这一帧该显示什么;具体文案由宿主用本地化 key 渲染。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CapsuleOutcome {
/// 成功:宿主显示「已插入 N」。
Inserted,
Cancelled,
Failed,
/// 进行中:原样转发 Core 的 message(通常是空串)。
Progress(String),
}
/// 相位 + message → 胶囊分类。绝不把内部错误码当文案。
pub fn capsule_outcome(phase: DictationPhase, message: Option<&str>) -> CapsuleOutcome {
let message = message.unwrap_or("").trim();
match phase {
DictationPhase::Completed => CapsuleOutcome::Inserted,
DictationPhase::Cancelled => CapsuleOutcome::Cancelled,
// Core 的失败 message 永远是错误码名,交给宿主显示本地化文案。
DictationPhase::Failed => CapsuleOutcome::Failed,
_ => {
if message.is_empty() || message == "inserted" || is_backend_error_code(message) {
CapsuleOutcome::Progress(String::new())
} else {
CapsuleOutcome::Progress(message.to_string())
}
}
}
}
/// 终态收起延时:成功/失败 2 秒、取消立即;进行中(含 `Inserting`)不收起。
pub fn capsule_hide_delay(phase: DictationPhase) -> Option<Duration> {
match phase {
DictationPhase::Completed | DictationPhase::Failed => {
Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS))
}
DictationPhase::Cancelled => Some(Duration::ZERO),
_ => None,
}
}
/// 延时到点时是否还该收起。
///
/// 判据是「这个会话已经不再进行」而不是「快照仍停在终态」:Core 在终态事件之后
/// **立刻** `reset_dictation_session()`(见 `api.rs` 的
/// `mark_dictation_failed(..); reset_dictation_session(..); return Err(..)`),
/// 整个 `DictationStateSnapshot` 被重置成 `Idle` 且 `session_id` 清空。
/// 所以 2 秒后回看快照,`current_session` 是 `None` —— 早先要求
/// `current_session == Some(scheduled)` 的写法让**报错路径永远收不回胶囊**。
///
/// 保留的防护:用户在这 2 秒里又按了录音 → 快照里是**另一个**会话 id → 不收;
/// 同一会话又回到进行中相位(理论上不会,兜底)→ 不收。
pub fn capsule_hide_is_still_current(
current_session: Option<&str>,
scheduled_session: &str,
phase: DictationPhase,
) -> bool {
match current_session {
// Core 已经收尾(终态后重置,或根本没有会话语义)→ 正是该收起的时候。
None => true,
Some(current) if current == scheduled_session => !phase_shows_capsule(phase),
// 另一个会话正在进行 → 不能把新胶囊一起关掉。
Some(_) => false,
}
}
/// 快照里会话已经消失、而宿主从未安排收起时,该补收的会话 id。
///
/// Core 只有**部分**错误路径会先 `mark_dictation_failed`(发布终态事件)再 reset;
/// 另一些(例如转写阶段的空音频)**只 reset 不发布**,宿主就永远等不到终态,
/// 药丸会一直贴在屏幕上。这里按快照自身判断「会话已经没了」,补一次收起。
///
/// 返回 `Some(session)` = 该为这个会话安排收起;`None` = 什么都不用做。
pub fn capsule_needs_fallback_dismissal(
capsule_session: Option<&str>,
live_session: Option<&str>,
already_scheduled: Option<&str>,
) -> Option<String> {
let capsule_session = capsule_session?;
// 同一会话仍在跑:等它自己的终态。
if live_session == Some(capsule_session) {
return None;
}
// 已经为它安排过收起(事件路径已经处理):别重复计时。
if already_scheduled == Some(capsule_session) {
return None;
}
Some(capsule_session.to_string())
}
/// 这个相位是否需要胶囊在屏幕上:只有进行中的相位才该按需拉起弹窗。
///
/// 终态不再拉起——否则一个迟到的终态事件会把刚刚自动收起的药丸又喊回来;
/// `Idle` 也不拉(它没有内容可显示)。
pub fn phase_shows_capsule(phase: DictationPhase) -> bool {
matches!(
phase,
DictationPhase::Starting
| DictationPhase::Recording
| DictationPhase::Transcribing
| DictationPhase::Polishing
| DictationPhase::Inserting
)
}
/// 停止/取消听写时「本来就可能发生」的错误,不该弹成失败:
/// `InvalidArgument` = 没录到音频(没说话、麦克风没出声);
/// `InvalidState`/`Busy` = 会话已经收尾(连点两次、自动停止与手动停止撞车);
/// `Cancelled` = 用户自己取消。
pub fn is_expected_stop_error(code: BackendErrorCode) -> bool {
matches!(
code,
BackendErrorCode::InvalidArgument
| BackendErrorCode::InvalidState
| BackendErrorCode::Busy
| BackendErrorCode::Cancelled
)
}
/// 归一化一次停止/取消听写的结果:预期内的错误当作「没有结果」而不是失败。
///
/// 真正的失败(网络、鉴权、持久化…)仍然原样向上报。
pub fn normalize_stop_result<T>(
result: Result<T, BackendError>,
) -> Result<Option<T>, BackendError> {
match result {
Ok(value) => Ok(Some(value)),
Err(error) if is_expected_stop_error(error.code) => Ok(None),
Err(error) => Err(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_timing_matches_the_tauri_host_contract() {
assert_eq!(
capsule_hide_delay(DictationPhase::Completed),
Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS))
);
assert_eq!(
capsule_hide_delay(DictationPhase::Failed),
Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS))
);
assert_eq!(
capsule_hide_delay(DictationPhase::Cancelled),
Some(Duration::ZERO)
);
for phase in [
DictationPhase::Idle,
DictationPhase::Starting,
DictationPhase::Recording,
DictationPhase::Transcribing,
DictationPhase::Polishing,
DictationPhase::Inserting,
] {
assert_eq!(capsule_hide_delay(phase), None, "{phase:?} must stay up");
}
}
#[test]
fn silence_failure_is_a_normal_terminal_state() {
// 没说话 → Core 报 InvalidArgument(空音频):算终态、2 秒后收起。
let phase = DictationPhase::Failed;
assert_eq!(
capsule_outcome(phase, Some("InvalidArgument")),
CapsuleOutcome::Failed
);
assert_eq!(
capsule_hide_delay(phase),
Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS))
);
assert!(is_expected_stop_error(BackendErrorCode::InvalidArgument));
}
#[test]
fn backend_error_codes_never_become_capsule_copy() {
for code in [
"InvalidArgument",
"InvalidState",
"Busy",
"Cancelled",
"PermissionDenied",
"Unsupported",
"Provider",
"Persistence",
"Platform",
"OutcomeUnknown",
"Internal",
] {
assert!(is_backend_error_code(code), "{code} is an internal code");
assert_eq!(
capsule_outcome(DictationPhase::Transcribing, Some(code)),
CapsuleOutcome::Progress(String::new()),
"{code} must not reach the capsule"
);
}
assert!(!is_backend_error_code("12 characters"));
assert!(!is_backend_error_code(""));
}
#[test]
fn progress_messages_still_pass_through() {
assert_eq!(
capsule_outcome(DictationPhase::Transcribing, Some("12 characters")),
CapsuleOutcome::Progress("12 characters".to_string())
);
// Core 成功时塞的内部词同样不显示。
assert_eq!(
capsule_outcome(DictationPhase::Polishing, Some("inserted")),
CapsuleOutcome::Progress(String::new())
);
assert_eq!(
capsule_outcome(DictationPhase::Recording, None),
CapsuleOutcome::Progress(String::new())
);
}
#[test]
fn a_new_session_cancels_the_pending_dismissal() {
assert!(capsule_hide_is_still_current(
Some("s1"),
"s1",
DictationPhase::Completed
));
// 另一个会话(用户已经又按了录音)→ 不能收起新胶囊。
assert!(!capsule_hide_is_still_current(
Some("s2"),
"s1",
DictationPhase::Completed
));
// 同一会话又回到进行中相位 → 也不收。
assert!(!capsule_hide_is_still_current(
Some("s1"),
"s1",
DictationPhase::Recording
));
}
#[test]
fn a_session_that_vanishes_without_a_terminal_event_still_hides_the_capsule() {
// Core 有些错误路径只 reset、不发布终态事件(转写阶段空音频就是),
// 宿主必须自己发现「胶囊的会话已经不在快照里」并补一次收起。
assert_eq!(
capsule_needs_fallback_dismissal(Some("s1"), None, None),
Some("s1".to_string())
);
// 会话仍在跑 → 等它自己的终态。
assert_eq!(
capsule_needs_fallback_dismissal(Some("s1"), Some("s1"), None),
None
);
// 事件路径已经安排过 → 不重复计时。
assert_eq!(
capsule_needs_fallback_dismissal(Some("s1"), None, Some("s1")),
None
);
// 新会话顶掉了旧会话(旧胶囊复用同一进程)→ 旧会话该收。
assert_eq!(
capsule_needs_fallback_dismissal(Some("s1"), Some("s2"), None),
Some("s1".to_string())
);
// 根本没有胶囊在屏上 → 什么都不做。
assert_eq!(capsule_needs_fallback_dismissal(None, None, None), None);
}
#[test]
fn a_vanished_session_hides_the_capsule_through_the_fallback_path() {
// 兜底链路的端到端判据:会话消失(无终态事件)→ 取兜底会话 → 按失败终态
// 的时长 → 到点时判据为真 → 收起。
let session = capsule_needs_fallback_dismissal(Some("s1"), None, None)
.expect("a vanished session must be picked up");
assert_eq!(
capsule_hide_delay(DictationPhase::Failed),
Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS))
);
assert!(capsule_hide_is_still_current(
None,
&session,
DictationPhase::Idle
));
}
#[test]
fn the_failure_path_ends_with_a_dismissal() {
// 串起报错路径的四环(不看实现,看行为):
// 1) 失败是终态 → 2 秒后收起;
// 2) 这 2 秒里 Core 已经把快照 reset 成 Idle、session_id 清空;
// 3) 到点时的判据必须为真 → 真的收起;
// 4) 但若这 2 秒里用户又按了录音(新会话)→ 不收,新胶囊活着。
assert_eq!(
capsule_hide_delay(DictationPhase::Failed),
Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS))
);
assert!(capsule_hide_is_still_current(
None,
"session",
DictationPhase::Idle
));
assert!(!capsule_hide_is_still_current(
Some("next"),
"session",
DictationPhase::Starting
));
}
#[test]
fn the_core_reset_after_a_terminal_phase_still_dismisses_the_capsule() {
// 真实链路:Core 的失败路径是
// `mark_dictation_failed(..); reset_dictation_session(..); return Err(..)`,
// 后者把整个快照重置成 Idle 并清空 session_id。2 秒后回看快照只剩
// `None` —— 这正是「报错弹窗收不回」的原因,必须仍然收起。
for phase in [
DictationPhase::Idle,
DictationPhase::Completed,
DictationPhase::Failed,
DictationPhase::Cancelled,
] {
assert!(
capsule_hide_is_still_current(None, "s1", phase),
"a finished session ({phase:?}) must still hide the capsule"
);
// 同一 id 但相位已经落回 Idle:同样属于「不再进行」。
assert!(capsule_hide_is_still_current(Some("s1"), "s1", phase));
}
}
#[test]
fn only_in_flight_phases_spawn_a_capsule() {
for phase in [
DictationPhase::Starting,
DictationPhase::Recording,
DictationPhase::Transcribing,
DictationPhase::Polishing,
DictationPhase::Inserting,
] {
assert!(
phase_shows_capsule(phase),
"{phase:?} must be able to spawn"
);
}
for phase in [
DictationPhase::Idle,
DictationPhase::Completed,
DictationPhase::Cancelled,
DictationPhase::Failed,
] {
assert!(!phase_shows_capsule(phase), "{phase:?} must not re-spawn");
}
}
#[test]
fn expected_stop_errors_do_not_become_failures() {
for code in [
BackendErrorCode::InvalidArgument,
BackendErrorCode::InvalidState,
BackendErrorCode::Busy,
BackendErrorCode::Cancelled,
] {
assert!(
normalize_stop_result::<()>(Err(BackendError::new(code, "boom")))
.unwrap()
.is_none(),
"{code:?} is an expected stop outcome"
);
}
let real = normalize_stop_result::<()>(Err(BackendError::new(
BackendErrorCode::Provider,
"upstream 500",
)));
assert_eq!(real.unwrap_err().code, BackendErrorCode::Provider);
assert_eq!(
normalize_stop_result(Ok(7)).unwrap(),
Some(7),
"successful stops keep their value"
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,61 +0,0 @@
impl OpenLessEguiApp {
fn history_transform(&mut self, retranscribe: bool) {
if self.history_task.is_some() {return;}
let (Some(backend),Some(entry))=(self.backend(),self.frontend_vm.history_entries.get(self.frontend_vm.history_selected).cloned()) else {return;};
let tx=self.tx.clone();self.history_generation+=1;let generation=self.history_generation;
let style=self.frontend_vm.history_repolish_style.clone();
self.frontend_vm.history_busy=true;
self.history_task=Some(self.tokio.spawn(async move {
let id=entry.id.clone();
let result:Result<String,BackendError>=async {
if retranscribe {
let directory=backend.config().data_dir.clone();let recording=id.clone();
let pcm=tokio::task::spawn_blocking(move || {
let wav=openless_linux_egui::read_recording_wav(&directory,&recording).map_err(|e|BackendError::new(openless_core::BackendErrorCode::Persistence,e.to_string()))?;
openless_linux_egui::recording_pcm(&wav).map(|p|p.to_vec()).map_err(|e|BackendError::new(openless_core::BackendErrorCode::Persistence,e.to_string()))
}).await.map_err(|e|BackendError::new(openless_core::BackendErrorCode::Platform,e.to_string()))??;
let started=std::time::Instant::now();
let result=backend.services().auxiliary.retranscribe_pcm(pcm).await.map_err(|e|e.error)?;
let updated=backend.apply_history_retranscription(&id,result.text,&result.asr,started.elapsed().as_millis() as u64)?;
Ok(updated.final_text)
} else {
backend.services().auxiliary.repolish(openless_core::RepolishRequest {
raw_text:entry.raw,style_pack_id:(!style.is_empty()).then_some(style),front_app:None,
}).await
}
}.await;
let _=tx.send(UiResult::HistoryTransform {generation,id,repolish:!retranscribe,result:result.map_err(|e|e.to_string())});
}));
}
fn history_confirmation_ui(&mut self, ctx: &egui::Context) {
let Some(target)=self.history_confirmation.clone() else {return;};
let response=egui::Modal::new(egui::Id::new("history-confirmation"))
.frame(egui::Frame::new().fill(theme::surface()).corner_radius(14).inner_margin(24))
.show(ctx,|ui| {
ui.set_width(340.0);
ui.heading(theme::text(if target.is_some() {"删除这条记录?"}else{"清空全部历史记录?"}));
ui.label(theme::text("关联的录音也将删除。"));
ui.add_space(20.0);
ui.horizontal(|ui| {
if ui.button(theme::text("取消")).clicked() {self.history_confirmation=None;}
if ui.button(theme::text("确认删除")).clicked() {
self.history_confirmation=None;
if let Some(backend)=self.backend() {
let target=target.clone();let playback=self.playback.clone();
self.spawn(async move {
playback.stop();
let ids=if let Some(id)=&target {vec![id.clone()]}else{backend.list_history()?.into_iter().map(|e|e.id).collect()};
for id in ids {
openless_linux_egui::remove_recording(&backend.config().data_dir,&id)
.map_err(|e|BackendError::new(openless_core::BackendErrorCode::Persistence,e.to_string()))?;
backend.delete_history(&id)?;
}
Ok("历史记录已删除".into())
});
}
}
});
});
if response.should_close() {self.history_confirmation=None;}
}
}
@@ -1,94 +0,0 @@
#[derive(Default)]
struct MarketplaceUi {
generation:u64,
loading:bool,
search_due:Option<std::time::Instant>,
mine_open:bool,
busy:bool,
upload_pack:String,
confirmation:Option<(MarketplaceMutation,String)>,
}
#[derive(Clone)]
enum MarketplaceMutation {Like(String),Upload(String,Option<String>),Delete(String)}
impl OpenLessEguiApp {
fn load_marketplace(&mut self) {
let Some(backend)=self.backend() else {return;};
self.marketplace_ui.generation+=1;
self.marketplace_ui.loading=true;self.marketplace_ui.search_due=None;
self.frontend_vm.marketplace_selected=None;
let generation=self.marketplace_ui.generation;
let query=self.marketplace_query.trim().to_string();let sort=self.frontend_vm.marketplace_sort;
let tx=self.tx.clone();
self.tokio.spawn(async move {
let result:Result<_,BackendError>=async {
use frontend::view_model::MarketplaceSort;
let mut items=backend.services().marketplace.list(openless_core::MarketplaceQuery {
query:(!query.is_empty()).then_some(query),limit:Some(100),
sort:Some(if sort==MarketplaceSort::New {"new"}else{"popular"}.into()),
}).await?;
let signed_in=backend.services().marketplace.auth_status().await?.signed_in;
let likes=if signed_in {backend.services().marketplace.my_likes().await?}else{vec![]};
if sort==MarketplaceSort::Liked {items.retain(|item|likes.contains(&item.id));}
Ok((items,likes))
}.await;
let _=tx.send(UiResult::Marketplace{generation,result:result.map_err(|e|e.to_string())});
});
}
fn mutate_marketplace(&mut self, operation:MarketplaceMutation) {
if self.marketplace_ui.busy {return;}
let Some(backend)=self.backend() else {return;};
self.marketplace_ui.busy=true;let tx=self.tx.clone();
self.tokio.spawn(async move {
let result:Result<String,BackendError>=async {
match operation {
MarketplaceMutation::Like(id)=>{let result=backend.services().marketplace.toggle_like(id).await?;Ok(format!("{} 个赞",result.like_count))}
MarketplaceMutation::Upload(id,origin)=>{let result=backend.services().marketplace.upload(id,origin).await?;Ok(result.message)}
MarketplaceMutation::Delete(id)=>{backend.services().marketplace.delete(id).await?;Ok("已删除发布".into())}
}
}.await;
let _=tx.send(UiResult::MarketplaceMutation(result.map_err(|e|e.to_string())));
});
}
fn marketplace_windows_v2(&mut self,ctx:&egui::Context) {
if self.marketplace_ui.search_due.is_some_and(|due|std::time::Instant::now()>=due){self.load_marketplace();}
if self.marketplace_ui.mine_open {
let mut open=true;
egui::Window::new(theme::text("我的发布")).open(&mut open).default_width(600.0).show(ctx,|ui| {
self.marketplace_account_v2(ui);
ui.separator();
egui::ComboBox::from_id_salt("upload-style").selected_text(self.style_packs.iter()
.find(|p|p.id==self.marketplace_ui.upload_pack).map(|p|p.name.as_str()).unwrap_or("选择本地风格包"))
.show_ui(ui,|ui| {for pack in &self.style_packs {ui.selectable_value(&mut self.marketplace_ui.upload_pack,pack.id.clone(),&pack.name);}});
if ui.add_enabled(!self.marketplace_ui.busy && !self.marketplace_ui.upload_pack.is_empty(),egui::Button::new(theme::text("发布风格包"))).clicked() {
if let Some(pack)=self.style_packs.iter().find(|p|p.id==self.marketplace_ui.upload_pack) {
self.marketplace_ui.confirmation=Some((MarketplaceMutation::Upload(pack.id.clone(),pack.origin_pack_id.clone()),format!("发布「{}」到风格市场?提示词及风格信息将公开。",pack.name)));
}
}
ui.add_space(12.0);
egui::ScrollArea::vertical().max_height(320.0).show(ui,|ui| {
for pack in &self.marketplace_my_packs {
ui.horizontal(|ui| {
ui.strong(&pack.summary.name);ui.label(&pack.state);
if ui.add_enabled(!self.marketplace_ui.busy,egui::Button::new(theme::text("删除"))).clicked(){
self.marketplace_ui.confirmation=Some((MarketplaceMutation::Delete(pack.summary.id.clone()),format!("从市场删除「{}」?",pack.summary.name)));
}
});ui.separator();
}
});
if self.marketplace_ui.busy {ui.spinner();}
ui.label(&self.status);
});
if !open {self.marketplace_ui.mine_open=false;}
}
if let Some((operation,message))=self.marketplace_ui.confirmation.clone() {
let response=egui::Modal::new(egui::Id::new("marketplace-confirmation")).show(ctx,|ui| {
ui.set_width(360.0);ui.label(message);ui.add_space(16.0);
ui.horizontal(|ui| {
if ui.button(theme::text("取消")).clicked(){self.marketplace_ui.confirmation=None;}
if ui.button(theme::text("确认")).clicked(){self.marketplace_ui.confirmation=None;self.mutate_marketplace(operation);}
});
});
if response.should_close(){self.marketplace_ui.confirmation=None;}
}
}
}
@@ -1,282 +0,0 @@
impl OpenLessEguiApp {
fn load_local_models(&mut self) {
if self.local_models_loading {
return;
}
let Some(backend) = self.backend() else {
return;
};
self.local_models_loading = true;
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = backend
.services()
.local_asr
.list_models(openless_core::LocalAsrRuntime::Generic)
.await
.map_err(|e| e.to_string());
let _ = tx.send(UiResult::LocalModels(result));
});
}
fn local_models_v2(
&mut self,
ui: &mut egui::Ui,
state: &mut crate::ui::settings::SettingsState,
) {
use crate::ui::settings::card;
if self.local_models.is_none() {
self.load_local_models();
}
card(ui, "本地语音识别", |ui| {
ui.label(theme::text("在本机处理录音。下载完成后可启用、测试或释放模型。"));
if ui
.add_enabled(
!self.local_models_loading,
egui::Button::new("刷新模型列表"),
)
.clicked()
{
self.load_local_models();
}
if self.local_models_loading {
ui.spinner();
}
});
for model in self.local_models.clone().unwrap_or_default() {
let target = model.target.clone();
let id = target.model_id().to_owned();
card(ui, &model.display_name, |ui| {
ui.label(format!(
"{} · {}",
model.family,
if model.installed {
"已下载"
} else {
"尚未下载"
}
));
if let Some(size) = model.size_bytes {
ui.label(format!("{:.1} MB", size as f64 / 1_048_576.0));
}
if let Some(progress)=self.model_downloads.get(&id) {
ui.label(format!("{} · {}/{}",progress.file,progress.file_index,progress.file_count));
if progress.bytes_total>0 {ui.add(egui::ProgressBar::new(progress.bytes_downloaded as f32 / progress.bytes_total as f32).show_percentage());}
if let Some(error)=&progress.error {ui.colored_label(egui::Color32::from_rgb(220, 38, 38),error);}
}
if let Some(progress)=self.model_prepare.as_ref().filter(|p|p.model_alias==id) {
ui.label(&progress.label); if let Some(percent)=progress.percent {ui.add(egui::ProgressBar::new((percent/100.0) as f32).show_percentage());}
if let Some(error)=&progress.error {ui.colored_label(egui::Color32::from_rgb(220, 38, 38),error);}
}
let mut action = None;
ui.horizontal_wrapped(|ui| {
if model.installed {
for (key, label) in [
("activate", "启用"),
("test", "测试模型"),
("release", "释放内存"),
("folder", "打开目录"),
("delete", "删除模型"),
] {
if ui.button(label).clicked() {
action = Some(key);
}
}
} else {
for (key, label) in [
("download", "下载"),
("cancel", "取消下载"),
("cleanup", "清理未完成下载"),
] {
if ui.button(label).clicked() {
action = Some(key);
}
}
}
if ui.button(theme::text("取消加载")).clicked() {
action = Some("cancel_prepare");
}
});
if action == Some("delete") {
state.confirmation = Some(format!("model:{id}"));
action = None;
}
if state.confirmation.as_deref() == Some(format!("model:{id}").as_str()) {
ui.label(theme::text("确认删除此模型文件?"));
ui.horizontal(|ui| {
if ui.button(theme::text("确认删除")).clicked() {
action = Some("delete");
state.confirmation = None;
}
if ui.button(theme::text("取消")).clicked() {
state.confirmation = None;
}
});
}
if let Some(action) = action {
self.local_model_action(target, action);
}
});
}
card(ui, "存储与下载", |ui| {
let document = self
.preferences
.as_ref()
.and_then(|p| serde_json::to_value(p).ok())
.unwrap_or_default();
let mut edits = std::collections::BTreeMap::new();
preference_field(
ui,
&document,
&mut edits,
"/localAsrMirror",
"下载源",
FieldKind::Choice(&[
("huggingface", "Hugging Face"),
("hf-mirror", "HF Mirror"),
]),
);
preference_field(
ui,
&document,
&mut edits,
"/localAsrKeepLoadedSecs",
"模型保留时间(秒)",
FieldKind::Number(0.0, 86400.0),
);
self.save_field_edits(edits);
if ui.button(theme::text("更改模型目录…")).clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
let folder = rfd::AsyncFileDialog::new().pick_folder().await;
if let Some(folder) = folder {
backend
.services()
.local_asr
.set_models_base_dir(Some(folder.path().to_owned()))
.await?;
Ok("模型目录已保存".into())
} else {
Ok("已取消".into())
}
});
}
}
});
}
fn local_model_action(&self, target: openless_core::LocalAsrTarget, action: &str) {
let Some(backend) = self.backend() else {
return;
};
let action = action.to_owned();
let tx = self.tx.clone();
self.tokio.spawn(async move {
let api = backend.services().local_asr.clone();
let result: Result<String, BackendError> = async {
match action.as_str() {
"download" => api.start_download(target, None).await?,
"cancel" => api.cancel_download(target).await?,
"cleanup" => api.cleanup_incomplete(target).await?,
"cancel_prepare" => api.cancel_prepare(target.runtime).await?,
"activate" => {
api.activate(openless_core::LocalAsrActivationRequest {
provider_id: if target.model_id().starts_with("whisper") {
"local-whisper".into()
} else {
target.runtime.provider_id().into()
},
target,
})
.await?;
}
"release" => api.release(target.runtime).await?,
"delete" => api.delete_model(target).await?,
"test" => {
let result = api.test_model(target).await?;
return Ok(format!(
"{} · {} ms",
result.transcribed_text, result.transcribe_ms
));
}
"folder" => {
let path = api.model_dir(target).await?;
tokio::task::spawn_blocking(move || {
openless_linux_egui::open_local_file(&path)
})
.await
.map_err(|e| {
BackendError::new(
openless_core::BackendErrorCode::Platform,
e.to_string(),
)
})?
.map_err(|e| {
BackendError::new(
openless_core::BackendErrorCode::Platform,
e.to_string(),
)
})?;
}
_ => unreachable!(),
}
Ok("模型操作完成".into())
}
.await;
let _ = tx.send(UiResult::LocalModelAction(
result.map_err(|e| e.to_string()),
));
});
}
fn marketplace_account_v2(&mut self, ui: &mut egui::Ui) {
if ui.button(theme::text("使用 GitHub 登录")).clicked() {
if let Some(backend) = self.backend() {
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = backend
.services()
.marketplace
.start_device_flow()
.await
.map_err(|e| e.to_string());
let _ = tx.send(UiResult::MarketplaceFlow(result));
});
}
}
if let Some(flow) = self.marketplace_flow.clone() {
ui.monospace(&flow.user_code);
ui.hyperlink_to("打开 GitHub 授权页", &flow.verification_uri);
if ui.button(theme::text("检查授权状态")).clicked() {
if let Some(backend) = self.backend() {
let tx = self.tx.clone();
let flow_id=flow.flow_id.clone();
self.tokio.spawn(async move {
let result = backend
.services()
.marketplace
.poll_device_flow(flow_id)
.await
.map_err(|e| e.to_string());
let _ = tx.send(UiResult::MarketplaceAuthPoll(result));
});
}
}
if ui.button(theme::text("取消登录")).clicked() {
if let Some(backend)=self.backend() {
let id=flow.flow_id.clone();
self.spawn(async move {backend.services().marketplace.cancel_device_flow(Some(id)).await?;Ok("已取消登录".into())});
}
self.marketplace_flow = None;
}
}
if ui.button(theme::text("退出登录")).clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend.services().marketplace.logout().await?;
Ok("已退出登录".into())
});
}
}
}
}
@@ -1,276 +0,0 @@
#[derive(Clone, Default)]
struct OmniEditor {
provider: String,
endpoint: String,
model: String,
secret: String,
headers: String,
temperature: String,
models: Vec<String>,
}
impl OpenLessEguiApp {
fn load_omni(&mut self, requested: Option<String>) {
if self.omni_loading {
return;
}
let Some(backend) = self.backend() else {
return;
};
self.omni_loading = true;
self.omni_error=None;
self.omni = None;
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result: Result<OmniEditor, BackendError> = async {
let provider =
requested.unwrap_or_else(|| backend.get_preferences().active_omni_provider);
let descriptor = openless_core::provider_rules::provider_descriptor(
openless_core::ProviderKind::Omni,
&provider,
)
.ok_or_else(|| {
BackendError::new(
openless_core::BackendErrorCode::InvalidArgument,
"未知的 Omni 服务",
)
})?;
let mut editor = OmniEditor {
provider: provider.clone(),
endpoint: descriptor.default_endpoint.unwrap_or_default(),
model: descriptor.default_model.unwrap_or_default(),
models: descriptor.static_models,
..Default::default()
};
for (account, field) in [
(
openless_core::credentials::OMNI_ENDPOINT_ACCOUNT,
&mut editor.endpoint,
),
(
openless_core::credentials::OMNI_MODEL_ACCOUNT,
&mut editor.model,
),
(
openless_core::credentials::OMNI_EXTRA_HEADERS_ACCOUNT,
&mut editor.headers,
),
(
openless_core::credentials::OMNI_TEMPERATURE_ACCOUNT,
&mut editor.temperature,
),
] {
let key = openless_core::CredentialKey::new(
openless_core::CredentialNamespace::Omni,
Some(provider.clone()),
account,
)?;
if let Some(value) = backend.read_credential(key).await? {
*field = value.into_exposed();
}
}
Ok(editor)
}
.await;
let _ = tx.send(UiResult::Omni(result.map_err(|e| e.to_string())));
});
}
fn omni_v2(&mut self, ui: &mut egui::Ui) {
if let Some(error)=self.omni_error.clone() {
ui.label(error);if ui.button(theme::text("重试")).clicked(){self.load_omni(None);}return;
}
if self.omni.is_none() && !self.omni_loading {
self.load_omni(None);
}
if self.omni_loading {
ui.spinner();
return;
}
let Some(mut editor) = self.omni.clone() else {
return;
};
crate::ui::settings::card(ui, "Omni 多模态", |ui| {
let mut provider = editor.provider.clone();
egui::ComboBox::from_id_salt("omni-provider")
.selected_text(&provider)
.show_ui(ui, |ui| {
for descriptor in openless_core::provider_rules::provider_descriptors(
openless_core::ProviderKind::Omni,
) {
ui.selectable_value(
&mut provider,
descriptor.provider_type.to_string(),
theme::text_key(&descriptor.label_key),
);
}
});
if provider != editor.provider {
self.load_omni(Some(provider));
return;
}
for (label, value) in [
("API 地址", &mut editor.endpoint),
("模型", &mut editor.model),
("Temperature", &mut editor.temperature),
] {
ui.horizontal(|ui| {
ui.label(theme::text(label));
ui.text_edit_singleline(value);
});
}
ui.horizontal(|ui| {
ui.label("API Key");
ui.add(
egui::TextEdit::singleline(&mut editor.secret)
.password(true)
.hint_text(theme::text("留空保留已保存的密钥")),
);
});
ui.label(theme::text("附加请求头(JSON)"));
ui.add(
egui::TextEdit::multiline(&mut editor.headers)
.desired_rows(2)
.desired_width(f32::INFINITY),
);
egui::ComboBox::from_id_salt("omni-models")
.selected_text(theme::text("选择模型"))
.show_ui(ui, |ui| {
for model in &editor.models {
ui.selectable_value(&mut editor.model, model.clone(), model);
}
});
let mut operation = None;
ui.horizontal_wrapped(|ui| {
for (key, label) in [
("save", "保存并启用"),
("test", "测试连接"),
("models", "获取模型"),
("clear", "清除密钥"),
] {
if ui.button(theme::text(label)).clicked() {
operation = Some(key);
}
}
});
if let Some(operation) = operation {
if let Some(backend) = self.backend() {
let saved = editor.clone();
editor.secret.clear();
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result: Result<String, BackendError> = async {
use openless_core::credentials::*;
let key = |account| {
openless_core::CredentialKey::new(
openless_core::CredentialNamespace::Omni,
Some(saved.provider.clone()),
account,
)
};
if operation == "clear" {
backend
.remove_credential(key(OMNI_API_KEY_ACCOUNT)?)
.await?;
return Ok("密钥已清除".into());
}
if !saved.headers.trim().is_empty() {
let parsed: serde_json::Value =
serde_json::from_str(&saved.headers).map_err(|e| {
BackendError::new(
openless_core::BackendErrorCode::InvalidArgument,
e.to_string(),
)
})?;
if !parsed
.as_object()
.is_some_and(|o| o.values().all(|v| v.is_string()))
{
return Err(BackendError::new(
openless_core::BackendErrorCode::InvalidArgument,
"请求头必须是 JSON 字符串映射",
));
}
}
if !saved.temperature.trim().is_empty()
&& !saved
.temperature
.parse::<f64>()
.ok()
.is_some_and(|n| n.is_finite() && (0.0..=2.0).contains(&n))
{
return Err(BackendError::new(
openless_core::BackendErrorCode::InvalidArgument,
"Temperature 应在 0 到 2 之间",
));
}
for (account, value) in [
(OMNI_ENDPOINT_ACCOUNT, saved.endpoint),
(OMNI_MODEL_ACCOUNT, saved.model),
(OMNI_EXTRA_HEADERS_ACCOUNT, saved.headers),
(OMNI_TEMPERATURE_ACCOUNT, saved.temperature),
] {
if value.trim().is_empty() {
backend.remove_credential(key(account)?).await?;
} else {
backend
.set_credential(
key(account)?,
openless_core::SecretValue::new(value),
)
.await?;
}
}
if !saved.secret.is_empty() {
backend
.set_credential(
key(OMNI_API_KEY_ACCOUNT)?,
openless_core::SecretValue::new(saved.secret),
)
.await?;
}
backend
.set_active_provider(
openless_core::ProviderSlot::Omni,
saved.provider.clone(),
)
.await?;
let request = openless_core::ProviderRequest {
kind: openless_core::ProviderKind::Omni,
channel_id: Some(saved.provider.clone()),
thinking_enabled: false,
};
match operation {
"test" => {
let status =
backend.services().provider.validate(request).await?;
Ok(if status.ok {
"连接成功"
} else {
"连接失败"
}
.into())
}
"models" => {
let models = backend
.services()
.provider
.list_models(request)
.await?
.models;
let _ = tx.send(UiResult::OmniModels(saved.provider,Ok(models)));
Ok("模型列表已更新".into())
}
_ => Ok("Omni 配置已保存".into()),
}
}
.await;
let _ =
tx.send(UiResult::Message(result.unwrap_or_else(|e| e.to_string())));
});
}
}
});
if !self.omni_loading {
self.omni = Some(editor);
}
}
}
@@ -1,106 +0,0 @@
impl OpenLessEguiApp {
fn desktop_component(&self, mode: &'static str) {
self.spawn(async move {
tokio::task::spawn_blocking(move || {
openless_linux_egui::desktop_bridge::manage_component(mode)
})
.await
.map_err(|e| {
BackendError::new(openless_core::BackendErrorCode::Platform, e.to_string())
})?
});
}
fn onboarding_v2(&mut self, ctx: &egui::Context) {
let id = egui::Id::new("onboarding-completed");
let completed = ctx.data(|d| d.get_temp::<bool>(id)).unwrap_or_else(|| {
let completed = openless_linux_egui::load_ui_value("onboardingComplete")
.and_then(|v| v.as_bool())
.unwrap_or(false);
ctx.data_mut(|d| d.insert_temp(id, completed));
completed
});
if completed {
return;
}
egui::Modal::new(egui::Id::new("onboarding-2.0"))
.frame(
egui::Frame::new()
.fill(theme::surface())
.stroke(egui::Stroke::new(0.5, theme::line()))
.corner_radius(14)
.inner_margin(32),
)
.show(ctx, |ui| {
ui.set_width((ctx.content_rect().width() - 104.0).clamp(240.0, 456.0));
ui.label(
egui::RichText::new("OpenLess")
.size(24.0)
.strong()
.color(theme::blue()),
);
ui.add_space(16.0);
ui.heading(theme::text_key("onboarding.welcome"));
ui.label(theme::text_key("onboarding.intro"));
ui.add_space(24.0);
crate::ui::settings::card(ui, &theme::text_key("onboarding.hotkeyTitle"), |ui| {
ui.label(theme::text(
"使用 fcitx5 输入,并启用当前桌面的 OpenLess 组件。",
));
ui.label(if openless_linux_egui::desktop_bridge::active() {
theme::text("桌面热键已连接")
} else {
theme::text("桌面组件尚未连接")
});
if ui.button(theme::text("安装并启用桌面组件")).clicked() {
self.desktop_component("install");
}
if ui.button(theme::text("重新启用")).clicked() {
self.desktop_component("enable");
}
});
crate::ui::settings::card(ui, &theme::text_key("onboarding.micTitle"), |ui| {
ui.label(theme::text_key("onboarding.micDesc"));
ui.label(format!(
"{} {}",
self.microphones.len(),
theme::text("个输入设备")
));
if ui
.button(theme::text_key("onboarding.actionRequestMic"))
.clicked()
{
if let Some(backend) = self.backend() {
self.spawn(async move {
backend
.services()
.platform
.request_microphone_permission()
.await?;
Ok("麦克风权限已检查".into())
});
}
self.load_microphones();
}
});
ui.label(&self.status);
if ui
.add_sized(
[ui.available_width(), 34.0],
egui::Button::new(theme::text_key("onboarding.continueToSettings")),
)
.clicked()
{
match openless_linux_egui::save_ui_value(
"onboardingComplete",
serde_json::json!(true),
) {
Ok(()) => {
ctx.data_mut(|d| d.insert_temp(id, true));
self.frontend_vm.settings_open = true;
}
Err(error) => self.status = error.to_string(),
}
}
});
}
}
@@ -1,768 +0,0 @@
// Included inside linux_app so native editors can use the same session owners
// and result queue as the main window. No second backend or credential cache.
impl OpenLessEguiApp {
fn save_field_edits(&mut self, edits: std::collections::BTreeMap<String, serde_json::Value>) {
if edits.is_empty() {
return;
}
let Some(native) = &self.native else {
return;
};
if let Some(preferences) = &self.preferences {
match openless_linux_egui::patch_preferences(preferences, &edits) {
Ok(draft) => self.preferences = Some(draft),
Err(error) => {
self.status = error.to_string();
return;
}
}
}
let host = native.host_arc();
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = tokio::task::spawn_blocking(move || host.update_preference_fields(&edits))
.await
.map_err(|e| e.to_string())
.and_then(|r| r.map_err(|e| e.to_string()));
let _ = tx.send(UiResult::SettingsSaved(Box::new(result)));
});
}
fn settings_v2(&mut self, ctx: &egui::Context) {
use crate::ui::settings::{self, SettingsState};
let id = egui::Id::new("openless-settings-state");
let mut state = ctx
.data(|d| d.get_temp::<SettingsState>(id))
.unwrap_or_default();
if settings::modal(ctx, &mut state, |ui, state| {
self.settings_section_v2(ui, state)
}) {
self.frontend_vm.settings_open = false;
}
ctx.data_mut(|d| d.insert_temp(id, state));
}
fn settings_section_v2(
&mut self,
ui: &mut egui::Ui,
state: &mut crate::ui::settings::SettingsState,
) {
use crate::ui::settings::{card, Section};
use serde_json::{json, Value};
let mut edits = std::collections::BTreeMap::new();
let document = self
.preferences
.as_ref()
.and_then(|p| serde_json::to_value(p).ok())
.unwrap_or(Value::Null);
macro_rules! fields {
($ui:expr,$title:expr,$rows:expr) => {
card($ui, $title, |ui| {
for (pointer, label, kind) in $rows {
preference_field(ui, &document, &mut edits, pointer, label, *kind);
}
})
};
}
match state.section {
Section::General => {
fields!(
ui,
"录音",
&[
(
"/hotkey/mode",
"录音方式",
FieldKind::Choice(&[
("hold", "按住说话"),
("toggle", "切换录音"),
("auto", "智能模式")
])
),
("/silenceAutoStopEnabled", "静音时自动停止", FieldKind::Bool),
(
"/stableTranscriptionEnabled",
"稳定模式(先录音后识别)",
FieldKind::Bool
),
(
"/silenceAutoStopSeconds",
"静音等待时间(秒)",
FieldKind::Number(0.5, 10.0)
),
(
"/muteDuringRecording",
"录音期间静音系统声音",
FieldKind::Bool
),
(
"/audioCueOnRecord",
"开始和停止时播放提示音",
FieldKind::Bool
),
("/showCapsule", "显示录音胶囊", FieldKind::Bool),
]
);
card(ui, "麦克风", |ui| {
let selected = document["microphoneDeviceName"]
.as_str()
.unwrap_or_default();
let mut value = selected.to_string();
egui::ComboBox::from_id_salt("microphone-v2")
.selected_text(if selected.is_empty() {
"跟随系统"
} else {
selected
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut value, String::new(), "跟随系统");
for device in &self.microphones {
ui.selectable_value(&mut value, device.name.clone(), &device.name);
}
});
if value != selected {
edits.insert("/microphoneDeviceName".into(), json!(value));
}
});
card(ui, "文本输入", |ui| {
for (pointer, label) in [
("/streamingInsert", "流式插入"),
("/restoreClipboardAfterPaste", "粘贴后恢复剪贴板"),
("/streamingInsertSaveClipboard", "保留流式文本到剪贴板"),
] {
preference_field(
ui,
&document,
&mut edits,
pointer,
label,
FieldKind::Bool,
);
}
});
card(ui, "手机输入", |ui| {
preference_field(
ui,
&document,
&mut edits,
"/remoteInputEnabled",
"启用手机输入",
FieldKind::Bool,
);
preference_field(
ui,
&document,
&mut edits,
"/remoteInputPort",
"服务端口",
FieldKind::Number(1024.0, 65535.0),
);
if let Some((remote, pin)) = &self.remote_access {
ui.label(if remote.running {
"服务运行中"
} else if remote.starting {
"正在启动…"
} else {
"服务已停止"
});
if remote.running {
ui.label(format!("已连接 {} 台设备", remote.connection_count));
ui.monospace(format!("PIN {pin}"));
for url in &remote.urls {
ui.hyperlink(url);
}
}
}
if ui.button(theme::text("重置配对码")).clicked() {
if let Some(backend) = self.backend() {
self.spawn(async move {
backend
.services()
.remote_input
.regenerate_pairing_pin()
.await?;
Ok("配对码已重置".into())
});
}
}
});
}
Section::Shortcuts => {
card(ui, "全局快捷键", |ui| {
if let Some(prefs) = self.preferences.as_mut() {
let mut changed = false;
changed |= shortcut_editor(ui, "听写", &mut prefs.dictation_hotkey);
changed |= shortcut_editor(ui, "翻译", &mut prefs.translation_hotkey);
changed |= optional_shortcut_editor(
ui,
self.lang,
"划词追问",
&mut prefs.qa_hotkey,
";",
);
changed |= optional_shortcut_editor(
ui,
self.lang,
"划词润色",
&mut prefs.selection_polish_hotkey,
"P",
);
changed |= optional_shortcut_editor(
ui,
self.lang,
"切换风格",
&mut prefs.switch_style_hotkey,
"S",
);
changed |= optional_shortcut_editor(
ui,
self.lang,
"显示主窗口",
&mut prefs.open_app_hotkey,
"O",
);
changed |= optional_shortcut_editor(
ui,
self.lang,
"Less Computer 语音",
&mut prefs.coding_agent_voice_hotkey,
"L",
);
changed |= optional_shortcut_editor(
ui,
self.lang,
"Less Computer 面板",
&mut prefs.coding_agent_panel_hotkey,
"K",
);
changed |= optional_shortcut_editor(ui,self.lang,"Less Computer 快速输入",&mut prefs.coding_agent_quick_hotkey,"J");
for pack in &self.style_packs {
let mut binding=prefs.style_pack_hotkeys.iter().find(|h|h.pack_id==pack.id).map(|h|h.binding.clone());
if optional_shortcut_editor(ui,self.lang,&pack.name,&mut binding,"1") {
set_style_pack_hotkey(prefs,&pack.id,binding);changed=true;
}
}
if changed {
self.settings_dirty.hotkeys = true;
}
}
});
fields!(
ui,
"划词语音",
&[
("/selectionVoiceEnabled", "启用划词语音", FieldKind::Bool),
(
"/selectionVoiceIntentMode",
"意图识别",
FieldKind::Choice(&[("auto", "自动识别"), ("manual", "手动选择")])
),
(
"/selectionVoiceManualIntent",
"默认意图",
FieldKind::Choice(&[("question", "追问"), ("edit", "修改")])
),
("/qaSaveHistory", "保存追问历史", FieldKind::Bool)
]
);
self.save_settings_if_dirty();
}
Section::Services => {
let omni_enabled = self.preferences.as_ref().is_some_and(|p| p.multimodal_pipeline_enabled);
let omni = self.preferences.as_ref().is_some_and(|p| {
p.multimodal_pipeline_enabled
&& p.pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal
});
ui.horizontal_wrapped(|ui| {
let mut tabs = if omni {
vec![(2, "多模态"), (3, "本地模型"), (4, "网络与连接")]
} else {
vec![
(0, "语言模型"),
(1, "语音识别"),
(3, "本地模型"),
(4, "网络与连接"),
]
};
if omni_enabled && !omni {tabs.insert(2,(2,"多模态"));}
if !tabs.iter().any(|(index, _)| *index == state.service) {
state.service = tabs[0].0;
}
for (index, label) in tabs {
if ui.selectable_label(state.service == index, label).clicked() {
state.service = index;
}
}
});
ui.add_space(12.0);
match state.service {
2 => self.omni_v2(ui),
0..=1 => {
let kind = match state.service {
1 => openless_core::ChannelKind::Asr,
_ => openless_core::ChannelKind::Llm,
};
if self.provider_kind != kind {
self.load_providers(kind);
}
self.provider_management_ui(ui);
}
3 => self.local_models_v2(ui, state),
_ => {
fields!(
ui,
"网络",
&[("/useSystemProxy", "使用系统代理", FieldKind::Bool)]
);
card(ui, "GitHub 账户与风格市场", |ui| {
self.marketplace_account_v2(ui)
});
}
}
}
Section::Appearance => {
fields!(
ui,
"外观",
&[
(
"/themeMode",
"主题",
FieldKind::Choice(&[
("system", "跟随系统"),
("light", "浅色"),
("dark", "深色")
])
),
("/stackedRowLayout", "纵向排列设置项", FieldKind::Bool),
("/conservativeLayout", "简洁布局", FieldKind::Bool),
(
"/showOverviewActivityHeatmap",
"显示活动热力图",
FieldKind::Bool
)
]
);
card(ui, "语言与字体", |ui| {
self.language_selector_ui(ui);
let id = egui::Id::new("openless-font-scale");
let mut size = ui.ctx().data(|d| d.get_temp::<f32>(id)).unwrap_or_else(|| openless_linux_egui::load_ui_value("fontScale").and_then(|v|v.as_f64()).unwrap_or(1.0) as f32);
if ui
.add(egui::Slider::new(&mut size, 0.85..=1.35).text("字体大小"))
.changed()
{
ui.ctx().data_mut(|d| d.insert_temp(id, size));
ui.ctx().set_zoom_factor(size);
if let Err(error) = openless_linux_egui::save_ui_value("fontScale", serde_json::json!(size)) {self.status=error.to_string();}
}
});
}
Section::Privacy => {
fields!(
ui,
"隐私",
&[
("/cursorContextEnabled", "读取光标上下文", FieldKind::Bool),
("/qaSaveHistory", "保存划词追问历史", FieldKind::Bool)
]
);
fields!(
ui,
"历史与录音",
&[
(
"/historyRetentionDays",
"历史保留天数(0 为永久)",
FieldKind::Number(0.0, 3650.0)
),
(
"/historyMaxEntries",
"历史条目上限",
FieldKind::OptionalNumber
),
("/recordAudioForDebug", "保存录音", FieldKind::Bool),
(
"/audioRecordingMaxEntries",
"录音条目上限",
FieldKind::OptionalNumber
)
]
);
card(ui, "云同步", |ui| {
if let Some(status) = &self.cloud_sync_status {
ui.label(if status.has_snapshot {
"已有云端备份"
} else {
"尚无云端备份"
});
if let Some(updated) = &status.updated_at {
ui.label(updated);
}
}
ui.horizontal_wrapped(|ui| {
for (action, label) in [
("status", "刷新状态"),
("upload", "备份到云端"),
("restore", "从云端恢复"),
("delete", "删除云端备份"),
] {
if ui.button(label).clicked() {
if matches!(action, "restore" | "delete") {
state.confirmation = Some(action.into());
} else {
self.cloud_sync_action(action);
}
}
}
});
if let Some(action) = state
.confirmation
.clone()
.filter(|s| s == "restore" || s == "delete")
{
ui.colored_label(
egui::Color32::from_rgb(217, 119, 6),
if action == "restore" {
"将用云端快照替换本地词典、纠错与风格,确认继续?"
} else {
"确认删除云端备份?"
},
);
ui.horizontal(|ui| {
if ui.button(theme::text("确认")).clicked() {
state.confirmation = None;
self.cloud_sync_action(&action);
}
if ui.button(theme::text("取消")).clicked() {
state.confirmation = None;
}
});
}
});
card(ui, "权限与数据目录", |ui| {
if let Some(backend) = self.backend() {
ui.label(backend.config().data_dir.display().to_string());
if ui.button(theme::text("打开数据目录")).clicked() {
let path = backend.config().data_dir.clone();
std::thread::spawn(move || {
let _ = openless_linux_egui::open_local_file(&path);
});
}
}
ui.label(format!(
"桌面会话:{:?}",
std::env::var("XDG_SESSION_TYPE").unwrap_or_default()
));
});
}
Section::Advanced => {
ui.horizontal_wrapped(|ui| {
for (index, label) in ["Less Computer", "多模态管线", "调试工具"]
.iter()
.enumerate()
{
ui.selectable_value(&mut state.advanced, index, *label);
}
});
match state.advanced {
0 => {
fields!(
ui,
"Less Computer",
&[
("/codingAgentEnabled", "启用 Less Computer", FieldKind::Bool),
("/codingAgentProvider", "后端", FieldKind::Text),
("/codingAgentExe", "可执行文件", FieldKind::OptionalText),
("/codingAgentWorkdir", "工作目录", FieldKind::OptionalText),
("/codingAgentModel", "模型", FieldKind::OptionalText),
(
"/codingAgentPermissionMode",
"权限模式",
FieldKind::Choice(&[
("default", "默认"),
("plan", "规划"),
("acceptEdits", "允许编辑")
])
)
]
);
self.less_computer_ui(ui);
}
1 => {
fields!(
ui,
"多模态管线",
&[
(
"/multimodalPipelineEnabled",
"启用多模态管线",
FieldKind::Bool
),
(
"/pipelineMode",
"管线模式",
FieldKind::Choice(&[
("traditional", "语音识别 + 语言模型"),
("multimodal", "多模态")
])
),
("/llmThinkingEnabled", "启用模型思考", FieldKind::Bool)
]
);
}
_ => {
card(ui, "诊断", |ui| {
ui.label(&self.status);
if ui.button(theme::text("导出日志")).clicked() {
self.apply_settings_action(
frontend::view_model::SettingsActionField::ExportDiagnostics,
);
}
if ui.button(theme::text("重新加载 fcitx5 插件")).clicked() {
self.status = if reload_running_fcitx5() {
"已请求重新加载"
} else {
"未能重新加载 fcitx5"
}
.into();
}
});
}
}
}
Section::About => {
card(ui,"Linux 桌面集成",|ui| {
for (mode,label) in [("install","安装桌面组件"),("enable","启用桌面组件"),("uninstall","卸载桌面组件")] {
if ui.button(theme::text(label)).clicked() {self.desktop_component(mode);}
}
});
card(ui, "OpenLess", |ui| {
ui.heading(format!("OpenLess {}", self.app_version()));
ui.label(theme::text("开源语音输入 · Linux"));
ui.hyperlink_to("GitHub", "https://github.com/Open-Less/openless");
});
fields!(
ui,
"启动与更新",
&[
("/launchAtLogin", "登录时启动", FieldKind::Bool),
("/startMinimized", "启动时最小化", FieldKind::Bool),
("/autoUpdateCheck", "自动检查更新", FieldKind::Bool),
(
"/updateChannel",
"更新频道",
FieldKind::Choice(&[("stable", "稳定版"), ("beta", "Beta")])
)
]
);
self.update_controls_v2(ui);
}
}
self.save_field_edits(edits);
if !self.status.is_empty() {
ui.add_space(8.0);
ui.label(
egui::RichText::new(&self.status)
.size(11.0)
.color(theme::ink_3()),
);
}
}
fn app_version(&self) -> String {
serde_json::from_str::<serde_json::Value>(include_str!("../../package.json"))
.ok()
.and_then(|v| v["version"].as_str().map(str::to_owned))
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").into())
}
fn update_controls_v2(&mut self, ui: &mut egui::Ui) {
let channel = self
.preferences
.as_ref()
.map(|p| p.update_channel)
.unwrap_or_default();
ui.horizontal(|ui| {
if ui
.add_enabled(!self.update_busy, egui::Button::new("检查更新"))
.clicked()
{
self.request_update_check(channel);
}
if self.update_manifest.is_some()
&& ui
.add_enabled(!self.update_busy, egui::Button::new("下载并安装"))
.clicked()
{
self.install_update();
}
});
if self.update_busy && self.update_cancellation.is_some() && ui.button(theme::text("取消下载")).clicked() {
if self.update_cancellation.as_ref().is_some_and(|c|c.cancel()) {self.status="正在取消更新…".into();}
}
if let Some(progress) = self.update_progress {
if let Some(total) = progress.content_length.filter(|n| *n > 0) {
ui.add(egui::ProgressBar::new(
progress.downloaded as f32 / total as f32,
));
}
}
}
fn cloud_sync_action(&self, action: &str) {
let Some(backend) = self.backend() else {
return;
};
let action = action.to_string();
let scale = openless_linux_egui::load_ui_value("fontScale").and_then(|v|v.as_f64()).unwrap_or(1.0);
let ui_preferences = openless_core::CloudSyncUiPreferences {
locale: serde_json::from_value(serde_json::json!(self.locale_pref.to_tag())).ok(),
font_scale: Some(if scale < 0.95 {openless_core::SyncFontScale::Small} else if scale > 1.05 {openless_core::SyncFontScale::Large} else {openless_core::SyncFontScale::Medium}),
};
let tx = self.tx.clone();
self.tokio.spawn(async move {
let result = async {
let latest = backend.cloud_sync_status().await?;
match action.as_str() {
"upload" => {
backend
.cloud_sync_upload(
latest.revision,
ui_preferences,
)
.await
}
"restore" => {let restored=backend.cloud_sync_restore().await?; let _=tx.send(UiResult::CloudUi(restored.ui_preferences));Ok(restored.status)},
"delete" => backend.cloud_sync_delete(latest.revision).await,
_ => Ok(latest),
}
}
.await
.map_err(|e: BackendError| e.to_string());
let _ = tx.send(UiResult::CloudSync(result));
});
}
}
#[derive(Clone, Copy)]
enum FieldKind {
Bool,
Text,
OptionalText,
OptionalNumber,
Number(f64, f64),
Choice(&'static [(&'static str, &'static str)]),
}
fn preference_field(
ui: &mut egui::Ui,
document: &serde_json::Value,
edits: &mut std::collections::BTreeMap<String, serde_json::Value>,
pointer: &str,
label: &str,
kind: FieldKind,
) {
use serde_json::{json, Value};
let Some(value) = document.pointer(pointer) else {
return;
};
ui.push_id(pointer, |ui| {
ui.horizontal_wrapped(|ui| {
ui.set_min_height(32.0);
ui.label(theme::text(label));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| match kind {
FieldKind::Bool => {
let mut v = value.as_bool().unwrap_or(false);
if ui.checkbox(&mut v, "").changed() {
edits.insert(pointer.into(), json!(v));
}
}
FieldKind::Choice(options) => {
let mut v = value.as_str().unwrap_or_default().to_string();
let title = options
.iter()
.find(|(id, _)| *id == v)
.map(|(_, label)| *label)
.unwrap_or(&v);
egui::ComboBox::from_id_salt(pointer)
.selected_text(title)
.show_ui(ui, |ui| {
for (id, label) in options {
ui.selectable_value(&mut v, id.to_string(), theme::text(label));
}
});
if value.as_str() != Some(v.as_str()) {
edits.insert(pointer.into(), json!(v));
}
}
FieldKind::Number(min, max) => {
let mut v = value.as_f64().unwrap_or(min);
if ui
.add(egui::DragValue::new(&mut v).range(min..=max))
.changed()
{
edits.insert(
pointer.into(),
if value.is_u64() {
json!(v.round() as u64)
} else {
json!(v)
},
);
}
}
FieldKind::OptionalNumber => {
let mut enabled = !value.is_null();
if ui.checkbox(&mut enabled, "限制数量").changed() {
edits.insert(
pointer.into(),
if enabled { json!(500) } else { Value::Null },
);
}
if enabled {
let mut count = value.as_u64().unwrap_or(500);
if ui
.add(egui::DragValue::new(&mut count).range(1..=100_000))
.changed()
{
edits.insert(pointer.into(), json!(count));
}
}
}
FieldKind::Text | FieldKind::OptionalText => {
let id = ui.make_persistent_id(("draft", pointer));
let mut text = ui
.ctx()
.data(|d| d.get_temp::<String>(id))
.unwrap_or_else(|| value.as_str().unwrap_or_default().into());
let response =
ui.add(egui::TextEdit::singleline(&mut text).desired_width(230.0));
if response.lost_focus()
|| (response.has_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter)))
{
if text != value.as_str().unwrap_or_default() {
edits.insert(
pointer.into(),
if matches!(kind, FieldKind::OptionalText)
&& text.trim().is_empty()
{
Value::Null
} else {
json!(text)
},
);
}
ui.ctx().data_mut(|d| d.remove::<String>(id));
} else if response.has_focus() {
ui.ctx().data_mut(|d| d.insert_temp(id, text));
}
}
},
);
});
ui.separator();
});
}
@@ -1,68 +0,0 @@
impl OpenLessEguiApp {
fn open_style_v2(&mut self, pack: openless_core::StylePack) {
let vm=&mut self.frontend_vm;
vm.style_editor_open=true; vm.style_saving=false;
vm.style_name=pack.name.clone(); vm.style_description=pack.description.clone();
vm.style_prompt=pack.prompt.clone(); vm.style_selection_prompt=pack.selection_prompt.clone();
vm.style_builtin=pack.kind==openless_core::StylePackKind::Builtin;
self.style_editor=Some(pack);
}
fn activate_style_v2(&mut self, index: usize) {
let id=if index==usize::MAX {"builtin.raw".into()}else{
let Some(pack)=self.style_packs.get(index) else {return;}; pack.id.clone()
};
if self.frontend_vm.style_selection_workflow {
self.save_field_edits(std::collections::BTreeMap::from([("/selectionPolishStylePackId".into(),serde_json::json!(id))]));
} else if let Some(backend)=self.backend() {
self.spawn(async move {backend.activate_style_pack(&id)?; Ok("风格已切换".into())});
}
}
fn save_style_v2(&mut self, prompt: String) {
let Some(mut pack)=self.style_editor.clone() else {self.frontend_vm.style_saving=false;return;};
let Some(backend)=self.backend() else {self.frontend_vm.style_saving=false;return;};
pack.name=self.frontend_vm.style_name.trim().to_string();
pack.description=self.frontend_vm.style_description.clone();
pack.prompt=prompt; pack.selection_prompt=self.frontend_vm.style_selection_prompt.clone();
let exists=self.style_packs.iter().any(|p|p.id==pack.id);
let tx=self.tx.clone();
self.tokio.spawn(async move {
let result=tokio::task::spawn_blocking(move || {
if exists {backend.update_style_pack(pack)}else{backend.create_style_pack(pack)}
}).await.map_err(|e|e.to_string()).and_then(|r|r.map_err(|e|e.to_string()));
let _=tx.send(UiResult::StyleSaved(result));
});
}
fn reset_style_v2(&mut self) {
let (Some(pack),Some(backend))=(self.style_editor.as_ref(),self.backend()) else {return;};
let id=pack.id.clone(); let tx=self.tx.clone(); self.frontend_vm.style_saving=true;
self.tokio.spawn(async move {
let result=tokio::task::spawn_blocking(move ||backend.reset_builtin_style_pack(&id)).await
.map_err(|e|e.to_string()).and_then(|r|r.map_err(|e|e.to_string()));
let _=tx.send(UiResult::StyleSaved(result));
});
}
fn style_delete_confirmation_ui(&mut self, ctx:&egui::Context) {
let Some(id)=self.style_delete_pending.clone() else {return;};
let response=egui::Modal::new(egui::Id::new("delete-style"))
.frame(egui::Frame::new().fill(theme::surface()).corner_radius(14).inner_margin(24))
.show(ctx,|ui| {
ui.set_width(340.0);ui.heading(theme::text("删除风格包?"));
ui.label(theme::text("此操作会同时解除关联的快捷键。"));ui.add_space(16.0);
ui.horizontal(|ui| {
if ui.button(theme::text("取消")).clicked(){self.style_delete_pending=None;}
if ui.button(theme::text("删除")).clicked(){
self.style_delete_pending=None;
let exists=self.style_packs.iter().any(|p|p.id==id);
if exists {
if let Some(native)=&self.native {
let host=native.host_arc();
self.spawn(async move {host.remove_style_pack(&id)?;Ok("风格包已删除".into())});
}
}
self.frontend_vm.style_editor_open=false;self.style_editor=None;
}
});
});
if response.should_close(){self.style_delete_pending=None;}
}
}
@@ -1,179 +0,0 @@
impl OpenLessEguiApp {
fn native_windows(&mut self, ctx: &egui::Context) {
use openless_core::SelectionVoicePhase as Phase;
if let Some(snapshot) = self
.selection_voice_state
.clone()
.filter(|s| matches!(s.phase, Phase::AwaitingIntent | Phase::Preview))
{
let intent = snapshot.phase == Phase::AwaitingIntent;
let title = if intent {
"OpenLess Voice Intent"
} else {
"OpenLess Voice Preview"
};
let placement=egui::Id::new(("voice-placement",snapshot.session_id,intent));
if !ctx.data(|d|d.get_temp::<bool>(placement).unwrap_or(false)) {
openless_linux_egui::desktop_bridge::place_popup(title,480,320,false);
ctx.data_mut(|d|d.insert_temp(placement,true));
}
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("selection-voice"),
egui::ViewportBuilder::default()
.with_title(title)
.with_inner_size([480.0, 320.0])
.with_always_on_top(),
|ctx, _class| {
theme::apply_visuals(
ctx,
self.preferences
.as_ref()
.map(|p| p.theme_mode)
.unwrap_or_default(),
);
let close = ctx.input(|i| {
i.viewport().close_requested() || i.key_pressed(egui::Key::Escape)
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.add_space(12.0);
ui.heading(if intent {
theme::text("你想对选中文字做什么?")
} else {
theme::text("预览修改")
});
ui.add_space(12.0);
ui.label(snapshot.source_text.as_deref().unwrap_or_default());
let driver = self
.native
.as_ref()
.map(|native| native.host().selection_voice());
let mut cancel = close;
if intent {
ui.label(
snapshot
.instruction_polished
.as_deref()
.or(snapshot.instruction_raw.as_deref())
.unwrap_or_default(),
);
ui.horizontal(|ui| {
for (intent, label) in [("question", "追问"), ("edit", "修改")]
{
if ui.button(theme::text(label)).clicked() {
if let (Some(driver), Some(id)) =
(driver.clone(), snapshot.session_id)
{
self.spawn(async move {
driver.confirm_intent(id, intent.into()).await?;
Ok("已选择语音意图".into())
});
}
}
}
});
} else if let Some(preview) = &snapshot.preview {
let id =
egui::Id::new(("voice-preview", preview.session_id.to_string()));
let mut text = ctx
.data(|data| data.get_temp::<String>(id))
.unwrap_or_else(|| preview.text.clone());
ui.add(
egui::TextEdit::multiline(&mut text)
.desired_rows(6)
.desired_width(f32::INFINITY),
);
ctx.data_mut(|data| data.insert_temp(id, text.clone()));
if ui.button(theme::text("应用修改")).clicked() {
if let Some(driver) = driver.clone() {
let owner = preview.owner_session_id;
self.spawn(async move {
driver.apply(text, owner).await?;
Ok("已应用修改".into())
});
}
}
if preview.can_revert && ui.button(theme::text("撤回")).clicked() {
if let Some(backend) = self.backend() {
let owner = preview.owner_session_id;
self.spawn(async move {
backend.services().selection_voice.revert_preview(owner)?;
Ok("已撤回到上一版预览".into())
});
}
}
}
cancel |= ui.button(theme::text("取消")).clicked();
if cancel {
if let (Some(driver), Some(id)) = (driver, snapshot.session_id) {
self.spawn(async move {
driver.cancel(id).await?;
Ok("已取消".into())
});
}
self.selection_voice_state = None;
}
});
},
);
}
if let Some(driver)=self.native.as_ref().map(|n|n.host().selection_voice()) {
if let Some(id)=driver.applied_target() {
egui::Window::new(theme::text("修改已应用")).id(egui::Id::new("voice-applied"))
.anchor(egui::Align2::RIGHT_BOTTOM,[-20.0,-20.0]).resizable(false).collapsible(false).show(ctx,|ui| {
ui.label(theme::text("撤回前将核验原应用和已写入的文本。"));
ui.horizontal(|ui| {
if ui.button(theme::text("撤回修改")).clicked() {
let driver=driver.clone();self.spawn(async move {driver.revert_applied(id).await?;Ok("修改已撤回".into())});
}
if ui.button(theme::text("完成")).clicked() {driver.dismiss_applied();}
});
});
}
}
if self.less_computer_visible {
let placement=egui::Id::new("less-computer-placement");
if !ctx.data(|d|d.get_temp::<bool>(placement).unwrap_or(false)) {
openless_linux_egui::desktop_bridge::place_popup("OpenLess Less Computer",520,600,false);
ctx.data_mut(|d|d.insert_temp(placement,true));
}
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("less-computer-panel"),
egui::ViewportBuilder::default()
.with_title("OpenLess Less Computer")
.with_inner_size([520.0, 600.0])
.with_always_on_top(),
|ctx, _class| {
theme::apply_visuals(
ctx,
self.preferences
.as_ref()
.map(|p| p.theme_mode)
.unwrap_or_default(),
);
if ctx.input(|i| i.viewport().close_requested()) {
self.less_computer_visible = false;
ctx.data_mut(|d|d.remove::<bool>(egui::Id::new("less-computer-placement")));
}
egui::CentralPanel::default().show(ctx, |ui| {
let t = ui.input(|i| i.time) as f32;
let glow = if self.pending_approval.is_some() {
egui::Color32::from_rgb(217, 119, 6)
} else {
theme::blue()
};
let rect = ui.max_rect().shrink(2.0);
ui.painter().rect_stroke(
rect,
14,
egui::Stroke::new(1.5 + 0.5 * (t * 2.0).sin(), glow),
egui::StrokeKind::Inside,
);
egui::ScrollArea::vertical().show(ui, |ui| {
self.less_computer_ui(ui);
});
});
},
);
}
}
}
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff. Show more