diff --git a/.gitignore b/.gitignore index 35063fc..51be0f2 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,6 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml +.vs +.gitignore diff --git a/Actions/Actions.cs b/Actions/Actions.cs index 843c259..39bd8ca 100644 --- a/Actions/Actions.cs +++ b/Actions/Actions.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using BuildSoft.VRChat.Osc; using BuildSoft.VRChat.Osc.Avatar; +using NLog; namespace Tailgrab.Actions { @@ -13,10 +14,13 @@ public interface IAction public class DelayAction : IAction { public int DelayMilliseconds { get; set; } + public Logger logger = LogManager.GetCurrentClassLogger(); public DelayAction(int delayMilliseconds) { DelayMilliseconds = delayMilliseconds; + logger.Warn($"Added DelayAction: Will delay for : '{DelayMilliseconds}' milliseconds."); + } public void PerformAction() @@ -39,26 +43,122 @@ public class KeystrokesAction : IAction [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint nInputs, [In] INPUT[] pInputs, int cbSize); + // Additional native methods for reliably setting foreground focus + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + + [DllImport("user32.dll")] + private static extern bool BringWindowToTop(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + private static extern IntPtr SetFocus(IntPtr hWnd); + private const uint INPUT_KEYBOARD = 1; private const uint KEYEVENTF_KEYUP = 0x0002; private const uint KEYEVENTF_UNICODE = 0x0004; + private const int SW_RESTORE = 9; + + public Logger logger = LogManager.GetCurrentClassLogger(); + + public string WindowTitle { get; set; } + public string Keys { get; set; } + + public KeystrokesAction(string windowTitle, string keys) + { + WindowTitle = windowTitle; + Keys = keys; - public required string WindowTitle { get; set; } - public required string Keys { get; set; } + logger.Warn($"Added KeystrokesAction: Window Title: '{WindowTitle}' with Keys: {Keys}."); + } public void PerformAction() { if( WindowTitle == null || Keys == null ) { + logger.Warn($"KeystrokesAction: Window Title: '{WindowTitle}' or Keys: {Keys} not supplied."); return; } - var processes = System.Diagnostics.Process.GetProcessesByName(WindowTitle); - if (processes.Length > 0) + System.Diagnostics.Process? targetProcess = null; + + try + { + var all = System.Diagnostics.Process.GetProcesses(); + foreach (var p in all) + { + try + { + if (p.MainWindowHandle == System.IntPtr.Zero) continue; + var title = p.MainWindowTitle; + if (string.IsNullOrEmpty(title)) continue; + if (title.IndexOf(WindowTitle, StringComparison.CurrentCultureIgnoreCase) >= 0) + { + targetProcess = p; + break; + } + } + catch + { + // Access denied or process exited -- ignore and continue + } + } + + // Fallback: try by process name if no title match + if (targetProcess == null) + { + var byName = System.Diagnostics.Process.GetProcessesByName(WindowTitle); + if (byName.Length > 0) targetProcess = byName[0]; + } + + if (targetProcess != null) + { + IntPtr handle = targetProcess.MainWindowHandle; + + logger.Debug($"KeystrokesAction: Sending to process '{targetProcess.ProcessName}' (PID {targetProcess.Id}) Title: '{targetProcess.MainWindowTitle}' Keys: {Keys}."); + + // Attempt to reliably bring the target window to the foreground + uint targetThread = GetWindowThreadProcessId(handle, out _); + uint currentThread = GetCurrentThreadId(); + bool attached = false; + + try + { + // Attach input threads so SetForegroundWindow works reliably + attached = AttachThreadInput(currentThread, targetThread, true); + + ShowWindow(handle, SW_RESTORE); + BringWindowToTop(handle); + SetForegroundWindow(handle); + SetFocus(handle); + + // Use SendInput with unicode characters for reliable keystroke delivery + SendUnicodeString(Keys); + } + finally + { + if (attached) + { + AttachThreadInput(currentThread, targetThread, false); + } + } + } + else + { + logger.Warn($"KeystrokesAction: Window with title containing '{WindowTitle}' not found."); + } + } + catch (Exception ex) { - IntPtr handle = processes[0].MainWindowHandle; - SetForegroundWindow(handle); - SendUnicodeString(Keys); + logger.Error(ex, "KeystrokesAction: Error while attempting to find target process/window"); } } @@ -70,6 +170,190 @@ private void SendUnicodeString(string s) } } + // New: parse SendKeys-style notation and send via SendInput. + // Supports modifiers '^' (Ctrl), '%' (Alt), '+' (Shift), grouping with '()' and braced keys like '{ENTER}', '{F1}', etc. + private void SendKeysNotation(string keys) + { + if (string.IsNullOrEmpty(keys)) return; + + for (int i = 0; i < keys.Length; i++) + { + char c = keys[i]; + + // Handle modifiers prefixing a token + if (c == '^' || c == '%' || c == '+') + { + var mods = new List(); + // collect consecutive modifier symbols + while (i < keys.Length && (keys[i] == '^' || keys[i] == '%' || keys[i] == '+')) + { + if (keys[i] == '^') mods.Add(0x11); // VK_CONTROL + if (keys[i] == '%') mods.Add(0x12); // VK_MENU (Alt) + if (keys[i] == '+') mods.Add(0x10); // VK_SHIFT + i++; + } + + if (i >= keys.Length) break; + + // Determine the token: group '(... )', braced '{...}', or single char + if (keys[i] == '(') + { + int start = i + 1; + int end = keys.IndexOf(')', start); + if (end == -1) end = keys.Length - 1; + string group = keys.Substring(start, end - start); + foreach (var ch in group) + { + SendCharWithModifiers(ch, mods); + } + i = end; + } + else if (keys[i] == '{') + { + int start = i + 1; + int end = keys.IndexOf('}', start); + if (end == -1) end = keys.Length - 1; + string token = keys.Substring(start, end - start); + SendTokenWithModifiers(token, mods); + i = end; + } + else + { + SendCharWithModifiers(keys[i], mods); + } + + continue; + } + + // Braced token or single character + if (c == '{') + { + int start = i + 1; + int end = keys.IndexOf('}', start); + if (end == -1) end = keys.Length - 1; + string token = keys.Substring(start, end - start); + SendTokenWithModifiers(token, new List()); + i = end; + } + else + { + // Regular char + SendUnicodeChar(c); + } + } + } + + private void SendCharWithModifiers(char ch, List mods) + { + // press mods + foreach (var m in mods) + { + SendVirtualKeyDown(m); + } + + // send character + SendUnicodeChar(ch); + + // release mods in reverse order + for (int j = mods.Count - 1; j >= 0; j--) + { + SendVirtualKeyUp(mods[j]); + } + } + + private void SendTokenWithModifiers(string token, List mods) + { + // Normalize token + var t = token.ToUpperInvariant(); + + // Mapping of common SendKeys tokens to virtual-key codes + Dictionary map = new Dictionary + { + {"ENTER", 0x0D}, + {"TAB", 0x09}, + {"BACKSPACE", 0x08}, + {"BS", 0x08}, + {"BKSP", 0x08}, + {"ESC", 0x1B}, + {"LEFT", 0x25}, + {"UP", 0x26}, + {"RIGHT", 0x27}, + {"DOWN", 0x28}, + {"HOME", 0x24}, + {"END", 0x23}, + {"PGUP", 0x21}, + {"PRIOR", 0x21}, + {"PGDN", 0x22}, + {"NEXT", 0x22}, + {"INSERT", 0x2D}, + {"DELETE", 0x2E}, + {"DEL", 0x2E}, + {"SPACE", 0x20}, + {"F1", 0x70}, {"F2", 0x71}, {"F3", 0x72}, {"F4", 0x73}, {"F5", 0x74}, + {"F6", 0x75}, {"F7", 0x76}, {"F8", 0x77}, {"F9", 0x78}, {"F10", 0x79}, + {"F11", 0x7A}, {"F12", 0x7B}, + {"LWIN", 0x5B}, {"RWIN", 0x5C}, {"APPS", 0x5D} + }; + + if (map.TryGetValue(t, out ushort vk)) + { + // press mods + foreach (var m in mods) SendVirtualKeyDown(m); + + // send vk + SendVirtualKey(vk); + + // release mods + for (int j = mods.Count - 1; j >= 0; j--) SendVirtualKeyUp(mods[j]); + } + else + { + // If token length == 1, send that character + if (token.Length == 1) + { + SendCharWithModifiers(token[0], mods); + } + else + { + // For unknown tokens, attempt to send the text literally + foreach (var ch in token) + { + SendCharWithModifiers(ch, mods); + } + } + } + } + + private void SendVirtualKeyDown(ushort vk) + { + INPUT[] inputs = new INPUT[1]; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].U.ki.wVk = vk; + inputs[0].U.ki.wScan = 0; + inputs[0].U.ki.dwFlags = 0; + inputs[0].U.ki.time = 0; + inputs[0].U.ki.dwExtraInfo = System.IntPtr.Zero; + SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); + } + + private void SendVirtualKeyUp(ushort vk) + { + INPUT[] inputs = new INPUT[1]; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].U.ki.wVk = vk; + inputs[0].U.ki.wScan = 0; + inputs[0].U.ki.dwFlags = KEYEVENTF_KEYUP; + inputs[0].U.ki.time = 0; + inputs[0].U.ki.dwExtraInfo = System.IntPtr.Zero; + SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); + } + + private void SendVirtualKey(ushort vk) + { + SendVirtualKeyDown(vk); + SendVirtualKeyUp(vk); + } + private void SendUnicodeChar(char ch) { INPUT[] inputs = new INPUT[2]; @@ -138,9 +422,9 @@ private struct HARDWAREINPUT } - public class OSCAction : IAction { + public Logger logger = LogManager.GetCurrentClassLogger(); private OscAvatarConfig? oscAvatarConfig = OscAvatarConfig.CreateAtCurrent(); @@ -155,6 +439,9 @@ public OSCAction(string parameterName, OscType type, string value) ParameterName = parameterName; OscTypeValue = type; Value = value; + + logger.Warn($"Added OSCAction: Parameter: '{ParameterName}'; Type: {OscTypeValue}; Value: {Value}."); + } public void PerformAction() diff --git a/LineHandlers/PenNetworkIdHandler.cs b/LineHandlers/PenNetworkIdHandler.cs index d1fb505..aa8e6f8 100644 --- a/LineHandlers/PenNetworkIdHandler.cs +++ b/LineHandlers/PenNetworkIdHandler.cs @@ -1,9 +1,10 @@ -namespace Tailgrab.LineHandler; - +using System.IO; using System.Text; using System.Text.RegularExpressions; using Tailgrab.PlayerManagement; +namespace Tailgrab.LineHandler; + public class PenNetworkHandler : AbstractLineHandler { diff --git a/PlayerManagement/PlayerManagement.cs b/PlayerManagement/PlayerManagement.cs index 25bfdf9..1a593b0 100644 --- a/PlayerManagement/PlayerManagement.cs +++ b/PlayerManagement/PlayerManagement.cs @@ -30,6 +30,7 @@ public class Player { public string UserId { get; set; } public string DisplayName { get; set; } + public string AvatarName { get; set; } public int NetworkId { get; set; } public DateTime InstanceStartTime { get; set; } public DateTime? InstanceEndTime { get; set; } @@ -39,6 +40,7 @@ public Player(string userId, string displayName) { UserId = userId; DisplayName = displayName; + AvatarName = ""; InstanceStartTime = DateTime.Now; } @@ -48,6 +50,26 @@ public void AddEvent(PlayerEvent playerEvent) } } + public class PlayerChangedEventArgs : EventArgs + { + public enum ChangeType + { + Added, + Updated, + Removed, + Cleared + } + + public ChangeType Type { get; } + public Player Player { get; } + + public PlayerChangedEventArgs(ChangeType type, Player player) + { + Type = type; + Player = player; + } + } + public static class PlayerManager { private static Dictionary playersByNetworkId = new Dictionary(); @@ -59,6 +81,21 @@ public static class PlayerManager public static readonly string COLOR_RESET = "\u001b[0m"; public static Logger logger = LogManager.GetCurrentClassLogger(); + // Event for UI and other listeners + public static event EventHandler? PlayerChanged; + + private static void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, Player player) + { + try + { + PlayerChanged?.Invoke(null, new PlayerChangedEventArgs(changeType, player)); + } + catch (Exception ex) + { + logger.Error(ex, "Error raising PlayerChanged event"); + } + } + public static void PlayerJoined(string userId, string displayName, AbstractLineHandler handler) { if (!playersByUserId.ContainsKey(userId)) @@ -73,12 +110,32 @@ public static void PlayerJoined(string userId, string displayName, AbstractLineH if( avatarByDisplayName.TryGetValue(displayName, out string? avatarName)) { + SetAvatarForPlayer(displayName, avatarName); AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarChange, $"Joined with Avatar: {avatarName}"); if( handler.LogOutput ) { logger.Info($"{COLOR_PREFIX_GREEN}\tAvatar on Join: {avatarName}{COLOR_RESET}"); } } + + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Added, newPlayer); + } + else + { + // If existing, treat as update (display name may have changed etc.) + var existing = playersByUserId[userId]; + if (existing.DisplayName != displayName) + { + // remove old display-name mapping if present + if (!string.IsNullOrEmpty(existing.DisplayName)) + { + playersByDisplayName.Remove(existing.DisplayName); + } + + existing.DisplayName = displayName; + playersByDisplayName[displayName] = existing; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, existing); + } } } @@ -87,6 +144,9 @@ public static void PlayerLeft(string displayName, AbstractLineHandler handler ) if (playersByDisplayName.TryGetValue(displayName, out Player? player)) { player.InstanceEndTime = DateTime.Now; + // Raise event with updated player before removing from internal dictionaries + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Removed, player); + playersByDisplayName.Remove(displayName); playersByNetworkId.Remove(player.NetworkId); playersByUserId.Remove(player.UserId); @@ -121,6 +181,7 @@ public static void PlayerLeft(string displayName, AbstractLineHandler handler ) { player.NetworkId = networkId; playersByNetworkId[networkId] = player; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); } return player; @@ -141,11 +202,16 @@ public static void ClearAllPlayers(AbstractLineHandler handler) { PrintPlayerInfo(player); } + // Notify removed for each player + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Removed, player); } playersByNetworkId.Clear(); playersByUserId.Clear(); playersByDisplayName.Clear(); + + // Also a global cleared notification (consumers may want to reset) + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Cleared, new Player("","") { InstanceStartTime = DateTime.MinValue }); } public static int GetPlayerCount() @@ -170,6 +236,7 @@ public static void LogAllPlayers(AbstractLineHandler handler) { PlayerEvent newEvent = new PlayerEvent(eventType, eventDescription); player.AddEvent(newEvent); + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); return player; } @@ -179,6 +246,11 @@ public static void LogAllPlayers(AbstractLineHandler handler) public static void SetAvatarForPlayer(string displayName, string avatarId) { avatarByDisplayName[displayName] = avatarId; + if (playersByDisplayName.TryGetValue(displayName, out var p)) + { + p.AvatarName = avatarId; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, p); + } } private static void PrintPlayerInfo(Player player) diff --git a/PlayerManagement/TailgrabPannel.xaml b/PlayerManagement/TailgrabPannel.xaml new file mode 100644 index 0000000..ddbc8f4 --- /dev/null +++ b/PlayerManagement/TailgrabPannel.xaml @@ -0,0 +1,182 @@ + + + + + + + + + + + + +