use std::{ collections::{BTreeSet, HashSet}, mem, path::{Path, PathBuf}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, thread::{self, JoinHandle}, time::{Duration, Instant}, }; #[cfg(not(windows))] use std::{collections::HashMap, fs, time::SystemTime}; use serde_json::json; #[cfg(not(windows))] use walkdir::WalkDir; use super::{ addon::{self, PackType}, config::ResolvedModDir, ipc::DebugIpcServer, log::{self, ConsoleColor}, win, }; const DEBOUNCE_MS: u64 = 150; #[derive(Clone, Copy, Debug, Default)] pub struct HotReloadOptions { pub python: bool, pub json_ui: bool, } impl HotReloadOptions { fn enabled(self) -> bool { self.python || self.json_ui } } #[derive(Debug, Default, Eq, PartialEq)] struct PendingReload { python_modules: BTreeSet, json_ui: bool, } impl PendingReload { fn is_empty(&self) -> bool { self.python_modules.is_empty() && !self.json_ui } fn merge(&mut self, mut other: Self) { self.python_modules.append(&mut other.python_modules); self.json_ui |= other.json_ui; } } #[derive(Debug, Default)] struct ReloadState { pending: PendingReload, is_foreground: bool, } #[derive(Debug, Eq, PartialEq)] enum ReloadAction { Python(Vec), JsonUi, } #[derive(Debug, Default)] struct DebouncedPaths { paths: HashSet, last_event: Option, } impl DebouncedPaths { fn push(&mut self, path: PathBuf, now: Instant) { self.paths.insert(path); self.last_event = Some(now); } fn take_ready(&mut self, now: Instant) -> Vec { let ready = self .last_event .map(|last| now.duration_since(last) >= Duration::from_millis(DEBOUNCE_MS)) .unwrap_or(false); if !ready { return Vec::new(); } self.last_event = None; self.paths.drain().collect() } } pub struct HotReloadTask { stop: Arc, file_thread: Option>, foreground_thread: Option>, } impl HotReloadTask { pub fn start( process_id: u32, mod_dirs: &[ResolvedModDir], ipc: Option, options: HotReloadOptions, ) -> Option { if !options.enabled() { return None; } let roots: Vec = 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 state = Arc::new(Mutex::new(ReloadState::default())); let file_thread = { let stop = Arc::clone(&stop); let state = Arc::clone(&state); let ipc = ipc.clone(); let roots = roots.clone(); thread::spawn(move || watch_files(process_id, roots, stop, state, ipc, options)) }; let foreground_thread = { let stop = Arc::clone(&stop); let state = Arc::clone(&state); thread::spawn(move || watch_foreground(process_id, stop, state, 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_files( process_id: u32, roots: Vec, stop: Arc, state: Arc>, ipc: Option, options: HotReloadOptions, ) { watch_files_native(process_id, roots, stop, state, ipc, options); } #[cfg(windows)] fn watch_files_native( process_id: u32, roots: Vec, stop: Arc, state: Arc>, ipc: Option, options: HotReloadOptions, ) { use std::{mem, os::windows::ffi::OsStrExt, ptr}; use windows_sys::Win32::{ Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT}, Storage::FileSystem::{ CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_FILE_NAME, 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, } 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 | FILE_NOTIFY_CHANGE_FILE_NAME, &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 = 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 debounced = DebouncedPaths::default(); while !stop.load(Ordering::Relaxed) { let handles: Vec = items.iter().map(|item| item.event).collect(); let result = unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 50) }; if result == WAIT_FAILED { break; } if result != WAIT_TIMEOUT { let index = (result - WAIT_OBJECT_0) as usize; if index < items.len() { 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 { let mut offset = 0usize; let now = Instant::now(); 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 is_supported_file_action(info.Action) && is_tracked_reload_path(&path, &roots, options) { debounced.push(path, now); } if info.NextEntryOffset == 0 { break; } offset += info.NextEntryOffset as usize; } } start_watch(item); } } let paths = debounced.take_ready(Instant::now()); if !paths.is_empty() { record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref()); } } } #[cfg(windows)] fn is_supported_file_action(action: u32) -> bool { use windows_sys::Win32::Storage::FileSystem::{ FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_NEW_NAME, FILE_ACTION_RENAMED_OLD_NAME, }; matches!( action, FILE_ACTION_ADDED | FILE_ACTION_MODIFIED | FILE_ACTION_REMOVED | FILE_ACTION_RENAMED_OLD_NAME | FILE_ACTION_RENAMED_NEW_NAME ) } #[cfg(not(windows))] fn watch_files_native( process_id: u32, roots: Vec, stop: Arc, state: Arc>, ipc: Option, options: HotReloadOptions, ) { let mut last_seen = snapshot_files(&roots, options); while !stop.load(Ordering::Relaxed) { thread::sleep(Duration::from_millis(DEBOUNCE_MS)); let current = snapshot_files(&roots, options); let mut paths = Vec::new(); for (path, modified) in ¤t { if last_seen.get(path) != Some(modified) { paths.push(path.clone()); } } for path in last_seen.keys() { if !current.contains_key(path) { paths.push(path.clone()); } } if !paths.is_empty() { record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref()); } last_seen = current; } } fn record_changed_paths( process_id: u32, roots: &[PathBuf], paths: Vec, options: HotReloadOptions, state: &Arc>, ipc: Option<&DebugIpcServer>, ) { let (batch, tracked_paths) = build_reload_batch(paths, roots, options); if batch.is_empty() { return; } for path in tracked_paths { log::print_colored( &format!("[HotReload] Detected change in: {}", path.display()), ConsoleColor::Yellow, ); } let should_trigger = { let mut guard = state.lock().unwrap(); guard.pending.merge(batch); guard.is_foreground }; if should_trigger { trigger_reload(process_id, state, ipc); } } fn build_reload_batch( paths: Vec, roots: &[PathBuf], options: HotReloadOptions, ) -> (PendingReload, Vec) { let mut batch = PendingReload::default(); let mut tracked_paths = Vec::new(); for path in paths { let mut tracked = false; if options.python { if let Some(module) = py_path_to_module_name(&path, roots) { batch.python_modules.insert(module); tracked = true; } } if options.json_ui && is_json_ui_path(&path, roots) { batch.json_ui = true; tracked = true; } if tracked { tracked_paths.push(path); } } (batch, tracked_paths) } fn watch_foreground( process_id: u32, stop: Arc, state: Arc>, ipc: Option, ) { let mut last_state = false; while !stop.load(Ordering::Relaxed) { let current = foreground_process_id() == Some(process_id); let should_trigger = { let mut guard = state.lock().unwrap(); guard.is_foreground = current; current && !last_state && !guard.pending.is_empty() }; if should_trigger { trigger_reload(process_id, &state, ipc.as_ref()); } last_state = current; thread::sleep(Duration::from_millis(80)); } } fn trigger_reload(process_id: u32, state: &Arc>, ipc: Option<&DebugIpcServer>) { let batch = { let mut guard = state.lock().unwrap(); mem::take(&mut guard.pending) }; for action in reload_actions(batch) { match action { ReloadAction::Python(modules) => { log::print_colored( "[HotReload] 检测到 Python 修改,已触发增量热更新。", ConsoleColor::Yellow, ); if let Some(ipc) = ipc { let payload = json!(modules).to_string(); let _ = ipc.send_message(2, payload.as_bytes()); } } ReloadAction::JsonUi => match win::trigger_ui_reload_shortcut(process_id) { Ok(()) => log::print_colored( "[HotReload] JSON UI 修改已触发原生 Ctrl+R 重载。", ConsoleColor::Green, ), Err(err) => { log::print_colored( &format!("[HotReload] JSON UI 原生 Ctrl+R 重载失败:{err}"), ConsoleColor::Red, ); state.lock().unwrap().pending.json_ui = true; } }, } } } fn reload_actions(batch: PendingReload) -> Vec { let mut actions = Vec::with_capacity(2); if !batch.python_modules.is_empty() { actions.push(ReloadAction::Python( batch.python_modules.into_iter().collect(), )); } if batch.json_ui { actions.push(ReloadAction::JsonUi); } actions } #[cfg(not(windows))] fn snapshot_files(roots: &[PathBuf], options: HotReloadOptions) -> HashMap { 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() || !is_tracked_reload_path(path, roots, options) { continue; } if let Ok(metadata) = fs::metadata(path) { if let Ok(modified) = metadata.modified() { files.insert(path.to_path_buf(), modified); } } } } files } fn is_tracked_reload_path(path: &Path, roots: &[PathBuf], options: HotReloadOptions) -> bool { (options.python && py_path_to_module_name(path, roots).is_some()) || (options.json_ui && is_json_ui_path(path, roots)) } fn is_json_ui_path(file_path: &Path, mod_roots: &[PathBuf]) -> bool { let is_json = file_path .extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.eq_ignore_ascii_case("json")) .unwrap_or(false); if !is_json { return false; } let Some(parent) = file_path.parent() else { return false; }; for ancestor in parent.ancestors() { let is_ui_dir = ancestor .file_name() .and_then(|name| name.to_str()) .map(|name| name.eq_ignore_ascii_case("ui")) .unwrap_or(false); if !is_ui_dir { continue; } let Some(pack_root) = ancestor.parent() else { continue; }; let under_tracked_root = mod_roots .iter() .any(|root| same_path(root, pack_root) || pack_root.strip_prefix(root).is_ok()); if !under_tracked_root { continue; } if matches!( addon::parse_pack_info(pack_root), Ok(Some(info)) if info.pack_type == PackType::Resource ) { return true; } } false } pub fn py_path_to_module_name(file_path: &Path, mod_roots: &[PathBuf]) -> Option { 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 = 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 { 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 { None } #[cfg(test)] mod tests { use std::{ fs, path::{Path, PathBuf}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use serde_json::json; use super::{ DEBOUNCE_MS, DebouncedPaths, HotReloadOptions, ReloadAction, build_reload_batch, is_json_ui_path, py_path_to_module_name, reload_actions, }; struct TempProject { path: PathBuf, } impl TempProject { fn new() -> Self { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let path = std::env::temp_dir().join(format!( "emod-hot-reload-test-{}-{nonce}", std::process::id() )); fs::create_dir_all(&path).unwrap(); Self { path } } } impl Drop for TempProject { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } fn create_pack(project: &Path, name: &str, module_type: &str) -> PathBuf { let pack = project.join(name); fs::create_dir_all(&pack).unwrap(); let manifest = json!({ "header": { "name": name, "uuid": "00000000-0000-0000-0000-000000000001", "version": [1, 0, 0] }, "modules": [{ "type": module_type, "uuid": "00000000-0000-0000-0000-000000000002", "version": [1, 0, 0] }] }); fs::write( pack.join("pack_manifest.json"), serde_json::to_vec(&manifest).unwrap(), ) .unwrap(); pack } #[test] fn maps_py_file_to_module_name_under_manifest() { let project = TempProject::new(); let pack = create_pack(&project.path, "behavior_pack", "data"); let module = pack.join("foo/bar.py"); fs::create_dir_all(module.parent().unwrap()).unwrap(); fs::write(&module, "").unwrap(); assert_eq!( py_path_to_module_name(&module, std::slice::from_ref(&project.path)), Some("foo.bar".to_string()) ); let outside = project.path.join("x.py"); fs::write(&outside, "").unwrap(); assert_eq!( py_path_to_module_name(&outside, std::slice::from_ref(&project.path)), None ); } #[test] fn recognizes_only_resource_pack_json_ui_files() { let project = TempProject::new(); let resource = create_pack(&project.path, "resource_pack", "resources"); let behavior = create_pack(&project.path, "behavior_pack", "data"); let roots = std::slice::from_ref(&project.path); let screen = resource.join("ui/screen.json"); let nested = resource.join("ui/widgets/_global_variables.json"); let defs = resource.join("UI/_ui_defs.JSON"); let removed = resource.join("ui/removed.json"); let texture_json = resource.join("textures/ui.json"); let nested_texture_json = resource.join("textures/ui/icons.json"); let mcgui = resource.join("ui/screen.mcgui"); let behavior_ui = behavior.join("ui/screen.json"); let mcp_state = project.path.join(".emod-cli/debug-mcp.json"); fs::create_dir_all(nested.parent().unwrap()).unwrap(); fs::create_dir_all(defs.parent().unwrap()).unwrap(); fs::write(&screen, "{}").unwrap(); fs::write(&nested, "{}").unwrap(); fs::write(&defs, "{}").unwrap(); fs::write(&removed, "{}").unwrap(); fs::remove_file(&removed).unwrap(); assert!(is_json_ui_path(&screen, roots)); assert!(is_json_ui_path(&nested, roots)); assert!(is_json_ui_path(&defs, roots)); assert!(is_json_ui_path(&removed, roots)); assert!(is_json_ui_path(&screen, std::slice::from_ref(&resource))); assert!(!is_json_ui_path(&texture_json, roots)); assert!(!is_json_ui_path(&nested_texture_json, roots)); assert!(!is_json_ui_path(&mcgui, roots)); assert!(!is_json_ui_path(&behavior_ui, roots)); assert!(!is_json_ui_path(&mcp_state, roots)); let (excluded, tracked) = build_reload_batch( vec![ texture_json, nested_texture_json, mcgui, behavior_ui, mcp_state, ], roots, HotReloadOptions { python: false, json_ui: true, }, ); assert!(excluded.is_empty()); assert!(tracked.is_empty()); let (disabled, tracked) = build_reload_batch(vec![screen], roots, HotReloadOptions::default()); assert!(disabled.is_empty()); assert!(tracked.is_empty()); } #[test] fn batches_python_and_json_ui_changes_together() { let project = TempProject::new(); let behavior = create_pack(&project.path, "behavior_pack", "data"); let resource = create_pack(&project.path, "resource_pack", "resources"); let python = behavior.join("scripts/client.py"); let screen = resource.join("ui/screen.json"); fs::create_dir_all(python.parent().unwrap()).unwrap(); fs::create_dir_all(screen.parent().unwrap()).unwrap(); fs::write(&python, "").unwrap(); fs::write(&screen, "{}").unwrap(); let (batch, tracked) = build_reload_batch( vec![screen, python], std::slice::from_ref(&project.path), HotReloadOptions { python: true, json_ui: true, }, ); assert_eq!(tracked.len(), 2); assert_eq!( reload_actions(batch), vec![ ReloadAction::Python(vec!["scripts.client".to_string()]), ReloadAction::JsonUi, ] ); } #[test] fn uses_global_trailing_edge_debounce() { let start = Instant::now(); let mut paths = DebouncedPaths::default(); paths.push(PathBuf::from("ui/screen.json"), start); paths.push( PathBuf::from("ui/screen.json"), start + Duration::from_millis(50), ); paths.push( PathBuf::from("ui/_ui_defs.json"), start + Duration::from_millis(100), ); assert!( paths .take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS - 1)) .is_empty() ); assert_eq!( paths .take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS)) .len(), 2 ); assert!( paths .take_ready(start + Duration::from_millis(100 + DEBOUNCE_MS * 2)) .is_empty() ); } #[cfg(windows)] #[test] fn accepts_windows_content_and_name_change_actions() { use windows_sys::Win32::Storage::FileSystem::{ FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_NEW_NAME, FILE_ACTION_RENAMED_OLD_NAME, }; for action in [ FILE_ACTION_ADDED, FILE_ACTION_MODIFIED, FILE_ACTION_REMOVED, FILE_ACTION_RENAMED_OLD_NAME, FILE_ACTION_RENAMED_NEW_NAME, ] { assert!(super::is_supported_file_action(action)); } } }