using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using System.Timers; using NLog; namespace VRCX { /// /// The class responsible for launching user-defined applications when VRChat opens/closes. /// public class AutoAppLaunchManager { public static AutoAppLaunchManager Instance { get; private set; } private static readonly Logger logger = LogManager.GetCurrentClassLogger(); public static readonly string VRChatProcessName = "VRChat"; public bool Enabled = false; /// Whether or not to kill child processes when VRChat closes. public bool KillChildrenOnExit = true; public bool RunProcessOnce = true; public readonly string AppShortcutDirectory; public readonly string AppShortcutDesktop; public readonly string AppShortcutVR; private DateTime startTime = DateTime.Now; private Dictionary> startedProcesses = new Dictionary>(); private readonly Timer childUpdateTimer; private int timerTicks = 0; private static readonly byte[] shortcutSignatureBytes = { 0x4C, 0x00, 0x00, 0x00 }; // signature for ShellLinkHeader private const uint TH32CS_SNAPPROCESS = 2; // Requires access rights 'PROCESS_QUERY_INFORMATION' and 'PROCESS_VM_READ' [DllImport("kernel32.dll")] public static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID); [DllImport("kernel32.dll")] public static extern bool Process32First(IntPtr hSnapshot, ref PROCESSENTRY32 lppe); [DllImport("kernel32.dll")] public static extern bool Process32Next(IntPtr hSnapshot, ref PROCESSENTRY32 lppe); [StructLayout(LayoutKind.Sequential)] public struct PROCESSENTRY32 { public uint dwSize; public uint cntUsage; public uint th32ProcessID; public IntPtr th32DefaultHeapID; public uint th32ModuleID; public uint cntThreads; public uint th32ParentProcessID; public int pcPriClassBase; public uint dwFlags; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExeFile; }; static AutoAppLaunchManager() { Instance = new AutoAppLaunchManager(); } public AutoAppLaunchManager() { AppShortcutDirectory = Path.Join(Program.AppDataDirectory, "startup"); AppShortcutDesktop = Path.Join(AppShortcutDirectory, "desktop"); AppShortcutVR = Path.Join(AppShortcutDirectory, "vr"); Directory.CreateDirectory(AppShortcutDirectory); Directory.CreateDirectory(AppShortcutDesktop); Directory.CreateDirectory(AppShortcutVR); ProcessMonitor.Instance.ProcessStarted += OnProcessStarted; ProcessMonitor.Instance.ProcessExited += OnProcessExited; childUpdateTimer = new Timer(); childUpdateTimer.Interval = 60000; childUpdateTimer.Elapsed += ChildUpdateTimer_Elapsed; } private void OnProcessExited(MonitoredProcess monitoredProcess) { if (!monitoredProcess.HasName(VRChatProcessName)) return; lock (startedProcesses) { if (KillChildrenOnExit) { childUpdateTimer.Stop(); KillChildProcesses(); } else UpdateChildProcesses(); } } private void OnProcessStarted(MonitoredProcess monitoredProcess) { if (!Enabled || !monitoredProcess.HasName(VRChatProcessName) || monitoredProcess.Process.StartTime < startTime) return; lock (startedProcesses) { if (KillChildrenOnExit) KillChildProcesses(); else UpdateChildProcesses(); var shortcutFiles = FindShortcutFiles(AppShortcutDirectory); shortcutFiles.AddRange(FindShortcutFiles(Program.AppApiInstance.IsSteamVRRunning() ? AppShortcutVR : AppShortcutDesktop)); foreach (var file in shortcutFiles) { if (RunProcessOnce && IsProcessRunning(file)) continue; if (IsChildProcessRunning(file)) continue; StartChildProcess(file); } if (shortcutFiles.Count == 0) return; timerTicks = 0; childUpdateTimer.Interval = 1000; childUpdateTimer.Start(); } } /// /// Kills all running child processes. /// private void KillChildProcesses() { UpdateChildProcesses(); // Ensure the list contains all current child processes. Parallel.ForEach(startedProcesses.ToArray(), pair => { var processes = pair.Value; var pids = processes.ToArray(); foreach (var pid in pids) { if (!WinApi.HasProcessExited(pid)) KillProcessTree(pid); } }); startedProcesses.Clear(); } // TODO: Proper error handling for winapi calls. // Will fail if process is protected, has admin rights, user account is limited(?), or process is dead // catch win32exceptions // This is a recursive function that kills a process and all of its children. // It uses the CreateToolhelp32Snapshot winapi func to get a snapshot of all running processes, loops through them with Process32First/Process32Next, and kills any processes that have the given pid as their parent. /// /// Returns the child processes of a process. /// /// The process ID of the parent process. public static List FindChildProcesses(int pid, bool recursive = true) { List pids = new List(); IntPtr snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == IntPtr.Zero) { return pids; } PROCESSENTRY32 procEntry = new PROCESSENTRY32(); procEntry.dwSize = (uint)Marshal.SizeOf(typeof(PROCESSENTRY32)); if (Process32First(snapshot, ref procEntry)) { do { if (procEntry.th32ParentProcessID == pid) { pids.Add((int)procEntry.th32ProcessID); if (recursive) // Recursively find child processes pids.AddRange(FindChildProcesses((int)procEntry.th32ProcessID)); } } while (Process32Next(snapshot, ref procEntry)); } return pids; } /// /// Kills a process and all of its child processes. /// /// The process ID of the parent process. public static void KillProcessTree(int pid) { var pids = FindChildProcesses(pid); pids.Add(pid); // Kill parent foreach (var p in pids) { try { using var proc = Process.GetProcessById(p); if (proc.HasExited) continue; if (proc.CloseMainWindow()) continue; if (proc.WaitForExit(1000)) continue; proc.Kill(); } catch { } } } /// /// Starts a new child process. /// /// The path. private void StartChildProcess(string path) { try { var process = new Process(); process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true }; process.Start(); if (process.Id != 0) startedProcesses.Add(path, new HashSet() { process.Id }); } catch (Exception ex) { logger.Error(ex); } } /// /// Updates the child processes list. /// Removes any processes that have exited. /// private void UpdateChildProcesses() { foreach (var pair in startedProcesses.ToArray()) { var processes = pair.Value; foreach (var pid in processes.ToArray()) { bool recursiveChildSearch = processes.Count == 1; // Disable recursion when this list may already contain the entire process tree var childProcesses = FindChildProcesses(pid, recursiveChildSearch); foreach (int childPid in childProcesses) // Monitor child processes processes.Add(childPid); // HashSet will prevent duplication if (WinApi.HasProcessExited(pid)) processes.Remove(pid); } if (processes.Count == 0) // All processes associated with the shortcut have exited. startedProcesses.Remove(pair.Key); } } /// /// Checks to see if a given file matches a current running child process. /// /// The path. /// /// true if child process running; otherwise, false. /// private bool IsChildProcessRunning(string path) { return startedProcesses.ContainsKey(path); } private bool IsProcessRunning(string filePath) { try { var processName = Path.GetFileNameWithoutExtension(filePath); var processes = Process.GetProcessesByName(processName); var isProcessRunning = processes.Length != 0; foreach (var process in processes) process.Dispose(); return isProcessRunning; } catch (Exception ex) { logger.Error(ex, "Error checking if process is running: {0}", filePath); return false; } } public void Init() { // What are you lookin at? :eyes: } public void Exit() { childUpdateTimer.Stop(); Enabled = false; // people thought this behavior was a bug // lock (startedProcesses) // KillChildProcesses(); } private void ChildUpdateTimer_Elapsed(object sender, ElapsedEventArgs e) { lock (startedProcesses) UpdateChildProcesses(); if (timerTicks < 5) { timerTicks++; if(timerTicks == 5) childUpdateTimer.Interval = 60000; } } /// /// Finds windows shortcut files in a given folder. /// /// The folder path. /// An array of shortcut paths. If none, then empty. private static List FindShortcutFiles(string folderPath) { DirectoryInfo directoryInfo = new DirectoryInfo(folderPath); FileInfo[] files = directoryInfo.GetFiles(); List ret = new List(); foreach (FileInfo file in files) { if (IsShortcutFile(file.FullName)) { ret.Add(file.FullName); } } return ret; } /// /// Determines whether the specified file path is a shortcut by checking the file header. /// /// The file path. /// true if the given file path is a shortcut, otherwise false private static bool IsShortcutFile(string filePath) { var headerBytes = new byte[4]; using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); if (fileStream.Length < 4) return false; fileStream.ReadExactly(headerBytes, 0, 4); return headerBytes.SequenceEqual(shortcutSignatureBytes); } } }