GNOME 默认的输入法框架是 IBus:加一个 IBus 前端(纯 Rust,与 Fcitx5 插件共用同一个 Server),安装脚本按机器上有哪个框架装对应的前端(--frontend auto/fcitx5/ibus/both),可选把 Server 装成随登录启动的 systemd 用户服务。 协议类型搬进 qingjian_platform::protocol::linux,两个前端共用。GNOME Wayland 上组句中按应用快捷键时先把拼音上屏到原来的框。 合并 main 时保留 #351 的 --fcitx4(单独一支,不与 --frontend 同用)。
15 KiB
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 判定「已经可见」就不再转发。端到端的断言跟着改了过来。
两条经验:
- 错在「我们这边一点错都看不到」:信号发出去了、zbus 没报错、引擎日志干干净净, 出错的是收信号那一头,而它的抱怨只写在 ibus-daemon 自己的 stderr 里。 验 IBus 这类「对面是个 C 程序」的协议,得去看对面的日志,这是当天漏掉的一步。
- 合成客户端能验的东西比当天以为的多。它验不了的是排版与观感,协议对不对它验得了。
验收
装机后(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 转换那一层(libssl3t64vslibssl3), 所以*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 钉住这两处。
- preedit 用
还没做的
:当天卡在「没有能验的环境」,2026-09-16 用 docker 解决了——构建与验证都在 fedora 容器里跑(.rpmbuild-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 可行但只覆盖一半平台,不做。