Skip to content

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

Open
aeoform wants to merge 143 commits into
Open-Less:betafrom
aeoform:feat/linux-egui-tauri-parity
Open

aeoform wants to merge 143 commits into
Open-Less:betafrom
aeoform:feat/linux-egui-tauri-parity

Conversation

@aeoform

@aeoform aeoform commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Introduces the native Linux EGUI host and parity-oriented desktop integration.
  • Restores Linux desktop services, settings, history, marketplace, Fcitx5 hotkeys, updater, localization, and native popup flows.
  • Adds Linux release packaging and contract coverage.

Follow-up before merge

  • Review the local-model / Qwen ASR surface separately; it should not be introduced as a Linux runtime dependency without an explicit decision.
  • Rebase onto the current beta baseline and run the Linux build/package validation.

合并 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 干净)。
- 阴影上移的真因: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 面积已不等于卡片)。
…f.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 平台覆盖配置。

调用点与编译期门禁在后续提交里一并清理(见 7b778d9、68fe1442)。
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 出包成功。
图标(用户要求:复用 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())。
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 测试全绿。
@aeoform
aeoform force-pushed the feat/linux-egui-tauri-parity branch from 4053528 to 1b676a1 Compare September 21, 2026 14:50
原先 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 的豁免)。
对齐 Tauri 市场页(src/pages/Marketplace.tsx):
- 搜索加 300ms 防抖(原先每敲一个字符、以及每次输入法预编辑变化都发请求)
- my_likes 从 load_marketplace 拆出,改为每次会话只取一次(原先每次搜索都带一个认证请求)
- 列表请求加单调序号,丢弃过期响应(对齐 Tauri 的 reqSeqRef,latest-wins)
- limit 100 -> 50

原先一次 4~6 字符的搜索会打出 8~12 个请求(半数为认证请求),输入法预编辑下更多。
SetAuxDown/ClearAuxDown 的实现、焦点切换补写、PostInputMethod 恢复、
lastAuxText_ 缓存全部删除:Tauri 的 Linux 调用点已随 linux_fcitx.rs 一起删除,
egui 从未调用过,全仓库已无调用者(纯死代码)。
- 删除独立窗口 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 的窗口清单同步
Tauri 的独立润色预览窗口(SelectionPolishPreview.tsx)已下线,注释不再指向已删文件。
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
- 删 [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 Open-Less#801 的 quick-xml 漏洞)
- arboard 去掉 wayland-data-control feature
- Cargo.lock 随 manifest 重解析(-379 行)
- 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 提供产品)
- 新增 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 标题栏断言
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 个单测覆盖决策树
现象:在新选区里选中了没有 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 注入探针结果,新增 ①②③ 用例。
任务书 ② 要求“新选区没有 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() 也为空,缓存里有值也不读。
「点阴影后阴影上移、设置没法用」的根因是遮罩与卡片处在不同容器: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」,还原后通过。
插件只在 fcitx5 的 InputContextKeyEvent 上匹配热键,而我们的窗口从不注册
text-input(winit 默认 ime_allowed=false),所以窗口有焦点时插件一个信号都
不发:主窗口里热键全死,选区助手弹出并开始录音后按语音热键也停不下来。
现在每个窗口进程(主窗口 + 各弹窗)自己做本地匹配,复用设置页录制器的
captured_binding 与注册给插件的 (keysym, states),并与插件信号共用一张去重表
(同一物理按键只处理一次)。

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

QA 热键以前走 Core 的 toggle_recording(切换录音),与 Tauri 的「切换面板显隐」
不一致;现在按 Tauri 语义切面板。
润色内容并进选区助手面板后,egui 侧仍自己一套排版:没有头尾分隔线、
没有选区助手的 14px 内容边距,看着就像已经下线的独立预览窗口。现在
CardHeader / CardContent / CardFooter 的留白、字号、分隔线全部与选区
助手面板一致(头部还可拖动窗口)。

新增回归测试 the_polish_result_reuses_the_selection_ask_chrome:断言
润色模式画出的头部分隔线与选区助手面板的位置、宽度一致。
把升级调研结论固化进仓库:当前图形栈与全部依赖的可升/已最新清单、
四批执行计划(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 升级必查的回归清单。

状态:暂缓执行,后续照单执行。
两个宿主侧状态/时序 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 存在才算面板在
   提问态。
- SelectionStateChanged 以前只看 phase:起手 Capturing 就 ensure_popup +
  送一帧空 PolishPreview,于是按 alt+X 会先冒出一个空的润色结果面板
  (用户报的「弹窗不对」),没有选区时更是空转一圈再消失。
- 现在要求 phase=Preview 且 preview_text 非空、并且 source_text(捕获到的
  选区)非空,才把润色结果帧送进选区助手面板并置 polish_result_visible。
- 没有选区时改为弹语音输入窗(胶囊)报错后退出:文案用 Tauri 的
  capsule.selectionPolish.noSelection('未选中内容'),终态 2 秒后自动收起,
  不再静默无反馈。
- 诊断日志:有快照但结果不可用时打 debug,无选区时打 warn,便于下次真机定位。
CI 的 linux job 卡在 Enforce Linux dependency boundaries:
crates/openless-core/src/dictation_engine.rs:1154 在生产代码里直接
tokio::spawn,绕过 TaskSpawner(check-core-runtime-seam.ps1 明令禁止)。
该行由上游 a8ebcf6Open-Less#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,行为不变。
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::*。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant