Recover Rime faults, persist window modes and fix GPU allocation
This commit is contained in:
1 parent
8e9a5838ab
commit
6f34fd080f
27 files changed
+1260
-34
No files matched your search
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/python3
|
||||
"""App-scoped native Wayland QQ/Teams checks with separate application profiles.
|
||||
Never imports the host IBus/Fcitx launchers or modifies their desktop entries.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
spec=importlib.util.spec_from_file_location("preview",ROOT/"scripts/rime-preview.py")
|
||||
p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
|
||||
APPS={"qq":"/usr/bin/linuxqq","teams":"/usr/bin/teams-for-linux-electron"}
|
||||
|
||||
def unit(name):return f"ferese-rime-app-{name}-20261028.service"
|
||||
|
||||
if __name__=="__main__":
|
||||
parser=argparse.ArgumentParser();parser.add_argument("action",choices=[*APPS,"stop","status"]);args=parser.parse_args()
|
||||
if args.action=="stop":
|
||||
for name in APPS:subprocess.run(["systemctl","--user","stop",unit(name)],capture_output=True)
|
||||
elif args.action=="status":
|
||||
for name in APPS:print(name,p.prop(unit(name),"ActiveState"))
|
||||
else:
|
||||
name=args.action;p.start()
|
||||
if p.prop(unit(name),"ActiveState")=="active":print(f"{name} test instance is already running")
|
||||
else:
|
||||
data=p.DATA/f"app-{name}";data.mkdir(mode=0o700,exist_ok=True);data.chmod(0o700)
|
||||
command=[APPS[name],"--ozone-platform=wayland","--enable-wayland-ime","--wayland-text-input-version=3",f"--user-data-dir={data}"]
|
||||
if name=="qq":command += ["--no-sandbox"]
|
||||
p.start_unit(unit(name),command,p.child_environment(),"2G")
|
||||
print(f"{name} native Wayland validation instance opened")
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/bash
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
/usr/bin/python3 "$root/scripts/rime-drm-validation.py" clients &
|
||||
exec /usr/bin/ferese-shell
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/python3
|
||||
"""Disposable, separate logind/DRM session on an unused VT. Never logs GNOME out.
|
||||
All system services are transient; an independent timer returns to the original
|
||||
session even if this validation compositor fails. Requires existing sudo access.
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA = ROOT / ".artifacts/rime-drm-validation-20261028"
|
||||
UID = ROOT.stat().st_uid
|
||||
USER = pwd.getpwuid(UID).pw_name
|
||||
RUNTIME = Path(f"/run/user/{UID}/ferese-rime-drm-validation")
|
||||
MAIN = "ferese-drm-validation-20261028.service"
|
||||
RETURN = "ferese-drm-return-20261028"
|
||||
IME = "ferese-rime-drm-20261028.service"
|
||||
APP = "ferese-rime-drm-entry-20261028.service"
|
||||
LOCK = "ferese-drm-real-lock-20261028.service"
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return subprocess.check_output(args, text=True, **kwargs).strip()
|
||||
|
||||
|
||||
def manager_env():
|
||||
return {**os.environ, "XDG_RUNTIME_DIR":f"/run/user/{UID}",
|
||||
"DBUS_SESSION_BUS_ADDRESS":f"unix:path=/run/user/{UID}/bus"}
|
||||
|
||||
|
||||
def start():
|
||||
return_seconds=int(os.environ.get("FERESE_DRM_RETURN_SECONDS","60"))
|
||||
if not 30 <= return_seconds <= 180:raise ValueError("Return interval must be 30-180 seconds")
|
||||
DATA.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
DATA.chmod(0o700)
|
||||
# Build the disposable protocol fixture from source, never a PAM mock.
|
||||
xml=Path(run(["pkg-config","--variable=pkgdatadir","wayland-protocols"])) / "staging/ext-session-lock/ext-session-lock-v1.xml"
|
||||
for mode,name in (("client-header","ext-session-lock-client-protocol.h"),("private-code","ext-session-lock-protocol.c")):
|
||||
subprocess.run(["wayland-scanner",mode,str(xml),str(DATA/name)],check=True)
|
||||
flags=run(["pkg-config","--cflags","--libs","wayland-client"]).split()
|
||||
subprocess.run(["cc","-Wall","-Wextra","-Werror",f"-I{DATA}",str(ROOT/"scripts/tests/fixtures/session-lock-cycle.c"),
|
||||
str(DATA/"ext-session-lock-protocol.c"),"-o",str(DATA/"lock-cycle"),*flags],check=True)
|
||||
RUNTIME.mkdir(mode=0o700, exist_ok=True)
|
||||
if RUNTIME.stat().st_uid != UID or RUNTIME.stat().st_mode & 0o077:
|
||||
raise RuntimeError("Unexpected private DRM runtime owner/permissions")
|
||||
active = run(["loginctl", "show-seat", "seat0", "-p", "ActiveSession", "--value"])
|
||||
if not active or run(["loginctl", "show-session", active, "-p", "Name", "--value"]) != USER:
|
||||
raise RuntimeError("The original seat must belong to the repository owner")
|
||||
vt = "3"
|
||||
if any(run(["loginctl", "show-session", sid, "-p", "VTNr", "--value"]) == vt
|
||||
for sid in run(["loginctl", "list-sessions", "--no-legend"]).splitlines()
|
||||
for sid in [sid.split()[0]]):
|
||||
raise RuntimeError("Validation VT is already in use")
|
||||
(DATA / "original-session.json").write_text(json.dumps({"session":active,"vt":vt})+"\n")
|
||||
(DATA / "context.json").unlink(missing_ok=True)
|
||||
config=DATA/"profile/config/ferese";config.mkdir(parents=True,exist_ok=True)
|
||||
(config/"config.kdl").write_text('''
|
||||
xwayland { enabled #false; }
|
||||
status { keybinding-guide #false; }
|
||||
window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560
|
||||
theme { typography { font-family "MiSans"; }; }
|
||||
output-profile "validation" layout="extend" {
|
||||
output "DP-4" mode="3840x2160@60" scale=1.5 { position 0 0; }
|
||||
output "DP-5" mode="3840x2160@160" scale=1.5 { position 2560 0; }
|
||||
output "DP-6" mode="3840x2160@144" scale=1.5 { position 5120 0; }
|
||||
output "HDMI-A-2" required=#false enabled=#false
|
||||
}
|
||||
''')
|
||||
# Install the return path before anything may acquire the physical seat.
|
||||
subprocess.run(["sudo","-n","systemd-run",f"--unit={RETURN}",f"--on-active={return_seconds}s",
|
||||
"--timer-property=RemainAfterElapse=no","--collect",
|
||||
"/usr/bin/loginctl","activate",active],check=True)
|
||||
env={"XDG_SEAT":"seat0","XDG_VTNR":vt,"XDG_SESSION_TYPE":"wayland","XDG_SESSION_CLASS":"user",
|
||||
"XDG_RUNTIME_DIR":str(RUNTIME),"XDG_CURRENT_DESKTOP":"Ferese","XDG_SESSION_DESKTOP":"ferese",
|
||||
"FERESE_SESSION_IMPORT_ENV":"0","FERESE_ENABLE_INPUT_METHOD":"1","FERESE_ENABLE_SCREENCOPY":"1",
|
||||
"FERESE_RIME_CONTROL":str(RUNTIME/"rime-control.sock"),"LIBSEAT_BACKEND":"logind",
|
||||
"FERESE_ENABLE_TEST_POINTER":"1",
|
||||
"GTK_IM_MODULE":"wayland","QT_IM_MODULE":"wayland","GDK_BACKEND":"wayland",
|
||||
"GSETTINGS_BACKEND":"keyfile","GTK_A11Y":"none","NO_AT_BRIDGE":"1","IBUS_ADDRESS":""}
|
||||
env.update({f"XDG_{kind.upper()}_HOME":str(DATA/"profile"/kind) for kind in ("config","cache","data","state")})
|
||||
args=["sudo","-n","systemd-run","--collect",f"--unit={MAIN}",f"--uid={USER}","--service-type=exec",
|
||||
"--property=PAMName=login",f"--property=TTYPath=/dev/tty{vt}","--property=StandardInput=tty",
|
||||
"--property=StandardOutput=journal","--property=StandardError=journal","--property=TTYReset=yes",
|
||||
"--property=RuntimeMaxSec=15min","--property=TimeoutStopSec=8",
|
||||
f"--property=ExecStopPost=+/usr/bin/python3 {Path(__file__)} cleanup",f"--working-directory={ROOT}"]
|
||||
args += [f"--setenv={k}={v}" for k,v in env.items()]
|
||||
# pam_systemd sets its standard runtime directory. Restore the private one
|
||||
# before starting the private bus, including its activation environment.
|
||||
subprocess.run(args+["/usr/bin/env",f"XDG_RUNTIME_DIR={RUNTIME}","/usr/bin/dbus-run-session","--","/usr/bin/python3",str(Path(__file__)),"compositor"],check=True)
|
||||
# PAM/logind supplies a distinct session; activate it without terminating GNOME.
|
||||
for _ in range(100):
|
||||
candidates=[line.split()[0] for line in run(["loginctl","list-sessions","--no-legend"]).splitlines()]
|
||||
found=[sid for sid in candidates if run(["loginctl","show-session",sid,"-p","VTNr","--value"])==vt]
|
||||
if found:
|
||||
(DATA/"original-session.json").write_text(json.dumps({"session":active,"vt":vt,"validation_session":found[0]})+"\n")
|
||||
subprocess.run(["sudo","-n","loginctl","activate",found[0]],check=True)
|
||||
print(json.dumps({"validation_session":found[0],"original_session":active,"return_timer_seconds":return_seconds}));return
|
||||
time.sleep(.1)
|
||||
raise RuntimeError("The separate logind session did not appear; return timer remains armed")
|
||||
|
||||
|
||||
def clients():
|
||||
# These clients use the public Wayland socket; shell capabilities never leak.
|
||||
env={k:v for k,v in os.environ.items() if k not in ("WAYLAND_SOCKET","FERESE_SHELL_CONTROL_SOCKET","NOTIFY_SOCKET","WATCHDOG_USEC")}
|
||||
env["WAYLAND_DISPLAY"]=env["FERESE_PUBLIC_WAYLAND_DISPLAY"]
|
||||
sock=socket.socket(socket.AF_UNIX);sock.connect(str(RUNTIME/env["WAYLAND_DISPLAY"]))
|
||||
pid,_,_=struct.unpack("3i",sock.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12));sock.close()
|
||||
env["FERESE_TEST_DRM_PID"]=str(pid)
|
||||
(DATA/"context.json").write_text(json.dumps({"socket":env["FERESE_SOCKET"],"wayland":env["WAYLAND_DISPLAY"],
|
||||
"bus":env["DBUS_SESSION_BUS_ADDRESS"],"compositor_pid":pid},indent=2)+"\n")
|
||||
def unit(name,command,ime=False):
|
||||
args=["systemd-run","--user","--collect",f"--unit={name}",f"--service-type={'notify' if ime else 'exec'}",
|
||||
f"--working-directory={ROOT}","--property=MemoryMax=1G"]
|
||||
if ime:
|
||||
args += ["--property=Restart=on-failure","--property=RestartSec=1","--property=StartLimitIntervalSec=60",
|
||||
"--property=StartLimitBurst=5","--property=WatchdogSec=10","--property=WatchdogSignal=SIGKILL",
|
||||
"--property=TimeoutStartSec=45","--property=TimeoutStopSec=3"]
|
||||
# Only the explicitly constructed session environment enters the user unit.
|
||||
allowed={k:v for k,v in env.items() if k.startswith(("XDG_","FERESE_","GTK_","QT_","GDK_")) or
|
||||
k in ("WAYLAND_DISPLAY","DBUS_SESSION_BUS_ADDRESS","GSETTINGS_BACKEND","NO_AT_BRIDGE","IBUS_ADDRESS","PATH","HOME","LANG")}
|
||||
subprocess.run(args+[f"--setenv={k}={v}" for k,v in allowed.items()]+command,env=manager_env(),check=True)
|
||||
unit(IME,[str(ROOT/"target/debug/ferese-rime"),"--user-data",str(DATA/"rime-user")],True)
|
||||
unit(APP,["/usr/bin/gjs",str(ROOT/"scripts/tests/fixtures/rime-entry.js"),str(DATA/"entry-state.json")])
|
||||
|
||||
|
||||
def cleanup():
|
||||
args=["systemctl","--user","stop",APP,IME]
|
||||
if subprocess.run(["systemctl","--user","show",LOCK,"-p","LoadState","--value"],
|
||||
env=manager_env(),text=True,capture_output=True).stdout.strip()=="loaded":args.append(LOCK)
|
||||
if os.getuid()==0:
|
||||
args=["runuser","-u",USER,"--","env",f"XDG_RUNTIME_DIR=/run/user/{UID}",
|
||||
f"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{UID}/bus",*args]
|
||||
subprocess.run(args,env=manager_env(),capture_output=True)
|
||||
|
||||
|
||||
def original():
|
||||
return json.loads((DATA/"original-session.json").read_text())["session"]
|
||||
|
||||
|
||||
def real_lock():
|
||||
c=json.loads((DATA/"context.json").read_text())
|
||||
env={"XDG_RUNTIME_DIR":str(RUNTIME),"WAYLAND_DISPLAY":c["wayland"],"DBUS_SESSION_BUS_ADDRESS":c["bus"],
|
||||
"FERESE_SOCKET":c["socket"],"XDG_CURRENT_DESKTOP":"Ferese","XDG_SESSION_TYPE":"wayland"}
|
||||
env.update({f"XDG_{kind.upper()}_HOME":str(DATA/"profile"/kind) for kind in ("config","cache","data","state")})
|
||||
subprocess.run(["systemd-run","--user","--collect",f"--unit={LOCK}","--service-type=exec",
|
||||
"--property=MemoryMax=1G",*[f"--setenv={k}={v}" for k,v in env.items()],
|
||||
"/usr/bin/ferese-lock","--foreground"],env=manager_env(),check=True)
|
||||
|
||||
|
||||
def stop():
|
||||
subprocess.run(["sudo","-n","loginctl","activate",original()],check=True)
|
||||
subprocess.run(["sudo","-n","systemctl","stop",MAIN],check=True)
|
||||
subprocess.run(["sudo","-n","systemctl","stop",f"{RETURN}.timer"],capture_output=True)
|
||||
record=json.loads((DATA/"original-session.json").read_text());sid=record.get("validation_session")
|
||||
if sid and sid!=record["session"]:
|
||||
# PAM may leave its private bus in the abandoned session scope.
|
||||
info=subprocess.run(["loginctl","show-session",sid,"-p","VTNr","--value"],text=True,capture_output=True)
|
||||
if info.returncode==0 and info.stdout.strip()==record["vt"]:
|
||||
subprocess.run(["sudo","-n","loginctl","terminate-session",sid],check=True)
|
||||
subprocess.run(["sudo","-n","loginctl","kill-session","--signal=KILL",sid],capture_output=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser=argparse.ArgumentParser();parser.add_argument("action",choices=["start","stop","cleanup","clients","compositor","return","status","real-lock"])
|
||||
action=parser.parse_args().action
|
||||
if action=="compositor":
|
||||
os.environ["XDG_RUNTIME_DIR"]=str(RUNTIME)
|
||||
os.environ.pop("DISPLAY",None);os.environ.pop("WAYLAND_DISPLAY",None)
|
||||
os.execv(str(ROOT/"target/debug/ferese"),["ferese","--backend","drm","--supervise-client","--grant-effects","--grant-shell-control","--","/usr/bin/bash",str(ROOT/"scripts/rime-drm-shell")])
|
||||
elif action=="return":subprocess.run(["sudo","-n","loginctl","activate",original()],check=True)
|
||||
elif action=="status":print(run(["systemctl","show",MAIN,"-p","ActiveState","-p","MainPID"]))
|
||||
elif action=="real-lock":real_lock()
|
||||
else:globals()[action]()
|
||||
+14
-2
@@ -30,10 +30,15 @@ def prop(unit, field):
|
||||
|
||||
|
||||
def start_unit(unit, command, env, limit):
|
||||
args = ["systemd-run", "--user", "--collect", f"--unit={unit}", "--service-type=exec",
|
||||
args = ["systemd-run", "--user", "--collect", f"--unit={unit}", f"--service-type={'notify' if unit == IME else 'exec'}",
|
||||
f"--working-directory={ROOT}", f"--property=MemoryMax={limit}", "--property=TasksMax=512"]
|
||||
if unit != MAIN:
|
||||
args += [f"--property=BindsTo={MAIN}", f"--property=After={MAIN}"]
|
||||
if unit == IME:
|
||||
args += ["--property=Restart=on-failure", "--property=RestartSec=1",
|
||||
"--property=StartLimitIntervalSec=60", "--property=StartLimitBurst=5",
|
||||
"--property=WatchdogSec=10", "--property=WatchdogSignal=SIGKILL",
|
||||
"--property=TimeoutStopSec=3", "--property=TimeoutStartSec=45"]
|
||||
run(args + [f"--setenv={key}={value}" for key, value in env.items()] + list(command))
|
||||
|
||||
|
||||
@@ -76,6 +81,8 @@ def environment():
|
||||
"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_RIME_CONTROL": str(RUNTIME / "rime-control.sock"),
|
||||
"FERESE_ENABLE_TEST_POINTER": "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
|
||||
@@ -97,7 +104,8 @@ 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"])
|
||||
entry=next(w for w in windows() if w["app_id"]=="dev.ferese.RimeValidation")
|
||||
run([str(ROOT / "target/debug/feresectl"), "--socket", c["socket"], "focus-window", str(entry["id"])])
|
||||
wait_for(lambda: any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()), "Validation window did not focus")
|
||||
|
||||
|
||||
@@ -114,6 +122,8 @@ def start():
|
||||
if prop(MAIN, "ActiveState") == "active":
|
||||
print("开发预览已在运行。")
|
||||
return
|
||||
DATA.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
DATA.chmod(0o700)
|
||||
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"):
|
||||
@@ -126,6 +136,8 @@ 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
|
||||
window-rule app-id="QQ" floating=#true width=1000 height=720
|
||||
window-rule app-id="teams-for-linux" floating=#true width=1000 height=720
|
||||
theme { typography { font-family "MiSans"; }; }
|
||||
''')
|
||||
host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
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'});
|
||||
const application = new Gtk.Application({application_id: ARGV[1] || '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);
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/* Disposable protocol locker: no credentials, no PAM bypass in real locker.
|
||||
* Blank lock frames are presented by the compositor on every physical output.
|
||||
* The release file instructs this actual protocol owner to unlock normally. */
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
#include <wayland-client.h>
|
||||
#include "ext-session-lock-client-protocol.h"
|
||||
static struct ext_session_lock_manager_v1 *manager;
|
||||
static int confirmed, outputs;
|
||||
static void global(void *d, struct wl_registry *r, uint32_t n, const char *i, uint32_t v) {
|
||||
(void)d; (void)v;
|
||||
if (!strcmp(i,"ext_session_lock_manager_v1")) manager=wl_registry_bind(r,n,&ext_session_lock_manager_v1_interface,1);
|
||||
if (!strcmp(i,"wl_output")) outputs++;
|
||||
}
|
||||
static void removed(void *d, struct wl_registry *r, uint32_t n) {(void)d;(void)r;(void)n;}
|
||||
static void locked(void *d, struct ext_session_lock_v1 *l) {
|
||||
(void)d;(void)l;confirmed=1;printf("{\"event\":\"locked\",\"outputs\":%d}\n",outputs);fflush(stdout);
|
||||
}
|
||||
static void finished(void *d, struct ext_session_lock_v1 *l) {(void)d;(void)l;exit(3);}
|
||||
int main(int argc,char **argv) {
|
||||
if (argc!=2 || !getenv("FERESE_TEST_LOCK") || strcmp(getenv("FERESE_TEST_LOCK"),"1")) return 2;
|
||||
struct wl_display *display=wl_display_connect(NULL);if(!display)return 1;
|
||||
struct wl_registry *registry=wl_display_get_registry(display);
|
||||
const struct wl_registry_listener r={global,removed};wl_registry_add_listener(registry,&r,NULL);
|
||||
if(wl_display_roundtrip(display)<0 || !manager)return 1;
|
||||
struct ext_session_lock_v1 *lock=ext_session_lock_manager_v1_lock(manager);
|
||||
const struct ext_session_lock_v1_listener listener={locked,finished};ext_session_lock_v1_add_listener(lock,&listener,NULL);
|
||||
for (;;) {
|
||||
if(wl_display_dispatch_pending(display)<0 || wl_display_flush(display)<0)return 1;
|
||||
if(confirmed && access(argv[1],F_OK)==0) {
|
||||
ext_session_lock_v1_unlock_and_destroy(lock);wl_display_roundtrip(display);
|
||||
puts("{\"event\":\"unlocked\"}");return 0;
|
||||
}
|
||||
struct pollfd fd={wl_display_get_fd(display),POLLIN,0};
|
||||
if(poll(&fd,1,100)>0 && wl_display_dispatch(display)<0)return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Physical test against the separately started disposable logind session.
|
||||
FERESE_TEST_DRM=1 python3 scripts/tests/test_rime_drm_isolated.py
|
||||
Never starts/switches sessions itself. Requires its prepared lock-cycle binary.
|
||||
"""
|
||||
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("drm",ROOT/"scripts/rime-drm-validation.py")
|
||||
d=importlib.util.module_from_spec(spec);spec.loader.exec_module(d)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_DRM")=="1","explicit physical-session validation")
|
||||
class PhysicalRime(unittest.TestCase):
|
||||
def test_three_outputs_and_lock_cycle(self):
|
||||
for _ in range(100):
|
||||
if (d.DATA/"context.json").exists() and (d.DATA/"entry-state.json").exists():break
|
||||
time.sleep(.1)
|
||||
c=json.loads((d.DATA/"context.json").read_text())
|
||||
self.assertTrue(Path(f"/proc/{c['compositor_pid']}").exists())
|
||||
self.assertNotEqual(subprocess.check_output(["loginctl","show-seat","seat0","-p","ActiveSession","--value"],text=True).strip(),d.original())
|
||||
env={**os.environ,"XDG_RUNTIME_DIR":str(d.RUNTIME),"WAYLAND_DISPLAY":c["wayland"],
|
||||
"DBUS_SESSION_BUS_ADDRESS":c["bus"],"FERESE_SOCKET":c["socket"],"GDK_BACKEND":"wayland",
|
||||
"GTK_IM_MODULE":"wayland","FERESE_TEST_INPUT":"1","FERESE_TEST_DRM_PID":str(c["compositor_pid"]),
|
||||
"FERESE_RIME_CONTROL":str(d.RUNTIME/"rime-control.sock"),"GTK_A11Y":"none","GSETTINGS_BACKEND":"keyfile"}
|
||||
env["GSK_RENDERER"]=os.environ.get("FERESE_TEST_GSK","vulkan")
|
||||
control=[str(ROOT/"target/debug/feresectl"),"--socket",c["socket"]]
|
||||
def run(args):return subprocess.check_output(args,env=env,text=True,stderr=subprocess.PIPE).strip()
|
||||
def ctl(*args):return json.loads(run([str(ROOT/"target/debug/ferese-rime"),*args]))["state"]
|
||||
def wait(fn,message,timeout=5):
|
||||
until=time.monotonic()+timeout
|
||||
while time.monotonic()<until:
|
||||
if fn():return
|
||||
time.sleep(.1)
|
||||
raise AssertionError(message)
|
||||
def keys(*args):run([str(ROOT/"target/debug/ferese-rime-probe"),*args]);time.sleep(.1)
|
||||
outputs=json.loads(run(control+["--json","outputs"]))
|
||||
active=[o for o in outputs if o["enabled"]]
|
||||
self.assertEqual({o["name"] for o in active},{"DP-4","DP-5","DP-6"})
|
||||
checks=[];apps=[]
|
||||
(d.DATA/"outputs.json").write_text(json.dumps(outputs,indent=2)+"\n")
|
||||
try:
|
||||
for output in sorted(active,key=lambda o:o["x"]):
|
||||
name=output["name"]; app=f"dev.ferese.RimeDRM{output['id']}";file=d.DATA/f"entry-{name}.json"
|
||||
file.unlink(missing_ok=True)
|
||||
run(control+["focus-output",name])
|
||||
proc=subprocess.Popen(["gjs",str(ROOT/"scripts/tests/fixtures/rime-entry.js"),str(file),app],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL);apps.append(proc)
|
||||
wait(lambda:file.exists(),"Fresh GTK fixture did not initialize",timeout=10)
|
||||
wait(lambda: (ctl("--status")["context"] or {}).get("app_id")==app,"Monitor fixture did not activate")
|
||||
time.sleep(.25)
|
||||
ctl("--mode","rime")
|
||||
keys("--keys","nihao")
|
||||
image=subprocess.check_output(control+["screenshot","--geometry",f"{output['x']},{output['y']} {output['width']}x{output['height']}"],env=env)
|
||||
(d.DATA/f"candidate-{name}.png").write_bytes(image)
|
||||
keys("--keys"," ")
|
||||
wait(lambda:json.loads(file.read_text())["normal"]=="你好",f"Chinese input failed on {name}")
|
||||
checks.append(f"{name}: Chinese preedit/candidates/commit at {output['scale']}x, {output['current_mode']['refresh_hz']} Hz")
|
||||
|
||||
keys("--keys","nihao")
|
||||
release=d.DATA/"unlock-trigger";release.unlink(missing_ok=True)
|
||||
log=(d.DATA/"lock-cycle.log").open("w")
|
||||
locker=subprocess.Popen([str(d.DATA/"lock-cycle"),str(release)],env={**env,"FERESE_TEST_LOCK":"1"},stdout=log,stderr=subprocess.STDOUT)
|
||||
try:
|
||||
wait(lambda:'"event":"locked"' in (d.DATA/"lock-cycle.log").read_text(),"Physical outputs did not confirm protected presentation")
|
||||
self.assertIn('"outputs":3',(d.DATA/"lock-cycle.log").read_text())
|
||||
wait(lambda:not ctl("--status")["active"],"IME remained active while locked")
|
||||
before=file.read_text();keys("--keys","secret-test")
|
||||
self.assertEqual(file.read_text(),before)
|
||||
screenshot=subprocess.run(control+["screenshot"],env=env,capture_output=True)
|
||||
self.assertNotEqual(screenshot.returncode,0)
|
||||
old=ctl("--status")["pid"]
|
||||
subprocess.run(["systemctl","--user","kill","--signal=KILL",d.IME],env=d.manager_env(),check=True)
|
||||
def recovered():
|
||||
try:return ctl("--status")["pid"]!=old
|
||||
except (subprocess.SubprocessError,ValueError):return False
|
||||
wait(recovered,"IME did not recover during lock",timeout=8)
|
||||
self.assertFalse(ctl("--status")["active"])
|
||||
release.touch();self.assertEqual(locker.wait(timeout=5),0)
|
||||
wait(lambda:ctl("--status")["active"],"Text input did not reactivate on unlock")
|
||||
keys("--key","End","--keys","nihao ")
|
||||
wait(lambda:json.loads(file.read_text())["normal"]=="你好你好","Chinese input failed after unlock")
|
||||
checks.append("three physical outputs confirmed locked; candidates/input disabled; capture denied; IME restarted while locked; unlock restores Chinese")
|
||||
finally:
|
||||
release.touch()
|
||||
if locker.poll() is None:locker.wait(timeout=5)
|
||||
log.close()
|
||||
(d.DATA/f"validation-{env['GSK_RENDERER']}.json").write_text(json.dumps({"status":"passed","renderer":env["GSK_RENDERER"],"checks":checks,"pam_authentication":"not covered by protocol-owner test"},ensure_ascii=False,indent=2)+"\n")
|
||||
print(json.dumps(checks,ensure_ascii=False))
|
||||
finally:
|
||||
for proc in apps:
|
||||
proc.terminate();proc.wait(timeout=5)
|
||||
|
||||
|
||||
if __name__=="__main__":unittest.main()
|
||||
@@ -47,11 +47,11 @@ class NativeRimeTest(unittest.TestCase):
|
||||
(preview.DATA / name).write_bytes(p.stdout)
|
||||
|
||||
clear()
|
||||
baseline_windows={window["id"] for window in preview.windows()}
|
||||
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"})
|
||||
self.assertEqual({window["id"] for window in preview.windows()},baseline_windows)
|
||||
probe("--keys", " ")
|
||||
expect("你好")
|
||||
results.append("native GTK preedit, candidate popup and space commit")
|
||||
@@ -145,18 +145,19 @@ class NativeRimeTest(unittest.TestCase):
|
||||
clear()
|
||||
probe("--keys", "nihao")
|
||||
compositor_pid = preview.prop(preview.MAIN, "MainPID")
|
||||
old_ime_pid = preview.prop(preview.IME, "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")
|
||||
preview.wait_for(lambda: preview.prop(preview.IME, "MainPID") not in ("", "0", old_ime_pid)
|
||||
and preview.prop(preview.IME, "ActiveState") == "active", "IME did not automatically recover")
|
||||
self.assertEqual(preview.prop(preview.MAIN, "ActiveState"), "active")
|
||||
self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid)
|
||||
probe("--keys", "abc")
|
||||
expect("abc")
|
||||
preview.wait_for(lambda: '"event":"ready"' in preview.run(
|
||||
["journalctl", "--user", f"_PID={preview.prop(preview.IME, 'MainPID')}", "--no-pager", "-o", "cat"]), "Recovered IME was not ready")
|
||||
time.sleep(.3)
|
||||
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")
|
||||
expect("你好")
|
||||
results.append("IME SIGKILL automatically restarts with desktop alive and Chinese restored")
|
||||
|
||||
# The validation driver must refuse the real host before creating a keyboard.
|
||||
host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0"))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Opt-in multi-window preferences and real service fault recovery.
|
||||
FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_resilience_isolated.py
|
||||
Uses disposable native GTK windows only; never writes user chat/profile data.
|
||||
"""
|
||||
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")
|
||||
p = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(p)
|
||||
ALT = "ferese-rime-second-20261028.service"
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("FERESE_TEST_RIME") == "1", "opt-in Rime resilience validation")
|
||||
class Resilience(unittest.TestCase):
|
||||
def test_preferences_and_recovery(self):
|
||||
p.start()
|
||||
env = {**os.environ, **p.child_environment()}
|
||||
results = []
|
||||
|
||||
def ctl(*args):
|
||||
return json.loads(p.run([str(ROOT / "target/debug/ferese-rime"), *args], env=env))["state"]
|
||||
|
||||
def keys(*args):
|
||||
p.probe(list(args)); time.sleep(.15)
|
||||
|
||||
def text(name="entry-state.json"):
|
||||
return json.loads((p.DATA / name).read_text())["normal"]
|
||||
|
||||
def expect(value, name="entry-state.json"):
|
||||
p.wait_for(lambda: text(name) == value, f"Expected {value!r} in {name}", timeout=5)
|
||||
|
||||
def ready_after(pid, timeout=20):
|
||||
def ready():
|
||||
try:
|
||||
state = ctl("--status")
|
||||
return state["pid"] != int(pid) and state["active"] and state["context"]
|
||||
except (RuntimeError, ValueError):
|
||||
return False
|
||||
p.wait_for(ready, "Input service did not automatically recover", timeout=timeout)
|
||||
|
||||
p.focus_entry(); p.action("clear")
|
||||
keys("--key", "Escape")
|
||||
ctl("--select-schema", "luna_pinyin_simp"); ctl("--mode", "rime")
|
||||
keys("--keys", "nihao "); expect("你好")
|
||||
try:
|
||||
p.start_unit(ALT, ["/usr/bin/gjs", str(ROOT / "scripts/tests/fixtures/rime-entry.js"),
|
||||
str(p.DATA / "second-state.json"), "dev.ferese.RimeSecond"], p.child_environment(), "512M")
|
||||
p.wait_for(lambda: any(w["focused"] and w["app_id"] == "dev.ferese.RimeSecond" for w in p.windows()), "Second window did not focus")
|
||||
p.wait_for(lambda: ctl("--status")["context"]["app_id"] == "dev.ferese.RimeSecond", "Second input context missing")
|
||||
ctl("--mode", "ascii"); keys("--keys", "abc"); expect("abc", "second-state.json")
|
||||
p.focus_entry(); p.action("normal"); time.sleep(.25)
|
||||
self.assertFalse(ctl("--status")["profile"]["ascii"])
|
||||
p.action("clear"); keys("--keys", "nihao "); expect("你好")
|
||||
results.append("two windows retain independent ASCII/Rime modes on focus changes")
|
||||
|
||||
ctl("--select-schema", "luna_pinyin")
|
||||
p.action("clear"); keys("--keys", "hanzi "); expect("漢字")
|
||||
# Reopening the same application must use its persisted preference.
|
||||
ctl("--mode", "ascii")
|
||||
subprocess.run(["systemctl", "--user", "stop", p.APP], check=True)
|
||||
p.wait_for(lambda: not any(w["app_id"] == "dev.ferese.RimeValidation" for w in p.windows()), "First window did not close")
|
||||
p.start_unit(p.APP, ["/usr/bin/gjs", str(ROOT / "scripts/tests/fixtures/rime-entry.js"),
|
||||
str(p.DATA / "entry-state.json")], p.child_environment(), "512M")
|
||||
p.focus_entry()
|
||||
p.wait_for(lambda: (ctl("--status")["context"] or {}).get("app_id") == "dev.ferese.RimeValidation", "Reopened context missing")
|
||||
self.assertEqual(ctl("--status")["profile"], {"schema":"luna_pinyin", "ascii":True})
|
||||
keys("--keys", "abc"); expect("abc")
|
||||
results.append("closed/reopened application restores mode and Rime schema")
|
||||
|
||||
ctl("--mode", "rime"); p.action("clear"); keys("--keys", "hanzi")
|
||||
desktop = p.prop(p.MAIN, "MainPID"); old = p.prop(p.IME, "MainPID")
|
||||
started = time.monotonic()
|
||||
subprocess.run(["systemctl", "--user", "kill", "--signal=KILL", p.IME], check=True)
|
||||
ready_after(old)
|
||||
self.assertEqual(p.prop(p.MAIN, "MainPID"), desktop)
|
||||
self.assertEqual(ctl("--status")["profile"], {"schema":"luna_pinyin", "ascii":False})
|
||||
keys("--keys", "hanzi "); expect("漢字")
|
||||
results.append(f"SIGKILL recovery restores current-window schema in {time.monotonic()-started:.2f}s without stale composition")
|
||||
|
||||
p.action("clear"); old = p.prop(p.IME, "MainPID"); started=time.monotonic()
|
||||
subprocess.run(["systemctl", "--user", "kill", "--signal=STOP", p.IME], check=True)
|
||||
ready_after(old, timeout=20)
|
||||
self.assertEqual(p.prop(p.MAIN, "MainPID"), desktop)
|
||||
keys("--keys", "hanzi "); expect("漢字")
|
||||
results.append(f"stalled input loop is killed by watchdog and restored in {time.monotonic()-started:.2f}s")
|
||||
finally:
|
||||
subprocess.run(["systemctl", "--user", "stop", ALT], capture_output=True)
|
||||
p.focus_entry()
|
||||
ctl("--select-schema", "luna_pinyin_simp"); ctl("--mode", "rime")
|
||||
(p.DATA / "resilience-validation.json").write_text(json.dumps({"status":"passed","checks":results}, ensure_ascii=False,indent=2)+"\n")
|
||||
print(json.dumps(results, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user