514 lines
15 KiB
Rust
514 lines
15 KiB
Rust
|
|
use std::{
|
||
|
|
collections::{HashMap, HashSet},
|
||
|
|
path::{Path, PathBuf},
|
||
|
|
sync::{
|
||
|
|
Arc, Mutex,
|
||
|
|
atomic::{AtomicBool, Ordering},
|
||
|
|
},
|
||
|
|
thread::{self, JoinHandle},
|
||
|
|
time::Duration,
|
||
|
|
};
|
||
|
|
|
||
|
|
#[cfg(not(windows))]
|
||
|
|
use std::{fs, time::SystemTime};
|
||
|
|
|
||
|
|
use serde_json::json;
|
||
|
|
#[cfg(not(windows))]
|
||
|
|
use walkdir::WalkDir;
|
||
|
|
|
||
|
|
use super::{
|
||
|
|
config::ResolvedModDir,
|
||
|
|
ipc::DebugIpcServer,
|
||
|
|
log::{self, ConsoleColor},
|
||
|
|
};
|
||
|
|
|
||
|
|
const DEBOUNCE_MS: u64 = 100;
|
||
|
|
|
||
|
|
pub struct HotReloadTask {
|
||
|
|
stop: Arc<AtomicBool>,
|
||
|
|
|
||
|
|
file_thread: Option<JoinHandle<()>>,
|
||
|
|
foreground_thread: Option<JoinHandle<()>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl HotReloadTask {
|
||
|
|
pub fn start(
|
||
|
|
process_id: u32,
|
||
|
|
mod_dirs: &[ResolvedModDir],
|
||
|
|
ipc: DebugIpcServer,
|
||
|
|
) -> Option<Self> {
|
||
|
|
let roots: Vec<PathBuf> = mod_dirs
|
||
|
|
.iter()
|
||
|
|
.filter(|dir| dir.hot_reload)
|
||
|
|
.map(|dir| dir.path.clone())
|
||
|
|
.collect();
|
||
|
|
if roots.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
|
||
|
|
println!("[HotReload] 追踪目录列表:");
|
||
|
|
for root in &roots {
|
||
|
|
println!(" └── {}", root.display());
|
||
|
|
}
|
||
|
|
|
||
|
|
let stop = Arc::new(AtomicBool::new(false));
|
||
|
|
let need_update = Arc::new(AtomicBool::new(false));
|
||
|
|
let is_foreground = Arc::new(AtomicBool::new(false));
|
||
|
|
let cached_paths = Arc::new(Mutex::new(HashSet::new()));
|
||
|
|
|
||
|
|
let file_thread = {
|
||
|
|
let stop = Arc::clone(&stop);
|
||
|
|
let need_update = Arc::clone(&need_update);
|
||
|
|
let is_foreground = Arc::clone(&is_foreground);
|
||
|
|
let cached_paths = Arc::clone(&cached_paths);
|
||
|
|
let ipc = ipc.clone();
|
||
|
|
let roots = roots.clone();
|
||
|
|
thread::spawn(move || {
|
||
|
|
watch_py_files(roots, stop, need_update, is_foreground, cached_paths, ipc)
|
||
|
|
})
|
||
|
|
};
|
||
|
|
|
||
|
|
let foreground_thread = {
|
||
|
|
let stop = Arc::clone(&stop);
|
||
|
|
let need_update = Arc::clone(&need_update);
|
||
|
|
let is_foreground = Arc::clone(&is_foreground);
|
||
|
|
let cached_paths = Arc::clone(&cached_paths);
|
||
|
|
let ipc = ipc.clone();
|
||
|
|
let roots = roots.clone();
|
||
|
|
thread::spawn(move || {
|
||
|
|
watch_foreground(
|
||
|
|
process_id,
|
||
|
|
roots,
|
||
|
|
stop,
|
||
|
|
need_update,
|
||
|
|
is_foreground,
|
||
|
|
cached_paths,
|
||
|
|
ipc,
|
||
|
|
)
|
||
|
|
})
|
||
|
|
};
|
||
|
|
|
||
|
|
Some(Self {
|
||
|
|
stop,
|
||
|
|
|
||
|
|
file_thread: Some(file_thread),
|
||
|
|
foreground_thread: Some(foreground_thread),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn safe_exit(&mut self) {
|
||
|
|
self.stop.store(true, Ordering::Relaxed);
|
||
|
|
if let Some(handle) = self.file_thread.take() {
|
||
|
|
let _ = handle.join();
|
||
|
|
}
|
||
|
|
if let Some(handle) = self.foreground_thread.take() {
|
||
|
|
let _ = handle.join();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Drop for HotReloadTask {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
self.safe_exit();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn watch_py_files(
|
||
|
|
roots: Vec<PathBuf>,
|
||
|
|
stop: Arc<AtomicBool>,
|
||
|
|
need_update: Arc<AtomicBool>,
|
||
|
|
is_foreground: Arc<AtomicBool>,
|
||
|
|
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||
|
|
ipc: DebugIpcServer,
|
||
|
|
) {
|
||
|
|
watch_py_files_native(roots, stop, need_update, is_foreground, cached_paths, ipc);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(windows)]
|
||
|
|
fn watch_py_files_native(
|
||
|
|
roots: Vec<PathBuf>,
|
||
|
|
stop: Arc<AtomicBool>,
|
||
|
|
need_update: Arc<AtomicBool>,
|
||
|
|
is_foreground: Arc<AtomicBool>,
|
||
|
|
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||
|
|
ipc: DebugIpcServer,
|
||
|
|
) {
|
||
|
|
use std::{mem, os::windows::ffi::OsStrExt, ptr, time::Instant};
|
||
|
|
use windows_sys::Win32::{
|
||
|
|
Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
|
||
|
|
Storage::FileSystem::{
|
||
|
|
CreateFileW, FILE_ACTION_MODIFIED, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED,
|
||
|
|
FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION,
|
||
|
|
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||
|
|
ReadDirectoryChangesW,
|
||
|
|
},
|
||
|
|
System::{
|
||
|
|
IO::{GetOverlappedResult, OVERLAPPED},
|
||
|
|
Threading::{CreateEventW, WaitForMultipleObjects},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
struct WatchItem {
|
||
|
|
dir: PathBuf,
|
||
|
|
h_dir: HANDLE,
|
||
|
|
event: HANDLE,
|
||
|
|
overlapped: OVERLAPPED,
|
||
|
|
buffer: Vec<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Drop for WatchItem {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
unsafe {
|
||
|
|
if self.h_dir != INVALID_HANDLE_VALUE && !self.h_dir.is_null() {
|
||
|
|
windows_sys::Win32::Foundation::CloseHandle(self.h_dir);
|
||
|
|
}
|
||
|
|
if !self.event.is_null() {
|
||
|
|
windows_sys::Win32::Foundation::CloseHandle(self.event);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn start_watch(item: &mut WatchItem) -> bool {
|
||
|
|
item.overlapped = unsafe { mem::zeroed() };
|
||
|
|
item.overlapped.hEvent = item.event;
|
||
|
|
let mut bytes_returned = 0u32;
|
||
|
|
unsafe {
|
||
|
|
ReadDirectoryChangesW(
|
||
|
|
item.h_dir,
|
||
|
|
item.buffer.as_mut_ptr().cast(),
|
||
|
|
item.buffer.len() as u32,
|
||
|
|
1,
|
||
|
|
FILE_NOTIFY_CHANGE_LAST_WRITE,
|
||
|
|
&mut bytes_returned,
|
||
|
|
&mut item.overlapped,
|
||
|
|
None,
|
||
|
|
) != 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut items = Vec::new();
|
||
|
|
for root in &roots {
|
||
|
|
if !root.is_dir() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let mut wide: Vec<u16> = root.as_os_str().encode_wide().collect();
|
||
|
|
wide.push(0);
|
||
|
|
let event = unsafe { CreateEventW(ptr::null(), 0, 0, ptr::null()) };
|
||
|
|
if event.is_null() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let h_dir = unsafe {
|
||
|
|
CreateFileW(
|
||
|
|
wide.as_ptr(),
|
||
|
|
FILE_LIST_DIRECTORY,
|
||
|
|
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||
|
|
ptr::null(),
|
||
|
|
OPEN_EXISTING,
|
||
|
|
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
|
||
|
|
ptr::null_mut(),
|
||
|
|
)
|
||
|
|
};
|
||
|
|
if h_dir == INVALID_HANDLE_VALUE {
|
||
|
|
unsafe {
|
||
|
|
windows_sys::Win32::Foundation::CloseHandle(event);
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
items.push(WatchItem {
|
||
|
|
dir: root.clone(),
|
||
|
|
h_dir,
|
||
|
|
event,
|
||
|
|
overlapped: unsafe { mem::zeroed() },
|
||
|
|
buffer: vec![0u8; 64 * 1024],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if items.is_empty() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if items.len() > 64 {
|
||
|
|
log::print_colored(
|
||
|
|
"[HotReload] 追踪目录超过 Windows WaitForMultipleObjects 限制,已忽略超出部分。",
|
||
|
|
ConsoleColor::Yellow,
|
||
|
|
);
|
||
|
|
items.truncate(64);
|
||
|
|
}
|
||
|
|
|
||
|
|
for item in &mut items {
|
||
|
|
start_watch(item);
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut debounce = HashMap::<PathBuf, Instant>::new();
|
||
|
|
while !stop.load(Ordering::Relaxed) {
|
||
|
|
let handles: Vec<HANDLE> = items.iter().map(|item| item.event).collect();
|
||
|
|
let result =
|
||
|
|
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 100) };
|
||
|
|
if result == WAIT_TIMEOUT {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if result == WAIT_FAILED {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
let index = (result - WAIT_OBJECT_0) as usize;
|
||
|
|
if index >= items.len() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let item = &mut items[index];
|
||
|
|
let mut bytes = 0u32;
|
||
|
|
let ok = unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) };
|
||
|
|
if ok == 0 || bytes == 0 {
|
||
|
|
start_watch(item);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut offset = 0usize;
|
||
|
|
while offset < bytes as usize {
|
||
|
|
let info =
|
||
|
|
unsafe { &*(item.buffer.as_ptr().add(offset) as *const FILE_NOTIFY_INFORMATION) };
|
||
|
|
let name_len = (info.FileNameLength / 2) as usize;
|
||
|
|
let name = unsafe { std::slice::from_raw_parts(info.FileName.as_ptr(), name_len) };
|
||
|
|
let path = item.dir.join(String::from_utf16_lossy(name));
|
||
|
|
if info.Action == FILE_ACTION_MODIFIED
|
||
|
|
&& path.extension().and_then(|ext| ext.to_str()) == Some("py")
|
||
|
|
{
|
||
|
|
let now = Instant::now();
|
||
|
|
let should_trigger = debounce
|
||
|
|
.get(&path)
|
||
|
|
.map(|last| now.duration_since(*last) >= Duration::from_millis(DEBOUNCE_MS))
|
||
|
|
.unwrap_or(true);
|
||
|
|
if should_trigger {
|
||
|
|
debounce.insert(path.clone(), now);
|
||
|
|
record_changed_path(
|
||
|
|
&roots,
|
||
|
|
&path,
|
||
|
|
&cached_paths,
|
||
|
|
&need_update,
|
||
|
|
&is_foreground,
|
||
|
|
&ipc,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if info.NextEntryOffset == 0 {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
offset += info.NextEntryOffset as usize;
|
||
|
|
}
|
||
|
|
start_watch(item);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(not(windows))]
|
||
|
|
fn watch_py_files_native(
|
||
|
|
roots: Vec<PathBuf>,
|
||
|
|
stop: Arc<AtomicBool>,
|
||
|
|
need_update: Arc<AtomicBool>,
|
||
|
|
is_foreground: Arc<AtomicBool>,
|
||
|
|
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||
|
|
ipc: DebugIpcServer,
|
||
|
|
) {
|
||
|
|
let mut last_seen = snapshot_py_files(&roots);
|
||
|
|
let mut debounce = HashMap::<PathBuf, SystemTime>::new();
|
||
|
|
|
||
|
|
while !stop.load(Ordering::Relaxed) {
|
||
|
|
thread::sleep(Duration::from_millis(DEBOUNCE_MS));
|
||
|
|
let now = SystemTime::now();
|
||
|
|
let current = snapshot_py_files(&roots);
|
||
|
|
for (path, modified) in ¤t {
|
||
|
|
let changed = match last_seen.get(path) {
|
||
|
|
Some(previous) => modified > previous,
|
||
|
|
None => false,
|
||
|
|
};
|
||
|
|
if !changed {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if let Some(last) = debounce.get(path) {
|
||
|
|
if now.duration_since(*last).unwrap_or_default()
|
||
|
|
< Duration::from_millis(DEBOUNCE_MS)
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
debounce.insert(path.clone(), now);
|
||
|
|
record_changed_path(
|
||
|
|
&roots,
|
||
|
|
path,
|
||
|
|
&cached_paths,
|
||
|
|
&need_update,
|
||
|
|
&is_foreground,
|
||
|
|
&ipc,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
last_seen = current;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn record_changed_path(
|
||
|
|
roots: &[PathBuf],
|
||
|
|
path: &Path,
|
||
|
|
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
|
||
|
|
need_update: &Arc<AtomicBool>,
|
||
|
|
is_foreground: &Arc<AtomicBool>,
|
||
|
|
ipc: &DebugIpcServer,
|
||
|
|
) {
|
||
|
|
log::print_colored(
|
||
|
|
&format!("[HotReload] Detected change in: {}", path.display()),
|
||
|
|
ConsoleColor::Yellow,
|
||
|
|
);
|
||
|
|
cached_paths.lock().unwrap().insert(path.to_path_buf());
|
||
|
|
need_update.store(true, Ordering::Relaxed);
|
||
|
|
if is_foreground.load(Ordering::Relaxed) {
|
||
|
|
trigger_reload(roots, cached_paths, need_update, ipc);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn watch_foreground(
|
||
|
|
process_id: u32,
|
||
|
|
roots: Vec<PathBuf>,
|
||
|
|
stop: Arc<AtomicBool>,
|
||
|
|
need_update: Arc<AtomicBool>,
|
||
|
|
is_foreground: Arc<AtomicBool>,
|
||
|
|
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
||
|
|
ipc: DebugIpcServer,
|
||
|
|
) {
|
||
|
|
let mut last_state = false;
|
||
|
|
while !stop.load(Ordering::Relaxed) {
|
||
|
|
let current = foreground_process_id() == Some(process_id);
|
||
|
|
is_foreground.store(current, Ordering::Relaxed);
|
||
|
|
if current && !last_state && need_update.load(Ordering::Relaxed) {
|
||
|
|
trigger_reload(&roots, &cached_paths, &need_update, &ipc);
|
||
|
|
}
|
||
|
|
last_state = current;
|
||
|
|
thread::sleep(Duration::from_millis(80));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn trigger_reload(
|
||
|
|
roots: &[PathBuf],
|
||
|
|
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
|
||
|
|
need_update: &Arc<AtomicBool>,
|
||
|
|
ipc: &DebugIpcServer,
|
||
|
|
) {
|
||
|
|
let paths: Vec<PathBuf> = {
|
||
|
|
let mut guard = cached_paths.lock().unwrap();
|
||
|
|
guard.drain().collect()
|
||
|
|
};
|
||
|
|
let modules: Vec<String> = paths
|
||
|
|
.iter()
|
||
|
|
.filter_map(|path| py_path_to_module_name(path, roots))
|
||
|
|
.collect();
|
||
|
|
need_update.store(false, Ordering::Relaxed);
|
||
|
|
if modules.is_empty() {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
log::print_colored(
|
||
|
|
"[HotReload] 检测到修改,已触发热更新。",
|
||
|
|
ConsoleColor::Yellow,
|
||
|
|
);
|
||
|
|
let payload = json!(modules).to_string();
|
||
|
|
let _ = ipc.send_message(2, payload.as_bytes());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(not(windows))]
|
||
|
|
fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
||
|
|
let mut files = HashMap::new();
|
||
|
|
for root in roots {
|
||
|
|
if !root.is_dir() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
|
||
|
|
let path = entry.path();
|
||
|
|
if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("py") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if let Ok(metadata) = fs::metadata(path) {
|
||
|
|
if let Ok(modified) = metadata.modified() {
|
||
|
|
files.insert(path.to_path_buf(), modified);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
files
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn py_path_to_module_name(file_path: &Path, mod_roots: &[PathBuf]) -> Option<String> {
|
||
|
|
if file_path.extension().and_then(|ext| ext.to_str()) != Some("py") {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let mut cur = file_path.parent()?;
|
||
|
|
loop {
|
||
|
|
if cur.join("manifest.json").is_file() || cur.join("pack_manifest.json").is_file() {
|
||
|
|
let rel = file_path.strip_prefix(cur).ok()?;
|
||
|
|
let mut parts: Vec<String> = rel
|
||
|
|
.iter()
|
||
|
|
.map(|part| part.to_string_lossy().into_owned())
|
||
|
|
.collect();
|
||
|
|
let last = parts.last_mut()?;
|
||
|
|
if !last.ends_with(".py") {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
last.truncate(last.len() - 3);
|
||
|
|
return Some(parts.join("."));
|
||
|
|
}
|
||
|
|
if mod_roots.iter().any(|root| same_path(root, cur)) {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
cur = cur.parent()?;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn same_path(left: &Path, right: &Path) -> bool {
|
||
|
|
left == right || (left.canonicalize().ok().as_deref() == right.canonicalize().ok().as_deref())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(windows)]
|
||
|
|
fn foreground_process_id() -> Option<u32> {
|
||
|
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||
|
|
GetForegroundWindow, GetWindowThreadProcessId,
|
||
|
|
};
|
||
|
|
unsafe {
|
||
|
|
let hwnd = GetForegroundWindow();
|
||
|
|
if hwnd.is_null() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
let mut pid = 0u32;
|
||
|
|
GetWindowThreadProcessId(hwnd, &mut pid);
|
||
|
|
if pid == 0 { None } else { Some(pid) }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(not(windows))]
|
||
|
|
fn foreground_process_id() -> Option<u32> {
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use std::fs;
|
||
|
|
|
||
|
|
use super::py_path_to_module_name;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn maps_py_file_to_module_name_under_manifest() {
|
||
|
|
let root = std::env::temp_dir().join(format!("emod-hot-{}", std::process::id()));
|
||
|
|
let pack = root.join("behavior_pack");
|
||
|
|
let module = pack.join("foo/bar.py");
|
||
|
|
fs::create_dir_all(module.parent().unwrap()).unwrap();
|
||
|
|
fs::write(pack.join("manifest.json"), "{}").unwrap();
|
||
|
|
fs::write(&module, "").unwrap();
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
py_path_to_module_name(&module, std::slice::from_ref(&root)),
|
||
|
|
Some("foo.bar".to_string())
|
||
|
|
);
|
||
|
|
let outside = root.join("x.py");
|
||
|
|
fs::write(&outside, "").unwrap();
|
||
|
|
assert_eq!(
|
||
|
|
py_path_to_module_name(&outside, std::slice::from_ref(&root)),
|
||
|
|
None
|
||
|
|
);
|
||
|
|
|
||
|
|
let _ = fs::remove_dir_all(root);
|
||
|
|
}
|
||
|
|
}
|