use std::ffi::OsStr;
use std::ops::{Deref, DerefMut};
use std::os::windows::ffi::OsStrExt;
use std::os::windows::process::CommandExt;
use std::sync::atomic::{AtomicU32, Ordering};
use windows::Win32::Foundation::HWND;
use windows::Win32::Foundation::{LPARAM, LRESULT, WPARAM};
use windows::Win32::Graphics::Gdi::HBRUSH;
use windows::Win32::System::StationsAndDesktops::HDESK;
use windows::Win32::UI::WindowsAndMessaging::{
CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, HCURSOR,
HICON, RegisterClassExW, SW_MINIMIZE, SW_RESTORE, ShowWindow, WINDOW_EX_STYLE, WNDCLASSEXW,
WS_OVERLAPPEDWINDOW, WS_VISIBLE,
};
use windows::core::PCWSTR;
use flow_wm::registry::desktop;
static DESKTOP_COUNTER: AtomicU32 = AtomicU32::new(0);
static TITLE_COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_desktop_name() -> String {
let id = DESKTOP_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("flow-test-{id}")
}
pub fn unique_title(base: &str) -> String {
let id = TITLE_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("FlowTest-{base}-{id}")
}
pub struct TestDesktop {
pub name: String,
desktop: HDESK,
original: HDESK,
}
impl TestDesktop {
pub fn create() -> Result<Self, String> {
let name = unique_desktop_name();
let original = desktop::current_desktop()?;
let desk_handle = desktop::create_desktop(&name)?;
desktop::set_thread_desktop(desk_handle)?;
log::info!("test: created and switched to desktop '{name}'");
Ok(Self {
name,
desktop: desk_handle,
original,
})
}
}
impl Drop for TestDesktop {
fn drop(&mut self) {
if let Err(e) = desktop::set_thread_desktop(self.original) {
log::error!("test: failed to restore original desktop: {e}");
}
desktop::close_desktop(self.desktop);
log::info!("test: cleaned up desktop '{}'", self.name);
}
}
pub struct TestWindow {
pub hwnd: HWND,
pub title: String,
}
impl TestWindow {
pub fn create(title: &str) -> Result<Self, String> {
let class_name = wide("FlowTestClass");
register_test_class(&class_name)?;
let wide_title = wide(title);
let hwnd = unsafe {
CreateWindowExW(
WINDOW_EX_STYLE::default(),
PCWSTR(class_name.as_ptr()),
PCWSTR(wide_title.as_ptr()),
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
640,
480,
None,
None,
None,
None,
)
}
.map_err(|e| format!("CreateWindowExW failed for '{title}': {e}"))?;
std::thread::sleep(std::time::Duration::from_millis(200));
log::info!("test: created window '{title}' (hwnd={:?})", hwnd);
Ok(Self {
hwnd,
title: title.to_owned(),
})
}
pub fn minimize(&self) {
unsafe {
let _ = ShowWindow(self.hwnd, SW_MINIMIZE);
}
std::thread::sleep(std::time::Duration::from_millis(200));
log::debug!("test: minimized window '{}'", self.title);
}
pub fn restore(&self) {
unsafe {
let _ = ShowWindow(self.hwnd, SW_RESTORE);
}
std::thread::sleep(std::time::Duration::from_millis(200));
log::debug!("test: restored window '{}'", self.title);
}
}
impl Drop for TestWindow {
fn drop(&mut self) {
unsafe {
let _ = DestroyWindow(self.hwnd);
}
std::thread::sleep(std::time::Duration::from_millis(100));
log::info!("test: destroyed window '{}'", self.title);
}
}
pub struct KillingChild(std::process::Child);
impl KillingChild {
pub fn new(child: std::process::Child) -> Self {
Self(child)
}
}
impl Deref for KillingChild {
type Target = std::process::Child;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for KillingChild {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Drop for KillingChild {
fn drop(&mut self) {
if matches!(self.0.try_wait(), Ok(Some(_))) {
return;
}
let _ = self.0.kill();
let _ = self.0.wait();
}
}
pub fn start_test_daemon(pipe: &str, desktop_name: &str) -> Result<KillingChild, String> {
start_test_daemon_with_extra_args(pipe, desktop_name, &[])
}
pub fn start_test_daemon_with_extra_args(
pipe: &str,
desktop_name: &str,
extra_args: &[&str],
) -> Result<KillingChild, String> {
let exe = assert_cmd::cargo_bin!("flowd");
const DETACHED: u32 = 0x00000200 | 0x08000000;
let mut cmd = std::process::Command::new(exe);
cmd.arg("--desktop")
.arg(desktop_name)
.env("FLOW_PIPE_NAME", pipe)
.creation_flags(DETACHED);
let safe: String = pipe
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
const TEST_RULES_TOML: &str = include_str!("fixtures/flow-rules.toml");
let config_overridden = extra_args.contains(&"--config");
if !config_overridden {
let config_dir = std::env::temp_dir().join(format!("flow-test-config-{safe}"));
std::fs::create_dir_all(&config_dir)
.map_err(|e| format!("failed to create test config dir: {e}"))?;
let rules_path = config_dir.join("flow-rules.toml");
std::fs::write(&rules_path, TEST_RULES_TOML)
.map_err(|e| format!("failed to write test flow-rules.toml: {e}"))?;
eprintln!("[test] flowd config dir -> {}", config_dir.display());
cmd.arg("--config").arg(&config_dir);
}
let already_redirected = extra_args.contains(&"--log-file");
if !already_redirected {
let log_path = std::env::temp_dir().join(format!("flowd-test-{safe}.log"));
eprintln!("[test] flowd log -> {}", log_path.display());
cmd.arg("--log-file").arg(log_path);
}
for arg in extra_args {
cmd.arg(arg);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn flowd: {e}"))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match child.try_wait().map_err(|e| format!("wait error: {e}"))? {
Some(status) => {
if !status.success() {
return Err(format!("flowd exited with {status}"));
}
return Err("flowd exited unexpectedly with success".into());
}
None => {
if is_pipe_available(pipe) {
return Ok(KillingChild::new(child));
}
if std::time::Instant::now() >= deadline {
return Err("timed out waiting for test daemon to start".into());
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
}
}
fn is_pipe_available(pipe: &str) -> bool {
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ,
FILE_SHARE_WRITE, OPEN_EXISTING,
};
let wide = wide(pipe);
let result = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0,
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
None,
)
};
if let Ok(handle) = result {
unsafe {
let _ = CloseHandle(handle);
}
true
} else {
false
}
}
pub struct DaemonGuard {
pipe: String,
}
impl DaemonGuard {
pub fn new(pipe: &str) -> Self {
Self {
pipe: pipe.to_owned(),
}
}
}
impl Drop for DaemonGuard {
fn drop(&mut self) {
stop_test_daemon(&self.pipe);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
pub fn query_windows(pipe: &str) -> Result<serde_json::Value, String> {
use flow_wm::ipc::message::{SocketMessage, SocketResponse};
use flow_wm::ipc::transport;
let response = transport::send_message_to(pipe, &SocketMessage::QueryWindowsAll)
.map_err(|e| format!("query failed: {e}"))?;
match response {
SocketResponse::Data { payload } => Ok(payload),
SocketResponse::Error { message } => Err(format!("daemon error: {message}")),
SocketResponse::Ok => Err("unexpected Ok response".into()),
}
}
pub fn query_layout_virtual(pipe: &str) -> Result<serde_json::Value, String> {
use flow_wm::ipc::message::{SocketMessage, SocketResponse};
use flow_wm::ipc::transport;
let response = transport::send_message_to(pipe, &SocketMessage::QueryLayoutVirtual)
.map_err(|e| format!("query_layout_virtual failed: {e}"))?;
match response {
SocketResponse::Data { payload } => Ok(payload),
SocketResponse::Error { message } => Err(format!("daemon error: {message}")),
SocketResponse::Ok => Err("unexpected Ok response".into()),
}
}
pub fn send_ipc_ignore(pipe: &str, msg: &flow_wm::ipc::message::SocketMessage) {
use flow_wm::ipc::transport;
let _ = transport::send_message_to(pipe, msg);
}
pub fn send_ipc_retry(
pipe: &str,
msg: &flow_wm::ipc::message::SocketMessage,
) -> Result<flow_wm::ipc::message::SocketResponse, String> {
use flow_wm::ipc::transport;
const ATTEMPTS: u32 = 20;
const SLEEP: std::time::Duration = std::time::Duration::from_millis(25);
let mut last_err = String::new();
for _ in 0..ATTEMPTS {
match transport::send_message_to(pipe, msg) {
Ok(resp) => return Ok(resp),
Err(e) => {
last_err = format!("{e}");
std::thread::sleep(SLEEP);
}
}
}
Err(format!(
"IPC send failed after {ATTEMPTS} attempts ({} ms total): {last_err}",
ATTEMPTS * 25
))
}
pub fn active_window_ids(json: &serde_json::Value) -> Vec<i64> {
json["columns"]
.as_array()
.map(|cols| {
cols.iter()
.flat_map(|col| {
col["rows"]
.as_array()
.map(|rows| {
rows.iter()
.filter_map(|r| r["window_id"].as_i64())
.collect::<Vec<_>>()
})
.unwrap_or_default()
})
.collect()
})
.unwrap_or_default()
}
pub fn wait_until_windows_tiled(pipe: &str, expected: usize) -> Result<serde_json::Value, String> {
const ATTEMPTS: u32 = 40;
const SLEEP: std::time::Duration = std::time::Duration::from_millis(50);
let mut last = String::new();
for _ in 0..ATTEMPTS {
let json = query_layout_virtual(pipe)?;
if active_window_ids(&json).len() == expected {
return Ok(json);
}
last = format!("{json:?}");
std::thread::sleep(SLEEP);
}
Err(format!(
"timed out waiting for {expected} windows to be tiled (last layout: {last})"
))
}
#[allow(dead_code)]
pub fn query_layout_actual(pipe: &str) -> Result<serde_json::Value, String> {
use flow_wm::ipc::message::{SocketMessage, SocketResponse};
use flow_wm::ipc::transport;
let response = transport::send_message_to(pipe, &SocketMessage::QueryLayoutActual)
.map_err(|e| format!("query layout actual failed: {e}"))?;
match response {
SocketResponse::Data { payload } => Ok(payload),
SocketResponse::Error { message } => Err(format!("daemon error: {message}")),
SocketResponse::Ok => Err("unexpected Ok response".into()),
}
}
pub fn stop_test_daemon(pipe: &str) {
use flow_wm::ipc::message::SocketMessage;
use flow_wm::ipc::transport;
let _ = transport::send_message_to(pipe, &SocketMessage::Stop);
}
fn register_test_class(class_name: &[u16]) -> Result<(), String> {
let wnd_class = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(test_wnd_proc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: windows::Win32::Foundation::HINSTANCE::default(),
hIcon: HICON::default(),
hCursor: HCURSOR::default(),
hbrBackground: HBRUSH::default(),
lpszMenuName: PCWSTR::null(),
lpszClassName: PCWSTR(class_name.as_ptr()),
hIconSm: HICON::default(),
};
let atom = unsafe { RegisterClassExW(&wnd_class) };
if atom == 0 {
log::debug!("test: RegisterClassExW returned 0 (class may already exist)");
}
Ok(())
}
unsafe extern "system" fn test_wnd_proc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
}
pub fn wide(s: &str) -> Vec<u16> {
OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}