feat(debug): 支持 JSON UI 热重载
新增可选的 auto_hot_reload_ui 配置,追踪资源包 UI JSON 的新增、修改、删除和重命名,并在游戏回到前台时发送原生 Ctrl+R。 统一 Python 与 JSON UI 的批处理和拖尾防抖逻辑,同时补充路径分类、事件动作与快捷键消息测试。
This commit is contained in:
@@ -30,5 +30,6 @@ windows-sys = { version = "0.59", features = [
|
|||||||
"Win32_System_SystemServices",
|
"Win32_System_SystemServices",
|
||||||
"Win32_System_SystemInformation",
|
"Win32_System_SystemInformation",
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_Input_KeyboardAndMouse",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub struct DebugConfig {
|
|||||||
pub include_debug_mod: bool,
|
pub include_debug_mod: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub auto_hot_reload_mods: bool,
|
pub auto_hot_reload_mods: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_hot_reload_ui: bool,
|
||||||
#[serde(default = "default_world_type")]
|
#[serde(default = "default_world_type")]
|
||||||
pub world_type: i32,
|
pub world_type: i32,
|
||||||
#[serde(default = "default_game_mode")]
|
#[serde(default = "default_game_mode")]
|
||||||
@@ -200,6 +202,7 @@ fn create_default_config() -> DebugConfig {
|
|||||||
auto_join_game: true,
|
auto_join_game: true,
|
||||||
include_debug_mod: true,
|
include_debug_mod: true,
|
||||||
auto_hot_reload_mods: true,
|
auto_hot_reload_mods: true,
|
||||||
|
auto_hot_reload_ui: false,
|
||||||
world_type: default_world_type(),
|
world_type: default_world_type(),
|
||||||
game_mode: default_game_mode(),
|
game_mode: default_game_mode(),
|
||||||
enable_cheats: true,
|
enable_cheats: true,
|
||||||
@@ -350,7 +353,7 @@ mod tests {
|
|||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{allocate_new_world, resolve_mod_dir, strip_json_comments};
|
use super::{DebugConfig, allocate_new_world, resolve_mod_dir, strip_json_comments};
|
||||||
|
|
||||||
struct TempProject {
|
struct TempProject {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -388,6 +391,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_ui_hot_reload_is_opt_in() {
|
||||||
|
let default_config: DebugConfig = serde_json::from_str("{}").unwrap();
|
||||||
|
assert!(!default_config.auto_hot_reload_ui);
|
||||||
|
|
||||||
|
let enabled: DebugConfig = serde_json::from_str(r#"{"auto_hot_reload_ui":true}"#).unwrap();
|
||||||
|
assert!(enabled.auto_hot_reload_ui);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allocate_new_world_updates_folder_and_name() {
|
fn allocate_new_world_updates_folder_and_name() {
|
||||||
let project = TempProject::new();
|
let project = TempProject::new();
|
||||||
|
|||||||
@@ -1,32 +1,101 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{BTreeSet, HashSet},
|
||||||
|
mem,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{
|
sync::{
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
thread::{self, JoinHandle},
|
thread::{self, JoinHandle},
|
||||||
time::Duration,
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
use std::{fs, time::SystemTime};
|
use std::{collections::HashMap, fs, time::SystemTime};
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
addon::{self, PackType},
|
||||||
config::ResolvedModDir,
|
config::ResolvedModDir,
|
||||||
ipc::DebugIpcServer,
|
ipc::DebugIpcServer,
|
||||||
log::{self, ConsoleColor},
|
log::{self, ConsoleColor},
|
||||||
|
win,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEBOUNCE_MS: u64 = 100;
|
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<String>,
|
||||||
|
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<String>),
|
||||||
|
JsonUi,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct DebouncedPaths {
|
||||||
|
paths: HashSet<PathBuf>,
|
||||||
|
last_event: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PathBuf> {
|
||||||
|
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 {
|
pub struct HotReloadTask {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
|
|
||||||
file_thread: Option<JoinHandle<()>>,
|
file_thread: Option<JoinHandle<()>>,
|
||||||
foreground_thread: Option<JoinHandle<()>>,
|
foreground_thread: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -35,8 +104,13 @@ impl HotReloadTask {
|
|||||||
pub fn start(
|
pub fn start(
|
||||||
process_id: u32,
|
process_id: u32,
|
||||||
mod_dirs: &[ResolvedModDir],
|
mod_dirs: &[ResolvedModDir],
|
||||||
ipc: DebugIpcServer,
|
ipc: Option<DebugIpcServer>,
|
||||||
|
options: HotReloadOptions,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
|
if !options.enabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let roots: Vec<PathBuf> = mod_dirs
|
let roots: Vec<PathBuf> = mod_dirs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|dir| dir.hot_reload)
|
.filter(|dir| dir.hot_reload)
|
||||||
@@ -52,45 +126,24 @@ impl HotReloadTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
let need_update = Arc::new(AtomicBool::new(false));
|
let state = Arc::new(Mutex::new(ReloadState::default()));
|
||||||
let is_foreground = Arc::new(AtomicBool::new(false));
|
|
||||||
let cached_paths = Arc::new(Mutex::new(HashSet::new()));
|
|
||||||
|
|
||||||
let file_thread = {
|
let file_thread = {
|
||||||
let stop = Arc::clone(&stop);
|
let stop = Arc::clone(&stop);
|
||||||
let need_update = Arc::clone(&need_update);
|
let state = Arc::clone(&state);
|
||||||
let is_foreground = Arc::clone(&is_foreground);
|
|
||||||
let cached_paths = Arc::clone(&cached_paths);
|
|
||||||
let ipc = ipc.clone();
|
let ipc = ipc.clone();
|
||||||
let roots = roots.clone();
|
let roots = roots.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || watch_files(process_id, roots, stop, state, ipc, options))
|
||||||
watch_py_files(roots, stop, need_update, is_foreground, cached_paths, ipc)
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let foreground_thread = {
|
let foreground_thread = {
|
||||||
let stop = Arc::clone(&stop);
|
let stop = Arc::clone(&stop);
|
||||||
let need_update = Arc::clone(&need_update);
|
let state = Arc::clone(&state);
|
||||||
let is_foreground = Arc::clone(&is_foreground);
|
thread::spawn(move || watch_foreground(process_id, stop, state, ipc))
|
||||||
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 {
|
Some(Self {
|
||||||
stop,
|
stop,
|
||||||
|
|
||||||
file_thread: Some(file_thread),
|
file_thread: Some(file_thread),
|
||||||
foreground_thread: Some(foreground_thread),
|
foreground_thread: Some(foreground_thread),
|
||||||
})
|
})
|
||||||
@@ -113,32 +166,32 @@ impl Drop for HotReloadTask {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn watch_py_files(
|
fn watch_files(
|
||||||
|
process_id: u32,
|
||||||
roots: Vec<PathBuf>,
|
roots: Vec<PathBuf>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
need_update: Arc<AtomicBool>,
|
state: Arc<Mutex<ReloadState>>,
|
||||||
is_foreground: Arc<AtomicBool>,
|
ipc: Option<DebugIpcServer>,
|
||||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
options: HotReloadOptions,
|
||||||
ipc: DebugIpcServer,
|
|
||||||
) {
|
) {
|
||||||
watch_py_files_native(roots, stop, need_update, is_foreground, cached_paths, ipc);
|
watch_files_native(process_id, roots, stop, state, ipc, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn watch_py_files_native(
|
fn watch_files_native(
|
||||||
|
process_id: u32,
|
||||||
roots: Vec<PathBuf>,
|
roots: Vec<PathBuf>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
need_update: Arc<AtomicBool>,
|
state: Arc<Mutex<ReloadState>>,
|
||||||
is_foreground: Arc<AtomicBool>,
|
ipc: Option<DebugIpcServer>,
|
||||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
options: HotReloadOptions,
|
||||||
ipc: DebugIpcServer,
|
|
||||||
) {
|
) {
|
||||||
use std::{mem, os::windows::ffi::OsStrExt, ptr, time::Instant};
|
use std::{mem, os::windows::ffi::OsStrExt, ptr};
|
||||||
use windows_sys::Win32::{
|
use windows_sys::Win32::{
|
||||||
Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
|
Foundation::{HANDLE, INVALID_HANDLE_VALUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT},
|
||||||
Storage::FileSystem::{
|
Storage::FileSystem::{
|
||||||
CreateFileW, FILE_ACTION_MODIFIED, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED,
|
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY,
|
||||||
FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION,
|
FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_INFORMATION,
|
||||||
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||||
ReadDirectoryChangesW,
|
ReadDirectoryChangesW,
|
||||||
},
|
},
|
||||||
@@ -179,7 +232,7 @@ fn watch_py_files_native(
|
|||||||
item.buffer.as_mut_ptr().cast(),
|
item.buffer.as_mut_ptr().cast(),
|
||||||
item.buffer.len() as u32,
|
item.buffer.len() as u32,
|
||||||
1,
|
1,
|
||||||
FILE_NOTIFY_CHANGE_LAST_WRITE,
|
FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME,
|
||||||
&mut bytes_returned,
|
&mut bytes_returned,
|
||||||
&mut item.overlapped,
|
&mut item.overlapped,
|
||||||
None,
|
None,
|
||||||
@@ -239,178 +292,236 @@ fn watch_py_files_native(
|
|||||||
start_watch(item);
|
start_watch(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut debounce = HashMap::<PathBuf, Instant>::new();
|
let mut debounced = DebouncedPaths::default();
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
let handles: Vec<HANDLE> = items.iter().map(|item| item.event).collect();
|
let handles: Vec<HANDLE> = items.iter().map(|item| item.event).collect();
|
||||||
let result =
|
let result =
|
||||||
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 100) };
|
unsafe { WaitForMultipleObjects(handles.len() as u32, handles.as_ptr(), 0, 50) };
|
||||||
if result == WAIT_TIMEOUT {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if result == WAIT_FAILED {
|
if result == WAIT_FAILED {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let index = (result - WAIT_OBJECT_0) as usize;
|
|
||||||
if index >= items.len() {
|
if result != WAIT_TIMEOUT {
|
||||||
continue;
|
let index = (result - WAIT_OBJECT_0) as usize;
|
||||||
}
|
if index < items.len() {
|
||||||
let item = &mut items[index];
|
let item = &mut items[index];
|
||||||
let mut bytes = 0u32;
|
let mut bytes = 0u32;
|
||||||
let ok = unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) };
|
let ok =
|
||||||
if ok == 0 || bytes == 0 {
|
unsafe { GetOverlappedResult(item.h_dir, &mut item.overlapped, &mut bytes, 0) };
|
||||||
start_watch(item);
|
if ok != 0 && bytes != 0 {
|
||||||
continue;
|
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 mut offset = 0usize;
|
let paths = debounced.take_ready(Instant::now());
|
||||||
while offset < bytes as usize {
|
if !paths.is_empty() {
|
||||||
let info =
|
record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref());
|
||||||
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(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))]
|
#[cfg(not(windows))]
|
||||||
fn watch_py_files_native(
|
fn watch_files_native(
|
||||||
|
process_id: u32,
|
||||||
roots: Vec<PathBuf>,
|
roots: Vec<PathBuf>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
need_update: Arc<AtomicBool>,
|
state: Arc<Mutex<ReloadState>>,
|
||||||
is_foreground: Arc<AtomicBool>,
|
ipc: Option<DebugIpcServer>,
|
||||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
options: HotReloadOptions,
|
||||||
ipc: DebugIpcServer,
|
|
||||||
) {
|
) {
|
||||||
let mut last_seen = snapshot_py_files(&roots);
|
let mut last_seen = snapshot_files(&roots, options);
|
||||||
let mut debounce = HashMap::<PathBuf, SystemTime>::new();
|
|
||||||
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
thread::sleep(Duration::from_millis(DEBOUNCE_MS));
|
thread::sleep(Duration::from_millis(DEBOUNCE_MS));
|
||||||
let now = SystemTime::now();
|
let current = snapshot_files(&roots, options);
|
||||||
let current = snapshot_py_files(&roots);
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
for (path, modified) in ¤t {
|
for (path, modified) in ¤t {
|
||||||
let changed = match last_seen.get(path) {
|
if last_seen.get(path) != Some(modified) {
|
||||||
Some(previous) => modified > previous,
|
paths.push(path.clone());
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
if !changed {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if let Some(last) = debounce.get(path) {
|
}
|
||||||
if now.duration_since(*last).unwrap_or_default()
|
for path in last_seen.keys() {
|
||||||
< Duration::from_millis(DEBOUNCE_MS)
|
if !current.contains_key(path) {
|
||||||
{
|
paths.push(path.clone());
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
debounce.insert(path.clone(), now);
|
}
|
||||||
record_changed_path(
|
|
||||||
&roots,
|
if !paths.is_empty() {
|
||||||
path,
|
record_changed_paths(process_id, &roots, paths, options, &state, ipc.as_ref());
|
||||||
&cached_paths,
|
|
||||||
&need_update,
|
|
||||||
&is_foreground,
|
|
||||||
&ipc,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
last_seen = current;
|
last_seen = current;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_changed_path(
|
fn record_changed_paths(
|
||||||
|
process_id: u32,
|
||||||
roots: &[PathBuf],
|
roots: &[PathBuf],
|
||||||
path: &Path,
|
paths: Vec<PathBuf>,
|
||||||
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
|
options: HotReloadOptions,
|
||||||
need_update: &Arc<AtomicBool>,
|
state: &Arc<Mutex<ReloadState>>,
|
||||||
is_foreground: &Arc<AtomicBool>,
|
ipc: Option<&DebugIpcServer>,
|
||||||
ipc: &DebugIpcServer,
|
|
||||||
) {
|
) {
|
||||||
log::print_colored(
|
let (batch, tracked_paths) = build_reload_batch(paths, roots, options);
|
||||||
&format!("[HotReload] Detected change in: {}", path.display()),
|
if batch.is_empty() {
|
||||||
ConsoleColor::Yellow,
|
return;
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<PathBuf>,
|
||||||
|
roots: &[PathBuf],
|
||||||
|
options: HotReloadOptions,
|
||||||
|
) -> (PendingReload, Vec<PathBuf>) {
|
||||||
|
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(
|
fn watch_foreground(
|
||||||
process_id: u32,
|
process_id: u32,
|
||||||
roots: Vec<PathBuf>,
|
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
need_update: Arc<AtomicBool>,
|
state: Arc<Mutex<ReloadState>>,
|
||||||
is_foreground: Arc<AtomicBool>,
|
ipc: Option<DebugIpcServer>,
|
||||||
cached_paths: Arc<Mutex<HashSet<PathBuf>>>,
|
|
||||||
ipc: DebugIpcServer,
|
|
||||||
) {
|
) {
|
||||||
let mut last_state = false;
|
let mut last_state = false;
|
||||||
while !stop.load(Ordering::Relaxed) {
|
while !stop.load(Ordering::Relaxed) {
|
||||||
let current = foreground_process_id() == Some(process_id);
|
let current = foreground_process_id() == Some(process_id);
|
||||||
is_foreground.store(current, Ordering::Relaxed);
|
let should_trigger = {
|
||||||
if current && !last_state && need_update.load(Ordering::Relaxed) {
|
let mut guard = state.lock().unwrap();
|
||||||
trigger_reload(&roots, &cached_paths, &need_update, &ipc);
|
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;
|
last_state = current;
|
||||||
thread::sleep(Duration::from_millis(80));
|
thread::sleep(Duration::from_millis(80));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trigger_reload(
|
fn trigger_reload(process_id: u32, state: &Arc<Mutex<ReloadState>>, ipc: Option<&DebugIpcServer>) {
|
||||||
roots: &[PathBuf],
|
let batch = {
|
||||||
cached_paths: &Arc<Mutex<HashSet<PathBuf>>>,
|
let mut guard = state.lock().unwrap();
|
||||||
need_update: &Arc<AtomicBool>,
|
mem::take(&mut guard.pending)
|
||||||
ipc: &DebugIpcServer,
|
|
||||||
) {
|
|
||||||
let paths: Vec<PathBuf> = {
|
|
||||||
let mut guard = cached_paths.lock().unwrap();
|
|
||||||
guard.drain().collect()
|
|
||||||
};
|
};
|
||||||
let modules: Vec<String> = paths
|
|
||||||
.iter()
|
for action in reload_actions(batch) {
|
||||||
.filter_map(|path| py_path_to_module_name(path, roots))
|
match action {
|
||||||
.collect();
|
ReloadAction::Python(modules) => {
|
||||||
need_update.store(false, Ordering::Relaxed);
|
log::print_colored(
|
||||||
if modules.is_empty() {
|
"[HotReload] 检测到 Python 修改,已触发增量热更新。",
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log::print_colored(
|
}
|
||||||
"[HotReload] 检测到修改,已触发热更新。",
|
|
||||||
ConsoleColor::Yellow,
|
fn reload_actions(batch: PendingReload) -> Vec<ReloadAction> {
|
||||||
);
|
let mut actions = Vec::with_capacity(2);
|
||||||
let payload = json!(modules).to_string();
|
if !batch.python_modules.is_empty() {
|
||||||
let _ = ipc.send_message(2, payload.as_bytes());
|
actions.push(ReloadAction::Python(
|
||||||
|
batch.python_modules.into_iter().collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if batch.json_ui {
|
||||||
|
actions.push(ReloadAction::JsonUi);
|
||||||
|
}
|
||||||
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
fn snapshot_files(roots: &[PathBuf], options: HotReloadOptions) -> HashMap<PathBuf, SystemTime> {
|
||||||
let mut files = HashMap::new();
|
let mut files = HashMap::new();
|
||||||
for root in roots {
|
for root in roots {
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
@@ -418,7 +529,7 @@ fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
|||||||
}
|
}
|
||||||
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
|
for entry in WalkDir::new(root).into_iter().filter_map(Result::ok) {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("py") {
|
if !path.is_file() || !is_tracked_reload_path(path, roots, options) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(metadata) = fs::metadata(path) {
|
if let Ok(metadata) = fs::metadata(path) {
|
||||||
@@ -431,6 +542,53 @@ fn snapshot_py_files(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
|
|||||||
files
|
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<String> {
|
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") {
|
if file_path.extension().and_then(|ext| ext.to_str()) != Some("py") {
|
||||||
return None;
|
return None;
|
||||||
@@ -484,30 +642,224 @@ fn foreground_process_id() -> Option<u32> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::fs;
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
use super::py_path_to_module_name;
|
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]
|
#[test]
|
||||||
fn maps_py_file_to_module_name_under_manifest() {
|
fn maps_py_file_to_module_name_under_manifest() {
|
||||||
let root = std::env::temp_dir().join(format!("emod-hot-{}", std::process::id()));
|
let project = TempProject::new();
|
||||||
let pack = root.join("behavior_pack");
|
let pack = create_pack(&project.path, "behavior_pack", "data");
|
||||||
let module = pack.join("foo/bar.py");
|
let module = pack.join("foo/bar.py");
|
||||||
fs::create_dir_all(module.parent().unwrap()).unwrap();
|
fs::create_dir_all(module.parent().unwrap()).unwrap();
|
||||||
fs::write(pack.join("manifest.json"), "{}").unwrap();
|
|
||||||
fs::write(&module, "").unwrap();
|
fs::write(&module, "").unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
py_path_to_module_name(&module, std::slice::from_ref(&root)),
|
py_path_to_module_name(&module, std::slice::from_ref(&project.path)),
|
||||||
Some("foo.bar".to_string())
|
Some("foo.bar".to_string())
|
||||||
);
|
);
|
||||||
let outside = root.join("x.py");
|
let outside = project.path.join("x.py");
|
||||||
fs::write(&outside, "").unwrap();
|
fs::write(&outside, "").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
py_path_to_module_name(&outside, std::slice::from_ref(&root)),
|
py_path_to_module_name(&outside, std::slice::from_ref(&project.path)),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(root);
|
#[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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ mod tests {
|
|||||||
auto_join_game: true,
|
auto_join_game: true,
|
||||||
include_debug_mod: true,
|
include_debug_mod: true,
|
||||||
auto_hot_reload_mods: true,
|
auto_hot_reload_mods: true,
|
||||||
|
auto_hot_reload_ui: false,
|
||||||
world_type: 1,
|
world_type: 1,
|
||||||
game_mode: 1,
|
game_mode: 1,
|
||||||
enable_cheats: true,
|
enable_cheats: true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::error::{CliError, Result};
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
config::{DebugConfig, ResolvedModDir},
|
config::{DebugConfig, ResolvedModDir},
|
||||||
hotreload::HotReloadTask,
|
hotreload::{HotReloadOptions, HotReloadTask},
|
||||||
ipc::DebugIpcServer,
|
ipc::DebugIpcServer,
|
||||||
log::{self, LineBuffer},
|
log::{self, LineBuffer},
|
||||||
};
|
};
|
||||||
@@ -52,9 +52,13 @@ pub fn launch_game(
|
|||||||
|
|
||||||
let command = build_command(config, config_arg);
|
let command = build_command(config, config_arg);
|
||||||
let mut command_w = wide_null(OsStr::new(&command));
|
let mut command_w = wide_null(OsStr::new(&command));
|
||||||
let env_block = ipc
|
let env_block = if ipc.is_some() || config.auto_hot_reload_ui {
|
||||||
.as_ref()
|
Some(build_environment_block(
|
||||||
.map(|server| build_environment_block(server.port()));
|
ipc.as_ref().map(|server| server.port()),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let mut security = SECURITY_ATTRIBUTES {
|
let mut security = SECURITY_ATTRIBUTES {
|
||||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
@@ -116,12 +120,15 @@ pub fn launch_game(
|
|||||||
let stderr_thread =
|
let stderr_thread =
|
||||||
thread::spawn(move || read_pipe(err_read, stderr_filter, log::process_stderr_line));
|
thread::spawn(move || read_pipe(err_read, stderr_filter, log::process_stderr_line));
|
||||||
|
|
||||||
let mut hotreload = if config.auto_hot_reload_mods {
|
let mut hotreload = HotReloadTask::start(
|
||||||
ipc.clone()
|
pid,
|
||||||
.and_then(|server| HotReloadTask::start(pid, mod_dirs, server))
|
mod_dirs,
|
||||||
} else {
|
ipc.clone(),
|
||||||
None
|
HotReloadOptions {
|
||||||
};
|
python: config.auto_hot_reload_mods,
|
||||||
|
json_ui: config.auto_hot_reload_ui,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
unsafe { WaitForSingleObject(process_info.hProcess, INFINITE) };
|
unsafe { WaitForSingleObject(process_info.hProcess, INFINITE) };
|
||||||
|
|
||||||
@@ -223,14 +230,21 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn build_environment_block(ipc_port: u16) -> Vec<u16> {
|
fn build_environment_block(ipc_port: Option<u16>) -> Vec<u16> {
|
||||||
let mut pairs: Vec<(Vec<u16>, Vec<u16>)> = std::env::vars_os()
|
let mut pairs: Vec<(Vec<u16>, Vec<u16>)> = std::env::vars_os()
|
||||||
|
.filter(|(key, _)| {
|
||||||
|
let key = key.to_string_lossy();
|
||||||
|
!key.eq_ignore_ascii_case("MCDEV_DEBUG_IPC_PORT")
|
||||||
|
})
|
||||||
.map(|(key, value)| (key.encode_wide().collect(), value.encode_wide().collect()))
|
.map(|(key, value)| (key.encode_wide().collect(), value.encode_wide().collect()))
|
||||||
.collect();
|
.collect();
|
||||||
pairs.push((
|
|
||||||
OsStr::new("MCDEV_DEBUG_IPC_PORT").encode_wide().collect(),
|
if let Some(port) = ipc_port {
|
||||||
OsStr::new(&ipc_port.to_string()).encode_wide().collect(),
|
pairs.push((
|
||||||
));
|
OsStr::new("MCDEV_DEBUG_IPC_PORT").encode_wide().collect(),
|
||||||
|
OsStr::new(&port.to_string()).encode_wide().collect(),
|
||||||
|
));
|
||||||
|
}
|
||||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
let mut block = Vec::new();
|
let mut block = Vec::new();
|
||||||
|
|||||||
126
src/debug/win.rs
126
src/debug/win.rs
@@ -10,6 +10,12 @@ use windows_sys::Win32::{
|
|||||||
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING,
|
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING,
|
||||||
},
|
},
|
||||||
System::IO::DeviceIoControl,
|
System::IO::DeviceIoControl,
|
||||||
|
UI::{
|
||||||
|
Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW, VK_CONTROL},
|
||||||
|
WindowsAndMessaging::{
|
||||||
|
GetForegroundWindow, GetWindowThreadProcessId, PostMessageW, WM_KEYDOWN, WM_KEYUP,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -148,6 +154,89 @@ pub fn create_junction(target: &Path, link: &Path) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn trigger_ui_reload_shortcut(process_id: u32) -> io::Result<()> {
|
||||||
|
let window = unsafe { GetForegroundWindow() };
|
||||||
|
if window.is_null() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::WouldBlock,
|
||||||
|
"当前没有前台窗口,无法向 Minecraft 发送 Ctrl+R",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut foreground_process_id = 0u32;
|
||||||
|
let foreground_thread_id =
|
||||||
|
unsafe { GetWindowThreadProcessId(window, &mut foreground_process_id) };
|
||||||
|
if foreground_thread_id == 0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::Other,
|
||||||
|
"无法读取当前前台窗口的进程 ID",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if foreground_process_id != process_id {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::WouldBlock,
|
||||||
|
format!(
|
||||||
|
"Minecraft 不在前台:期望 PID {process_id},当前前台 PID {foreground_process_id}"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let [ctrl_down, r_down, r_up, ctrl_up] = ui_reload_key_messages();
|
||||||
|
post_key_message(window, ctrl_down)?;
|
||||||
|
if let Err(err) = post_key_message(window, r_down) {
|
||||||
|
let _ = post_key_message(window, ctrl_up);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
let r_up_result = post_key_message(window, r_up);
|
||||||
|
let ctrl_up_result = post_key_message(window, ctrl_up);
|
||||||
|
r_up_result?;
|
||||||
|
ctrl_up_result?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn ui_reload_key_messages() -> [(u32, u32, bool); 4] {
|
||||||
|
let control = u32::from(VK_CONTROL);
|
||||||
|
[
|
||||||
|
(WM_KEYDOWN, control, false),
|
||||||
|
(WM_KEYDOWN, u32::from(b'R'), false),
|
||||||
|
(WM_KEYUP, u32::from(b'R'), true),
|
||||||
|
(WM_KEYUP, control, true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn post_key_message(
|
||||||
|
window: *mut std::ffi::c_void,
|
||||||
|
key_message: (u32, u32, bool),
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let (message, key, key_up) = key_message;
|
||||||
|
let posted = unsafe {
|
||||||
|
PostMessageW(
|
||||||
|
window,
|
||||||
|
message,
|
||||||
|
key as usize,
|
||||||
|
key_message_lparam(key, key_up),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if posted == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn key_message_lparam(key: u32, key_up: bool) -> isize {
|
||||||
|
let scan_code = unsafe { MapVirtualKeyW(key, MAPVK_VK_TO_VSC) };
|
||||||
|
let mut value = 1isize | ((scan_code as isize) << 16);
|
||||||
|
if key_up {
|
||||||
|
value |= (1isize << 30) | (1isize << 31);
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> {
|
pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::new(
|
||||||
@@ -155,3 +244,40 @@ pub fn create_junction(_target: &Path, _link: &Path) -> io::Result<()> {
|
|||||||
"debug launch is only supported on Windows",
|
"debug launch is only supported on Windows",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn trigger_ui_reload_shortcut(_process_id: u32) -> io::Result<()> {
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::Unsupported,
|
||||||
|
"debug launch is only supported on Windows",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, windows))]
|
||||||
|
mod tests {
|
||||||
|
use windows_sys::Win32::UI::{
|
||||||
|
Input::KeyboardAndMouse::VK_CONTROL,
|
||||||
|
WindowsAndMessaging::{WM_KEYDOWN, WM_KEYUP},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{key_message_lparam, ui_reload_key_messages};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_reload_shortcut_uses_native_ctrl_r_order() {
|
||||||
|
let control = u32::from(VK_CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
ui_reload_key_messages(),
|
||||||
|
[
|
||||||
|
(WM_KEYDOWN, control, false),
|
||||||
|
(WM_KEYDOWN, u32::from(b'R'), false),
|
||||||
|
(WM_KEYUP, u32::from(b'R'), true),
|
||||||
|
(WM_KEYUP, control, true),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let r_down = key_message_lparam(u32::from(b'R'), false) as usize;
|
||||||
|
let r_up = key_message_lparam(u32::from(b'R'), true) as usize;
|
||||||
|
assert_eq!((r_down >> 30) & 0b11, 0);
|
||||||
|
assert_eq!((r_up >> 30) & 0b11, 0b11);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user