Recover Rime faults, persist window modes and fix GPU allocation

This commit is contained in:
aria committed 2026-10-28 02:32:19 +09:00
1 parent 8e9a5838ab
commit 6f34fd080f
27 files changed
+1259 -33

No files matched your search

Generated
+1
View File
@@ -1970,6 +1970,7 @@ dependencies = [
"wayland-client", "wayland-client",
"wayland-protocols", "wayland-protocols",
"wayland-protocols-misc", "wayland-protocols-misc",
"wayland-protocols-wlr",
"xkbcommon 0.9.0", "xkbcommon 0.9.0",
] ]
+13 -1
View File
@@ -999,6 +999,14 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
.session .session
.clone(); .clone();
let node = DrmNode::from_path(path)?; let node = DrmNode::from_path(path)?;
// Advertise one allocation entry point. Otherwise clients may bind an
// idle secondary GPU's global and allocate buffers that the display GPU
// cannot import. Per-surface feedback still describes output tranches.
let allocation_device = primary_gpu(session.seat())
.ok()
.flatten()
.and_then(|path| DrmNode::from_path(path).ok())
.is_none_or(|primary| primary == node);
let session_device = session_device::SessionDevice::open(session, path)?; let session_device = session_device::SessionDevice::open(session, path)?;
let fd = DrmDeviceFd::new(session_device.device_fd()?); let fd = DrmDeviceFd::new(session_device.device_fd()?);
let (drm, notifier) = DrmDevice::new(fd.clone(), true)?; let (drm, notifier) = DrmDevice::new(fd.clone(), true)?;
@@ -1177,7 +1185,11 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
let dmabuf_global = state let dmabuf_global = state
.dmabuf_state .dmabuf_state
.create_global_with_default_feedback::<Ferese>(&display_handle, &feedback); .create_global_with_filter_and_default_feedback::<Ferese, _>(
&display_handle,
&feedback,
move |_| allocation_device,
);
state.dmabuf_imports.register(dmabuf_global); state.dmabuf_imports.register(dmabuf_global);
state.dmabuf_feedback.register(dmabuf_global, feedback); state.dmabuf_feedback.register(dmabuf_global, feedback);
state state
+2
View File
@@ -53,6 +53,8 @@ macro_rules! delegate_protocols {
} }
delegate_protocols!( delegate_protocols!(
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
protocols::ext::image_capture_source::v1::server::ext_image_capture_source_v1::ExtImageCaptureSourceV1, protocols::ext::image_capture_source::v1::server::ext_image_capture_source_v1::ExtImageCaptureSourceV1,
protocols::ext::image_capture_source::v1::server::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, protocols::ext::image_capture_source::v1::server::ext_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1,
protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1, protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1,
+1
View File
@@ -12,6 +12,7 @@ pub(crate) mod security_context;
#[cfg(test)] #[cfg(test)]
mod toplevel_icon_tests; mod toplevel_icon_tests;
mod virtual_keyboard; mod virtual_keyboard;
mod virtual_pointer;
pub(crate) mod window_capture; pub(crate) mod window_capture;
mod xdg_shell; mod xdg_shell;
@@ -0,0 +1,13 @@
use crate::Ferese;
use smithay::backend::input::InputEvent;
use smithay::wayland::virtual_pointer::{VirtualPointerBackend, VirtualPointerHandler};
impl VirtualPointerHandler for Ferese {
fn process_virtual_pointer_event(&mut self, event: InputEvent<VirtualPointerBackend>) {
// The global exists only with the explicit test-pointer environment
// switch. Synthetic pointers must never enter a real lock surface.
if !self.session_lock.active() {
self.process_input_event(event);
}
}
}
+59
View File
@@ -1205,6 +1205,27 @@ impl Ferese {
.reload_config() .reload_config()
.map_err(|e| CommandError::new("invalid_config", e))?, .map_err(|e| CommandError::new("invalid_config", e))?,
"focus" => self.focus_direction(direction_arg(args)?), "focus" => self.focus_direction(direction_arg(args)?),
"focus-output" => {
let name = args["name"]
.as_str()
.ok_or_else(|| CommandError::new("invalid_argument", "Missing output name"))?;
let geometry = self
.space
.outputs()
.find(|output| output.name() == name)
.and_then(|output| self.space.output_geometry(output))
.ok_or_else(|| {
CommandError::new("invalid_argument", "Output is not enabled")
})?;
self.focus_output_at(
(
geometry.loc.x as f64 + geometry.size.w as f64 / 2.,
geometry.loc.y as f64 + geometry.size.h as f64 / 2.,
)
.into(),
);
self.restore_keyboard_focus();
}
"focus-last-window" => self.focus_last_window(), "focus-last-window" => self.focus_last_window(),
"focus-floating" => self.focus_floating(), "focus-floating" => self.focus_floating(),
"focus-mru-next" => self.cycle_focus(false, false), "focus-mru-next" => self.cycle_focus(false, false),
@@ -1249,6 +1270,44 @@ impl Ferese {
"expel" => self.expel_focused_window(), "expel" => self.expel_focused_window(),
"close" => self.close_focused_window(), "close" => self.close_focused_window(),
"get-focused-window" => return Ok(self.focused_window_json()), "get-focused-window" => return Ok(self.focused_window_json()),
"focus-window" => {
let id = args["window"]
.as_u64()
.map(ferese_layout::WindowId)
.ok_or_else(|| CommandError::new("invalid_argument", "Missing window ID"))?;
if self.windows.record(id).is_none() {
return Err(CommandError::new(
"invalid_argument",
"Window does not exist",
));
}
self.activate_managed_window(id);
}
"get-input-context" => {
// No window titles or application input are exposed to the IME.
// A fresh compositor session cannot reuse another session's IDs.
static SESSION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let session = SESSION.get_or_init(|| {
format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
)
});
let context = self
.seat
.get_keyboard()
.and_then(|keyboard| keyboard.current_focus())
.and_then(|surface| self.windows.id_for_surface(&surface))
.and_then(|id| {
self.windows.record(id).map(|record|
json!({"session":session,"window":id.0,"app_id":record.app_id}))
});
return Ok(context.unwrap_or(Value::Null));
}
"get-windows" => return Ok(self.windows_json()), "get-windows" => return Ok(self.windows_json()),
"media-get" => return Ok(self.media_engine.value()), "media-get" => return Ok(self.media_engine.value()),
"get-idle-inhibition" => { "get-idle-inhibition" => {
+11
View File
@@ -573,6 +573,8 @@ pub struct Ferese {
pub idle_notifier_state: IdleNotifierState<Self>, pub idle_notifier_state: IdleNotifierState<Self>,
pub input_method_manager_state: InputMethodManagerState, pub input_method_manager_state: InputMethodManagerState,
pub virtual_keyboard_manager_state: VirtualKeyboardManagerState, pub virtual_keyboard_manager_state: VirtualKeyboardManagerState,
pub virtual_pointer_manager_state:
Option<smithay::wayland::virtual_pointer::VirtualPointerManagerState>,
pub keyboard_shortcuts_inhibit_state: KeyboardShortcutsInhibitState, pub keyboard_shortcuts_inhibit_state: KeyboardShortcutsInhibitState,
pub output_manager_state: OutputManagerState, pub output_manager_state: OutputManagerState,
pub pointer_constraints_state: PointerConstraintsState, pub pointer_constraints_state: PointerConstraintsState,
@@ -680,6 +682,14 @@ impl Ferese {
VirtualKeyboardManagerState::new::<Self, _>(&display_handle, move |_| { VirtualKeyboardManagerState::new::<Self, _>(&display_handle, move |_| {
input_method_enabled input_method_enabled
}); });
let virtual_pointer_manager_state = (std::env::var_os("FERESE_ENABLE_TEST_POINTER")
.is_some_and(|value| value == "1"))
.then(|| {
smithay::wayland::virtual_pointer::VirtualPointerManagerState::new::<Self, _>(
&display_handle,
|_| true,
)
});
let keyboard_shortcuts_inhibit_state = let keyboard_shortcuts_inhibit_state =
KeyboardShortcutsInhibitState::new::<Self>(&display_handle); KeyboardShortcutsInhibitState::new::<Self>(&display_handle);
let shortcut_inhibit_enabled = let shortcut_inhibit_enabled =
@@ -900,6 +910,7 @@ impl Ferese {
idle_notifier_state, idle_notifier_state,
input_method_manager_state, input_method_manager_state,
virtual_keyboard_manager_state, virtual_keyboard_manager_state,
virtual_pointer_manager_state,
keyboard_shortcuts_inhibit_state, keyboard_shortcuts_inhibit_state,
output_manager_state, output_manager_state,
pointer_constraints_state, pointer_constraints_state,
+19 -2
View File
@@ -3,6 +3,7 @@ use std::error::Error;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use smithay::backend::egl::EGLDevice;
use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::damage::OutputDamageTracker;
use smithay::backend::renderer::element::RenderElementStates; use smithay::backend::renderer::element::RenderElementStates;
use smithay::backend::renderer::gles::GlesRenderer; use smithay::backend::renderer::gles::GlesRenderer;
@@ -13,6 +14,7 @@ use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
use smithay::reexports::calloop::EventLoop; use smithay::reexports::calloop::EventLoop;
use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
use smithay::utils::{Physical, Rectangle, Size, Transform}; use smithay::utils::{Physical, Rectangle, Size, Transform};
use smithay::wayland::dmabuf::DmabufFeedbackBuilder;
use crate::Ferese; use crate::Ferese;
use crate::metrics::{FrameEffectMetrics, RenderMetrics}; use crate::metrics::{FrameEffectMetrics, RenderMetrics};
@@ -35,9 +37,24 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
let drawable = Rc::new(Cell::new(usable_size(initial_size))); let drawable = Rc::new(Cell::new(usable_size(initial_size)));
let dmabuf_formats = backend.renderer().dmabuf_formats(); let dmabuf_formats = backend.renderer().dmabuf_formats();
let display_handle = state.display_handle.clone(); let display_handle = state.display_handle.clone();
let dmabuf_global = state // V3 formats alone leave multi-GPU clients choosing their system default
// GPU. Publish the actual renderer device so new buffers match this EGL
// context instead of requiring an unrelated cross-GPU import.
let node = EGLDevice::device_for_display(backend.renderer().egl_context().display())
.ok()
.and_then(|device| device.try_get_render_node().ok().flatten());
let dmabuf_global = if let Some(node) = node {
let feedback = DmabufFeedbackBuilder::new(node.dev_id(), dmabuf_formats).build()?;
let global = state
.dmabuf_state .dmabuf_state
.create_global::<Ferese>(&display_handle, dmabuf_formats); .create_global_with_default_feedback::<Ferese>(&display_handle, &feedback);
state.dmabuf_feedback.register(global, feedback);
global
} else {
state
.dmabuf_state
.create_global::<Ferese>(&display_handle, dmabuf_formats)
};
state.dmabuf_imports.register(dmabuf_global); state.dmabuf_imports.register(dmabuf_global);
let initial_scale = normalized_scale(backend.scale_factor()); let initial_scale = normalized_scale(backend.scale_factor());
let mode = Mode { let mode = Mode {
+1
View File
@@ -20,6 +20,7 @@ tiny-skia = "0.11.4"
wayland-client.workspace = true wayland-client.workspace = true
wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } wayland-protocols = { version = "0.32.13", features = ["client", "staging"] }
wayland-protocols-misc = { version = "0.3.12", features = ["client"] } wayland-protocols-misc = { version = "0.3.12", features = ["client"] }
wayland-protocols-wlr.workspace = true
xkbcommon = "0.9.0" xkbcommon = "0.9.0"
[build-dependencies] [build-dependencies]
+152 -7
View File
@@ -1,14 +1,18 @@
//! Integration-test input source. Refuses physical sessions and non-Ferese //! Integration-test input source. Refuses physical sessions and non-Ferese
//! servers before creating a keyboard; never injects into the host desktop. //! servers before creating a keyboard. Physical validation additionally requires
//! the private test runtime and the exact disposable compositor's peer PID.
use std::error::Error; use std::error::Error;
use std::io::{Seek, Write}; use std::io::{Seek, Write};
use std::os::fd::AsFd; use std::os::fd::{AsFd, AsRawFd};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use wayland_client::protocol::{wl_output, wl_registry, wl_seat}; use wayland_client::protocol::{wl_output, wl_pointer, wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop}; use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{ use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
zwp_virtual_keyboard_manager_v1 as manager, zwp_virtual_keyboard_v1 as keyboard, zwp_virtual_keyboard_manager_v1 as manager, zwp_virtual_keyboard_v1 as keyboard,
}; };
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1 as pointer_manager, zwlr_virtual_pointer_v1 as virtual_pointer,
};
use xkbcommon::xkb; use xkbcommon::xkb;
#[derive(Default)] #[derive(Default)]
@@ -17,6 +21,7 @@ struct State {
seat: Option<wl_seat::WlSeat>, seat: Option<wl_seat::WlSeat>,
ferese: bool, ferese: bool,
outputs: Vec<String>, outputs: Vec<String>,
pointer_manager: Option<pointer_manager::ZwlrVirtualPointerManagerV1>,
} }
impl Dispatch<wl_registry::WlRegistry, ()> for State { impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event( fn event(
@@ -42,6 +47,9 @@ impl Dispatch<wl_registry::WlRegistry, ()> for State {
"wl_output" if version >= 4 => { "wl_output" if version >= 4 => {
let _: wl_output::WlOutput = registry.bind(name, 4, qh, ()); let _: wl_output::WlOutput = registry.bind(name, 4, qh, ());
} }
"zwlr_virtual_pointer_manager_v1" => {
state.pointer_manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => (), _ => (),
} }
} }
@@ -65,6 +73,16 @@ delegate_noop!(State: ignore wl_seat::WlSeat);
delegate_noop!(State: ignore manager::ZwpVirtualKeyboardManagerV1); delegate_noop!(State: ignore manager::ZwpVirtualKeyboardManagerV1);
delegate_noop!(State: ignore keyboard::ZwpVirtualKeyboardV1); delegate_noop!(State: ignore keyboard::ZwpVirtualKeyboardV1);
fn parse_numbers(text: &str) -> Result<Vec<u32>, Box<dyn Error>> {
Ok(text
.split(',')
.map(str::parse)
.collect::<Result<Vec<_>, _>>()?)
}
delegate_noop!(State: ignore pointer_manager::ZwlrVirtualPointerManagerV1);
delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1);
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") { if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") {
return Err( return Err(
@@ -72,9 +90,33 @@ fn main() -> Result<(), Box<dyn Error>> {
); );
} }
let mut sequences: Vec<Vec<u32>> = Vec::new(); let mut sequences: Vec<Vec<u32>> = Vec::new();
let mut clicks = Vec::new();
let mut drag = None;
let mut extent = (1280, 800);
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
"--extent" => {
let v = parse_numbers(&args.next().ok_or("missing extent")?)?;
if v.len() != 2 || v[0] == 0 || v[1] == 0 {
return Err("invalid extent".into());
}
extent = (v[0], v[1]);
}
"--click" => {
let v = parse_numbers(&args.next().ok_or("missing click position")?)?;
if v.len() != 2 {
return Err("invalid click".into());
}
clicks.push((v[0], v[1]));
}
"--drag" => {
let v = parse_numbers(&args.next().ok_or("missing drag positions")?)?;
if v.len() != 4 {
return Err("invalid drag".into());
}
drag = Some(v);
}
"--keys" => { "--keys" => {
for character in args.next().ok_or("missing text")?.chars() { for character in args.next().ok_or("missing text")?.chars() {
sequences.push(vec![character as u32]); sequences.push(vec![character as u32]);
@@ -101,10 +143,36 @@ fn main() -> Result<(), Box<dyn Error>> {
let mut state = State::default(); let mut state = State::default();
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
if !state.ferese let nested = state.outputs.iter().all(|name| name == "ferese-winit");
|| state.outputs.is_empty() // SAFETY: getuid takes no pointers and has no preconditions.
|| state.outputs.iter().any(|name| name != "ferese-winit") let uid = unsafe { libc::getuid() };
{ let drm = std::env::var("XDG_RUNTIME_DIR")
.ok()
.is_some_and(|runtime| runtime == format!("/run/user/{uid}/ferese-rime-drm-validation"))
&& std::env::var("FERESE_TEST_DRM_PID")
.ok()
.and_then(|pid| pid.parse::<i32>().ok())
.is_some_and(|expected| {
let mut credentials = libc::ucred {
pid: 0,
uid: 0,
gid: 0,
};
let mut size = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
// SAFETY: the socket is live and the output/length pointers
// refer to correctly sized initialized storage for SO_PEERCRED.
unsafe {
libc::getsockopt(
connection.as_fd().as_raw_fd(),
libc::SOL_SOCKET,
libc::SO_PEERCRED,
(&mut credentials as *mut libc::ucred).cast(),
&mut size,
) == 0
&& credentials.pid == expected
}
});
if !state.ferese || state.outputs.is_empty() || !(nested || drm) {
return Err("refusing input: target is not an isolated Ferese nested output".into()); return Err("refusing input: target is not an isolated Ferese nested output".into());
} }
let keyboard = state let keyboard = state
@@ -152,6 +220,83 @@ fn main() -> Result<(), Box<dyn Error>> {
std::thread::sleep(Duration::from_millis(35)); std::thread::sleep(Duration::from_millis(35));
} }
} }
if !clicks.is_empty() || drag.is_some() {
let pointer = state
.pointer_manager
.as_ref()
.ok_or("test pointer is not enabled on this compositor")?
.create_virtual_pointer(state.seat.as_ref(), &qh, ());
for (x, y) in clicks {
pointer.motion_absolute(
started.elapsed().as_millis() as u32,
x,
y,
extent.0,
extent.1,
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(80));
pointer.button(
started.elapsed().as_millis() as u32,
0x110,
wl_pointer::ButtonState::Pressed,
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(80));
pointer.button(
started.elapsed().as_millis() as u32,
0x110,
wl_pointer::ButtonState::Released,
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(150));
}
if let Some(v) = drag {
pointer.motion_absolute(
started.elapsed().as_millis() as u32,
v[0],
v[1],
extent.0,
extent.1,
);
pointer.frame();
keyboard.key(started.elapsed().as_millis() as u32, 125, 1);
queue.roundtrip(&mut state)?;
pointer.button(
started.elapsed().as_millis() as u32,
0x110,
wl_pointer::ButtonState::Pressed,
);
pointer.frame();
queue.roundtrip(&mut state)?;
for step in 1..=20i64 {
let x = v[0] as i64 + (v[2] as i64 - v[0] as i64) * step / 20;
let y = v[1] as i64 + (v[3] as i64 - v[1] as i64) * step / 20;
pointer.motion_absolute(
started.elapsed().as_millis() as u32,
x as u32,
y as u32,
extent.0,
extent.1,
);
pointer.frame();
queue.roundtrip(&mut state)?;
std::thread::sleep(Duration::from_millis(20));
}
pointer.button(
started.elapsed().as_millis() as u32,
0x110,
wl_pointer::ButtonState::Released,
);
pointer.frame();
keyboard.key(started.elapsed().as_millis() as u32, 125, 0);
queue.roundtrip(&mut state)?;
}
pointer.destroy();
}
keyboard.destroy(); keyboard.destroy();
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
println!("nested test input complete"); println!("nested test input complete");
+14
View File
@@ -1,4 +1,5 @@
pub mod panel; pub mod panel;
pub mod profiles;
/// The number-row paging keys are local to a composition with candidates. /// The number-row paging keys are local to a composition with candidates.
/// Control/Alt/Super shortcuts and literal ASCII typing retain their symbols. /// Control/Alt/Super shortcuts and literal ASCII typing retain their symbols.
@@ -31,6 +32,7 @@ pub struct Candidate {
pub struct Snapshot { pub struct Snapshot {
pub accepted: bool, pub accepted: bool,
pub ascii: bool, pub ascii: bool,
pub schema: String,
pub commit: String, pub commit: String,
pub preedit: String, pub preedit: String,
/// Byte offset, matching text-input-v3 (not a character count). /// Byte offset, matching text-input-v3 (not a character count).
@@ -52,6 +54,7 @@ struct RawResult {
count: c_int, count: c_int,
commit: *mut c_char, commit: *mut c_char,
preedit: *mut c_char, preedit: *mut c_char,
schema: *mut c_char,
texts: [*mut c_char; 9], texts: [*mut c_char; 9],
comments: [*mut c_char; 9], comments: [*mut c_char; 9],
} }
@@ -62,6 +65,7 @@ unsafe extern "C" {
fn ferese_rime_snapshot(key: c_int, mask: c_int, out: *mut RawResult); fn ferese_rime_snapshot(key: c_int, mask: c_int, out: *mut RawResult);
fn ferese_rime_result_free(out: *mut RawResult); fn ferese_rime_result_free(out: *mut RawResult);
fn ferese_rime_clear(); fn ferese_rime_clear();
fn ferese_rime_profile(schema: *const c_char, ascii_mode: c_int) -> c_int;
fn ferese_rime_close(); fn ferese_rime_close();
} }
@@ -113,6 +117,15 @@ impl Engine {
unsafe { ferese_rime_clear() }; unsafe { ferese_rime_clear() };
} }
pub fn set_profile(&mut self, profile: &profiles::Profile) -> Result<(), String> {
let schema = CString::new(profile.schema.as_str()).map_err(|_| "invalid schema")?;
// SAFETY: the unique session and schema string remain live during this call.
if unsafe { ferese_rime_profile(schema.as_ptr(), i32::from(profile.ascii)) } == 0 {
return Err(format!("Rime schema is unavailable: {}", profile.schema));
}
Ok(())
}
fn snapshot(&mut self, key: i32, mask: i32) -> Snapshot { fn snapshot(&mut self, key: i32, mask: i32) -> Snapshot {
// SAFETY: all-zero is valid for this C struct's integers/null pointers; // SAFETY: all-zero is valid for this C struct's integers/null pointers;
// the shim initializes and returns owned, NUL-terminated UTF-8 strings. // the shim initializes and returns owned, NUL-terminated UTF-8 strings.
@@ -136,6 +149,7 @@ impl Engine {
let result = Snapshot { let result = Snapshot {
accepted: raw.accepted != 0, accepted: raw.accepted != 0,
ascii: raw.ascii_mode != 0, ascii: raw.ascii_mode != 0,
schema: text(raw.schema),
commit: text(raw.commit), commit: text(raw.commit),
preedit, preedit,
cursor: cursor as i32, cursor: cursor as i32,
+159 -2
View File
@@ -1,11 +1,13 @@
//! Independent native Wayland IME. librime and candidate rasterization never //! Independent native Wayland IME. librime and candidate rasterization never
//! execute on Ferese's compositor thread. //! execute on Ferese's compositor thread.
mod watchdog;
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::fs::File; use std::fs::File;
use std::io::{Read, Seek, Write}; use std::io::{Read, Seek, Write};
use std::os::fd::{AsFd, AsRawFd}; use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver}; use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -13,6 +15,7 @@ use std::time::{Duration, Instant};
use ferese_rime::{ use ferese_rime::{
Engine, Snapshot, Engine, Snapshot,
panel::{Colors, Panel}, panel::{Colors, Panel},
profiles::{Context, Profile, Profiles},
}; };
use wayland_client::protocol::{ use wayland_client::protocol::{
wl_buffer, wl_compositor, wl_keyboard, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface, wl_buffer, wl_compositor, wl_keyboard, wl_registry, wl_seat, wl_shm, wl_shm_pool, wl_surface,
@@ -60,6 +63,8 @@ struct State {
engine: Engine, engine: Engine,
panel: Panel, panel: Panel,
theme: Receiver<Colors>, theme: Receiver<Colors>,
profiles: Profiles,
control: UnixListener,
compositor: Option<wl_compositor::WlCompositor>, compositor: Option<wl_compositor::WlCompositor>,
shm: Option<wl_shm::WlShm>, shm: Option<wl_shm::WlShm>,
seat: Option<wl_seat::WlSeat>, seat: Option<wl_seat::WlSeat>,
@@ -77,6 +82,7 @@ struct State {
serial: u32, serial: u32,
active: bool, active: bool,
pending_active: bool, pending_active: bool,
new_context: bool,
sensitive: bool, sensitive: bool,
pending_sensitive: bool, pending_sensitive: bool,
last: Snapshot, last: Snapshot,
@@ -123,6 +129,27 @@ impl State {
self.routes.clear(); self.routes.clear();
return; return;
} }
if self.new_context {
self.new_context = false;
let context = ferese_ipc::Connection::connect()
.ok()
.and_then(|mut c| c.call("get-input-context", serde_json::json!({})).ok())
.and_then(|v| serde_json::from_value::<Context>(v).ok());
let profile = self.profiles.focus(context);
if let Err(error) = self.profiles.flush() {
eprintln!(
"{}",
serde_json::json!({"event":"preferences-error","error":error})
);
}
if let Err(error) = self.engine.set_profile(&profile) {
eprintln!(
"{}",
serde_json::json!({"event":"profile-error","error":error})
);
}
self.last = self.engine.current();
}
if self.grab.is_some() { if self.grab.is_some() {
return; return;
} }
@@ -184,6 +211,21 @@ impl State {
let keysym = xkb.key_get_one_sym(xkb::Keycode::new(key + 8)).raw(); let keysym = xkb.key_get_one_sym(xkb::Keycode::new(key + 8)).raw();
let repeatable = xkb.get_keymap().key_repeats(xkb::Keycode::new(key + 8)); let repeatable = xkb.get_keymap().key_repeats(xkb::Keycode::new(key + 8));
let mask = self.mask() | if pressed { 0 } else { 1 << 30 }; let mask = self.mask() | if pressed { 0 } else { 1 << 30 };
if pressed && keysym == 0x20 && mask & (1 << 2) != 0 && mask & ((1 << 3) | (1 << 26)) == 0 {
let profile = Profile {
ascii: !self.last.ascii,
schema: self.last.schema.clone(),
};
if let Err(error) = self.engine.set_profile(&profile) {
self.error = Some(error);
return;
}
self.routes.insert(key, false);
self.repeat = None;
let snapshot = self.engine.current();
self.publish(snapshot, qh);
return;
}
let (keysym, mask, paging) = let (keysym, mask, paging) =
ferese_rime::paging_key(keysym, mask, !self.last.candidates.is_empty()); ferese_rime::paging_key(keysym, mask, !self.last.candidates.is_empty());
let mut snapshot = self.engine.process(keysym, mask); let mut snapshot = self.engine.process(keysym, mask);
@@ -216,6 +258,17 @@ impl State {
} }
fn publish(&mut self, snapshot: Snapshot, qh: &QueueHandle<Self>) { fn publish(&mut self, snapshot: Snapshot, qh: &QueueHandle<Self>) {
if !snapshot.schema.is_empty()
&& let Err(error) = self.profiles.remember(Profile {
ascii: snapshot.ascii,
schema: snapshot.schema.clone(),
})
{
eprintln!(
"{}",
serde_json::json!({"event":"preferences-error","error":error})
);
}
let method = self.method.as_ref().unwrap(); let method = self.method.as_ref().unwrap();
if !snapshot.commit.is_empty() { if !snapshot.commit.is_empty() {
method.commit_string(snapshot.commit.clone()); method.commit_string(snapshot.commit.clone());
@@ -226,7 +279,7 @@ impl State {
eprintln!( eprintln!(
"{}", "{}",
serde_json::json!({"event":"composition", "preedit_chars":snapshot.preedit.chars().count(), serde_json::json!({"event":"composition", "preedit_chars":snapshot.preedit.chars().count(),
"candidates":snapshot.candidates.len(), "committed_chars":snapshot.commit.chars().count(), "ascii":snapshot.ascii, "page":snapshot.page}) "candidates":snapshot.candidates.len(), "committed_chars":snapshot.commit.chars().count(), "ascii":snapshot.ascii, "schema":snapshot.schema, "page":snapshot.page})
); );
self.last = snapshot; self.last = snapshot;
if !visible { if !visible {
@@ -290,6 +343,25 @@ impl State {
} }
fn tick(&mut self, qh: &QueueHandle<Self>) { fn tick(&mut self, qh: &QueueHandle<Self>) {
// Control is local and contains preferences only, never typed text.
while let Ok((mut stream, _)) = self.control.accept() {
let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(50)));
let mut bytes = [0u8; 4096];
let result = stream
.read(&mut bytes)
.map_err(|e| e.to_string())
.and_then(|n| {
serde_json::from_slice::<serde_json::Value>(&bytes[..n])
.map_err(|e| e.to_string())
})
.and_then(|request| self.control_request(request, qh));
let response = match result {
Ok(value) => serde_json::json!({"ok":true,"state":value}),
Err(error) => serde_json::json!({"ok":false,"error":error}),
};
let _ = writeln!(stream, "{response}");
}
let mut changed = false; let mut changed = false;
while let Ok(colors) = self.theme.try_recv() { while let Ok(colors) = self.theme.try_recv() {
self.panel.colors = colors; self.panel.colors = colors;
@@ -323,6 +395,34 @@ impl State {
self.publish(snapshot, qh); self.publish(snapshot, qh);
} }
} }
fn control_request(
&mut self,
request: serde_json::Value,
qh: &QueueHandle<Self>,
) -> Result<serde_json::Value, String> {
if request["command"] == "set" {
if !self.active || self.sensitive {
return Err("no ordinary text input is active".into());
}
let mut profile = self.profiles.current.clone();
if let Some(schema) = request["schema"].as_str() {
profile.schema = schema.to_owned();
}
if let Some(ascii) = request["ascii"].as_bool() {
profile.ascii = ascii;
}
self.engine.set_profile(&profile)?;
let snapshot = self.engine.current();
self.publish(snapshot, qh);
} else if request["command"] != "status" {
return Err("unknown input-method command".into());
}
Ok(
serde_json::json!({"active":self.active,"sensitive":self.sensitive,
"context":self.profiles.context,"profile":self.profiles.current,"pid":std::process::id()}),
)
}
} }
impl Dispatch<wl_registry::WlRegistry, ()> for State { impl Dispatch<wl_registry::WlRegistry, ()> for State {
@@ -377,6 +477,7 @@ impl Dispatch<method::ZwpInputMethodV2, ()> for State {
method::Event::Activate => { method::Event::Activate => {
state.pending_active = true; state.pending_active = true;
state.pending_sensitive = false; state.pending_sensitive = false;
state.new_context = true;
} }
method::Event::Deactivate => state.pending_active = false, method::Event::Deactivate => state.pending_active = false,
method::Event::ContentType { hint, purpose } => { method::Event::ContentType { hint, purpose } => {
@@ -555,6 +656,13 @@ fn main() -> Result<(), Box<dyn Error>> {
let mut shared = PathBuf::from("/usr/share/rime-data"); let mut shared = PathBuf::from("/usr/share/rime-data");
let mut schema = "luna_pinyin_simp".to_owned(); let mut schema = "luna_pinyin_simp".to_owned();
let mut self_test = false; let mut self_test = false;
let mut control = std::env::var_os("FERESE_RIME_CONTROL")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap_or_else(|| "/tmp".into()))
.join("ferese/rime-control.sock")
});
let mut command = None;
let mut args = std::env::args().skip(1); let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
match arg.as_str() { match arg.as_str() {
@@ -562,19 +670,46 @@ fn main() -> Result<(), Box<dyn Error>> {
"--shared-data" => shared = args.next().ok_or("missing shared data path")?.into(), "--shared-data" => shared = args.next().ok_or("missing shared data path")?.into(),
"--schema" => schema = args.next().ok_or("missing Rime schema")?, "--schema" => schema = args.next().ok_or("missing Rime schema")?,
"--self-test" => self_test = true, "--self-test" => self_test = true,
"--control" => control = args.next().ok_or("missing control path")?.into(),
"--status" => command = Some(serde_json::json!({"command":"status"})),
"--mode" => {
let mode = args.next().ok_or("missing mode")?;
if !matches!(mode.as_str(), "ascii" | "rime") {
return Err("mode must be ascii or rime".into());
}
command = Some(serde_json::json!({"command":"set","ascii":mode=="ascii"}));
}
"--select-schema" => {
command = Some(
serde_json::json!({"command":"set","schema":args.next().ok_or("missing schema")?}),
)
}
"--version" => { "--version" => {
println!("ferese-rime {}", env!("CARGO_PKG_VERSION")); println!("ferese-rime {}", env!("CARGO_PKG_VERSION"));
return Ok(()); return Ok(());
} }
"--help" => { "--help" => {
println!( println!(
"ferese-rime [--user-data PATH] [--shared-data PATH] [--schema NAME] [--self-test]" "ferese-rime [--user-data PATH] [--shared-data PATH] [--schema NAME] [--control PATH]\n --status | --mode ascii|rime | --select-schema NAME | --self-test"
); );
return Ok(()); return Ok(());
} }
_ => return Err(format!("unknown option {arg}").into()), _ => return Err(format!("unknown option {arg}").into()),
} }
} }
if let Some(command) = command {
let mut stream = UnixStream::connect(&control)?;
stream.set_read_timeout(Some(Duration::from_secs(3)))?;
writeln!(stream, "{command}")?;
stream.shutdown(std::net::Shutdown::Write)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
println!("{}", response.trim());
if serde_json::from_str::<serde_json::Value>(&response)?["ok"] != true {
return Err("input-method command failed".into());
}
return Ok(());
}
std::fs::create_dir_all(&user)?; std::fs::create_dir_all(&user)?;
std::fs::set_permissions(&user, std::fs::Permissions::from_mode(0o700))?; std::fs::set_permissions(&user, std::fs::Permissions::from_mode(0o700))?;
let mut engine = Engine::open(&user, &shared, &schema)?; let mut engine = Engine::open(&user, &shared, &schema)?;
@@ -598,6 +733,21 @@ fn main() -> Result<(), Box<dyn Error>> {
); );
return Ok(()); return Ok(());
} }
let profiles = Profiles::open(&user.join("input-profiles.json"), schema)?;
let parent = control.parent().ok_or("control path has no parent")?;
std::fs::create_dir_all(parent)?;
if control.exists() {
use std::os::unix::fs::FileTypeExt;
if !std::fs::symlink_metadata(&control)?.file_type().is_socket()
|| UnixStream::connect(&control).is_ok()
{
return Err("input-method control path is already in use".into());
}
std::fs::remove_file(&control)?;
}
let listener = UnixListener::bind(&control)?;
std::fs::set_permissions(&control, std::fs::Permissions::from_mode(0o600))?;
listener.set_nonblocking(true)?;
let panel = Panel::new()?; let panel = Panel::new()?;
let (send, theme) = mpsc::channel(); let (send, theme) = mpsc::channel();
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -625,6 +775,8 @@ fn main() -> Result<(), Box<dyn Error>> {
engine, engine,
panel, panel,
theme, theme,
profiles,
control: listener,
compositor: None, compositor: None,
shm: None, shm: None,
seat: None, seat: None,
@@ -642,6 +794,7 @@ fn main() -> Result<(), Box<dyn Error>> {
serial: 0, serial: 0,
active: false, active: false,
pending_active: false, pending_active: false,
new_context: false,
sensitive: false, sensitive: false,
pending_sensitive: false, pending_sensitive: false,
last: Snapshot::default(), last: Snapshot::default(),
@@ -652,9 +805,13 @@ fn main() -> Result<(), Box<dyn Error>> {
}; };
queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?;
state.initialize(&qh)?; state.initialize(&qh)?;
let mut watchdog = watchdog::Watchdog::from_environment();
loop { loop {
queue.dispatch_pending(&mut state)?; queue.dispatch_pending(&mut state)?;
state.tick(&qh); state.tick(&qh);
if let Some(watchdog) = &mut watchdog {
watchdog.tick();
}
if let Some(error) = state.error.take() { if let Some(error) = state.error.take() {
return Err(error.into()); return Err(error.into());
} }
+185
View File
@@ -0,0 +1,185 @@
//! Window-local mode/schema preferences, with application defaults for reopening.
//! Persist preferences only: never composition, surrounding text or window titles.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Profile {
pub schema: String,
pub ascii: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Context {
pub session: String,
pub window: u64,
pub app_id: String,
}
impl Context {
fn key(&self) -> String {
format!("{}:{}", self.session, self.window)
}
}
#[derive(Default, Deserialize, Serialize)]
struct Stored {
#[serde(default)]
applications: BTreeMap<String, Profile>,
#[serde(default)]
windows: BTreeMap<String, Profile>,
}
pub struct Profiles {
path: PathBuf,
default: Profile,
stored: Stored,
dirty: bool,
pub context: Option<Context>,
pub current: Profile,
}
impl Profiles {
pub fn open(path: &Path, schema: String) -> Result<Self, String> {
let stored = match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| format!("invalid IME preferences: {e}"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Stored::default(),
Err(e) => return Err(e.to_string()),
};
let default = Profile {
schema,
ascii: false,
};
Ok(Self {
path: path.to_owned(),
stored,
dirty: false,
context: None,
current: default.clone(),
default,
})
}
pub fn focus(&mut self, context: Option<Context>) -> Profile {
if let Some(context) = &context {
let prefix = format!("{}:", context.session);
self.stored
.windows
.retain(|key, _| key.starts_with(&prefix));
}
let profile = context
.as_ref()
.and_then(|c| {
self.stored
.windows
.get(&c.key())
.or_else(|| self.stored.applications.get(&c.app_id))
})
.unwrap_or(&self.default)
.clone();
self.context = context;
self.current = profile.clone();
// Pin even the initial default. A second window of the same application
// can change its mode without changing this already-open window.
if let Some(context) = &self.context {
self.dirty |= !self.stored.windows.contains_key(&context.key());
self.stored
.windows
.entry(context.key())
.or_insert_with(|| profile.clone());
}
profile
}
pub fn remember(&mut self, profile: Profile) -> Result<(), String> {
if self.current == profile {
return self.flush();
}
self.current = profile.clone();
let Some(context) = &self.context else {
return Ok(());
};
self.stored.windows.insert(context.key(), profile.clone());
if !context.app_id.is_empty() {
self.stored
.applications
.insert(context.app_id.clone(), profile);
}
self.dirty = true;
self.flush()
}
pub fn flush(&mut self) -> Result<(), String> {
if self.dirty {
self.save()?;
self.dirty = false;
}
Ok(())
}
fn save(&self) -> Result<(), String> {
let parent = self.path.parent().ok_or("preferences path has no parent")?;
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
temp.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| e.to_string())?;
serde_json::to_writer_pretty(&mut temp, &self.stored).map_err(|e| e.to_string())?;
temp.write_all(b"\n")
.and_then(|()| temp.as_file().sync_all())
.map_err(|e| e.to_string())?;
temp.persist(&self.path).map_err(|e| e.to_string())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(window: u64, app: &str) -> Option<Context> {
Some(Context {
session: "one".into(),
window,
app_id: app.into(),
})
}
#[test]
fn isolates_windows_restores_closed_apps_and_survives_service_restart() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("preferences.json");
let rime = Profile {
schema: "luna_pinyin_simp".into(),
ascii: false,
};
let english = Profile {
ascii: true,
..rime.clone()
};
let other = Profile {
schema: "luna_pinyin".into(),
ascii: false,
};
let mut p = Profiles::open(&path, rime.schema.clone()).unwrap();
assert_eq!(p.focus(context(1, "QQ")), rime);
p.remember(english.clone()).unwrap();
assert_eq!(p.focus(context(2, "QQ")), english);
p.remember(other.clone()).unwrap();
assert_eq!(p.focus(context(1, "QQ")), english);
assert_eq!(p.focus(context(3, "teams")), rime);
drop(p);
let mut p = Profiles::open(&path, rime.schema.clone()).unwrap();
assert_eq!(p.focus(context(1, "QQ")), english);
assert_eq!(p.focus(context(2, "QQ")), other);
assert_eq!(p.focus(context(4, "QQ")), other);
let mut fresh = context(1, "QQ").unwrap();
fresh.session = "two".into();
assert_eq!(p.focus(Some(fresh)), other);
assert_eq!(p.focus(None), rime);
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
+9 -2
View File
@@ -5,7 +5,7 @@
/* Keep the versioned librime ABI in this small, header-checked boundary. */ /* Keep the versioned librime ABI in this small, header-checked boundary. */
typedef struct { typedef struct {
int accepted, ascii_mode, cursor, highlighted, page_no, last_page, count; int accepted, ascii_mode, cursor, highlighted, page_no, last_page, count;
char *commit, *preedit; char *commit, *preedit, *schema;
char *texts[9], *comments[9]; char *texts[9], *comments[9];
} FereseRimeResult; } FereseRimeResult;
@@ -57,15 +57,22 @@ void ferese_rime_snapshot(int key, int mask, FereseRimeResult *out) {
RIME_STRUCT(RimeStatus, status); RIME_STRUCT(RimeStatus, status);
if (api->get_status(session, &status)) { if (api->get_status(session, &status)) {
out->ascii_mode = status.is_ascii_mode; out->ascii_mode = status.is_ascii_mode;
out->schema = strdup(status.schema_id ? status.schema_id : "");
api->free_status(&status); api->free_status(&status);
} }
} }
void ferese_rime_result_free(FereseRimeResult *out) { void ferese_rime_result_free(FereseRimeResult *out) {
free(out->commit); free(out->preedit); free(out->commit); free(out->preedit); free(out->schema);
for (int n = 0; n < out->count; n++) { free(out->texts[n]); free(out->comments[n]); } for (int n = 0; n < out->count; n++) { free(out->texts[n]); free(out->comments[n]); }
} }
void ferese_rime_clear(void) { if (session) api->clear_composition(session); } void ferese_rime_clear(void) { if (session) api->clear_composition(session); }
int ferese_rime_profile(const char *schema, int ascii_mode) {
if (!api->select_schema(session, schema)) return 0;
api->clear_composition(session);
api->set_option(session, "ascii_mode", ascii_mode);
return 1;
}
void ferese_rime_close(void) { void ferese_rime_close(void) {
if (session) api->destroy_session(session); if (session) api->destroy_session(session);
if (api) api->finalize(); if (api) api->finalize();
+39
View File
@@ -0,0 +1,39 @@
//! Heartbeat from the input-processing loop: a blocked engine cannot fake health.
use std::os::linux::net::SocketAddrExt;
use std::os::unix::net::{SocketAddr, UnixDatagram};
use std::time::{Duration, Instant};
pub struct Watchdog {
socket: UnixDatagram,
interval: Duration,
next: Instant,
}
impl Watchdog {
pub fn from_environment() -> Option<Self> {
let path = std::env::var("NOTIFY_SOCKET").ok()?;
let micros = std::env::var("WATCHDOG_USEC").ok()?.parse::<u64>().ok()?;
if micros == 0 {
return None;
}
let address = if let Some(name) = path.strip_prefix('@') {
SocketAddr::from_abstract_name(name.as_bytes()).ok()?
} else {
SocketAddr::from_pathname(path).ok()?
};
let socket = UnixDatagram::unbound().ok()?;
socket.connect_addr(&address).ok()?;
socket.set_nonblocking(true).ok()?;
socket.send(b"READY=1\nWATCHDOG=1").ok()?;
Some(Self {
socket,
interval: Duration::from_micros((micros / 3).max(1)),
next: Instant::now(),
})
}
pub fn tick(&mut self) {
if Instant::now() >= self.next {
let _ = self.socket.send(b"WATCHDOG=1");
self.next = Instant::now() + self.interval;
}
}
}
+5
View File
@@ -18,6 +18,11 @@ forward unconsumed keys. Both globals remain hidden by default. Treat the flag
as permission for trusted session clients; virtual key events are rejected as permission for trusted session clients; virtual key events are rejected
while the session lock is active. See [the isolated Rime prototype](../development/rime-poc.md). while the session lock is active. See [the isolated Rime prototype](../development/rime-poc.md).
`FERESE_ENABLE_TEST_POINTER=1` exposes `zwlr_virtual_pointer_manager_v1` for
isolated UI regression checks. It is off by default and rejects events while
locked. The test launcher uses a private runtime; this switch is not needed by
the Rime service or normal keyboard candidate selection.
Shell rounding is controlled in Settings → Panel & Shell → Shell corner radius. Shell rounding is controlled in Settings → Panel & Shell → Shell corner radius.
The shell and compositor use the same squircle profile for fills, borders, shadows The shell and compositor use the same squircle profile for fills, borders, shadows
and blur masks. Small controls cap the radius to fit their size; circles and pills and blur masks. Small controls cap the radius to fit their size; circles and pills
+87 -7
View File
@@ -51,7 +51,10 @@ python3 scripts/rime-preview.py status
并在本机存在 Strata 时打开单独的测试目录。GTK 使用 `GTK_IM_MODULE=wayland`。 并在本机存在 Strata 时打开单独的测试目录。GTK 使用 `GTK_IM_MODULE=wayland`。
`scripts/rime-preview.py restart` 重开开发预览;`stop` 停止它;`ime` 只启动输入法服务。 `scripts/rime-preview.py restart` 重开开发预览;`stop` 停止它;`ime` 只启动输入法服务。
输入法崩溃后桌面保留,英文可直接输入,执行 `ime` 可重新启用中文。 输入法由独立 systemd 用户临时服务管理。异常退出后 1 秒自动重启;输入循环停止响应时,
10 秒 watchdog 会终止旧进程并重启。心跳来自实际输入循环,librime 卡住时不会继续报健康。
每分钟最多尝试 5 次,避免配置错误造成无限快速重启;主动 `stop` 不会触发重启。
服务不可用期间桌面保留,英文继续由合成器直接处理。
本机应用菜单中已有“Ferese Rime 开发预览”入口,右键操作可重开或停止。 本机应用菜单中已有“Ferese Rime 开发预览”入口,右键操作可重开或停止。
入口仅调用本仓库脚本;移动仓库后应更新 入口仅调用本仓库脚本;移动仓库后应更新
@@ -64,6 +67,26 @@ python3 scripts/rime-preview.py status
## 验证 ## 验证
### 按窗口保留状态
运行时按合成器会话 ID 和窗口 ID 隔离中英文模式、Rime schema;新窗口按应用 ID 恢复上次选择。
同一应用的两个现存窗口可保留不同状态;关闭后重开时采用该应用最后显式选择的状态。
输入法进程重启后也会恢复当前窗口偏好,不恢复未提交的组字。
`input-profiles.json` 位于独立 Rime 用户目录,通过 0600 临时文件、fsync 和原子重命名保存。
只保存应用标识和输入偏好,不保存窗口标题、聊天内容或组字文本。
`Ctrl+Space` 切换当前窗口的英文/Rime;原有 Shift 切换也会被保存。
```sh
# 在对应会话环境中查询或更改当前窗口;敏感输入框拒绝更改。
target/debug/ferese-rime --status
target/debug/ferese-rime --mode ascii
target/debug/ferese-rime --mode rime
target/debug/ferese-rime --select-schema luna_pinyin
```
控制套接字由 `FERESE_RIME_CONTROL` 指定,权限 0600。服务不运行时,查询命令不会初始化词库。
```sh ```sh
cargo test -p ferese input::binding_mode_tests -- --nocapture cargo test -p ferese input::binding_mode_tests -- --nocapture
cargo test -p ferese handlers::input_method::tests -- --nocapture cargo test -p ferese handlers::input_method::tests -- --nocapture
@@ -71,6 +94,7 @@ cargo test -p ferese-rime --lib --locked
cargo clippy -p ferese-rime -p ferese --all-targets --locked -- -D warnings cargo clippy -p ferese-rime -p ferese --all-targets --locked -- -D warnings
./target/debug/ferese-rime --user-data .artifacts/rime-poc-20261028/rime-self-test --self-test ./target/debug/ferese-rime --user-data .artifacts/rime-poc-20261028/rime-self-test --self-test
FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py
FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_resilience_isolated.py
``` ```
最后一个测试实际启动构建树预览,操作专用原生 GTK 测试框,验证: 最后一个测试实际启动构建树预览,操作专用原生 GTK 测试框,验证:
@@ -80,26 +104,82 @@ FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py
- 数字行减号、等号及 Shift+等号翻页,翻页后数字选字;候选保持单行横向排列。 - 数字行减号、等号及 Shift+等号翻页,翻页后数字选字;候选保持单行横向排列。
- 敏感输入框直接收到测试英文,Rime 未收到这些组字事件。 - 敏感输入框直接收到测试英文,Rime 未收到这些组字事件。
- 底边候选组字和提交,以及候选主题的实时切换;截图单独检查位置和外观。 - 底边候选组字和提交,以及候选主题的实时切换;截图单独检查位置和外观。
- 在组字期间 SIGKILL 输入法:合成器进程保持不变,英文继续输入,重启服务后恢复中文。 - 在组字期间 SIGKILL 输入法:合成器进程保持不变,服务自动重启并恢复当前窗口方案。
- SIGSTOP 停止输入循环,确认 watchdog 真正回收并重启进程;关闭重开应用仍保留模式和 schema。
- 输入法进程实际映射 librime,未映射 IBus/Fcitx 库。 - 输入法进程实际映射 librime,未映射 IBus/Fcitx 库。
- 测试输入工具连接真实宿主时拒绝输入。 - 测试输入工具连接真实宿主时拒绝输入。
`ferese-rime-probe` 只供集成测试使用,需要显式 `FERESE_TEST_INPUT=1`,且在创建虚拟键盘前 `ferese-rime-probe` 只供集成测试使用,需要显式 `FERESE_TEST_INPUT=1`,且在创建虚拟键盘前
检查目标确为只有 `ferese-winit` 输出的 Ferese 嵌套实例。测试不向宿主注入键盘事件。 检查目标确为只有 `ferese-winit` 输出的 Ferese 嵌套实例。
独立 DRM 测试额外要求专用 runtime 目录和与连接端 SO_PEERCRED 一致的合成器 PID。
普通宿主显示连接仍被拒绝,测试不向宿主注入键盘事件。
测试框只输入可丢弃样本,其普通字段保存在 `entry-state.json` 便于独立读回;敏感字段只记录长度。 测试框只输入可丢弃样本,其普通字段保存在 `entry-state.json` 便于独立读回;敏感字段只记录长度。
自动结果和截图保存在同一证据目录,不能用单纯的编译通过代替桌面输入验收。 自动结果和截图保存在同一证据目录,不能用单纯的编译通过代替桌面输入验收。
2026-10-28 的本机自动检查全部通过;用户随后使用实际键盘确认 2026-10-28 的本机自动检查全部通过;用户随后使用实际键盘确认
“显示、翻页和选字正常”。底边候选向上展开、浅深色主题已逐张检查截图。 “显示、翻页和选字正常”。底边候选向上展开、浅深色主题已逐张检查截图。
输入法服务在组字时被强制终止后,桌面和英文输入仍工作,单独重启服务后中文恢复。 后续故障注入中,SIGKILL 后约 2.4 秒恢复,SIGSTOP 后约 11 秒被 watchdog 回收并恢复。
### 本设备的图形路径
这台设备由 NVIDIA 负责三个物理显示器,AMD 核显没有连接显示器。
嵌套后端原先只公开 v3 格式列表,现增加实际 EGL 渲染设备的 v4 DMA-BUF 反馈。
DRM 后端在可识别主 GPU 时只向客户端公开其分配入口,保留已有的按表面分配反馈。
修复后 QQ 图形子进程使用 NVIDIA 渲染节点;GTK 原先的空白内容恢复显示。
独立会话已分别用 GTK Vulkan 和 OpenGL 路径完成三屏输入及锁定/解锁协议测试。
Super+左键拖动沿用上游默认绑定,已用实际输入处理路径验证窗口位置改变。
`FERESE_ENABLE_TEST_POINTER=1` 仅在本机测试启动器启用虚拟指针;默认不公开该全局接口,
锁定时也拒绝虚拟指针事件。此接口用于重现 QQ 对话切换和拖动,不用于候选点选。
### 独立 logind/DRM 会话
```sh
python3 scripts/rime-drm-validation.py start
FERESE_TEST_DRM=1 FERESE_TEST_GSK=vulkan python3 scripts/tests/test_rime_drm_isolated.py
FERESE_TEST_DRM=1 FERESE_TEST_GSK=gl python3 scripts/tests/test_rime_drm_isolated.py
python3 scripts/rime-drm-validation.py return
python3 scripts/rime-drm-validation.py stop
```
启动器在空闲 tty3 建立独立的临时 PAM/logind 会话,保留原 GNOME 会话。
启动前先安排独立的返回定时器;默认 60 秒返回,人工解锁测试使用 120 秒。
配置、D-Bus、运行时目录均独立,私有 runtime 在启动 D-Bus 前设置,避免影响宿主激活环境。
停止时清理本次输入法、测试应用、临时服务和本次 logind scope;不注销原会话。
已验证 DP-4、DP-5、DP-6 同时以 3840×2160、150% 缩放工作,刷新率约 60/160/144 Hz。
各屏组字、候选和提交正常。协议测试确认三个输出都完成保护帧后才报告锁定,
锁定时普通应用不收到测试键,截屏被拒绝;锁定期间输入法自动恢复后,解锁可继续中文输入。
协议测试夹具不校验 PAM 密码;人工密码解锁另用原版 `/usr/bin/ferese-lock --foreground` 验证。
真实锁屏已由用户输入本人密码完成,状态读回包含锁定到解锁的完整转换,锁屏程序正常退出。
用户确认“三屏显示和解锁正常”。该测试不读取或记录密码。
### QQ 与 Teams
```sh
python3 scripts/rime-app-preview.py qq
python3 scripts/rime-app-preview.py teams
python3 scripts/rime-app-preview.py stop
```
直接使用原生 Wayland Electron 输入参数,在各自独立测试资料目录中启动。
原 GNOME 下的 IBus/Fcitx 启动器、登录资料和 desktop 文件不修改。
QQ 已由用户登录,搜索输入和实际消息编辑框中的候选及“你好”提交通过,
测试文字随后删除,窗口关闭重开后仍保持 Rime。
已自动反复切换群助手和其他对话,对比屏幕帧及独立窗口重绘;首次加载图片导致的差异单独检查。
用户复测确认 QQ 切换对话“不再花屏”。未发送测试消息。
Teams 使用额外空白资料目录验证实际登录页文本框:原生 Rime 提交“你好”,
通过页面字段值独立读回,然后清空,未提交登录。
用户账户的 Teams 登录停在人脸/指纹/安全密钥验证页,用户确认 GNOME 下也有同样表现;
该登录条件尚未解除,不能把 Teams 的消息编辑器输入标为已验收。
## 当前边界 ## 当前边界
- 已覆盖原生 GTK4 输入路径和 Strata 的候选显示,当前默认方案为 `luna_pinyin_simp`。 - 已覆盖原生 GTK4 输入路径和 Strata 的候选显示,当前默认方案为 `luna_pinyin_simp`。
- 候选支持键盘选字;鼠标点选、配置界面、自动恢复策略仍待实现。 - 候选只做键盘操作,按本次需求不实现鼠标选词;配置界面仍待实现。
- 使用 US 键盘布局和嵌套输出验证;多布局切换、其他方案、个人词库迁移尚未验收。 - 使用 US 键盘布局和嵌套输出验证;多布局切换、其他方案、个人词库迁移尚未验收。
- Qt、Electron、Xwayland 和 Shell 的 layer-surface 输入场景尚未验收。 - 原生 QQ 搜索和消息编辑框、Teams 登录页输入已验证;Teams 消息编辑器、Qt、Xwayland 和 Shell 的 layer-surface 输入仍未验收。
- 独立登录后的 DRM、多屏热插拔、锁屏解锁和长期使用仍需验证。 - 独立 DRM 三屏及锁定协议已覆盖;物理热插拔、跨不同 GPU 的显示器组合和长期使用仍需验证。
- 当前为调试构建;字体加载、词库部署和重复绘制尚未进行性能优化。 - 当前为调试构建;字体加载、词库部署和重复绘制尚未进行性能优化。
- `FERESE_ENABLE_INPUT_METHOD=1` 仍沿用上游“受信任会话客户端”的许可边界, - `FERESE_ENABLE_INPUT_METHOD=1` 仍沿用上游“受信任会话客户端”的许可边界,
专用私有连接授权属于后续工作。 专用私有连接授权属于后续工作。
+6
View File
@@ -46,6 +46,8 @@ pub enum Command {
/// Show the active window. /// Show the active window.
#[command(alias = "get-focused-window")] #[command(alias = "get-focused-window")]
FocusedWindow, FocusedWindow,
/// Activate a mapped window by its session-local ID.
FocusWindow { window: u64 },
/// List windows. /// List windows.
#[command(alias = "get-windows")] #[command(alias = "get-windows")]
Windows, Windows,
@@ -58,6 +60,8 @@ pub enum Command {
/// List connected outputs and their applied configuration. /// List connected outputs and their applied configuration.
#[command(alias = "get-outputs")] #[command(alias = "get-outputs")]
Outputs, Outputs,
/// Focus an enabled monitor by its connector name.
FocusOutput { name: String },
/// List configured keybindings. /// List configured keybindings.
#[command(alias = "get-keybindings")] #[command(alias = "get-keybindings")]
Keybindings, Keybindings,
@@ -262,6 +266,8 @@ impl Command {
Self::IdleInhibition => ("get-idle-inhibition", json!({})), Self::IdleInhibition => ("get-idle-inhibition", json!({})),
Self::Workspaces => ("get-workspaces", json!({})), Self::Workspaces => ("get-workspaces", json!({})),
Self::Outputs => ("outputs", json!({})), Self::Outputs => ("outputs", json!({})),
Self::FocusWindow { window } => ("focus-window", json!({"window":window})),
Self::FocusOutput { name } => ("focus-output", json!({"name":name})),
Self::Keybindings => ("get-keybindings", json!({})), Self::Keybindings => ("get-keybindings", json!({})),
Self::SessionState => ("get-session-state", json!({})), Self::SessionState => ("get-session-state", json!({})),
Self::ToggleFloating => ("toggle-floating", json!({})), Self::ToggleFloating => ("toggle-floating", json!({})),
+32
View File
@@ -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")
+5
View File
@@ -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
+180
View File
@@ -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
View File
@@ -30,10 +30,15 @@ def prop(unit, field):
def start_unit(unit, command, env, limit): 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"] f"--working-directory={ROOT}", f"--property=MemoryMax={limit}", "--property=TasksMax=512"]
if unit != MAIN: if unit != MAIN:
args += [f"--property=BindsTo={MAIN}", f"--property=After={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)) 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", "QT_IM_MODULE": "wayland", "QT_QPA_PLATFORM": "wayland", "NO_AT_BRIDGE": "1", "GTK_A11Y": "none",
"GSETTINGS_BACKEND": "keyfile", "GSETTINGS_BACKEND": "keyfile",
"IBUS_ADDRESS": "", "FERESE_SESSION_IMPORT_ENV": "0", "FERESE_ENABLE_INPUT_METHOD": "1", "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"} "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")}) values.update({f"XDG_{kind.upper()}_HOME": str(DATA / "profile" / kind) for kind in ("config", "data", "cache", "state")})
return values 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") 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()): if not any(w["focused"] and w["app_id"] == "dev.ferese.RimeValidation" for w in windows()):
c = context() 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") 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": if prop(MAIN, "ActiveState") == "active":
print("开发预览已在运行。") print("开发预览已在运行。")
return return
DATA.mkdir(mode=0o700,parents=True,exist_ok=True)
DATA.chmod(0o700)
if not (ROOT / "target/debug/ferese-rime").exists(): if not (ROOT / "target/debug/ferese-rime").exists():
raise RuntimeError("Build ferese, feresectl and ferese-rime first") raise RuntimeError("Build ferese, feresectl and ferese-rime first")
for kind in ("config/ferese", "data", "cache", "state"): for kind in ("config/ferese", "data", "cache", "state"):
@@ -126,6 +136,8 @@ xwayland { enabled #false; }
status { keybinding-guide #false; } status { keybinding-guide #false; }
window-rule app-id="dev.ferese.RimeValidation" floating=#true width=880 height=560 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="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"; }; } theme { typography { font-family "MiSans"; }; }
''') ''')
host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) host_runtime = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
+1 -1
View File
@@ -1,7 +1,7 @@
imports.gi.versions.Gtk = '4.0'; imports.gi.versions.Gtk = '4.0';
const {Gtk, Gio, GLib, Gdk} = imports.gi; const {Gtk, Gio, GLib, Gdk} = imports.gi;
const output = ARGV[0]; 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', () => { application.connect('activate', () => {
const css = new Gtk.CssProvider(); css.load_from_string('window { font-family: "MiSans"; }'); 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); Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
+40
View File
@@ -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;
}
}
+99
View File
@@ -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()
+10 -9
View File
@@ -47,11 +47,11 @@ class NativeRimeTest(unittest.TestCase):
(preview.DATA / name).write_bytes(p.stdout) (preview.DATA / name).write_bytes(p.stdout)
clear() clear()
baseline_windows={window["id"] for window in preview.windows()}
probe("--keys", "nihao") probe("--keys", "nihao")
expect("") # Preedit is displayed, not prematurely committed. expect("") # Preedit is displayed, not prematurely committed.
screenshot("candidate-normal.png") screenshot("candidate-normal.png")
self.assertLessEqual({window["app_id"] for window in preview.windows()}, self.assertEqual({window["id"] for window in preview.windows()},baseline_windows)
{"dev.ferese.RimeValidation", "io.github.lgse.Strata"})
probe("--keys", " ") probe("--keys", " ")
expect("你好") expect("你好")
results.append("native GTK preedit, candidate popup and space commit") results.append("native GTK preedit, candidate popup and space commit")
@@ -145,18 +145,19 @@ class NativeRimeTest(unittest.TestCase):
clear() clear()
probe("--keys", "nihao") probe("--keys", "nihao")
compositor_pid = preview.prop(preview.MAIN, "MainPID") 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) 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, "ActiveState"), "active")
self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid) self.assertEqual(preview.prop(preview.MAIN, "MainPID"), compositor_pid)
probe("--keys", "abc") preview.wait_for(lambda: '"event":"ready"' in preview.run(
expect("abc") ["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") screenshot("ime-crash-desktop-survives.png")
preview.start_ime()
time.sleep(.2)
probe("--keys", "nihao ") probe("--keys", "nihao ")
expect("abc你好") expect("你好")
results.append("IME SIGKILL leaves desktop alive; ASCII works; restarted IME commits Chinese") results.append("IME SIGKILL automatically restarts with desktop alive and Chinese restored")
# The validation driver must refuse the real host before creating a keyboard. # The validation driver must refuse the real host before creating a keyboard.
host = Path(os.environ.get("WAYLAND_DISPLAY", "wayland-0")) 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()