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