diff --git a/Controllers/ProcessController.cs b/Controllers/ProcessController.cs index 36d7fd7..7565ee9 100644 --- a/Controllers/ProcessController.cs +++ b/Controllers/ProcessController.cs @@ -23,7 +23,7 @@ namespace WebmrAPI.Controllers _monitor = monitor; _logger = logger; } - private string GetFormattedJson(T data, bool pretty) + internal static string GetFormattedJson(T data, bool pretty) { var options = new JsonSerializerOptions { @@ -269,11 +269,53 @@ namespace WebmrAPI.Controllers } } - private static IEnumerable Sort(IEnumerable data, Func selector, bool desc) + [HttpGet("{pid}/windows")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + async public Task GetProcessWindowsById(int pid, + [FromQuery] bool pretty = false, + [FromQuery] string sortBy = "", + [FromQuery] bool desc = false, + [FromQuery] int limit = 0, + [FromQuery] int offset = 0 + ) + { + try + { + var data = await _monitor.GetProcessWindows(pid); + + if (data == null) + { + return NotFound($"The process with the PID {pid} was not found or its windows could not be obtained."); + } + + if (!String.IsNullOrEmpty(sortBy)) + { + sortBy = sortBy.ToLowerInvariant(); + switch (sortBy) + { + case "title": data = ProcessController.Sort(data, p => p.Title, desc); break; + case "id": data = ProcessController.Sort(data, p => p.Id, desc); break; + default: return StatusCode(StatusCodes.Status400BadRequest, $"Unexpected search filter {sortBy}."); + } + } + + return Content(GetFormattedJson(Paginate(data, limit, offset), pretty), "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"An error occurred while receiving threads for PID {pid}."); + return StatusCode(StatusCodes.Status500InternalServerError, $"An internal server error occurred while receiving threads for PID {pid}."); + } + } + + internal static IEnumerable Sort(IEnumerable data, Func selector, bool desc) { return desc ? data.OrderByDescending(selector) : data.OrderBy(selector); } - private IEnumerable Paginate(IEnumerable data, int limit, int offset) + internal static IEnumerable Paginate(IEnumerable data, int limit, int offset) { if (offset > 0) data = data.Skip(offset); if (limit > 0) data = data.Take(limit); diff --git a/Controllers/WindowsController.cs b/Controllers/WindowsController.cs new file mode 100644 index 0000000..626ad71 --- /dev/null +++ b/Controllers/WindowsController.cs @@ -0,0 +1,68 @@ +/* This software is licensed by the MIT License, see LICENSE file */ +/* Copyright © 2024-2025 Gregory Lirent */ + +using Microsoft.AspNetCore.Mvc; +using WebmrAPI.Models; +using WebmrAPI.Services; + +namespace WebmrAPI.Controllers +{ + [ApiController] + [Route("api/v1/[controller]")] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public class WindowsController : ControllerBase + { + private readonly ProcessMonitor _monitor; + private readonly ILogger _logger; + + public WindowsController(ProcessMonitor monitor, ILogger logger) + { + _monitor = monitor; + _logger = logger; + } + + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetWindows( + [FromQuery] bool pretty = false, + [FromQuery] string sortBy = "", + [FromQuery] bool desc = false, + [FromQuery] int limit = 0, + [FromQuery] int offset = 0, + [FromQuery] string search = "" + ) + { + try + { + var data = _monitor.GetBufferedWindows(); + + if (data != null && !String.IsNullOrEmpty(search)) + { + data = data.Where(p => p.Title != null && p.Title.ToLowerInvariant().Contains(search.ToLowerInvariant())); + } + + if (data != null && !String.IsNullOrEmpty(sortBy)) + { + sortBy = sortBy.ToLowerInvariant(); + switch (sortBy) + { + case "pid": data = ProcessController.Sort(data, p => p.PID, desc); break; + case "title": data = ProcessController.Sort(data, p => p.Title, desc); break; + case "threadid": data = ProcessController.Sort(data, p => p.ThreadId, desc); break; + case "id": data = ProcessController.Sort(data, p => p.Id, desc); break; + default: return StatusCode(StatusCodes.Status400BadRequest, $"Unexpected search filter {sortBy}."); + } + } + + return Content(ProcessController.GetFormattedJson(ProcessController.Paginate(data, limit, offset), pretty), "application/json"); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred when getting the list of windows"); + return StatusCode(StatusCodes.Status500InternalServerError, "An internal server error occurred when receiving windows."); + } + } + } +} diff --git a/Models/ProcessInfo.cs b/Models/ProcessInfo.cs index 8ef8557..b01d6f1 100644 --- a/Models/ProcessInfo.cs +++ b/Models/ProcessInfo.cs @@ -10,6 +10,8 @@ namespace WebmrAPI.Models [SupportedOSPlatform("windows")] public class ProcessInfo : ProcessBaseInfo { + private IEnumerable? _windows; + [JsonIgnore] public LazyConcurrentContainer MemoryRegionsContainer { get; set; } = new(); [JsonIgnore] @@ -34,5 +36,12 @@ namespace WebmrAPI.Models { get => ThreadsContainer.Values; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IEnumerable? Windows + { + get => LockedGet(ref _windows); + internal set => LockedSet(ref _windows, value); + } } } diff --git a/Models/ProcessThreadInfo.cs b/Models/ProcessThreadInfo.cs index b2cf65d..59ee18b 100644 --- a/Models/ProcessThreadInfo.cs +++ b/Models/ProcessThreadInfo.cs @@ -10,6 +10,7 @@ namespace WebmrAPI.Models { private TimeSpan _lastPTime = TimeSpan.Zero; private TimeSpan _curPTime = TimeSpan.Zero; + private IEnumerable? _windows; private int _id = 0; private int _currentPriority = 0; @@ -49,5 +50,12 @@ namespace WebmrAPI.Models get => LockedGet(ref _cpuUsage); set => LockedSet(ref _cpuUsage, value); } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IEnumerable? Windows + { + get => LockedGet(ref _windows); + internal set => LockedSet(ref _windows, value); + } } } diff --git a/Models/WindowInfo.cs b/Models/WindowInfo.cs new file mode 100644 index 0000000..caaf7a5 --- /dev/null +++ b/Models/WindowInfo.cs @@ -0,0 +1,122 @@ +using System.Drawing; +using System.Text.Json.Serialization; +using WebmrAPI.Utils; + +namespace WebmrAPI.Models +{ + public class GeometryPoint + { + internal Point _point; + public static GeometryPoint Empty { get => new GeometryPoint(Point.Empty); } + + public int X { get => _point.X; } + public int Y { get => _point.Y; } + + public GeometryPoint(int x, int y) + { + _point = new Point(x, y); + } + public GeometryPoint(Point point) + { + _point = point; + } + } + + public class BaseGeometry + { + protected Rectangle _rectangle; + + public int Width { get => _rectangle.Width; } + public int Height { get => _rectangle.Height; } + public static BaseGeometry Empty { get => new BaseGeometry { _rectangle = Rectangle.Empty }; } + + public static Geometry FromLTRB(int left, int top, int right, int bottom) + { + return new Geometry + { + _rectangle = Rectangle.FromLTRB(left, top, right, bottom) + }; + } + + public bool Contains(GeometryPoint pt) + { + return _rectangle.Contains(pt._point); + } + } + + public class Geometry : BaseGeometry + { + public int X { get => _rectangle.X; } + public int Y { get => _rectangle.Y; } + public new static Geometry Empty { get => new Geometry { _rectangle = Rectangle.Empty }; } + } + + public class BaseWindowInfo : ConcurrentObject + { + private IntPtr _hwnd; + private string _title = String.Empty; + private GeometryPoint _cursor = GeometryPoint.Empty; + private Geometry _gWindow = Geometry.Empty; + private BaseGeometry _gContent = BaseGeometry.Empty; + private bool _isActive; + + public string Id + { + get => LockedGet(ref _hwnd).ToString(); + set + { + if (!IntPtr.TryParse(value, out IntPtr hwnd)) + throw new ArgumentException("Invalid window Id format. Must be a valid hwnd (IntPtr) string representation"); + LockedSet(ref _hwnd, hwnd); + } + } + public string Title + { + get => LockedGet(ref _title); + set => LockedSet(ref _title, value); + } + public GeometryPoint CursorPosition + { + get => LockedGet(ref _cursor); + set => LockedSet(ref _cursor, value); + } + public Geometry WindowGeometry + { + get => LockedGet(ref _gWindow); + set => LockedSet(ref _gWindow, value); + } + public BaseGeometry ContentGeometry + { + get => LockedGet(ref _gContent); + set => LockedSet(ref _gContent, value); + } + public bool IsActive + { + get => LockedGet(ref _isActive); + set => LockedSet(ref _isActive, value); + } + [JsonIgnore] + public IntPtr Hwnd + { + get => LockedGet(ref _hwnd); + set => LockedSet(ref _hwnd, value); + } + public bool HasCursor { get => IsActive && ContentGeometry.Contains(CursorPosition); } + } + public class WindowInfo : BaseWindowInfo + { + private int _pid; + private int _threadId; + + public int PID + { + get => LockedGet(ref _pid); + set => LockedSet(ref _pid, value); + } + public int ThreadId + { + get => LockedGet(ref _threadId); + set => LockedSet(ref _threadId, value); + } + } +} diff --git a/Services/ProcessMonitor.cs b/Services/ProcessMonitor.cs index 4a12f95..96531c2 100644 --- a/Services/ProcessMonitor.cs +++ b/Services/ProcessMonitor.cs @@ -2,6 +2,7 @@ /* Copyright © 2024-2025 Gregory Lirent */ using Microsoft.Extensions.Options; +using System.Collections.Generic; using System.Runtime.Versioning; using WebmrAPI.Configuration; using WebmrAPI.Exceptions; @@ -17,16 +18,22 @@ namespace WebmrAPI.Services private readonly ILogger _logger; private readonly MonitoringSettings _config; private LazyConcurrentContainer _processes = new(); + private LazyConcurrentContainer _windows = new(); private ScanProvider _provider; public ILogger Logger { get => _logger; } public MonitoringSettings Config { get => _config; } public LazyConcurrentContainer Processes { get => _processes; } + public LazyConcurrentContainer Windows { get => _windows; } public IEnumerable? GetBufferedProcesses() { return _processes.Values; } + public IEnumerable? GetBufferedWindows() + { + return _windows.Values; + } async public Task GetProcessDetails(int pid, ScanTarget target = ScanTarget.ProcessDetails) { @@ -50,6 +57,18 @@ namespace WebmrAPI.Services return null; } + public Task?> GetProcessWindows(int pid) + { + return Task.Run(() => + { + if (_processes.Container != null && _processes.Container.TryGetValue(pid, out var info)) + { + return info.Windows; + } + + return null; + }); + } public ProcessMonitor(ILogger logger, IOptions settings) { @@ -88,6 +107,46 @@ namespace WebmrAPI.Services try { await _provider.CreateScanTask().ScanAsync(); + await new WindowScanner(_provider, _windows).ScanAsync(); + + Dictionary> pWin = new(); + Dictionary> tWin = new(); + + if (_windows.Values != null) + foreach (var window in _windows.Values) + { + List p; + List t; + if (!pWin.TryGetValue(window.PID, out p)) + { + pWin.Add(window.PID, p = new()); + } + if (!tWin.TryGetValue(window.ThreadId, out t)) + { + tWin.Add(window.ThreadId, t = new()); + } + + p.Add(window); + t.Add(window); + } + + if (_processes.Values != null) + foreach (var proc in _processes.Values) + { + if (pWin.TryGetValue(proc.PID, out var pws)) + { + proc.Windows = pws; + if (proc.ThreadsContainer.Values != null) + foreach (var thread in proc.ThreadsContainer.Values) + { + if (tWin.TryGetValue(thread.ID, out var tws)) + { + thread.Windows = tws; + } + } + } + } + _logger.LogInformation($"Process buffer updated, contains {Processes.Container?.Count} processes."); } catch (ProcessMonitorException ex) diff --git a/Services/Scanners/ScanTarget.cs b/Services/Scanners/ScanTarget.cs index 5a02a5d..fc40301 100644 --- a/Services/Scanners/ScanTarget.cs +++ b/Services/Scanners/ScanTarget.cs @@ -10,6 +10,7 @@ namespace WebmrAPI.Services.Scanners MemoryRegions = 0x02, Modules = 0x04, Threads = 0x08, + Windows = 0x10, ProcessDetails = MemoryRegions | Modules | Threads, All = Processes | ProcessDetails diff --git a/Services/Scanners/WindowScanner.cs b/Services/Scanners/WindowScanner.cs new file mode 100644 index 0000000..bae1c4a --- /dev/null +++ b/Services/Scanners/WindowScanner.cs @@ -0,0 +1,130 @@ +using System.Drawing; +using System.Runtime.InteropServices; +using System.Text; +using WebmrAPI.Models; +using WebmrAPI.Utils; + +namespace WebmrAPI.Services.Scanners +{ + public class WindowScanner : AbstractScanner + { + [StructLayout(LayoutKind.Explicit)] + private struct RECT + { + [FieldOffset(0)] + public int Left; + [FieldOffset(4)] + public int Top; + [FieldOffset(8)] + public int Right; + [FieldOffset(12)] + public int Bottom; + [FieldOffset(0)] + public int X; + [FieldOffset(4)] + public int Y; + } + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out RECT lpPoint); + [DllImport("user32.dll")] + private static extern int GetWindowText(IntPtr hwnd, StringBuilder lpString, int nMaxCount); + [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)] + private static extern int GetWindowTextLength(IntPtr hWnd); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetClientRect(IntPtr hwnd, out RECT lpRect); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ScreenToClient(IntPtr hWnd, ref RECT lpPoint); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetWindowThreadProcessId(IntPtr hWnd, out int lpdwProcessId); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + override public ScanTarget Target { get => ScanTarget.Windows; } + private bool GetWindowInfo(WindowInfo info, IntPtr hwnd) + { + int length = GetWindowTextLength(hwnd); + if (length > 0) + { + var sb = new StringBuilder(length + 1); + GetWindowText(hwnd, sb, sb.Capacity); + info.Title = sb.ToString(); + + if (GetCursorPos(out RECT pt) && ScreenToClient(hwnd, ref pt)) + { + info.CursorPosition = new GeometryPoint(pt.X, pt.Y); + } + + if (GetWindowRect(hwnd, out RECT wRect)) + { + info.WindowGeometry = Geometry.FromLTRB(wRect.Left, wRect.Top, wRect.Right, wRect.Bottom); + } + + if (GetClientRect(hwnd, out RECT cRect)) + { + info.ContentGeometry = BaseGeometry.FromLTRB(cRect.Left, cRect.Top, cRect.Right, cRect.Bottom); + } + + return true; + } + + return false; + } + + override internal Task ScanAsync(Dictionary data) + { + return Task.Run(() => + { + var foreground = GetForegroundWindow(); + + EnumWindows(delegate (IntPtr hwnd, IntPtr lParam) + { + if (IsWindowVisible(hwnd)) + { + WindowInfo? info; + + if (!GetFromCacheOrNew(hwnd.ToInt64(), out info)) + { + info.Hwnd = hwnd; + int threadId; + + if ((threadId = GetWindowThreadProcessId(hwnd, out int pid)) != 0) + { + info.PID = pid; + info.ThreadId = threadId; + } + else + { + info = null; + } + } + + if (info != null && GetWindowInfo(info, hwnd)) + { + info.IsActive = foreground == info.Hwnd; + data.Add(hwnd.ToInt64(), info); + } + } + return true; + }, IntPtr.Zero); + + return true; + }); + } + + public WindowScanner(IScanProvider scanner, LazyConcurrentContainer container) + : base(scanner, container) { } + } +} diff --git a/webmr-api.csproj b/webmr-api.csproj index aca6ed7..b820bdc 100644 --- a/webmr-api.csproj +++ b/webmr-api.csproj @@ -14,7 +14,7 @@ 1.0.0.0 1.0.0.0 - 0.1.5 + 0.1.6 OpenSource Process Monitoring Agent A service for detailed monitoring processes and memory regions.