use actl_core::state::SignalPaths;
use actl_core::{CtlError, ErrorCode};
use windows::Win32::Foundation::{CloseHandle, FILETIME, HWND};
use windows::Win32::System::Threading::{
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows::Win32::UI::WindowsAndMessaging::{
GWL_EXSTYLE, GetWindowLongPtrW, GetWindowThreadProcessId, SetWindowLongPtrW, WS_EX_NOACTIVATE,
};
fn process_start(pid: u32) -> Option<u64> {
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
let mut create = FILETIME::default();
let mut exit = FILETIME::default();
let mut kernel = FILETIME::default();
let mut user = FILETIME::default();
let queried =
GetProcessTimes(handle, &mut create, &mut exit, &mut kernel, &mut user).is_ok();
let _ = CloseHandle(handle);
queried.then_some(((create.dwHighDateTime as u64) << 32) | create.dwLowDateTime as u64)
}
}
fn hwnd_belongs_to(hwnd: isize, pid: u32) -> bool {
unsafe {
let mut current: u32 = 0;
GetWindowThreadProcessId(HWND(hwnd as _), Some(&mut current));
current == pid
}
}
fn exstyle(hwnd: isize) -> Result<isize, CtlError> {
unsafe {
let style = GetWindowLongPtrW(HWND(hwnd as _), GWL_EXSTYLE);
if style == 0 {
let err = windows::core::HRESULT::from_thread();
if err.is_err() {
return Err(CtlError::internal(format!(
"GetWindowLongPtrW(GWL_EXSTYLE) failed: {err}"
)));
}
Ok(0)
} else {
Ok(style)
}
}
}
fn clear_no_activate_bit(hwnd: isize) {
unsafe {
let cur = GetWindowLongPtrW(HWND(hwnd as _), GWL_EXSTYLE);
if cur != 0 && (cur as u32 & WS_EX_NOACTIVATE.0) != 0 {
let _ = SetWindowLongPtrW(
HWND(hwnd as _),
GWL_EXSTYLE,
cur & !(WS_EX_NOACTIVATE.0 as isize),
);
}
}
}
fn journal_path(paths: &SignalPaths) -> std::path::PathBuf {
paths.dir.join("no-activate-journal.json")
}
fn read_journal(paths: &SignalPaths) -> Vec<(isize, u32, u64)> {
let text = std::fs::read_to_string(journal_path(paths)).unwrap_or_default();
let Some(arr) = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v.as_array().cloned())
else {
return Vec::new();
};
arr.iter()
.filter_map(|e| {
let hwnd = e["hwnd"].as_i64()?;
let pid = e["pid"].as_u64()?;
let start = e["start"].as_u64()?;
Some((hwnd as isize, pid as u32, start))
})
.collect()
}
fn write_journal(paths: &SignalPaths, entries: &[(isize, u32, u64)]) -> Result<(), CtlError> {
std::fs::create_dir_all(&paths.dir)
.map_err(|e| CtlError::internal(format!("create {}: {e}", paths.dir.display())))?;
let file = journal_path(paths);
let tmp = file.with_extension("journal-tmp");
let body: Vec<serde_json::Value> = entries
.iter()
.map(|(hwnd, pid, start)| serde_json::json!({ "hwnd": hwnd, "pid": pid, "start": start }))
.collect();
std::fs::write(&tmp, serde_json::to_vec(&body).unwrap_or_default())
.and_then(|_| std::fs::rename(&tmp, &file))
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
CtlError::internal(format!("journal write {}: {e}", file.display()))
})
}
pub(crate) fn recover_stale(paths: &SignalPaths) {
let entries = read_journal(paths);
if entries.is_empty() {
return;
}
let me = std::process::id();
let mut kept = Vec::new();
for (hwnd, pid, start) in entries {
let live = pid == me || process_start(pid).is_some_and(|s| s == start);
if live {
kept.push((hwnd, pid, start));
continue;
}
if hwnd_belongs_to(hwnd, pid) {
clear_no_activate_bit(hwnd);
}
}
let _ = write_journal(paths, &kept);
}
pub(crate) struct NoActivateGuard {
hwnd: isize,
paths: SignalPaths,
}
impl NoActivateGuard {
pub(crate) fn enter(hwnd: isize, paths: &SignalPaths) -> Result<Self, CtlError> {
recover_stale(paths);
let style = exstyle(hwnd)?;
if (style as u32 & WS_EX_NOACTIVATE.0) != 0 {
return Ok(Self {
hwnd,
paths: paths.clone(),
});
}
let pid = std::process::id();
let start = process_start(pid).unwrap_or_default();
let mut entries = read_journal(paths);
entries.push((hwnd, pid, start));
write_journal(paths, &entries)?;
unsafe {
if SetWindowLongPtrW(
HWND(hwnd as _),
GWL_EXSTYLE,
style | WS_EX_NOACTIVATE.0 as isize,
) == 0
{
let entries: Vec<_> = entries
.into_iter()
.filter(|(h, p, _)| !(*h == hwnd && *p == pid))
.collect();
let _ = write_journal(paths, &entries);
let err = windows::core::HRESULT::from_thread();
return Err(CtlError::new(
ErrorCode::PermDenied,
format!(
"cannot set WS_EX_NOACTIVATE on the target window \
(elevated or protected? HRESULT {err}); refusing: --no-activate \
promised no activation — rerun without it for a normal physical click"
),
));
}
}
Ok(Self {
hwnd,
paths: paths.clone(),
})
}
}
impl Drop for NoActivateGuard {
fn drop(&mut self) {
let pid = std::process::id();
let entries: Vec<_> = read_journal(&self.paths)
.into_iter()
.filter(|(h, p, _)| !(*h == self.hwnd && *p == pid))
.collect();
if hwnd_belongs_to(self.hwnd, pid) {
clear_no_activate_bit(self.hwnd);
}
let _ = write_journal(&self.paths, &entries);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_dir(tag: &str) -> SignalPaths {
let dir = std::env::temp_dir().join(format!("actl-bgstyle-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
SignalPaths::at(dir)
}
#[test]
fn journal_roundtrip_and_remove() {
let paths = tmp_dir("roundtrip");
write_journal(&paths, &[(1, 42, 100), (2, 43, 200)]).expect("write");
assert_eq!(read_journal(&paths), vec![(1, 42, 100), (2, 43, 200)]);
let kept: Vec<_> = read_journal(&paths)
.into_iter()
.filter(|(h, p, _)| !(*h == 2 && *p == 43))
.collect();
write_journal(&paths, &kept).expect("rewrite");
assert_eq!(read_journal(&paths), vec![(1, 42, 100)]);
let _ = std::fs::remove_dir_all(&paths.dir);
}
#[test]
fn corrupt_journal_reads_as_empty() {
let paths = tmp_dir("corrupt");
std::fs::create_dir_all(&paths.dir).unwrap();
std::fs::write(journal_path(&paths), "not json").unwrap();
assert!(read_journal(&paths).is_empty());
let _ = std::fs::remove_dir_all(&paths.dir);
}
#[test]
fn recover_keeps_live_owner_and_drops_dead_entries() {
let paths = tmp_dir("recover");
let me = std::process::id();
let my_start = process_start(me);
let dead_pid = 4_000_000; let entries = vec![
(111isize, me, my_start.unwrap_or(0)),
(222isize, dead_pid, 0),
];
write_journal(&paths, &entries).expect("write");
recover_stale(&paths);
let left = read_journal(&paths);
assert_eq!(left, vec![(111, me, my_start.unwrap_or(0))]);
let _ = std::fs::remove_dir_all(&paths.dir);
}
#[test]
fn empty_or_missing_journal_is_noop() {
let paths = tmp_dir("missing");
recover_stale(&paths); assert!(!journal_path(&paths).exists());
}
}