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:
2026-05-20 14:59:36 +07:00
commit ee3fa45469
22 changed files with 4798 additions and 0 deletions
+25
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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
+21
View File
@@ -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.
+66
View File
@@ -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
+23
View File
@@ -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]
+21
View File
@@ -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}")
}
+109
View File
@@ -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);
}
}
}
}
+43
View File
@@ -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))
}
}
+22
View File
@@ -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);
+27
View File
@@ -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",
] }
+47
View File
@@ -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();
}
}
+137
View File
@@ -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
}
+8
View File
@@ -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;
+41
View File
@@ -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"
+21
View File
@@ -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");
}
}
+351
View File
@@ -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);
}
}
}
+34
View File
@@ -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()
}
}
+116
View File
@@ -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"));
}
}
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly"
components = ["rustfmt", "clippy"]
profile = "minimal"