From 6f34fd080f85b4e1f9d8302dbff480234661b5b4 Mon Sep 17 00:00:00 2001 From: aria Date: Wed, 28 Oct 2026 02:32:19 +0900 Subject: [PATCH] Recover Rime faults, persist window modes and fix GPU allocation --- Cargo.lock | 1 + compositor/src/backends/direct.rs | 14 +- compositor/src/handlers/dispatch.rs | 2 + compositor/src/handlers/mod.rs | 1 + compositor/src/handlers/virtual_pointer.rs | 13 ++ compositor/src/ipc.rs | 59 ++++++ compositor/src/state.rs | 11 ++ compositor/src/winit.rs | 23 ++- crates/ferese-rime/Cargo.toml | 1 + crates/ferese-rime/src/bin/probe.rs | 159 ++++++++++++++- crates/ferese-rime/src/lib.rs | 14 ++ crates/ferese-rime/src/main.rs | 161 ++++++++++++++- crates/ferese-rime/src/profiles.rs | 185 ++++++++++++++++++ crates/ferese-rime/src/rime_shim.c | 11 +- crates/ferese-rime/src/watchdog.rs | 39 ++++ docs/configuration/session-protocols.md | 5 + docs/development/rime-poc.md | 94 ++++++++- feresectl/src/cli.rs | 6 + scripts/rime-app-preview.py | 32 +++ scripts/rime-drm-shell | 5 + scripts/rime-drm-validation.py | 180 +++++++++++++++++ scripts/rime-preview.py | 16 +- scripts/tests/fixtures/rime-entry.js | 2 +- scripts/tests/fixtures/session-lock-cycle.c | 40 ++++ scripts/tests/test_rime_drm_isolated.py | 99 ++++++++++ scripts/tests/test_rime_native_isolated.py | 19 +- .../tests/test_rime_resilience_isolated.py | 102 ++++++++++ 27 files changed, 1260 insertions(+), 34 deletions(-) create mode 100644 compositor/src/handlers/virtual_pointer.rs create mode 100644 crates/ferese-rime/src/profiles.rs create mode 100644 crates/ferese-rime/src/watchdog.rs create mode 100644 scripts/rime-app-preview.py create mode 100644 scripts/rime-drm-shell create mode 100644 scripts/rime-drm-validation.py create mode 100644 scripts/tests/fixtures/session-lock-cycle.c create mode 100644 scripts/tests/test_rime_drm_isolated.py create mode 100644 scripts/tests/test_rime_resilience_isolated.py diff --git a/Cargo.lock b/Cargo.lock index 2511b84..35e9b8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1970,6 +1970,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "wayland-protocols-misc", + "wayland-protocols-wlr", "xkbcommon 0.9.0", ] diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 1f99cfb..9e14efb 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -999,6 +999,14 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result Result(&display_handle, &feedback); + .create_global_with_filter_and_default_feedback::( + &display_handle, + &feedback, + move |_| allocation_device, + ); state.dmabuf_imports.register(dmabuf_global); state.dmabuf_feedback.register(dmabuf_global, feedback); state diff --git a/compositor/src/handlers/dispatch.rs b/compositor/src/handlers/dispatch.rs index 4be5b80..036b7ae 100644 --- a/compositor/src/handlers/dispatch.rs +++ b/compositor/src/handlers/dispatch.rs @@ -53,6 +53,8 @@ macro_rules! 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_output_image_capture_source_manager_v1::ExtOutputImageCaptureSourceManagerV1, protocols::ext::image_copy_capture::v1::server::ext_image_copy_capture_manager_v1::ExtImageCopyCaptureManagerV1, diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 98536b7..8e03697 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod security_context; #[cfg(test)] mod toplevel_icon_tests; mod virtual_keyboard; +mod virtual_pointer; pub(crate) mod window_capture; mod xdg_shell; diff --git a/compositor/src/handlers/virtual_pointer.rs b/compositor/src/handlers/virtual_pointer.rs new file mode 100644 index 0000000..f312d7b --- /dev/null +++ b/compositor/src/handlers/virtual_pointer.rs @@ -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) { + // 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); + } + } +} diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index ffae305..452bece 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -1205,6 +1205,27 @@ impl Ferese { .reload_config() .map_err(|e| CommandError::new("invalid_config", e))?, "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-floating" => self.focus_floating(), "focus-mru-next" => self.cycle_focus(false, false), @@ -1249,6 +1270,44 @@ impl Ferese { "expel" => self.expel_focused_window(), "close" => self.close_focused_window(), "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 = 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()), "media-get" => return Ok(self.media_engine.value()), "get-idle-inhibition" => { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index c3252d2..1e767f1 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -573,6 +573,8 @@ pub struct Ferese { pub idle_notifier_state: IdleNotifierState, pub input_method_manager_state: InputMethodManagerState, pub virtual_keyboard_manager_state: VirtualKeyboardManagerState, + pub virtual_pointer_manager_state: + Option, pub keyboard_shortcuts_inhibit_state: KeyboardShortcutsInhibitState, pub output_manager_state: OutputManagerState, pub pointer_constraints_state: PointerConstraintsState, @@ -680,6 +682,14 @@ impl Ferese { VirtualKeyboardManagerState::new::(&display_handle, move |_| { 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::( + &display_handle, + |_| true, + ) + }); let keyboard_shortcuts_inhibit_state = KeyboardShortcutsInhibitState::new::(&display_handle); let shortcut_inhibit_enabled = @@ -900,6 +910,7 @@ impl Ferese { idle_notifier_state, input_method_manager_state, virtual_keyboard_manager_state, + virtual_pointer_manager_state, keyboard_shortcuts_inhibit_state, output_manager_state, pointer_constraints_state, diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 48a63b1..81afff2 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -3,6 +3,7 @@ use std::error::Error; use std::rc::Rc; use std::time::{Duration, Instant}; +use smithay::backend::egl::EGLDevice; use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::element::RenderElementStates; 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::timer::{TimeoutAction, Timer}; use smithay::utils::{Physical, Rectangle, Size, Transform}; +use smithay::wayland::dmabuf::DmabufFeedbackBuilder; use crate::Ferese; use crate::metrics::{FrameEffectMetrics, RenderMetrics}; @@ -35,9 +37,24 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() let drawable = Rc::new(Cell::new(usable_size(initial_size))); let dmabuf_formats = backend.renderer().dmabuf_formats(); let display_handle = state.display_handle.clone(); - let dmabuf_global = state - .dmabuf_state - .create_global::(&display_handle, dmabuf_formats); + // 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 + .create_global_with_default_feedback::(&display_handle, &feedback); + state.dmabuf_feedback.register(global, feedback); + global + } else { + state + .dmabuf_state + .create_global::(&display_handle, dmabuf_formats) + }; state.dmabuf_imports.register(dmabuf_global); let initial_scale = normalized_scale(backend.scale_factor()); let mode = Mode { diff --git a/crates/ferese-rime/Cargo.toml b/crates/ferese-rime/Cargo.toml index 9f7cccc..55574de 100644 --- a/crates/ferese-rime/Cargo.toml +++ b/crates/ferese-rime/Cargo.toml @@ -20,6 +20,7 @@ tiny-skia = "0.11.4" wayland-client.workspace = true wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } wayland-protocols-misc = { version = "0.3.12", features = ["client"] } +wayland-protocols-wlr.workspace = true xkbcommon = "0.9.0" [build-dependencies] diff --git a/crates/ferese-rime/src/bin/probe.rs b/crates/ferese-rime/src/bin/probe.rs index b6f4f73..037a47b 100644 --- a/crates/ferese-rime/src/bin/probe.rs +++ b/crates/ferese-rime/src/bin/probe.rs @@ -1,14 +1,18 @@ //! 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::io::{Seek, Write}; -use std::os::fd::AsFd; +use std::os::fd::{AsFd, AsRawFd}; 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_protocols_misc::zwp_virtual_keyboard_v1::client::{ 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; #[derive(Default)] @@ -17,6 +21,7 @@ struct State { seat: Option, ferese: bool, outputs: Vec, + pointer_manager: Option, } impl Dispatch for State { fn event( @@ -42,6 +47,9 @@ impl Dispatch for State { "wl_output" if version >= 4 => { 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 keyboard::ZwpVirtualKeyboardV1); +fn parse_numbers(text: &str) -> Result, Box> { + Ok(text + .split(',') + .map(str::parse) + .collect::, _>>()?) +} + +delegate_noop!(State: ignore pointer_manager::ZwlrVirtualPointerManagerV1); +delegate_noop!(State: ignore virtual_pointer::ZwlrVirtualPointerV1); + fn main() -> Result<(), Box> { if std::env::var("FERESE_TEST_INPUT").as_deref() != Ok("1") { return Err( @@ -72,9 +90,33 @@ fn main() -> Result<(), Box> { ); } let mut sequences: Vec> = Vec::new(); + let mut clicks = Vec::new(); + let mut drag = None; + let mut extent = (1280, 800); let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { 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" => { for character in args.next().ok_or("missing text")?.chars() { sequences.push(vec![character as u32]); @@ -101,10 +143,36 @@ fn main() -> Result<(), Box> { let mut state = State::default(); queue.roundtrip(&mut state)?; queue.roundtrip(&mut state)?; - if !state.ferese - || state.outputs.is_empty() - || state.outputs.iter().any(|name| name != "ferese-winit") - { + let nested = state.outputs.iter().all(|name| name == "ferese-winit"); + // SAFETY: getuid takes no pointers and has no preconditions. + 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::().ok()) + .is_some_and(|expected| { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut size = std::mem::size_of::() 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()); } let keyboard = state @@ -152,6 +220,83 @@ fn main() -> Result<(), Box> { 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(); queue.roundtrip(&mut state)?; println!("nested test input complete"); diff --git a/crates/ferese-rime/src/lib.rs b/crates/ferese-rime/src/lib.rs index 1b4b499..80cc950 100644 --- a/crates/ferese-rime/src/lib.rs +++ b/crates/ferese-rime/src/lib.rs @@ -1,4 +1,5 @@ pub mod panel; +pub mod profiles; /// The number-row paging keys are local to a composition with candidates. /// Control/Alt/Super shortcuts and literal ASCII typing retain their symbols. @@ -31,6 +32,7 @@ pub struct Candidate { pub struct Snapshot { pub accepted: bool, pub ascii: bool, + pub schema: String, pub commit: String, pub preedit: String, /// Byte offset, matching text-input-v3 (not a character count). @@ -52,6 +54,7 @@ struct RawResult { count: c_int, commit: *mut c_char, preedit: *mut c_char, + schema: *mut c_char, texts: [*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_result_free(out: *mut RawResult); fn ferese_rime_clear(); + fn ferese_rime_profile(schema: *const c_char, ascii_mode: c_int) -> c_int; fn ferese_rime_close(); } @@ -113,6 +117,15 @@ impl Engine { 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 { // SAFETY: all-zero is valid for this C struct's integers/null pointers; // the shim initializes and returns owned, NUL-terminated UTF-8 strings. @@ -136,6 +149,7 @@ impl Engine { let result = Snapshot { accepted: raw.accepted != 0, ascii: raw.ascii_mode != 0, + schema: text(raw.schema), commit: text(raw.commit), preedit, cursor: cursor as i32, diff --git a/crates/ferese-rime/src/main.rs b/crates/ferese-rime/src/main.rs index dff66a5..91b1b2d 100644 --- a/crates/ferese-rime/src/main.rs +++ b/crates/ferese-rime/src/main.rs @@ -1,11 +1,13 @@ //! Independent native Wayland IME. librime and candidate rasterization never //! execute on Ferese's compositor thread. +mod watchdog; use std::collections::HashMap; use std::error::Error; use std::fs::File; use std::io::{Read, Seek, Write}; use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant}; @@ -13,6 +15,7 @@ use std::time::{Duration, Instant}; use ferese_rime::{ Engine, Snapshot, panel::{Colors, Panel}, + profiles::{Context, Profile, Profiles}, }; use wayland_client::protocol::{ 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, panel: Panel, theme: Receiver, + profiles: Profiles, + control: UnixListener, compositor: Option, shm: Option, seat: Option, @@ -77,6 +82,7 @@ struct State { serial: u32, active: bool, pending_active: bool, + new_context: bool, sensitive: bool, pending_sensitive: bool, last: Snapshot, @@ -123,6 +129,27 @@ impl State { self.routes.clear(); 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::(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() { return; } @@ -184,6 +211,21 @@ impl State { 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 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) = ferese_rime::paging_key(keysym, mask, !self.last.candidates.is_empty()); let mut snapshot = self.engine.process(keysym, mask); @@ -216,6 +258,17 @@ impl State { } fn publish(&mut self, snapshot: Snapshot, qh: &QueueHandle) { + 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(); if !snapshot.commit.is_empty() { method.commit_string(snapshot.commit.clone()); @@ -226,7 +279,7 @@ impl State { eprintln!( "{}", 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; if !visible { @@ -290,6 +343,25 @@ impl State { } fn tick(&mut self, qh: &QueueHandle) { + // 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::(&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; while let Ok(colors) = self.theme.try_recv() { self.panel.colors = colors; @@ -323,6 +395,34 @@ impl State { self.publish(snapshot, qh); } } + + fn control_request( + &mut self, + request: serde_json::Value, + qh: &QueueHandle, + ) -> Result { + 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 for State { @@ -377,6 +477,7 @@ impl Dispatch for State { method::Event::Activate => { state.pending_active = true; state.pending_sensitive = false; + state.new_context = true; } method::Event::Deactivate => state.pending_active = false, method::Event::ContentType { hint, purpose } => { @@ -555,6 +656,13 @@ fn main() -> Result<(), Box> { let mut shared = PathBuf::from("/usr/share/rime-data"); let mut schema = "luna_pinyin_simp".to_owned(); 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); while let Some(arg) = args.next() { match arg.as_str() { @@ -562,19 +670,46 @@ fn main() -> Result<(), Box> { "--shared-data" => shared = args.next().ok_or("missing shared data path")?.into(), "--schema" => schema = args.next().ok_or("missing Rime schema")?, "--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" => { println!("ferese-rime {}", env!("CARGO_PKG_VERSION")); return Ok(()); } "--help" => { 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 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::(&response)?["ok"] != true { + return Err("input-method command failed".into()); + } + return Ok(()); + } std::fs::create_dir_all(&user)?; std::fs::set_permissions(&user, std::fs::Permissions::from_mode(0o700))?; let mut engine = Engine::open(&user, &shared, &schema)?; @@ -598,6 +733,21 @@ fn main() -> Result<(), Box> { ); 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 (send, theme) = mpsc::channel(); std::thread::spawn(move || { @@ -625,6 +775,8 @@ fn main() -> Result<(), Box> { engine, panel, theme, + profiles, + control: listener, compositor: None, shm: None, seat: None, @@ -642,6 +794,7 @@ fn main() -> Result<(), Box> { serial: 0, active: false, pending_active: false, + new_context: false, sensitive: false, pending_sensitive: false, last: Snapshot::default(), @@ -652,9 +805,13 @@ fn main() -> Result<(), Box> { }; queue.roundtrip(&mut state)?; state.initialize(&qh)?; + let mut watchdog = watchdog::Watchdog::from_environment(); loop { queue.dispatch_pending(&mut state)?; state.tick(&qh); + if let Some(watchdog) = &mut watchdog { + watchdog.tick(); + } if let Some(error) = state.error.take() { return Err(error.into()); } diff --git a/crates/ferese-rime/src/profiles.rs b/crates/ferese-rime/src/profiles.rs new file mode 100644 index 0000000..e123cbd --- /dev/null +++ b/crates/ferese-rime/src/profiles.rs @@ -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, + #[serde(default)] + windows: BTreeMap, +} + +pub struct Profiles { + path: PathBuf, + default: Profile, + stored: Stored, + dirty: bool, + pub context: Option, + pub current: Profile, +} +impl Profiles { + pub fn open(path: &Path, schema: String) -> Result { + 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) -> 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 { + 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 + ); + } +} diff --git a/crates/ferese-rime/src/rime_shim.c b/crates/ferese-rime/src/rime_shim.c index aa27343..a5b8ee8 100644 --- a/crates/ferese-rime/src/rime_shim.c +++ b/crates/ferese-rime/src/rime_shim.c @@ -5,7 +5,7 @@ /* Keep the versioned librime ABI in this small, header-checked boundary. */ typedef struct { int accepted, ascii_mode, cursor, highlighted, page_no, last_page, count; - char *commit, *preedit; + char *commit, *preedit, *schema; char *texts[9], *comments[9]; } FereseRimeResult; @@ -57,15 +57,22 @@ void ferese_rime_snapshot(int key, int mask, FereseRimeResult *out) { RIME_STRUCT(RimeStatus, status); if (api->get_status(session, &status)) { out->ascii_mode = status.is_ascii_mode; + out->schema = strdup(status.schema_id ? status.schema_id : ""); api->free_status(&status); } } 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]); } } 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) { if (session) api->destroy_session(session); if (api) api->finalize(); diff --git a/crates/ferese-rime/src/watchdog.rs b/crates/ferese-rime/src/watchdog.rs new file mode 100644 index 0000000..913c790 --- /dev/null +++ b/crates/ferese-rime/src/watchdog.rs @@ -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 { + let path = std::env::var("NOTIFY_SOCKET").ok()?; + let micros = std::env::var("WATCHDOG_USEC").ok()?.parse::().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; + } + } +} diff --git a/docs/configuration/session-protocols.md b/docs/configuration/session-protocols.md index 554092a..686e5b2 100644 --- a/docs/configuration/session-protocols.md +++ b/docs/configuration/session-protocols.md @@ -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 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. 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 diff --git a/docs/development/rime-poc.md b/docs/development/rime-poc.md index f638b45..0c710a1 100644 --- a/docs/development/rime-poc.md +++ b/docs/development/rime-poc.md @@ -51,7 +51,10 @@ python3 scripts/rime-preview.py status 并在本机存在 Strata 时打开单独的测试目录。GTK 使用 `GTK_IM_MODULE=wayland`。 `scripts/rime-preview.py restart` 重开开发预览;`stop` 停止它;`ime` 只启动输入法服务。 -输入法崩溃后桌面保留,英文可直接输入,执行 `ime` 可重新启用中文。 +输入法由独立 systemd 用户临时服务管理。异常退出后 1 秒自动重启;输入循环停止响应时, +10 秒 watchdog 会终止旧进程并重启。心跳来自实际输入循环,librime 卡住时不会继续报健康。 +每分钟最多尝试 5 次,避免配置错误造成无限快速重启;主动 `stop` 不会触发重启。 +服务不可用期间桌面保留,英文继续由合成器直接处理。 本机应用菜单中已有“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 cargo test -p ferese input::binding_mode_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 ./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_resilience_isolated.py ``` 最后一个测试实际启动构建树预览,操作专用原生 GTK 测试框,验证: @@ -80,26 +104,82 @@ FERESE_TEST_RIME=1 python3 scripts/tests/test_rime_native_isolated.py - 数字行减号、等号及 Shift+等号翻页,翻页后数字选字;候选保持单行横向排列。 - 敏感输入框直接收到测试英文,Rime 未收到这些组字事件。 - 底边候选组字和提交,以及候选主题的实时切换;截图单独检查位置和外观。 -- 在组字期间 SIGKILL 输入法:合成器进程保持不变,英文继续输入,重启服务后恢复中文。 +- 在组字期间 SIGKILL 输入法:合成器进程保持不变,服务自动重启并恢复当前窗口方案。 +- SIGSTOP 停止输入循环,确认 watchdog 真正回收并重启进程;关闭重开应用仍保留模式和 schema。 - 输入法进程实际映射 librime,未映射 IBus/Fcitx 库。 - 测试输入工具连接真实宿主时拒绝输入。 `ferese-rime-probe` 只供集成测试使用,需要显式 `FERESE_TEST_INPUT=1`,且在创建虚拟键盘前 -检查目标确为只有 `ferese-winit` 输出的 Ferese 嵌套实例。测试不向宿主注入键盘事件。 +检查目标确为只有 `ferese-winit` 输出的 Ferese 嵌套实例。 +独立 DRM 测试额外要求专用 runtime 目录和与连接端 SO_PEERCRED 一致的合成器 PID。 +普通宿主显示连接仍被拒绝,测试不向宿主注入键盘事件。 测试框只输入可丢弃样本,其普通字段保存在 `entry-state.json` 便于独立读回;敏感字段只记录长度。 自动结果和截图保存在同一证据目录,不能用单纯的编译通过代替桌面输入验收。 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`。 -- 候选支持键盘选字;鼠标点选、配置界面、自动恢复策略仍待实现。 +- 候选只做键盘操作,按本次需求不实现鼠标选词;配置界面仍待实现。 - 使用 US 键盘布局和嵌套输出验证;多布局切换、其他方案、个人词库迁移尚未验收。 -- Qt、Electron、Xwayland 和 Shell 的 layer-surface 输入场景尚未验收。 -- 独立登录后的 DRM、多屏热插拔、锁屏解锁和长期使用仍需验证。 +- 原生 QQ 搜索和消息编辑框、Teams 登录页输入已验证;Teams 消息编辑器、Qt、Xwayland 和 Shell 的 layer-surface 输入仍未验收。 +- 独立 DRM 三屏及锁定协议已覆盖;物理热插拔、跨不同 GPU 的显示器组合和长期使用仍需验证。 - 当前为调试构建;字体加载、词库部署和重复绘制尚未进行性能优化。 - `FERESE_ENABLE_INPUT_METHOD=1` 仍沿用上游“受信任会话客户端”的许可边界, 专用私有连接授权属于后续工作。 diff --git a/feresectl/src/cli.rs b/feresectl/src/cli.rs index 09f12ee..3d98774 100644 --- a/feresectl/src/cli.rs +++ b/feresectl/src/cli.rs @@ -46,6 +46,8 @@ pub enum Command { /// Show the active window. #[command(alias = "get-focused-window")] FocusedWindow, + /// Activate a mapped window by its session-local ID. + FocusWindow { window: u64 }, /// List windows. #[command(alias = "get-windows")] Windows, @@ -58,6 +60,8 @@ pub enum Command { /// List connected outputs and their applied configuration. #[command(alias = "get-outputs")] Outputs, + /// Focus an enabled monitor by its connector name. + FocusOutput { name: String }, /// List configured keybindings. #[command(alias = "get-keybindings")] Keybindings, @@ -262,6 +266,8 @@ impl Command { Self::IdleInhibition => ("get-idle-inhibition", json!({})), Self::Workspaces => ("get-workspaces", 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::SessionState => ("get-session-state", json!({})), Self::ToggleFloating => ("toggle-floating", json!({})), diff --git a/scripts/rime-app-preview.py b/scripts/rime-app-preview.py new file mode 100644 index 0000000..c90f1aa --- /dev/null +++ b/scripts/rime-app-preview.py @@ -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") diff --git a/scripts/rime-drm-shell b/scripts/rime-drm-shell new file mode 100644 index 0000000..be48567 --- /dev/null +++ b/scripts/rime-drm-shell @@ -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 diff --git a/scripts/rime-drm-validation.py b/scripts/rime-drm-validation.py new file mode 100644 index 0000000..5631f91 --- /dev/null +++ b/scripts/rime-drm-validation.py @@ -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]() diff --git a/scripts/rime-preview.py b/scripts/rime-preview.py index 2493f08..f37783a 100644 --- a/scripts/rime-preview.py +++ b/scripts/rime-preview.py @@ -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()}")) diff --git a/scripts/tests/fixtures/rime-entry.js b/scripts/tests/fixtures/rime-entry.js index 94336a1..5505895 100644 --- a/scripts/tests/fixtures/rime-entry.js +++ b/scripts/tests/fixtures/rime-entry.js @@ -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); diff --git a/scripts/tests/fixtures/session-lock-cycle.c b/scripts/tests/fixtures/session-lock-cycle.c new file mode 100644 index 0000000..830d20b --- /dev/null +++ b/scripts/tests/fixtures/session-lock-cycle.c @@ -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 +#include +#include +#include +#include +#include +#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; + } +} diff --git a/scripts/tests/test_rime_drm_isolated.py b/scripts/tests/test_rime_drm_isolated.py new file mode 100644 index 0000000..2412508 --- /dev/null +++ b/scripts/tests/test_rime_drm_isolated.py @@ -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()