Files
qingjian/docs/notes/linux-bringup.md
T
jacklaoz 0f17aa15d5 feat(linux): IBus 前端,安装脚本按机器上的框架装对应前端 (#628)
GNOME 默认的输入法框架是 IBus:加一个 IBus 前端(纯 Rust,与 Fcitx5 插件共用同一个 Server),安装脚本按机器上有哪个框架装对应的前端(--frontend auto/fcitx5/ibus/both),可选把 Server 装成随登录启动的 systemd 用户服务。

协议类型搬进 qingjian_platform::protocol::linux,两个前端共用。GNOME Wayland 上组句中按应用快捷键时先把拼音上屏到原来的框。

合并 main 时保留 #351 的 --fcitx4(单独一支,不与 --frontend 同用)。
2026-10-23 13:43:26 +08:00

15 KiB
Raw Permalink Blame History

Linux 从零到能日用(2026-09-14)

这一页记的是走 IBus 那次,不是现行方案。 Linux 后来改成 Rust Server + Fcitx5 默认面板 (见 linux-fcitx5.md),IBus 壳与 deb / rpm / flatpak 打包都已从 main 移出, 完整代码在合并前的分支末端 26ece65(git show 26ece65:apps/linux/ime/src/ibus/service.rs 这样取); D-Bus 那层与按键翻译后来移植进了 apps/linux/ibus(改成连 Server 的前端,第二个框架)。留着这一页是因为坑与验收数字仍然有效: 下面五个坑(总线地址 / Shift+数字 / GVariant 嵌套 / 把 / 当仓库根 / 锁毒化)与 IBus 面板的限制, 换成瘦客户端接 Server 之后一样会碰到。当时的方案取舍文档 plan/linux_plan.md 已随路线一起删掉。

起因

Phase 5 只剩 Linux 没开工。这一天把它从「一行代码没有」推到「装在开发机上当会话输入法用」: apps/linux 建起来、走 IBus 接上 Core、打成三个 .deb 装机、切成系统输入法日常打字。 各模块的实现要点在 crate-notes.md,这一页只记过程里踩的坑和验出来的数字。

怎么分层

ibus/        D-Bus 那层:GVariant 序列化、工厂、引擎对象、总线地址
  ↑
dispatch/    按键 → 帧。不认 IBus 也不认 Wayland,所以能脱离输入法框架整段测
  ↑
assembly/    装 Engine
  ↑
Core

dispatch 这一刀切得值:80 个单元测试里绝大多数在这一层,不用起 daemon、不用 D-Bus。 按键规则是照 macOS 的 handle_text / handle_command 与 Windows 的 apply_key 手工对齐的—— 这是第三份孪生逻辑,assembly 也是第三份。(现行方案把这一份收进了 Linux Server,前端只转事件。)

比 Windows 少了会话分派:IBus 一个引擎实例服务当前焦点,没有「一个 Server 服务多个应用进程」那回事, 所以 Router 只有一份组句状态,形状更接近 macOS 的进程级单例。

五个只有真机才暴露的坑

1. ibus-daemon 不给引擎进程设 IBUS_ADDRESS

本来以为 daemon 拉起引擎时会把总线地址放进环境变量,写完发现引擎起来就退出:找不到地址。 要自己读 ~/.config/ibus/bus/ 下的地址文件。

而且不能扫目录随便挑:那个目录里常年躺着别的输入法或上次会话留下的陈旧文件—— 开发机上就有 fcitx 留的 <机器id>-unix-0,按字典序还排在当前会话的 <机器id>-unix-wayland-0 前面, 挑中它连上去一个信号都收不到。改成按 ibus 自己的规矩算文件名(<机器 id>-<主机>-<显示标识>), 算出来的不在才退回扫目录,且只认 IBUS_DAEMON_PID 还活着的。

2. Shift + 数字的 keysym 是 ! 不是 1

缺省的删候选快捷键正是 Shift + 数字。照 macOS 那样按字符认数字,这条快捷键在 Linux 上整个失效, 而且失效得很安静——键被当成普通标点吃掉了。得同时用硬件键码按键位认(evdev 的 KEY_1 + X11 的 8 偏移 = 10)。 Windows 用虚拟键码干的是同一件事,只是那边字符与键位天然分开,不容易想到 Linux 也要。

3. IBus 的嵌套 GVariant 要包变体

IBusText 的签名是 (sa{sv}sv),第四项那个 v 得把属性表包成变体再塞; 直接塞结构体会内联成 (sa{sv}av)。签名错了不报错,只是候选窗静默不出现。 av 的元素同理。现在三种对象的签名由单元测试钉死,改错了先红的是测试。

4. bundled_root 把 / 当成了仓库根

装完第一次跑 --check 就炸出来:随包数据根解析成了 /,回落到 148 条的样例词库。 原因是开发布局的回退写成「exe 往上数三层」(exe → {debug,release} → target → 仓库根), 装到 /usr/bin 之后往上三层正好是 /,而这台机器根目录下有个 /data,has_resources 就认了。

症状是装好了、能启动、就是一个词都打不出——最难查的那种。改成先确认 exe 真在 target/{debug,release}/ 里才认开发布局。这个函数 Windows 也在用,所以算顺手修了 Windows。

5. Mutex 毒化 = 崩一次就再也打不了字

D-Bus 那层要求接口对象 Send + Sync,而 Engine 因为几个 RefCell 缓存不是 Sync。 一开始按「另开一条工人线程持有它」设计,写之前测了一下发现 Router 是 Send 的, 套个 Mutex 就够,工人线程省了(dispatch::tests 里有一条编译期断言钉着)。

但满地 lock().expect(...) 埋了个雷:Mutex 一旦被 panic 毒化,之后每次 lock() 都返回错误。 按「崩溃不丢」那条,崩一次之后输入法该照常服务,而不是永久瘫掉。现在统一走 Shared::lock, 毒化了就把状态取回来接着用;按键外面再套一层 catch_unwind,拦下后清组句、这个键让给应用。

「带内容的 preedit 收不到」——当天归因错了

写了个 D-Bus 客户端当「应用」跑端到端,候选表和上屏都收得到,唯独带内容的 UpdatePreeditText 收不到。 一路怀疑到序列化、面板、能力位,试了半天:起面板一样收不到,同一个 IBusText 在另外两条路上完全正常。 当天的结论是「真实应用的 preedit 由 GTK / Qt 的输入法模块自己渲染,合成客户端本来就验不到」, 测试于是退而断言 ShowPreeditText。

这个归因是错的。 接本地整句模型时顺手看了一眼 ibus-daemon 自己的日志,里面一直在刷:

GLib-CRITICAL: the GVariant format string '(vubu)' has a type of '(vubu)' but the given value has a type of '(vub)'
IBUS-CRITICAL: bus_engine_proxy_g_signal: assertion 'arg0 != NULL' failed

UpdatePreeditText 的参数是 (vubu)——最后那个 mode(IBUS_ENGINE_PREEDIT_CLEAR / COMMIT)不能省。 我们只发了 (vub),daemon 解不出来,g_return_if_fail 打一条 CRITICAL 就把整条信号丢掉了。 补上那个参数之后,合成客户端立刻就收到了带内容的 preedit,反倒是 ShowPreeditText 不再单独来—— visible=true 已经把它显示出来,daemon 判定「已经可见」就不再转发。端到端的断言跟着改了过来。

两条经验:

  1. 错在「我们这边一点错都看不到」:信号发出去了、zbus 没报错、引擎日志干干净净, 出错的是收信号那一头,而它的抱怨只写在 ibus-daemon 自己的 stderr 里。 验 IBus 这类「对面是个 C 程序」的协议,得去看对面的日志,这是当天漏掉的一步。
  2. 合成客户端能验的东西比当天以为的多。它验不了的是排版与观感,协议对不对它验得了。

验收

装机后(GNOME / Wayland,真产品数据:22 万词库 + 42 MB 语言模型 + 24 万词释义表):

项 数字
每键耗时 最慢 3.34 ms、平均 857 µs(标准 10 ms 以内)
整句转换 zhonghuarenmingongheguowansui → 中华人民共和国万岁
引擎常驻 约 104 MB
测试 84 个单元测试 + 1 个对着真 ibus-daemon 的端到端(没有 ibus 就跳过,CI 不受影响)

日常打字验过的:词级、整句、简拼、拼写纠错(zhonoghua / zonghua 都救回了 中华)、 中英混输(clone 原样直通)、学习六张表落盘。

X11 也能用:方案 C 压根不碰显示服务器,候选窗的绘制与定位全是 IBus 面板的活。 GDK_BACKEND=x11 起的 XWayland 客户端里验过。计划里「只做 Wayland」说的是将来那一步自绘,不是整个 Linux 支持。

打包与安装

四个 .deb(当时的 apps/linux/packaging/build-deb.sh,已随路线删除):程序 3.5 MB、设置界面 1.4 MB、数据 27 MB、模型 49 MB。 数据与模型分开是因为发行版不收一百多兆的单包,而且它们的更新节奏跟代码不一样; 程序包不依赖这两个:缺语言模型退化成一元词频、缺模型不重排,Core 本来就支持。 设置界面单独一个包是后来拆的:输入法本体只链 libc / libm / libgcc(IBus 走纯 Rust 的 zbus), 设置界面是这一套里唯一要 GTK4 的东西,合在一起等于让 KDE / Qt 环境装个输入法就拖进整棵 GTK 树。

装到 /usr/bin/qingjian-linux + /usr/share/qingjian/{data,assets},组件 XML 落 /usr/share/ibus/component/, postinst / postrm 调 ibus write-cache --system 让 ibus 重扫。

依赖没走 dh_shlibdeps,所以两头都自己算。 漏了它们的后果不是装不上而是 装得上、跑不起来:dpkg 放行,启动才报错。

  • libc6:取二进制里最高的强符号版本(pidfd_spawnp 这类 Rust 标准库的弱引用不算),当前是 2.34, 也就是 Ubuntu 22.04 / Debian 12 / RHEL 9 起。
  • 动态库:readelf 取 NEEDED、ldd 找路径、dpkg -S 问包名。 一定要现查而不能手写——接上云联想之后 libssl / libcrypto 就进了 NEEDED(reqwest 带的 OpenSSL), 手写的那份不会自己跟上。包名还有 Debian time_t 转换那一层(libssl3t64 vs libssl3), 所以 *t64 的自动写成「或」依赖,两边发行版都装得上。

qingjian-data 进 Recommends 同理:缺了它只有几十条样例词库,症状又是「装好了打不出字」。

后来补上的

  • 本地整句模型(dispatch/rescore/):当天打了 qingjian-model 这个包,但壳根本没接 qingjian-neural,装了也不起作用——mac 的 host/model.rs 与 Windows 的 dispatch/rescore/ 都有, Linux 是第三份。接的时候顺带改了主循环:原先是固定一秒的 tokio::time::interval, 而重排要停键 80 ms 请求、20 ms 轮询,所以节拍改成由 Router::next_tick() 说了算,按键之后用 Notify 叫醒主循环重算。

  • 按键之外怎么重画:这是接模型时才发现的一个洞。zbus 把信号发出者(SignalEmitter)交在方法参数里, 主循环手上没有,所以原先 tick() 返回的那一帧被丢掉了——云联想的结果其实也从来没画出去过。 现在引擎对象拿到焦点时把自己的对象路径记进 ibus/active.rs,主循环照着它 SignalEmitter::from_parts 造一个再发。tick 也改成只有帧真变了才回,空转的一秒不往 D-Bus 上发信号。

  • 云联想:与本地模型同一个毛病——assembly 从没调过 with_predictor,[predict] 读进来只在 --check 里打印一行。 接法照 Windows 的 attach_cloud(第三份)。CloudPredictor 自己起工人线程、线程里自建 current-thread 运行时, 所以不占主循环那个多线程运行时,也不需要壳这边配合。 Linux 特有的一处:密钥不能指望环境变量——引擎进程是 ibus-daemon 拉起来的,用户在终端 export 的东西它看不到, 所以启动时用 dotenvy 读一遍 ~/.config/qingjian/.env(设置界面写密钥也写在那里)。

  • Flatpak(2026-09-16):给非 Debian 系发行版的一条路,但它不能像别的应用那样一装就好。 动手前先做了一个实验:把组件 XML 分别放进 $XDG_DATA_HOME/ibus/component 与 XDG_DATA_DIRS 里的 ibus/component,起一条私有 daemon,两份都没被发现,daemon 只认得 /usr/share/ibus/component/ 里 系统装的那份。也就是说 ibus 不按 XDG 数据目录找引擎,而 Flatpak 暴露文件恰恰只靠 exports 进 XDG_DATA_DIRS——这条把「Flatpak 装完就能用」堵死了,只能由用户在宿主机上放一个 <exec>flatpak run …</exec> 的组件 XML 把两边接起来。 另外三处是沙箱的常规代价,都能用 finish-args 解决:总线 socket 与地址文件在宿主机上(给只读权限, 而且 bus_dir 要往 $HOME/.config 回退一次,因为沙箱改掉了 XDG_CONFIG_HOME)、 随包数据落在 /app/share/qingjian(resources 的系统布局里加了这条)、 用户数据进 ~/.var/app/<id>/,与 .deb 装的那份不互通。

  • 组句打到一半换输入框(2026-10-15,评审时报的):原先在 FocusOut 里请 Server 把缓冲原样上屏、 自己发 CommitText。IBus 的引擎对象在各输入框之间共用,daemon 调完 FocusOut 不等回就把引擎挂到下一个框, 这条信号到的时候已经晚了:X11 上拼音打进了新框,GNOME Wayland 上两边都没有。改了两处:

    • preedit 用 IBUS_ENGINE_PREEDIT_COMMIT 发、FocusOut 报 client_preedit = true(Fcitx5 插件那套), 失焦时由框架当场交给原来的框。这只管用在 X11(GTK 输入模块一个输入框一个上下文)。
    • GNOME Wayland 上失焦时交什么都晚了:应用自己先挪焦点(Ctrl+F 打开搜索条),gnome-shell 之后才对上下文 Reset → FocusOut → FocusIn,它按 COMMIT 模式交出的 preedit 落进了新框(总线上一条 CommitText 都没有, 是 gnome-shell / mutter 自己交的;gresource extract 出 inputMethod.js 的 vfunc_focus_out 看得到)。 唯一来得及的时机是快捷键交给应用之前:组句中带 Ctrl / Alt / Super、Server 放行的键,前端先 Deactivate 原样上屏再放行,CommitText 在 ProcessKeyEvent 返回之前发出,比按键先到应用——与 macOS 在应用处理 快捷键前 commitComposition 的结果一样。鼠标点到别的框没有这样的时机,Wayland 上拼音会丢,写进了 keys.md。
    • 排查办法:dbus-monitor --address "$(ibus address)" 能旁听 IBus 总线(不支持新式 monitor,会退回 eavesdrop), 事件先后一眼就看见。端到端测试 pinyin_stays_in_the_old_box_when_focus_moves 与 shortcut_commits_pinyin_before_reaching_the_app 对着私有 daemon 钉住这两处。

还没做的

  • .rpm:当天卡在「没有能验的环境」,2026-09-16 用 docker 解决了——构建与验证都在 fedora 容器里跑(build-rpm.sh --verify 会 dnf install 装上再 --check)。 rpm 的自动依赖比 deb 省事得多(自己扫 ELF),但它不分强弱符号,glibc 门槛因此比 deb 严一档。
  • L3 / L4:自绘要先把 renderer-spike 分支合进 main(那份代码不在这台机器上), 而 L4 的 input-method-v2 只有 wlroots 系实现,GNOME / KDE 验不了。
  • 设置界面:当天之后补上了,GTK4 七页(当时的 apps/linux/settings,已随路线删除);也仍可直接编辑 ~/.config/qingjian/config.toml,热加载即时生效。
  • [apps] 按应用配置:Wayland 拿不到前台应用标识,X11 可行但只覆盖一半平台,不做。