mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-06-06 06:10:54 +00:00
feat: initial Rust port of time-mocker
Rust workspace with three crates: - time-mocker-core: shared MockTimeInfo + named MMF helper + FILETIME helpers - time-mocker-hook: cdylib injected into targets; inline detours via retour on GetSystemTime / GetLocalTime / GetSystemTimeAsFileTime / GetSystemTimePreciseAsFileTime / NtQuerySystemTime - time-mocker-ui: egui controller with process list, time picker, and glob/regex auto-inject rules; injects via dll-syringe; UAC manifest embedded in release builds IPC: 8-byte MMF named TimeMocker_<pid> holding an i64 delta in FILETIME ticks. Hook adds delta to the real FILETIME on every call. Note this differs from the C# version's .NET-tick delta -- contracts are not interoperable. Requires nightly toolchain (retour uses unboxed_closures / tuple_trait); pinned via rust-toolchain.toml.
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
# Rust
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
Cargo.lock.bak
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local config
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Plans (we keep the C# project's plans separate)
|
||||
/plans/
|
||||
Generated
+3471
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/time-mocker-core",
|
||||
"crates/time-mocker-hook",
|
||||
"crates/time-mocker-ui",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["tiennm99"]
|
||||
repository = "https://github.com/tiennm99/time-mocker-rs"
|
||||
rust-version = "1.90"
|
||||
|
||||
[workspace.dependencies]
|
||||
windows = { version = "0.58", default-features = false }
|
||||
windows-sys = "0.59"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
opt-level = 3
|
||||
panic = "abort"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tiennm99
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,66 @@
|
||||
# TimeMocker (Rust)
|
||||
|
||||
A Windows tool that injects fake time into running processes by hooking Win32 time APIs. Rust port of [time-mocker](https://github.com/tiennm99/time-mocker) (C# / EasyHook).
|
||||
|
||||
> **Status:** Active development. This Rust implementation is intended to become the canonical `time-mocker` once feature-complete; the C# project will be renamed to indicate its dotnet origin.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
time-mocker-rs/
|
||||
├── crates/
|
||||
│ ├── time-mocker-core/ — shared types (MockTimeInfo) + named MMF helper + tick conversions
|
||||
│ ├── time-mocker-hook/ — cdylib injected into target processes; hooks 5 time APIs via retour
|
||||
│ └── time-mocker-ui/ — egui controller binary; injects via dll-syringe, writes delta per PID
|
||||
```
|
||||
|
||||
## Hooked APIs
|
||||
|
||||
| API | DLL |
|
||||
|-----|-----|
|
||||
| `GetSystemTime` | kernel32 |
|
||||
| `GetLocalTime` | kernel32 |
|
||||
| `GetSystemTimeAsFileTime` | kernel32 |
|
||||
| `GetSystemTimePreciseAsFileTime` | kernel32 |
|
||||
| `NtQuerySystemTime` | ntdll |
|
||||
|
||||
## IPC Design
|
||||
|
||||
Named Memory-Mapped File per injected process:
|
||||
|
||||
```
|
||||
Name: TimeMocker_<PID>
|
||||
Size: 8 bytes
|
||||
[0..7] DeltaTicks (i64 — 100-ns units, added to the real FILETIME)
|
||||
```
|
||||
|
||||
The hook reads the delta on every time API call and returns `real_filetime + delta`. The controller writes the delta whenever the user picks a new fake time.
|
||||
|
||||
> **Tick epoch difference vs the C# version:** The C# version stores a delta against `DateTime.UtcNow.Ticks` (epoch 0001-01-01 UTC). The Rust version stores a delta in raw FILETIME units (epoch 1601-01-01 UTC). The two IPC contracts are not interoperable — the Rust UI and Rust hook DLL only talk to each other.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# Nightly Rust (required by retour for inline x64 detours)
|
||||
# A `rust-toolchain.toml` at the repo root pins the channel automatically.
|
||||
cargo build --release
|
||||
|
||||
# Outputs:
|
||||
# target/release/time_mocker_ui.exe
|
||||
# target/release/time_mocker_hook.dll (must be next to the UI exe)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10/11 x64
|
||||
- Rust nightly (pinned via `rust-toolchain.toml`)
|
||||
- Must run as Administrator (UAC manifest embedded)
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
## Related
|
||||
|
||||
- [time-mocker](https://github.com/tiennm99/time-mocker) — original C# / EasyHook implementation
|
||||
- [time-mocker-cpp](https://github.com/tiennm99/time-mocker-cpp) — C++ / MS Detours port
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "time-mocker-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Shared types and named MMF helper for time-mocker"
|
||||
|
||||
[lib]
|
||||
name = "time_mocker_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
windows-sys = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Time",
|
||||
"Win32_Security",
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Shared types and named MMF helper for time-mocker.
|
||||
//!
|
||||
//! IPC contract: an 8-byte memory-mapped file named `TimeMocker_<pid>` holds an
|
||||
//! i64 `DeltaTicks` — the offset (in 100-ns FILETIME units) added to the real
|
||||
//! system FILETIME by the injected hook.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
pub mod mmf;
|
||||
pub mod ticks;
|
||||
pub mod types;
|
||||
|
||||
pub use mmf::SharedDelta;
|
||||
pub use types::MockTimeInfo;
|
||||
|
||||
pub const MMF_PREFIX: &str = "TimeMocker_";
|
||||
|
||||
#[inline]
|
||||
pub fn mmf_name_for_pid(pid: u32) -> String {
|
||||
format!("{MMF_PREFIX}{pid}")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Named memory-mapped file wrapper around the 8-byte `MockTimeInfo` payload.
|
||||
//!
|
||||
//! Both the controller (writer) and the injected hook DLL (reader) attach to
|
||||
//! the same `TimeMocker_<pid>` mapping. We use raw `windows-sys` because
|
||||
//! `memmap2` doesn't expose named pagefile-backed mappings on Windows.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
CreateFileMappingW, MapViewOfFile, OpenFileMappingW, UnmapViewOfFile, FILE_MAP_ALL_ACCESS,
|
||||
FILE_MAP_READ, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE,
|
||||
};
|
||||
|
||||
use crate::types::MockTimeInfo;
|
||||
|
||||
/// Read/write handle to the shared delta.
|
||||
pub struct SharedDelta {
|
||||
handle: HANDLE,
|
||||
view: *mut AtomicI64,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
unsafe impl Send for SharedDelta {}
|
||||
unsafe impl Sync for SharedDelta {}
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
impl SharedDelta {
|
||||
/// Create (or open) the named mapping. Used by the controller side.
|
||||
pub fn create(name: &str) -> io::Result<Self> {
|
||||
let wname = wide(name);
|
||||
let handle = unsafe {
|
||||
CreateFileMappingW(
|
||||
INVALID_HANDLE_VALUE,
|
||||
ptr::null(),
|
||||
PAGE_READWRITE,
|
||||
0,
|
||||
MockTimeInfo::SIZE as u32,
|
||||
wname.as_ptr(),
|
||||
)
|
||||
};
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32));
|
||||
}
|
||||
Self::map_view(handle, name, FILE_MAP_ALL_ACCESS)
|
||||
}
|
||||
|
||||
/// Open an existing mapping. Used by the injected hook DLL.
|
||||
pub fn open(name: &str) -> io::Result<Self> {
|
||||
let wname = wide(name);
|
||||
let handle = unsafe { OpenFileMappingW(FILE_MAP_READ, 0, wname.as_ptr()) };
|
||||
if handle.is_null() {
|
||||
return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32));
|
||||
}
|
||||
Self::map_view(handle, name, FILE_MAP_READ)
|
||||
}
|
||||
|
||||
fn map_view(handle: HANDLE, name: &str, access: u32) -> io::Result<Self> {
|
||||
let view: MEMORY_MAPPED_VIEW_ADDRESS = unsafe {
|
||||
MapViewOfFile(handle, access, 0, 0, MockTimeInfo::SIZE)
|
||||
};
|
||||
if view.Value.is_null() {
|
||||
let err = unsafe { GetLastError() } as i32;
|
||||
unsafe { CloseHandle(handle) };
|
||||
return Err(io::Error::from_raw_os_error(err));
|
||||
}
|
||||
Ok(Self {
|
||||
handle,
|
||||
view: view.Value as *mut AtomicI64,
|
||||
name: name.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically write the delta. Safe for concurrent reads from the hook.
|
||||
#[inline]
|
||||
pub fn write_delta(&self, ticks: i64) {
|
||||
unsafe { (*self.view).store(ticks, Ordering::Relaxed) }
|
||||
}
|
||||
|
||||
/// Atomically read the delta. Hot path on the hook side.
|
||||
#[inline]
|
||||
pub fn read_delta(&self) -> i64 {
|
||||
unsafe { (*self.view).load(Ordering::Relaxed) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SharedDelta {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !self.view.is_null() {
|
||||
let addr = MEMORY_MAPPED_VIEW_ADDRESS {
|
||||
Value: self.view as *mut _,
|
||||
};
|
||||
UnmapViewOfFile(addr);
|
||||
}
|
||||
if !self.handle.is_null() {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! FILETIME / SYSTEMTIME conversion helpers.
|
||||
//!
|
||||
//! FILETIME = 100-ns units since 1601-01-01 00:00:00 UTC. This crate uses
|
||||
//! FILETIME ticks throughout to avoid extra arithmetic on the hot path.
|
||||
|
||||
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||
use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToFileTime};
|
||||
|
||||
#[inline]
|
||||
pub fn filetime_to_i64(ft: FILETIME) -> i64 {
|
||||
((ft.dwHighDateTime as i64) << 32) | (ft.dwLowDateTime as i64 & 0xFFFF_FFFF)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn i64_to_filetime(ticks: i64) -> FILETIME {
|
||||
FILETIME {
|
||||
dwLowDateTime: (ticks & 0xFFFF_FFFF) as u32,
|
||||
dwHighDateTime: ((ticks >> 32) & 0xFFFF_FFFF) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert FILETIME ticks to SYSTEMTIME (UTC). Returns None on Win32 failure.
|
||||
pub fn ticks_to_systemtime(ticks: i64) -> Option<SYSTEMTIME> {
|
||||
let ft = i64_to_filetime(ticks);
|
||||
let mut st: SYSTEMTIME = unsafe { std::mem::zeroed() };
|
||||
let ok = unsafe { FileTimeToSystemTime(&ft, &mut st) };
|
||||
if ok == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(st)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert SYSTEMTIME (UTC) to FILETIME ticks. Returns None on Win32 failure.
|
||||
pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option<i64> {
|
||||
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
|
||||
let ok = unsafe { SystemTimeToFileTime(st, &mut ft) };
|
||||
if ok == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(filetime_to_i64(ft))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::mem::size_of;
|
||||
|
||||
/// 8-byte payload of the shared MMF.
|
||||
///
|
||||
/// `delta_ticks` is added to the real FILETIME (100-ns since 1601-01-01 UTC)
|
||||
/// by every hooked time API call. May be negative to mock past times.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MockTimeInfo {
|
||||
pub delta_ticks: i64,
|
||||
}
|
||||
|
||||
impl MockTimeInfo {
|
||||
pub const SIZE: usize = size_of::<Self>();
|
||||
|
||||
#[inline]
|
||||
pub const fn zero() -> Self {
|
||||
Self { delta_ticks: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
const _: () = assert!(MockTimeInfo::SIZE == 8);
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "time-mocker-hook"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Injected DLL that hooks Win32 time APIs"
|
||||
|
||||
[lib]
|
||||
name = "time_mocker_hook"
|
||||
crate-type = ["cdylib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
time-mocker-core = { path = "../time-mocker-core" }
|
||||
retour = { version = "0.4.0-alpha.4", features = ["static-detour"] }
|
||||
once_cell = "1.20"
|
||||
windows-sys = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_SystemServices",
|
||||
"Win32_System_Time",
|
||||
] }
|
||||
@@ -0,0 +1,47 @@
|
||||
//! `DllMain` and worker-thread bootstrap.
|
||||
//!
|
||||
//! On `DLL_PROCESS_ATTACH` we MUST NOT do real work (loader lock). Instead we
|
||||
//! spawn a worker thread that opens the shared MMF and installs hooks.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::thread;
|
||||
|
||||
use time_mocker_core::{mmf_name_for_pid, SharedDelta};
|
||||
use windows_sys::Win32::Foundation::{BOOL, HMODULE, TRUE};
|
||||
use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||
|
||||
use crate::hooks;
|
||||
|
||||
#[no_mangle]
|
||||
#[allow(non_snake_case, clippy::missing_safety_doc)]
|
||||
pub unsafe extern "system" fn DllMain(
|
||||
_hinst: HMODULE,
|
||||
reason: u32,
|
||||
_reserved: *mut c_void,
|
||||
) -> BOOL {
|
||||
if reason == DLL_PROCESS_ATTACH {
|
||||
thread::spawn(bootstrap);
|
||||
}
|
||||
TRUE
|
||||
}
|
||||
|
||||
fn bootstrap() {
|
||||
let pid = unsafe { GetCurrentProcessId() };
|
||||
let name = mmf_name_for_pid(pid);
|
||||
|
||||
let shared = match SharedDelta::open(&name) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if hooks::install(shared).is_err() {
|
||||
// Hook failure is silent — the target process runs unmodified.
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the thread alive; hooks live for the lifetime of the process.
|
||||
loop {
|
||||
thread::park();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Inline detours for 5 Win32 time APIs.
|
||||
//!
|
||||
//! All hooks resolve the current FILETIME via the real API, add the shared
|
||||
//! delta, and return the adjusted value. Reads are atomic and lock-free.
|
||||
|
||||
use std::ffi::CStr;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use retour::static_detour;
|
||||
use time_mocker_core::ticks::{filetime_to_i64, i64_to_filetime, ticks_to_systemtime};
|
||||
use time_mocker_core::SharedDelta;
|
||||
use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME};
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
use windows_sys::Win32::System::Time::FileTimeToSystemTime;
|
||||
|
||||
static SHARED: OnceCell<SharedDelta> = OnceCell::new();
|
||||
|
||||
static_detour! {
|
||||
static GetSystemTimeDetour: unsafe extern "system" fn(*mut SYSTEMTIME);
|
||||
static GetLocalTimeDetour: unsafe extern "system" fn(*mut SYSTEMTIME);
|
||||
static GetSystemTimeAsFileTimeDetour: unsafe extern "system" fn(*mut FILETIME);
|
||||
static GetSystemTimePreciseAsFileTimeDetour: unsafe extern "system" fn(*mut FILETIME);
|
||||
static NtQuerySystemTimeDetour: unsafe extern "system" fn(*mut i64) -> i32;
|
||||
}
|
||||
|
||||
type FnSystemTime = unsafe extern "system" fn(*mut SYSTEMTIME);
|
||||
type FnFileTime = unsafe extern "system" fn(*mut FILETIME);
|
||||
type FnNtQuerySystemTime = unsafe extern "system" fn(*mut i64) -> i32;
|
||||
|
||||
pub fn install(shared: SharedDelta) -> Result<(), retour::Error> {
|
||||
let _ = SHARED.set(shared);
|
||||
|
||||
unsafe {
|
||||
if let Some(target) = resolve::<FnSystemTime>("kernel32.dll", "GetSystemTime") {
|
||||
GetSystemTimeDetour
|
||||
.initialize(target, hook_get_system_time)?
|
||||
.enable()?;
|
||||
}
|
||||
if let Some(target) = resolve::<FnSystemTime>("kernel32.dll", "GetLocalTime") {
|
||||
GetLocalTimeDetour
|
||||
.initialize(target, hook_get_local_time)?
|
||||
.enable()?;
|
||||
}
|
||||
if let Some(target) = resolve::<FnFileTime>("kernel32.dll", "GetSystemTimeAsFileTime") {
|
||||
GetSystemTimeAsFileTimeDetour
|
||||
.initialize(target, hook_get_system_time_as_filetime)?
|
||||
.enable()?;
|
||||
}
|
||||
if let Some(target) =
|
||||
resolve::<FnFileTime>("kernel32.dll", "GetSystemTimePreciseAsFileTime")
|
||||
{
|
||||
GetSystemTimePreciseAsFileTimeDetour
|
||||
.initialize(target, hook_get_system_time_precise_as_filetime)?
|
||||
.enable()?;
|
||||
}
|
||||
if let Some(target) = resolve::<FnNtQuerySystemTime>("ntdll.dll", "NtQuerySystemTime") {
|
||||
NtQuerySystemTimeDetour
|
||||
.initialize(target, hook_nt_query_system_time)?
|
||||
.enable()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn resolve<F: Copy>(module: &str, proc: &str) -> Option<F> {
|
||||
let module_c = std::ffi::CString::new(module).ok()?;
|
||||
let proc_c = std::ffi::CString::new(proc).ok()?;
|
||||
let h = GetModuleHandleA(module_c.as_ptr() as *const u8);
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
let _ = CStr::from_bytes_with_nul(b"\0");
|
||||
let addr = GetProcAddress(h, proc_c.as_ptr() as *const u8)?;
|
||||
Some(std::mem::transmute_copy::<_, F>(&addr))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn delta() -> i64 {
|
||||
SHARED.get().map(|s| s.read_delta()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn fake_filetime() -> FILETIME {
|
||||
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
|
||||
unsafe { GetSystemTimeAsFileTimeDetour.call(&mut ft) };
|
||||
let ticks = filetime_to_i64(ft).saturating_add(delta());
|
||||
i64_to_filetime(ticks)
|
||||
}
|
||||
|
||||
fn fake_filetime_precise() -> FILETIME {
|
||||
let mut ft: FILETIME = unsafe { std::mem::zeroed() };
|
||||
unsafe { GetSystemTimePreciseAsFileTimeDetour.call(&mut ft) };
|
||||
let ticks = filetime_to_i64(ft).saturating_add(delta());
|
||||
i64_to_filetime(ticks)
|
||||
}
|
||||
|
||||
fn hook_get_system_time(out: *mut SYSTEMTIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
let ft = fake_filetime();
|
||||
unsafe { FileTimeToSystemTime(&ft, out) };
|
||||
}
|
||||
|
||||
fn hook_get_local_time(out: *mut SYSTEMTIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
let ft = fake_filetime();
|
||||
let ticks = filetime_to_i64(ft);
|
||||
if let Some(st) = ticks_to_systemtime(ticks) {
|
||||
unsafe { *out = st };
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_get_system_time_as_filetime(out: *mut FILETIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe { *out = fake_filetime() };
|
||||
}
|
||||
|
||||
fn hook_get_system_time_precise_as_filetime(out: *mut FILETIME) {
|
||||
if out.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe { *out = fake_filetime_precise() };
|
||||
}
|
||||
|
||||
fn hook_nt_query_system_time(out: *mut i64) -> i32 {
|
||||
if out.is_null() {
|
||||
return -1;
|
||||
}
|
||||
let mut real: i64 = 0;
|
||||
unsafe { NtQuerySystemTimeDetour.call(&mut real) };
|
||||
unsafe { *out = real.saturating_add(delta()) };
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Injected DLL — hooks Win32 time APIs and rewrites them to read a shared delta.
|
||||
//!
|
||||
//! Implementation lives in `hooks.rs` and `entrypoint.rs`.
|
||||
|
||||
#![cfg(windows)]
|
||||
|
||||
mod entrypoint;
|
||||
mod hooks;
|
||||
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "time-mocker-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Controller UI for time-mocker — picks fake time and injects hook DLL into target processes"
|
||||
|
||||
[[bin]]
|
||||
name = "time_mocker_ui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
time-mocker-core = { path = "../time-mocker-core" }
|
||||
eframe = { version = "0.29", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"persistence",
|
||||
] }
|
||||
egui = "0.29"
|
||||
dll-syringe = "0.17"
|
||||
sysinfo = "0.32"
|
||||
globset = "0.4"
|
||||
regex = "1.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
windows-sys = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
"Win32_Security",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-manifest = "1.4"
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Embeds a Windows UAC manifest so the controller prompts for admin
|
||||
//! elevation on launch (required for `CreateRemoteThread` into other users'
|
||||
//! processes and for hooking system DLLs).
|
||||
|
||||
fn main() {
|
||||
// Only embed the UAC manifest in release builds — otherwise `cargo test`
|
||||
// and other dev workflows would fail with ERROR_ELEVATION_REQUIRED (740).
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
let profile = std::env::var("PROFILE").unwrap_or_default();
|
||||
if profile != "release" {
|
||||
return;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use embed_manifest::manifest::ExecutionLevel;
|
||||
use embed_manifest::{embed_manifest, new_manifest};
|
||||
let manifest = new_manifest("TimeMocker.UI")
|
||||
.requested_execution_level(ExecutionLevel::RequireAdministrator);
|
||||
embed_manifest(manifest).expect("failed to embed UAC manifest");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! `eframe::App` impl — three tabs (Processes, Auto-Inject Rules, Log) and a
|
||||
//! global Mock Time bar at the top.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Local, NaiveDate, NaiveTime, TimeZone, Utc};
|
||||
use eframe::{egui, CreationContext};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::injection_manager::InjectionManager;
|
||||
use crate::process_watcher::{ProcInfo, ProcessWatcher};
|
||||
use crate::rules::{CompiledRules, PatternKind, Rule};
|
||||
|
||||
/// Difference between Unix epoch (1970) and FILETIME epoch (1601), in 100-ns ticks.
|
||||
const UNIX_TO_FILETIME_TICKS: i64 = 116_444_736_000_000_000;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Persistent {
|
||||
rules: Vec<Rule>,
|
||||
auto_inject_enabled: bool,
|
||||
last_fake_date: Option<String>, // ISO-8601 of last applied UTC fake time
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum Tab {
|
||||
Processes,
|
||||
Rules,
|
||||
Log,
|
||||
}
|
||||
|
||||
pub struct TimeMockerApp {
|
||||
persistent: Persistent,
|
||||
tab: Tab,
|
||||
manager: Option<InjectionManager>,
|
||||
manager_err: Option<String>,
|
||||
watcher: ProcessWatcher,
|
||||
processes: Vec<ProcInfo>,
|
||||
last_refresh: Instant,
|
||||
last_auto_inject_scan: Instant,
|
||||
search: String,
|
||||
rule_input: String,
|
||||
rule_kind: PatternKind,
|
||||
fake_date: NaiveDate,
|
||||
fake_time: NaiveTime,
|
||||
current_delta_ticks: i64,
|
||||
}
|
||||
|
||||
impl TimeMockerApp {
|
||||
pub fn new(cc: &CreationContext<'_>) -> Self {
|
||||
let persistent: Persistent = cc
|
||||
.storage
|
||||
.and_then(|s| eframe::get_value(s, "time_mocker"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let (manager, manager_err) = match InjectionManager::new() {
|
||||
Ok(m) => (Some(m), None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
};
|
||||
|
||||
let mut watcher = ProcessWatcher::new();
|
||||
watcher.refresh();
|
||||
let processes = watcher.list();
|
||||
|
||||
let now_local = Local::now();
|
||||
Self {
|
||||
persistent,
|
||||
tab: Tab::Processes,
|
||||
manager,
|
||||
manager_err,
|
||||
watcher,
|
||||
processes,
|
||||
last_refresh: Instant::now(),
|
||||
last_auto_inject_scan: Instant::now(),
|
||||
search: String::new(),
|
||||
rule_input: String::new(),
|
||||
rule_kind: PatternKind::Glob,
|
||||
fake_date: now_local.date_naive(),
|
||||
fake_time: now_local.time(),
|
||||
current_delta_ticks: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_processes_if_due(&mut self) {
|
||||
if self.last_refresh.elapsed() >= Duration::from_millis(1500) {
|
||||
self.watcher.refresh();
|
||||
self.processes = self.watcher.list();
|
||||
self.processes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
if let Some(m) = self.manager.as_mut() {
|
||||
m.prune_dead(&self.watcher.alive_pids());
|
||||
}
|
||||
self.last_refresh = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_inject_scan_if_due(&mut self) {
|
||||
if !self.persistent.auto_inject_enabled {
|
||||
return;
|
||||
}
|
||||
if self.last_auto_inject_scan.elapsed() < Duration::from_millis(1500) {
|
||||
return;
|
||||
}
|
||||
self.last_auto_inject_scan = Instant::now();
|
||||
|
||||
let compiled = CompiledRules::compile(&self.persistent.rules);
|
||||
let Some(manager) = self.manager.as_mut() else { return };
|
||||
|
||||
for proc in &self.processes {
|
||||
if manager.is_injected(proc.pid) {
|
||||
continue;
|
||||
}
|
||||
if compiled.matches(&proc.path, &proc.name) {
|
||||
let _ = manager.inject(proc.pid, &proc.name, &proc.path, self.current_delta_ticks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_fake_time(&mut self) {
|
||||
let naive = self.fake_date.and_time(self.fake_time);
|
||||
let local: DateTime<Local> = match Local.from_local_datetime(&naive).single() {
|
||||
Some(dt) => dt,
|
||||
None => return,
|
||||
};
|
||||
let utc: DateTime<Utc> = local.with_timezone(&Utc);
|
||||
let fake_filetime = unix_micros_to_filetime_ticks(utc.timestamp_micros());
|
||||
let real_filetime = unix_micros_to_filetime_ticks(Utc::now().timestamp_micros());
|
||||
self.current_delta_ticks = fake_filetime - real_filetime;
|
||||
if let Some(m) = self.manager.as_ref() {
|
||||
m.set_delta_all(self.current_delta_ticks);
|
||||
}
|
||||
self.persistent.last_fake_date = Some(utc.to_rfc3339());
|
||||
}
|
||||
|
||||
fn reset_to_now(&mut self) {
|
||||
let now = Local::now();
|
||||
self.fake_date = now.date_naive();
|
||||
self.fake_time = now.time();
|
||||
}
|
||||
|
||||
fn ui_top_bar(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("Mock Time");
|
||||
ui.separator();
|
||||
let mut y = self.fake_date.format("%Y").to_string();
|
||||
let mut m = self.fake_date.format("%m").to_string();
|
||||
let mut d = self.fake_date.format("%d").to_string();
|
||||
ui.label("Date:");
|
||||
ui.add(egui::TextEdit::singleline(&mut y).desired_width(48.0));
|
||||
ui.label("-");
|
||||
ui.add(egui::TextEdit::singleline(&mut m).desired_width(28.0));
|
||||
ui.label("-");
|
||||
ui.add(egui::TextEdit::singleline(&mut d).desired_width(28.0));
|
||||
if let (Ok(yi), Ok(mi), Ok(di)) = (y.parse::<i32>(), m.parse::<u32>(), d.parse::<u32>()) {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(yi, mi, di) {
|
||||
self.fake_date = date;
|
||||
}
|
||||
}
|
||||
|
||||
ui.label("Time:");
|
||||
let mut h = self.fake_time.format("%H").to_string();
|
||||
let mut mn = self.fake_time.format("%M").to_string();
|
||||
let mut s = self.fake_time.format("%S").to_string();
|
||||
ui.add(egui::TextEdit::singleline(&mut h).desired_width(28.0));
|
||||
ui.label(":");
|
||||
ui.add(egui::TextEdit::singleline(&mut mn).desired_width(28.0));
|
||||
ui.label(":");
|
||||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(28.0));
|
||||
if let (Ok(hi), Ok(mi), Ok(si)) = (h.parse::<u32>(), mn.parse::<u32>(), s.parse::<u32>()) {
|
||||
if let Some(time) = NaiveTime::from_hms_opt(hi, mi, si) {
|
||||
self.fake_time = time;
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Now").clicked() {
|
||||
self.reset_to_now();
|
||||
}
|
||||
if ui.button("Set").clicked() {
|
||||
self.apply_fake_time();
|
||||
}
|
||||
ui.separator();
|
||||
let delta_secs = self.current_delta_ticks as f64 / 10_000_000.0;
|
||||
ui.label(format!("Δ = {delta_secs:+.1}s"));
|
||||
});
|
||||
}
|
||||
|
||||
fn ui_processes(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Search:");
|
||||
ui.add(egui::TextEdit::singleline(&mut self.search).desired_width(240.0));
|
||||
if ui.button("⟳ Refresh").clicked() {
|
||||
self.watcher.refresh();
|
||||
self.processes = self.watcher.list();
|
||||
self.processes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let manager_ready = self.manager.is_some();
|
||||
if let Some(err) = &self.manager_err {
|
||||
ui.colored_label(egui::Color32::RED, format!("InjectionManager unavailable: {err}"));
|
||||
}
|
||||
|
||||
let needle = self.search.to_lowercase();
|
||||
let rows: Vec<ProcInfo> = self
|
||||
.processes
|
||||
.iter()
|
||||
.filter(|p| needle.is_empty() || p.name.to_lowercase().contains(&needle) || p.path.to_lowercase().contains(&needle))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("processes")
|
||||
.num_columns(4)
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("Inject");
|
||||
ui.strong("PID");
|
||||
ui.strong("Name");
|
||||
ui.strong("Path");
|
||||
ui.end_row();
|
||||
|
||||
for p in &rows {
|
||||
let injected = self
|
||||
.manager
|
||||
.as_ref()
|
||||
.map(|m| m.is_injected(p.pid))
|
||||
.unwrap_or(false);
|
||||
let mut checked = injected;
|
||||
let resp = ui.add_enabled(manager_ready, egui::Checkbox::new(&mut checked, ""));
|
||||
if resp.changed() {
|
||||
if let Some(m) = self.manager.as_mut() {
|
||||
if checked {
|
||||
let _ = m.inject(p.pid, &p.name, &p.path, self.current_delta_ticks);
|
||||
} else {
|
||||
m.eject(p.pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.label(p.pid.to_string());
|
||||
ui.label(&p.name);
|
||||
ui.label(&p.path);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn ui_rules(&mut self, ui: &mut egui::Ui) {
|
||||
ui.checkbox(
|
||||
&mut self.persistent.auto_inject_enabled,
|
||||
"Enable auto-inject watcher",
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Pattern:");
|
||||
ui.add(egui::TextEdit::singleline(&mut self.rule_input).desired_width(360.0));
|
||||
egui::ComboBox::from_id_salt("rule_kind")
|
||||
.selected_text(self.rule_kind.label())
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.rule_kind, PatternKind::Glob, "Glob");
|
||||
ui.selectable_value(&mut self.rule_kind, PatternKind::Regex, "Regex");
|
||||
});
|
||||
if ui.button("+ Add Rule").clicked() && !self.rule_input.trim().is_empty() {
|
||||
self.persistent.rules.push(Rule {
|
||||
pattern: self.rule_input.trim().to_owned(),
|
||||
kind: self.rule_kind,
|
||||
enabled: true,
|
||||
});
|
||||
self.rule_input.clear();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
let mut remove_idx: Option<usize> = None;
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
egui::Grid::new("rules")
|
||||
.num_columns(4)
|
||||
.striped(true)
|
||||
.show(ui, |ui| {
|
||||
ui.strong("On");
|
||||
ui.strong("Kind");
|
||||
ui.strong("Pattern");
|
||||
ui.strong("");
|
||||
ui.end_row();
|
||||
for (i, rule) in self.persistent.rules.iter_mut().enumerate() {
|
||||
ui.checkbox(&mut rule.enabled, "");
|
||||
ui.label(rule.kind.label());
|
||||
ui.label(&rule.pattern);
|
||||
if ui.button("✕").clicked() {
|
||||
remove_idx = Some(i);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(i) = remove_idx {
|
||||
self.persistent.rules.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui_log(&mut self, ui: &mut egui::Ui) {
|
||||
let Some(m) = self.manager.as_ref() else {
|
||||
ui.label("InjectionManager unavailable.");
|
||||
return;
|
||||
};
|
||||
ui.label(format!("Hook DLL: {}", m.hook_dll_path().display()));
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical()
|
||||
.stick_to_bottom(true)
|
||||
.show(ui, |ui| {
|
||||
for line in &m.log {
|
||||
ui.monospace(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for TimeMockerApp {
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
eframe::set_value(storage, "time_mocker", &self.persistent);
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.refresh_processes_if_due();
|
||||
self.auto_inject_scan_if_due();
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
|
||||
egui::TopBottomPanel::top("topbar").show(ctx, |ui| {
|
||||
self.ui_top_bar(ui);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.tab, Tab::Processes, "Processes");
|
||||
ui.selectable_value(&mut self.tab, Tab::Rules, "Auto-Inject Rules");
|
||||
ui.selectable_value(&mut self.tab, Tab::Log, "Log");
|
||||
});
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| match self.tab {
|
||||
Tab::Processes => self.ui_processes(ui),
|
||||
Tab::Rules => self.ui_rules(ui),
|
||||
Tab::Log => self.ui_log(ui),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn unix_micros_to_filetime_ticks(unix_micros: i64) -> i64 {
|
||||
UNIX_TO_FILETIME_TICKS + unix_micros * 10
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Per-process injection state.
|
||||
//!
|
||||
//! For each injected PID we keep:
|
||||
//! - the `dll-syringe` process handle (so the DLL stays loaded)
|
||||
//! - a `SharedDelta` writer (so we can update the fake time)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use dll_syringe::process::OwnedProcess;
|
||||
use dll_syringe::Syringe;
|
||||
use time_mocker_core::{mmf_name_for_pid, SharedDelta};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct InjectedProcess {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
delta: SharedDelta,
|
||||
_syringe: Syringe,
|
||||
}
|
||||
|
||||
impl InjectedProcess {
|
||||
pub fn write_delta(&self, ticks: i64) {
|
||||
self.delta.write_delta(ticks);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InjectionManager {
|
||||
injected: HashMap<u32, InjectedProcess>,
|
||||
hook_dll_path: PathBuf,
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
impl InjectionManager {
|
||||
pub fn new() -> Result<Self> {
|
||||
let exe_dir = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(Path::to_path_buf))
|
||||
.ok_or_else(|| anyhow!("cannot resolve exe directory"))?;
|
||||
let hook_dll_path = exe_dir.join("time_mocker_hook.dll");
|
||||
Ok(Self {
|
||||
injected: HashMap::new(),
|
||||
hook_dll_path,
|
||||
log: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hook_dll_path(&self) -> &Path {
|
||||
&self.hook_dll_path
|
||||
}
|
||||
|
||||
pub fn is_injected(&self, pid: u32) -> bool {
|
||||
self.injected.contains_key(&pid)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn iter(&self) -> impl Iterator<Item = &InjectedProcess> {
|
||||
self.injected.values()
|
||||
}
|
||||
|
||||
pub fn inject(&mut self, pid: u32, name: &str, path: &str, initial_delta: i64) -> Result<()> {
|
||||
if self.injected.contains_key(&pid) {
|
||||
return Ok(());
|
||||
}
|
||||
if !self.hook_dll_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"hook DLL not found at {}",
|
||||
self.hook_dll_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mmf_name = mmf_name_for_pid(pid);
|
||||
let delta = SharedDelta::create(&mmf_name)
|
||||
.with_context(|| format!("create MMF {mmf_name}"))?;
|
||||
delta.write_delta(initial_delta);
|
||||
|
||||
let process = OwnedProcess::from_pid(pid)
|
||||
.with_context(|| format!("open process pid={pid}"))?;
|
||||
let syringe = Syringe::for_process(process);
|
||||
syringe
|
||||
.inject(&self.hook_dll_path)
|
||||
.with_context(|| format!("inject {} into pid={}", self.hook_dll_path.display(), pid))?;
|
||||
|
||||
self.log
|
||||
.push(format!("Injected into [{pid}] {name}"));
|
||||
self.injected.insert(
|
||||
pid,
|
||||
InjectedProcess {
|
||||
pid,
|
||||
name: name.to_owned(),
|
||||
path: path.to_owned(),
|
||||
delta,
|
||||
_syringe: syringe,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_delta_all(&self, ticks: i64) {
|
||||
for proc in self.injected.values() {
|
||||
proc.write_delta(ticks);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn eject(&mut self, pid: u32) {
|
||||
if let Some(p) = self.injected.remove(&pid) {
|
||||
// Best-effort: writing 0 restores real time even if the DLL stays loaded.
|
||||
p.write_delta(0);
|
||||
self.log.push(format!("Ejected [{pid}] {}", p.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop entries for processes that have exited.
|
||||
pub fn prune_dead(&mut self, alive: &std::collections::HashSet<u32>) {
|
||||
let dead: Vec<u32> = self
|
||||
.injected
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|pid| !alive.contains(pid))
|
||||
.collect();
|
||||
for pid in dead {
|
||||
self.injected.remove(&pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Controller UI for time-mocker.
|
||||
//!
|
||||
//! Modules:
|
||||
//! - `injection_manager`: dll-syringe-backed injector + per-PID `SharedDelta`
|
||||
//! - `process_watcher`: poll-based auto-inject scanner
|
||||
//! - `rules`: glob / regex pattern matcher
|
||||
//! - `app`: eframe `App` impl, tabs, persistent settings
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod app;
|
||||
mod injection_manager;
|
||||
mod process_watcher;
|
||||
mod rules;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([900.0, 600.0])
|
||||
.with_min_inner_size([640.0, 400.0])
|
||||
.with_title("TimeMocker"),
|
||||
persist_window: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"TimeMocker",
|
||||
native_options,
|
||||
Box::new(|cc| Ok(Box::new(app::TimeMockerApp::new(cc)))),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("eframe error: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Lightweight wrapper around `sysinfo` for the process tab + auto-inject scan.
|
||||
//!
|
||||
//! Pure data — no UI, no injection. The `App` polls `refresh()` and decides
|
||||
//! what to inject based on `CompiledRules`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use sysinfo::System;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcInfo {
|
||||
pub pid: u32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct ProcessWatcher {
|
||||
sys: System,
|
||||
}
|
||||
|
||||
impl ProcessWatcher {
|
||||
pub fn new() -> Self {
|
||||
Self { sys: System::new() }
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self) {
|
||||
self.sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<ProcInfo> {
|
||||
self.sys
|
||||
.processes()
|
||||
.iter()
|
||||
.filter_map(|(pid, proc)| {
|
||||
let name = proc.name().to_string_lossy().into_owned();
|
||||
let path = proc
|
||||
.exe()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ProcInfo {
|
||||
pid: pid.as_u32(),
|
||||
name,
|
||||
path,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn alive_pids(&self) -> HashSet<u32> {
|
||||
self.sys.processes().keys().map(|p| p.as_u32()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Auto-inject pattern rules — glob or regex matched against process path and name.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use globset::{Glob, GlobMatcher};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PatternKind {
|
||||
Glob,
|
||||
Regex,
|
||||
}
|
||||
|
||||
impl PatternKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PatternKind::Glob => "Glob",
|
||||
PatternKind::Regex => "Regex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Rule {
|
||||
pub pattern: String,
|
||||
pub kind: PatternKind,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CompiledRules {
|
||||
matchers: Vec<Matcher>,
|
||||
}
|
||||
|
||||
enum Matcher {
|
||||
Glob(GlobMatcher),
|
||||
Regex(Regex),
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
fn is_match(&self, s: &str) -> bool {
|
||||
match self {
|
||||
Matcher::Glob(g) => g.is_match(s),
|
||||
Matcher::Regex(r) => r.is_match(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompiledRules {
|
||||
pub fn compile(rules: &[Rule]) -> Self {
|
||||
let matchers = rules
|
||||
.iter()
|
||||
.filter(|r| r.enabled)
|
||||
.filter_map(|r| compile_one(r).ok())
|
||||
.collect();
|
||||
Self { matchers }
|
||||
}
|
||||
|
||||
pub fn matches(&self, path: &str, name: &str) -> bool {
|
||||
self.matchers
|
||||
.iter()
|
||||
.any(|m| m.is_match(path) || m.is_match(name))
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_one(rule: &Rule) -> Result<Matcher> {
|
||||
match rule.kind {
|
||||
PatternKind::Glob => {
|
||||
let g = Glob::new(&rule.pattern).map_err(|e| anyhow!("glob: {e}"))?;
|
||||
Ok(Matcher::Glob(g.compile_matcher()))
|
||||
}
|
||||
PatternKind::Regex => Ok(Matcher::Regex(
|
||||
Regex::new(&rule.pattern).map_err(|e| anyhow!("regex: {e}"))?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn glob_matches_name() {
|
||||
let rules = vec![Rule {
|
||||
pattern: "*chrome*".into(),
|
||||
kind: PatternKind::Glob,
|
||||
enabled: true,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(c.matches("", "chrome.exe"));
|
||||
assert!(!c.matches("", "firefox.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_matches_path() {
|
||||
let rules = vec![Rule {
|
||||
pattern: r"^.*\\MyApp\.exe$".into(),
|
||||
kind: PatternKind::Regex,
|
||||
enabled: true,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(c.matches(r"C:\foo\MyApp.exe", "MyApp.exe"));
|
||||
assert!(!c.matches(r"C:\foo\Other.exe", "Other.exe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_rules_skipped() {
|
||||
let rules = vec![Rule {
|
||||
pattern: "*".into(),
|
||||
kind: PatternKind::Glob,
|
||||
enabled: false,
|
||||
}];
|
||||
let c = CompiledRules::compile(&rules);
|
||||
assert!(!c.matches("anything", "anything"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
Reference in New Issue
Block a user