Add isolated native Rime service and MiSans candidate UI

This commit is contained in:
aria committed 2026-10-28 00:58:38 +09:00
1 parent f21167e1a2
commit 8e9a5838ab
28 files changed
+2381 -17

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/python3
"""Build-tree-only Rime proof of concept, isolated from the host desktop."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / ".artifacts/rime-poc-20261028"
RUNTIME = Path(f"/run/user/{os.getuid()}/ferese-rime-poc")
MAIN = "ferese-rime-poc-20261028.service"
IME = "ferese-rime-native-20261028.service"
APP = "ferese-rime-entry-20261028.service"
STRATA = "ferese-rime-strata-20261028.service"
UNITS = [APP, STRATA, IME, MAIN]
def run(args, *, env=None, check=True):
p = subprocess.run(args, env=env, text=True, capture_output=True)
if check and p.returncode:
raise RuntimeError(p.stderr.strip() or p.stdout.strip() or f"command failed: {args[0]}")
return p.stdout.strip()
def prop(unit, field):
return run(["systemctl", "--user", "show", unit, f"--property={field}", "--value"], check=False)
def start_unit(unit, command, env, limit):
args = ["systemd-run", "--user", "--collect", f"--unit={unit}", "--service-type=exec",
f"--working-directory={ROOT}", f"--property=MemoryMax={limit}", "--property=TasksMax=512"]
if unit != MAIN:
args += [f"--property=BindsTo={MAIN}", f"--property=After={MAIN}"]
run(args + [f"--setenv={key}={value}" for key, value in env.items()] + list(command))
def wait_for(fn, message, timeout=20):
until = time.monotonic() + timeout
while time.monotonic() < until:
value = fn()
if value:
return value
time.sleep(.1)
raise RuntimeError(message)
def context():
group = prop(MAIN, "ControlGroup")
if not group:
return None
try:
pids = (Path("/sys/fs/cgroup") / group.lstrip("/") / "cgroup.procs").read_text().split()
except OSError:
return None
for pid in pids:
try:
proc = Path("/proc") / pid
command = (proc / "cmdline").read_bytes().split(b"\0")[0].decode()
if Path(command).name != "ferese-shell":
continue
env = dict(s.split("=", 1) for s in (proc / "environ").read_text().split("\0") if "=" in s)
if env.get("FERESE_SOCKET") and env.get("FERESE_PUBLIC_WAYLAND_DISPLAY"):
return {"socket": env["FERESE_SOCKET"], "wayland": env["FERESE_PUBLIC_WAYLAND_DISPLAY"],
"bus": env["DBUS_SESSION_BUS_ADDRESS"], "shell_pid": int(pid)}
except (OSError, ValueError):
pass
return None
def environment():
values = {"XDG_RUNTIME_DIR": str(RUNTIME), "XDG_CURRENT_DESKTOP": "Ferese", "XDG_SESSION_DESKTOP": "ferese",
"XDG_SESSION_TYPE": "wayland", "GDK_BACKEND": "wayland", "GTK_IM_MODULE": "wayland",
"QT_IM_MODULE": "wayland", "QT_QPA_PLATFORM": "wayland", "NO_AT_BRIDGE": "1", "GTK_A11Y": "none",
"GSETTINGS_BACKEND": "keyfile",
"IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1",
"FERESE_ENABLE_SCREENCOPY": "1", "XDG_DATA_DIRS": f"{Path.home()}/.local/share:/usr/local/share:/usr/share"}
values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")})
return values
def child_environment():
c = context()
if not c:
raise RuntimeError("Rime development preview is not running")
return {**environment(), "WAYLAND_DISPLAY": c["wayland"], "DBUS_SESSION_BUS_ADDRESS": c["bus"], "FERESE_SOCKET": c["socket"]}
def windows():
c = context()
return json.loads(run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "--json", "windows"]))
def focus_entry():
wait_for(lambda: any(w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not map")
if not any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()):
c = context()
run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "focus-last-window"])
wait_for(lambda: any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not focus")
def start_ime():
if prop(IME, "ActiveState") == "active":
return
start_unit(IME, [str(ROOT / "target/debug/ferese-rime"), "--user-data", str(DATA / "rime-user")], child_environment(), "768M")
pid = prop(IME, "MainPID")
wait_for(lambda: '"event":"ready"' in run(
["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"], check=False), "Native Rime did not become ready", timeout=40)
def start():
if prop(MAIN, "ActiveState") == "active":
print("开发预览已在运行。")
return
if not (ROOT / "target/debug/ferese-rime").exists():
raise RuntimeError("Build ferese, feresectl and ferese-rime first")
for kind in ("config/ferese", "data", "cache", "state"):
(DATA / "profile" / kind).mkdir(parents=True, exist_ok=True)
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.stat().st_uid != os.getuid() or RUNTIME.stat().st_mode & 0o077:
raise RuntimeError("Unexpected private runtime owner or permissions")
(DATA / "profile/config/ferese/config.kdl").write_text('''
xwayland { enabled #false; }
status { keybinding-guide #false; }
window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560
window-rule app-id="io.github.lgse.Strata" floating=#true width=1050 height=720
theme { typography { font-family "MiSans"; }; }
''')
host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
host_display = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
if not host_display.is_absolute():
host_display = host_runtime / host_display
if not host_display.exists():
raise RuntimeError("Parent Wayland display is missing")
env = {**environment(), "WAYLAND_DISPLAY": str(host_display), "RUST_LOG": "info"}
start_unit(MAIN, ["/usr/bin/dbus-run-session", "--", str(ROOT / "target/debug/ferese"), "--backend", "nested",
"--supervise-client", "--grant-effects", "--grant-shell-control", "--", "/usr/bin/ferese-shell"], env, "3G")
c = wait_for(context, "Development compositor did not publish its private connection")
(DATA / "context.json").write_text(json.dumps(c, indent=2) + "\n")
start_ime()
fixture = DATA / "fixtures"
fixture.mkdir(exist_ok=True)
(fixture / "中文测试.txt").write_text("Ferese Rime 独立测试文件。\n")
strata = shutil.which("strata") or str(Path.home() / ".local/bin/strata")
if Path(strata).exists():
start_unit(STRATA, [strata, str(fixture)], child_environment(), "2G")
wait_for(lambda: any(w["app_id"] == "io.github.lgse.Strata" for w in windows()), "Strata did not map")
start_unit(APP, ["/usr/bin/gjs", str(ROOT / "scripts/tests/fixtures/rime-entry.js"), str(DATA / "entry-state.json")], child_environment(), "512M")
focus_entry()
print("Ferese 原生 Rime 开发预览已打开。")
def stop():
loaded = [u for u in UNITS if prop(u, "LoadState") not in ("", "not-found")]
if loaded:
run(["systemctl", "--user", "stop", *loaded])
run(["systemctl", "--user", "reset-failed", *loaded], check=False)
print("Rime 开发预览已停止。")
def action(name):
focus_entry()
env = child_environment()
return run(["gdbus", "call", "--address", env["DBUS_SESSION_BUS_ADDRESS"], "--dest", "dev.ferese.RimeValidation",
"--object-path", "/dev/ferese/RimeValidation", "--method", "org.gtk.Actions.Activate", name, "[]", "{}"])
def probe(arguments):
env = {**os.environ, **child_environment(), "FERESE_TEST_INPUT": "1"}
print(run([str(ROOT / "target/debug/ferese-rime-probe"), *arguments], env=env))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["start", "stop", "restart", "status", "ime", "probe", "entry"])
args, rest = parser.parse_known_args()
if args.action == "restart":
stop(); start()
elif args.action == "status":
print(json.dumps({u: prop(u, "ActiveState") for u in UNITS}, indent=2))
elif args.action == "entry":
print(action(rest[0]))
elif args.action == "probe":
probe(rest)
elif args.action == "ime":
start_ime()
else:
globals()[args.action]()
+37
View File
@@ -0,0 +1,37 @@
imports.gi.versions.Gtk = '4.0';
const {Gtk, Gio, GLib, Gdk} = imports.gi;
const output = ARGV[0];
const application = new Gtk.Application({application_id: 'dev.ferese.RimeValidation'});
application.connect('activate', () => {
const css = new Gtk.CssProvider(); css.load_from_string('window { font-family: "MiSans"; }');
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
const window = new Gtk.ApplicationWindow({application, title: 'Ferese 原生 Rime 验证', default_width: 880, default_height: 560});
const box = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 18,
margin_start: 28, margin_end: 28, margin_top: 28, margin_bottom: 28});
box.append(new Gtk.Label({label: '独立 Rime · 原生 Wayland 候选窗', xalign: 0}));
box.append(new Gtk.Label({label: '输入 nihao;空格或数字选字,− / + 翻页,Shift 切换中英文。', wrap: true, xalign: 0}));
const normal = new Gtk.Entry({placeholder_text: '普通输入框', hexpand: true});
const password = new Gtk.Entry({placeholder_text: '敏感输入测试框(只使用测试字符)', visibility: false,
input_purpose: Gtk.InputPurpose.PASSWORD, hexpand: true});
const status = new Gtk.Label({label: '等待输入', xalign: 0});
const save = () => {
const focused = window.get_focus();
const state = {normal: normal.get_text(), password_length: password.get_text().length,
focus: focused && (focused === password || focused.is_ancestor(password)) ? 'password' : 'normal'};
// This is a disposable validation fixture, containing only test input.
GLib.file_set_contents(output, JSON.stringify(state));
status.set_label('普通输入:' + normal.get_text().length + ' 字符;敏感输入:' + password.get_text().length + ' 字符');
};
for (const widget of [normal, password]) widget.connect('changed', save);
box.append(normal); box.append(password); box.append(status);
box.append(new Gtk.Box({vexpand: true}));
for (const [name, action] of [['normal', () => normal.grab_focus()], ['password', () => password.grab_focus()],
['clear', () => {normal.set_text(''); password.set_text(''); normal.grab_focus(); save();}],
['top', () => {box.remove(normal); box.insert_child_after(normal, box.get_first_child().get_next_sibling()); normal.grab_focus();}],
['bottom', () => {box.remove(normal); box.append(normal); normal.grab_focus();}]]) {
const entry = new Gio.SimpleAction({name}); entry.connect('activate', action); application.add_action(entry);
}
window.set_child(box); window.present();
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {normal.grab_focus(); save(); return GLib.SOURCE_REMOVE;});
});
application.run([]);
+185
View File
@@ -0,0 +1,185 @@
"""Opt-in real Wayland/GTK/Rime integration checks against the build-tree preview.
FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py
Only disposable fixture entries are modified. The probe refuses host displays.
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import time
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location("preview", ROOT / "scripts/rime-preview.py")
preview = importlib.util.module_from_spec(spec)
spec.loader.exec_module(preview)
@unittest.skipUnless(os.environ.get("FERESE_TEST_RIME") == "1", "opt-in native Rime validation")
class NativeRimeTest(unittest.TestCase):
def test_native_pipeline_and_process_boundary(self):
preview.start()
preview.start_ime()
results = []
def state():
return json.loads((preview.DATA / "entry-state.json").read_text())
def expect(text):
preview.wait_for(lambda: state()["normal"] == text,
f"Expected {text!r}; latest state {state()}", timeout=4)
def probe(*args):
preview.probe(list(args))
time.sleep(.12)
def clear():
preview.focus_entry()
probe("--key", "Escape")
preview.action("clear")
expect("")
def screenshot(name):
c = preview.context()
p = subprocess.run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "screenshot"], capture_output=True, check=True)
(preview.DATA / name).write_bytes(p.stdout)
clear()
probe("--keys", "nihao")
expect("") # Preedit is displayed, not prematurely committed.
screenshot("candidate-normal.png")
self.assertLessEqual({window["app_id"] for window in preview.windows()},
{"dev.ferese.RimeValidation", "io.github.lgse.Strata"})
probe("--keys", " ")
expect("你好")
results.append("native GTK preedit, candidate popup and space commit")
clear()
probe("--keys", "nihao1")
expect("你好")
results.append("numeric candidate selection")
clear()
probe("--keys", "ni")
pid = preview.prop(preview.IME, "MainPID")
def composition():
lines = preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).splitlines()
return [json.loads(line) for line in lines if line.startswith('{') and '"event":"composition"' in line][-1]
self.assertEqual(composition()["page"], 0)
probe("--keys", "=")
self.assertEqual(composition()["page"], 1)
screenshot("candidate-horizontal-page2.png")
probe("--keys", "-")
self.assertEqual(composition()["page"], 0)
probe("--chord", "Shift_L+equal")
self.assertEqual(composition()["page"], 1)
self.assertFalse(composition()["ascii"])
probe("--keys", "-1")
expect("你")
results.append("horizontal candidates page with number-row minus, equal and Shift+equal")
clear()
probe("--keys", "nihao", "--key", "BackSpace", "--keys", "o ")
expect("你好")
results.append("composition backspace and repair")
clear()
probe("--keys", "nihao", "--key", "Escape")
expect("")
probe("--key", "Shift_L", "--keys", "abc123")
expect("abc123")
time.sleep(.8)
expect("abc123") # Rejected keys must receive paired releases.
probe("--keys", "-=")
expect("abc123-=")
probe("--chord", "Control_L+a", "--key", "BackSpace")
expect("")
probe("--key", "Shift_L", "--keys", "nihao ")
expect("你好")
results.append("escape, Shift mode switching, ASCII and Ctrl+A release pairing")
clear()
preview.action("password")
time.sleep(.2)
pid = preview.prop(preview.IME, "MainPID")
def compositions():
return preview.run(["journalctl", "--user", f"_PID={pid}", "--no-pager", "-o", "cat"]).count('"event":"composition"')
before = compositions()
probe("--keys", "nihao")
preview.wait_for(lambda: state()["password_length"] == 5, "Sensitive field did not receive literal keys")
self.assertEqual(compositions(), before)
screenshot("sensitive-no-candidates.png")
results.append("sensitive field bypasses Rime and has no candidates")
clear()
c = preview.context()
control = [str(ROOT / "target/debug/feresectl"), "--socket", c["socket"]]
subprocess.run(control + ["toggle-maximized"], check=True)
time.sleep(.4)
preview.action("bottom")
time.sleep(.2)
probe("--keys", "nihao")
screenshot("candidate-bottom-flipped.png")
probe("--keys", " ")
expect("你好")
preview.action("top")
subprocess.run(control + ["toggle-maximized"], check=True)
time.sleep(.3)
results.append("bottom-edge native candidate composition and commit; screenshot for placement inspection")
clear()
probe("--keys", "nihao")
theme = json.loads(preview.run(control + ["--json", "theme", "get"]))
original_mode = theme["mode"]
subprocess.run(control + ["theme", "mode", "light"], check=True, capture_output=True)
preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == "light", "Light theme did not finish resolving")
time.sleep(.35)
screenshot("candidate-theme-light.png")
subprocess.run(control + ["theme", "mode", original_mode], check=True, capture_output=True)
preview.wait_for(lambda: json.loads(preview.run(control + ["--json", "theme", "get"]))["mode"] == original_mode, "Original theme did not finish resolving")
time.sleep(.35)
results.append("candidate UI follows Ferese light/dark theme through its existing IPC")
clear()
probe("--keys", "nihao")
compositor_pid = preview.prop(preview.MAIN, "MainPID")
subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", preview.IME], check=True)
preview.wait_for(lambda: preview.prop(preview.IME, "ActiveState") != "active", "IME did not terminate")
self.assertEqual(preview.prop(preview.MAIN, "ActiveState"), "active")
self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid)
probe("--keys", "abc")
expect("abc")
screenshot("ime-crash-desktop-survives.png")
preview.start_ime()
time.sleep(.2)
probe("--keys", "nihao ")
expect("abc你好")
results.append("IME SIGKILL leaves desktop alive; ASCII works; restarted IME commits Chinese")
# The validation driver must refuse the real host before creating a keyboard.
host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
if not host.is_absolute():
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
p = subprocess.run([str(ROOT / "target/debug/ferese-rime-probe"), "--keys", "x"],
env={**os.environ, "WAYLAND_DISPLAY": str(host), "FERESE_TEST_INPUT": "1"},
text=True, capture_output=True)
self.assertNotEqual(p.returncode, 0)
self.assertIn("not an isolated Ferese", p.stderr)
results.append("test driver refuses host desktop")
pid = preview.prop(preview.IME, "MainPID")
maps = (Path("/proc") / pid / "maps").read_text()
self.assertIn("librime.so", maps)
self.assertNotIn("libibus", maps)
self.assertNotIn("libfcitx", maps)
results.append("independent process links librime, with no IBus or Fcitx library")
clear()
probe("--keys", "nihao")
(preview.DATA / "validation.json").write_text(json.dumps({"status": "passed", "checks": results}, ensure_ascii=False, indent=2) + "\n")
if __name__ == "__main__":
unittest.main()